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:
@@ -17,6 +17,15 @@ type Params = {
|
||||
* `main` only for the owner's one main computer (`ensureMainComputer`).
|
||||
*/
|
||||
kind?: ComputerKind;
|
||||
/**
|
||||
* CPU cores its container may use. Defaults to `AppData.ComputerDefaultCpus`.
|
||||
*/
|
||||
cpus?: number;
|
||||
/**
|
||||
* Its container's memory limit in MB. Defaults to
|
||||
* `AppData.ComputerDefaultMemoryMb`.
|
||||
*/
|
||||
memory_mb?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,7 +33,8 @@ type Params = {
|
||||
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
|
||||
* in the background (`provisionComputer`). Returns the computer at once,
|
||||
* still `creating`, and the setup to await when the caller must (a CLI
|
||||
* script). The main computer of a server that reaches it at
|
||||
* script). Its resources (`cpus`, `memory_mb`) are saved on the computer
|
||||
* and applied to its container whenever it is set up. The main computer of a server that reaches it at
|
||||
* `MAIN_COMPUTER_URL` (a dev server) gets no container of its own.
|
||||
*/
|
||||
export default async function createComputer({
|
||||
@@ -32,6 +42,8 @@ export default async function createComputer({
|
||||
name,
|
||||
description,
|
||||
kind = "sandbox",
|
||||
cpus = AppData.ComputerDefaultCpus,
|
||||
memory_mb = AppData.ComputerDefaultMemoryMb,
|
||||
}: Params) {
|
||||
const external = kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
|
||||
|
||||
@@ -70,6 +82,8 @@ export default async function createComputer({
|
||||
name,
|
||||
description: description || undefined,
|
||||
kind,
|
||||
cpus,
|
||||
memory_mb,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -3,30 +3,39 @@ import type {
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||
} from "@/db/types/db";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ComputerMcpPath } from "@/src/dict/computer-dict";
|
||||
import { ComputerMcpPath, type ContainerStatus } from "@/src/dict/computer-dict";
|
||||
import grabEnv from "../envs/grab-env";
|
||||
import pickReachableAddress from "./pick-reachable-address";
|
||||
import refreshComputerContainer from "./refresh-computer-container";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
};
|
||||
|
||||
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "id" | "name" | "kind"> &
|
||||
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "hostname" | "port" | "metadata">;
|
||||
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "kind"> &
|
||||
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "hostname" | "ip_address" | "port">;
|
||||
|
||||
export type ComputerAddress =
|
||||
| { ready: true; url: string; base_url: string; name: string }
|
||||
| { ready: false; status: ContainerStatus; name: string };
|
||||
|
||||
/**
|
||||
* Where a computer's MCP server answers. In order: the container's
|
||||
* `metadata.mcp_url`, `MAIN_COMPUTER_URL` for the main computer (an app
|
||||
* setting, `grabEnv`: for a server outside docker, e.g.
|
||||
* `http://localhost:8170`), else its docker hostname and port. `base_url`
|
||||
* is the same address without `/mcp`, for the sandbox's REST API. Null
|
||||
* when the computer has no container.
|
||||
* Where a computer's MCP server answers: `MAIN_COMPUTER_URL` for the
|
||||
* main computer (an app setting, `grabEnv`: for a server outside docker,
|
||||
* e.g. `http://localhost:8170`), else its docker hostname and port, or
|
||||
* its IP address on the network when the hostname can't be reached (a
|
||||
* dev server outside docker, `pickReachableAddress`). `base_url` is the
|
||||
* same address without `/mcp`, for the sandbox's REST API. Only a
|
||||
* computer whose health check passed (`running`) is reached; any other
|
||||
* state is read again from docker first, since it may have come back
|
||||
* since the last sync, and returned as `ready: false`. Null when the
|
||||
* computer has no container.
|
||||
*/
|
||||
export default async function grabComputerMcpUrl({ computer_id }: Params) {
|
||||
export default async function grabComputerMcpUrl({ computer_id }: Params): Promise<ComputerAddress | null> {
|
||||
const row = (
|
||||
await BunSQLite.sql<ComputerRow>({
|
||||
sql: `SELECT co.id, co.name, co.kind, c.hostname, c.port, c.metadata
|
||||
sql: `SELECT co.name, co.kind, c.id, c.docker_id, c.status, c.hostname, c.ip_address, c.port
|
||||
FROM computers co JOIN containers c ON c.id = co.container_id
|
||||
WHERE co.id = ? LIMIT 1`,
|
||||
values: [computer_id],
|
||||
@@ -35,23 +44,37 @@ export default async function grabComputerMcpUrl({ computer_id }: Params) {
|
||||
|
||||
if (!row?.hostname) return null;
|
||||
|
||||
const metadata = parseJobJson({ text: row.metadata }) || {};
|
||||
|
||||
if (typeof metadata.mcp_url == "string" && metadata.mcp_url) {
|
||||
return {
|
||||
url: metadata.mcp_url,
|
||||
base_url: metadata.mcp_url.replace(/\/mcp\/?$/, ""),
|
||||
name: row.name || "",
|
||||
};
|
||||
}
|
||||
const name = row.name || "";
|
||||
|
||||
const main_override = row.kind == "main"
|
||||
? (await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()
|
||||
: undefined;
|
||||
|
||||
const base = main_override
|
||||
? main_override.replace(/\/+$/, "")
|
||||
: `http://${row.hostname}:${row.port || AppData.ComputerPort}`;
|
||||
if (main_override) {
|
||||
const base_url = main_override.replace(/\/+$/, "");
|
||||
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
|
||||
}
|
||||
|
||||
return { url: `${base}${ComputerMcpPath}`, base_url: base, name: row.name || "" };
|
||||
let status = (row.status || "unknown") as ContainerStatus;
|
||||
let ip_address: string | null | undefined = row.ip_address;
|
||||
|
||||
if (status != "running" && status != "creating" && status != "failed") {
|
||||
const refreshed = await refreshComputerContainer({ computer_id, container: row });
|
||||
|
||||
if (refreshed) ({ status, ip_address } = refreshed);
|
||||
}
|
||||
|
||||
if (status != "running") return { ready: false, status, name };
|
||||
|
||||
const port = row.port || AppData.ComputerPort;
|
||||
|
||||
const base_url = await pickReachableAddress({
|
||||
key: `computer:${computer_id}`,
|
||||
candidates: [
|
||||
`http://${row.hostname}:${port}`,
|
||||
...(ip_address ? [`http://${ip_address}:${port}`] : []),
|
||||
],
|
||||
});
|
||||
|
||||
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import pickReachableAddress from "./pick-reachable-address";
|
||||
|
||||
const server = Bun.serve({ port: 0, fetch: () => new Response("", { status: 404 }) });
|
||||
const reachable = `http://127.0.0.1:${server.port}`;
|
||||
const unreachable = "http://cobble-computer-test.invalid:8080";
|
||||
|
||||
afterAll(() => server.stop(true));
|
||||
|
||||
describe("pickReachableAddress", () => {
|
||||
test("falls back to the next address when the first can't be reached", async () => {
|
||||
expect(await pickReachableAddress({ key: "a", candidates: [unreachable, reachable] })).toBe(reachable);
|
||||
});
|
||||
|
||||
test("keeps the first address when it answers, whatever the status", async () => {
|
||||
expect(await pickReachableAddress({ key: "b", candidates: [reachable, unreachable] })).toBe(reachable);
|
||||
});
|
||||
|
||||
test("returns the first address when none answers", async () => {
|
||||
expect(await pickReachableAddress({ key: "c", candidates: [unreachable, "http://127.0.0.1:1"] })).toBe(unreachable);
|
||||
});
|
||||
|
||||
test("returns a lone address without trying it", async () => {
|
||||
expect(await pickReachableAddress({ key: "d", candidates: [unreachable] })).toBe(unreachable);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Caches the pick, e.g. `computer:<id>`.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Base URLs in the order to try, e.g. the hostname, then the IP address.
|
||||
*/
|
||||
candidates: [string, ...string[]];
|
||||
};
|
||||
|
||||
const picked = new Map<string, { base_url: string; at: number }>();
|
||||
|
||||
/**
|
||||
* The first of `candidates` that answers over HTTP (any status counts),
|
||||
* each given `AppData.ComputerReachTimeoutMs`. The pick is reused for
|
||||
* `AppData.ComputerAddressCacheMs` while it is still a candidate. When
|
||||
* none answers, the first, so the caller's own error names it.
|
||||
*/
|
||||
export default async function pickReachableAddress({ key, candidates }: Params) {
|
||||
if (candidates.length < 2) return candidates[0];
|
||||
|
||||
const cached = picked.get(key);
|
||||
|
||||
if (
|
||||
cached &&
|
||||
Date.now() - cached.at < AppData.ComputerAddressCacheMs &&
|
||||
candidates.includes(cached.base_url)
|
||||
) {
|
||||
return cached.base_url;
|
||||
}
|
||||
|
||||
for (const base_url of candidates) {
|
||||
const reached = await fetch(base_url, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(AppData.ComputerReachTimeoutMs),
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (reached) {
|
||||
picked.set(key, { base_url, at: Date.now() });
|
||||
return base_url;
|
||||
}
|
||||
}
|
||||
|
||||
picked.delete(key);
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import createComputerContainer from "@/src/functions/backend/docker/create-computer-container";
|
||||
@@ -17,15 +17,18 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Sets up a computer's container: prepares its home directory, pulls the
|
||||
* sandbox image and starts the container, then records it (`containers`:
|
||||
* docker id, state, and where to reach it outside docker). A failure is
|
||||
* sandbox image and starts the container with the computer's resources
|
||||
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
|
||||
* docker id, state, and its IP address on the network). A failure is
|
||||
* recorded as `failed` with its reason, which the computer lists show. The
|
||||
* owner's tabs hear about it either way. Never throws.
|
||||
*/
|
||||
export default async function provisionComputer({ computer_id, fresh }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
|
||||
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
|
||||
await BunSQLite.sql<
|
||||
BUN_SQLITE_COBBLE_CONTAINERS & Pick<BUN_SQLITE_COBBLE_COMPUTERS, "cpus" | "memory_mb">
|
||||
>({
|
||||
sql: `SELECT co.*, c.cpus, c.memory_mb FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
|
||||
values: [computer_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
@@ -41,26 +44,28 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
|
||||
});
|
||||
|
||||
const data_dir = await prepareComputerDataDir({ computer_id, fresh });
|
||||
const created = await createComputerContainer({ computer_id, name: row.name, data_dir });
|
||||
const created = await createComputerContainer({
|
||||
computer_id,
|
||||
name: row.name,
|
||||
data_dir,
|
||||
cpus: Number(row.cpus) || AppData.ComputerDefaultCpus,
|
||||
memory_mb: Number(row.memory_mb) || AppData.ComputerDefaultMemoryMb,
|
||||
});
|
||||
|
||||
const { error: _error, mcp_url: _mcp_url, ...kept } = metadata;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, port = ?,
|
||||
status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
|
||||
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, ip_address = ?,
|
||||
port = ?, status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [
|
||||
created.docker_id,
|
||||
AppData.ComputerImage,
|
||||
created.network,
|
||||
row.name,
|
||||
created.ip_address,
|
||||
AppData.ComputerPort,
|
||||
created.status,
|
||||
JSON.stringify({
|
||||
...kept,
|
||||
data_dir,
|
||||
host_dir: created.host_dir,
|
||||
...(created.mcp_url ? { mcp_url: created.mcp_url } : {}),
|
||||
}),
|
||||
JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }),
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
row.id,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import type { ContainerStatus } from "@/src/dict/computer-dict";
|
||||
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
|
||||
import pushComputerUpdate from "./push-computer-update";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
container: Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a computer container's state from docker and records it
|
||||
* (`status`, `ip_address`, which can change when it restarts,
|
||||
* `last_seen_at`), telling the owner when its state changed. A container
|
||||
* docker no longer has is `dead`. Returns the state recorded, or null
|
||||
* when docker couldn't be asked.
|
||||
*/
|
||||
export default async function refreshComputerContainer({ computer_id, container }: Params) {
|
||||
if (!container.id || !container.docker_id) return null;
|
||||
|
||||
const state = await inspectDockerContainer({ container: container.docker_id }).catch(() => undefined);
|
||||
|
||||
if (state === undefined) return null;
|
||||
|
||||
const status: ContainerStatus = state?.status || "dead";
|
||||
const ip_address = state?.ip_address || container.ip_address || null;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE containers SET status = ?, ip_address = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [status, ip_address, Date.now(), Date.now(), container.id],
|
||||
});
|
||||
|
||||
const changed = status != container.status;
|
||||
|
||||
if (changed) await pushComputerUpdate({ computer_id }).catch(() => {});
|
||||
|
||||
return { status, ip_address, changed };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import _ from "lodash";
|
||||
import grabEnv from "../envs/grab-env";
|
||||
import grabUserComputers from "./grab-user-computers";
|
||||
import grabUserComputer from "./grab-user-computer";
|
||||
import provisionComputer from "./provision-computer";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
computer_id: number | string;
|
||||
/**
|
||||
* Only the fields given are changed.
|
||||
*/
|
||||
data: Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "description" | "cpus" | "memory_mb">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates one of the user's computers. When its container's settings
|
||||
* change (`cpus`, `memory_mb`), the container is stopped, removed and
|
||||
* run again with them in the background (`provisionComputer`); its home
|
||||
* directory is bind-mounted, so its files stay. A name or description
|
||||
* alone leaves the container running. The main computer of a server that
|
||||
* reaches it at `MAIN_COMPUTER_URL` has no container to recreate.
|
||||
*/
|
||||
export default async function updateComputer({ user_id, computer_id, data }: Params) {
|
||||
const computer = await grabUserComputer({ computer_id, user_id });
|
||||
|
||||
if (!computer?.id) {
|
||||
throw new AppError(`Computer not found.`);
|
||||
}
|
||||
|
||||
const recreate =
|
||||
(data.cpus !== undefined && Number(data.cpus) != Number(computer.cpus)) ||
|
||||
(data.memory_mb !== undefined && Number(data.memory_mb) != Number(computer.memory_mb));
|
||||
|
||||
if (recreate && (await grabUserComputers({ computer_id: computer.id }))[0]?.status == "creating") {
|
||||
throw new AppError(`${computer.name || "The computer"} is still being set up. Try again once it's ready.`);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.update<
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "computers",
|
||||
targetId: computer.id,
|
||||
/**
|
||||
* `BunSQLite.update` writes every key it gets, so a field left out
|
||||
* here would be set to null.
|
||||
*/
|
||||
data: _.omitBy(
|
||||
{
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
cpus: data.cpus,
|
||||
memory_mb: data.memory_mb,
|
||||
},
|
||||
_.isUndefined,
|
||||
),
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't update the computer: ${res.msg}`);
|
||||
}
|
||||
|
||||
const external =
|
||||
computer.kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
|
||||
|
||||
const recreating = recreate && !external && Boolean(computer.container_id);
|
||||
|
||||
const provisioning = recreating
|
||||
? provisionComputer({ computer_id: Number(computer.id) })
|
||||
: Promise.resolve();
|
||||
|
||||
return { recreating, provisioning };
|
||||
}
|
||||
Reference in New Issue
Block a user