Run computers with resources and health checks, add settings for bots, workspaces, computers and users
- Computers get CPUs and memory (default 1 CPU, 2 GB), set at creation and editable; new resources stop, remove and recreate the container, its home dir kept. Every computer container has a docker health check and stays "creating" until it passes; only healthy computers are reached, by hostname, else by their IP on the network (dev outside docker). - Docker is required: the app creates the cobble network on startup and exits without docker. - Production container: deploy/run-app-container.sh mounts .env, the data dir and the docker socket; the database (DB_DIR), media, computers' homes and bun-sqlite's live schema now live on the data volume. - Settings pages for bots, workspaces, computers and users, sharing list components; bots can be deleted; users are created with a password and confirmation, edited and deleted through RBAC-gated APIs. - User types trimmed to super_admin, admin, member, read_only and revoked; admins manage members. UserAvatar shows profile photos from the user row. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ComputerMcpPath } from "@/src/dict/computer-dict";
|
||||
import dockerRequest from "./docker-request";
|
||||
import ensureDockerNetwork from "./ensure-docker-network";
|
||||
import grabAppContainer from "./grab-app-container";
|
||||
import grabFreePort from "./grab-free-port";
|
||||
import inspectDockerContainer from "./inspect-docker-container";
|
||||
|
||||
import pullDockerImage from "./pull-docker-image";
|
||||
import removeDockerContainer from "./remove-docker-container";
|
||||
import resolveHostPath from "./resolve-host-path";
|
||||
import waitForHealthyContainer from "./wait-for-healthy-container";
|
||||
|
||||
/**
|
||||
* Docker takes health check durations in nanoseconds.
|
||||
*/
|
||||
const seconds = (value: number) => value * 1e9;
|
||||
|
||||
type Params = {
|
||||
computer_id: number;
|
||||
@@ -19,22 +22,36 @@ type Params = {
|
||||
* The computer's home directory as this app sees it.
|
||||
*/
|
||||
data_dir: string;
|
||||
/**
|
||||
* CPU cores the container may use.
|
||||
*/
|
||||
cpus: number;
|
||||
/**
|
||||
* The container's memory limit in MB.
|
||||
*/
|
||||
memory_mb: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and starts an AIO sandbox container for a computer
|
||||
* (`AppData.ComputerImage`) on the computers' network, with its home
|
||||
* directory bind-mounted from `data_dir` and restarted with docker. A
|
||||
* container left by an earlier try is replaced. When the app runs outside
|
||||
* docker, the sandbox is also published on a localhost port, returned as
|
||||
* `mcp_url`'s host.
|
||||
* Creates and starts (the API's `docker run`) an AIO sandbox container
|
||||
* for a computer (`AppData.ComputerImage`), attached to the computers'
|
||||
* network (created first when missing), with its home directory
|
||||
* bind-mounted from `data_dir`, limited to `cpus` and `memory_mb`, with a
|
||||
* health check (`AppData.ComputerHealthcheck`), and restarted with
|
||||
* docker. A container left by an earlier try is stopped and replaced.
|
||||
* Resolves once the health check passes, with its IP address on the network, which a dev
|
||||
* server outside docker reaches it at, since it can't resolve the
|
||||
* hostname.
|
||||
*/
|
||||
export default async function createComputerContainer({ computer_id, name, data_dir }: Params) {
|
||||
export default async function createComputerContainer({
|
||||
computer_id,
|
||||
name,
|
||||
data_dir,
|
||||
cpus,
|
||||
memory_mb,
|
||||
}: Params) {
|
||||
const network = await ensureDockerNetwork();
|
||||
const in_docker = Boolean(await grabAppContainer());
|
||||
const host_dir = await resolveHostPath({ local_path: data_dir });
|
||||
const port_key = `${AppData.ComputerPort}/tcp`;
|
||||
const host_port = in_docker ? undefined : grabFreePort();
|
||||
|
||||
await pullDockerImage({ image: AppData.ComputerImage });
|
||||
await removeDockerContainer({ container: name });
|
||||
@@ -49,18 +66,25 @@ export default async function createComputerContainer({ computer_id, name, data_
|
||||
Labels: {
|
||||
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
|
||||
},
|
||||
ExposedPorts: { [port_key]: {} },
|
||||
ExposedPorts: { [`${AppData.ComputerPort}/tcp`]: {} },
|
||||
Healthcheck: {
|
||||
Test: [
|
||||
"CMD-SHELL",
|
||||
`curl -fs -o /dev/null http://localhost:${AppData.ComputerPort}/v1/sandbox && ([ "$DISABLE_BROWSER" = "true" ] || nc -z localhost "$BROWSER_REMOTE_DEBUGGING_PORT") || exit 1`,
|
||||
],
|
||||
Interval: seconds(AppData.ComputerHealthcheck.IntervalSeconds),
|
||||
Timeout: seconds(AppData.ComputerHealthcheck.TimeoutSeconds),
|
||||
StartPeriod: seconds(AppData.ComputerHealthcheck.StartPeriodSeconds),
|
||||
Retries: AppData.ComputerHealthcheck.Retries,
|
||||
},
|
||||
HostConfig: {
|
||||
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
|
||||
NetworkMode: network,
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
SecurityOpt: ["seccomp=unconfined"],
|
||||
ShmSize: AppData.ComputerShmBytes,
|
||||
Memory: AppData.ComputerMemoryBytes,
|
||||
NanoCpus: AppData.ComputerCpus * 1e9,
|
||||
PortBindings: host_port
|
||||
? { [port_key]: [{ HostIp: "127.0.0.1", HostPort: String(host_port) }] }
|
||||
: undefined,
|
||||
Memory: memory_mb * 1024 * 1024,
|
||||
NanoCpus: Math.round(cpus * 1e9),
|
||||
},
|
||||
NetworkingConfig: {
|
||||
EndpointsConfig: { [network]: { Aliases: [name] } },
|
||||
@@ -81,13 +105,13 @@ export default async function createComputerContainer({ computer_id, name, data_
|
||||
throw new Error(`Couldn't start the container: ${started.message}`);
|
||||
}
|
||||
|
||||
const state = await inspectDockerContainer({ container: created.data.Id });
|
||||
const state = await waitForHealthyContainer({ container: created.data.Id });
|
||||
|
||||
return {
|
||||
docker_id: created.data.Id,
|
||||
network,
|
||||
status: state?.status || "unknown",
|
||||
status: state.status,
|
||||
host_dir,
|
||||
mcp_url: host_port ? `http://127.0.0.1:${host_port}${ComputerMcpPath}` : undefined,
|
||||
ip_address: state.ip_address,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* A port nothing on this host listens on right now, for publishing a
|
||||
* computer when the app runs outside docker and can't reach it by name.
|
||||
*/
|
||||
export default function grabFreePort() {
|
||||
const listener = Bun.listen({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
socket: { data() {} },
|
||||
});
|
||||
const port = listener.port;
|
||||
|
||||
listener.stop(true);
|
||||
|
||||
return port;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ContainerStatus } from "@/src/dict/computer-dict";
|
||||
import dockerRequest from "./docker-request";
|
||||
|
||||
@@ -10,11 +11,19 @@ type Params = {
|
||||
|
||||
export type DockerContainerState = {
|
||||
id: string;
|
||||
/**
|
||||
* `State.Status`, with `running` narrowed by the health check
|
||||
* (`starting`, `unhealthy`). A container without one is `running`.
|
||||
*/
|
||||
status: ContainerStatus;
|
||||
/**
|
||||
* Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`.
|
||||
*/
|
||||
ports: Record<string, string>;
|
||||
/**
|
||||
* Its IP address on `AppData.DockerNetworkName`, when it's on it and running.
|
||||
*/
|
||||
ip_address: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -25,8 +34,14 @@ export default async function inspectDockerContainer({
|
||||
}: Params): Promise<DockerContainerState | null> {
|
||||
const res = await dockerRequest<{
|
||||
Id: string;
|
||||
State?: { Status?: ContainerStatus };
|
||||
NetworkSettings?: { Ports?: Record<string, { HostPort?: string }[] | null> };
|
||||
State?: {
|
||||
Status?: ContainerStatus;
|
||||
Health?: { Status?: "starting" | "healthy" | "unhealthy" | "none" };
|
||||
};
|
||||
NetworkSettings?: {
|
||||
Ports?: Record<string, { HostPort?: string }[] | null>;
|
||||
Networks?: Record<string, { IPAddress?: string }>;
|
||||
};
|
||||
}>({ path: `/containers/${encodeURIComponent(container)}/json` });
|
||||
|
||||
if (res.status == 404) return null;
|
||||
@@ -35,13 +50,20 @@ export default async function inspectDockerContainer({
|
||||
throw new Error(`Couldn't inspect ${container}: ${res.message}`);
|
||||
}
|
||||
|
||||
const status = res.data.State?.Status || "unknown";
|
||||
const health = res.data.State?.Health?.Status;
|
||||
|
||||
return {
|
||||
id: res.data.Id,
|
||||
status: res.data.State?.Status || "unknown",
|
||||
status:
|
||||
status == "running" && (health == "starting" || health == "unhealthy")
|
||||
? health
|
||||
: status,
|
||||
ports: Object.fromEntries(
|
||||
Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) =>
|
||||
bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [],
|
||||
),
|
||||
),
|
||||
ip_address: res.data.NetworkSettings?.Networks?.[AppData.DockerNetworkName]?.IPAddress || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import dockerRequest from "./docker-request";
|
||||
|
||||
type Params = {
|
||||
@@ -8,10 +9,17 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops and removes a container. Its bind-mounted files stay on the host.
|
||||
* A container docker doesn't have is already gone.
|
||||
* Stops a container (killed after `AppData.ComputerStopTimeoutSeconds`),
|
||||
* then removes it. Its bind-mounted files stay on the host. A container
|
||||
* docker doesn't have is already gone.
|
||||
*/
|
||||
export default async function removeDockerContainer({ container }: Params) {
|
||||
await dockerRequest({
|
||||
method: "POST",
|
||||
path: `/containers/${encodeURIComponent(container)}/stop`,
|
||||
query: { t: AppData.ComputerStopTimeoutSeconds },
|
||||
});
|
||||
|
||||
const res = await dockerRequest({
|
||||
method: "DELETE",
|
||||
path: `/containers/${encodeURIComponent(container)}`,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import inspectDockerContainer from "./inspect-docker-container";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The container's id or name.
|
||||
*/
|
||||
container: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits until a container's health check passes (`running`), checking
|
||||
* every `AppData.ComputerHealthPollMs` for at most
|
||||
* `AppData.ComputerHealthyTimeoutMs`, and returns its state. Throws when
|
||||
* it turns unhealthy, stops, or takes too long.
|
||||
*/
|
||||
export default async function waitForHealthyContainer({ container }: Params) {
|
||||
const deadline = Date.now() + AppData.ComputerHealthyTimeoutMs;
|
||||
|
||||
while (true) {
|
||||
const state = await inspectDockerContainer({ container });
|
||||
|
||||
if (state?.status == "running") return state;
|
||||
|
||||
if (!state || !["starting", "created", "restarting"].includes(state.status)) {
|
||||
throw new Error(`The container is ${state?.status || "gone"} instead of passing its health check.`);
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`The container didn't pass its health check within ${Math.round(AppData.ComputerHealthyTimeoutMs / 60000)} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
await Bun.sleep(AppData.ComputerHealthPollMs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user