Files
cobble/src/functions/backend/docker/create-computer-container.ts
T
tbenandClaude Opus 5.5 b9c5311fde Keep computers' browser sessions across restarts and recreates
Each computer's Chrome profile now lives on the data volume
(DATA_DIR/computer-browsers/<id>, mounted at ~/.cobble-browser) and
Chrome is pointed at it with --user-data-dir. The sandbox image resets
its own profile's preferences to session-only cookies on every boot,
which signed every site out whenever Chrome exited. New profiles are
seeded with the image's preferences minus that rule, stale Singleton
links are removed before each provision, and existing computers are
recreated once (skipped while a job runs on them).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 15:26:18 +01:00

134 lines
4.8 KiB
TypeScript

import { AppData } from "@/src/data/app-data";
import dockerRequest from "./docker-request";
import ensureDockerNetwork from "./ensure-docker-network";
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;
/**
* The container's name, also its hostname on the network.
*/
name: string;
/**
* The computer's workspace directory as this app sees it.
*/
data_dir: string;
/**
* The computer's browser profile directory as this app sees it.
*/
browser_dir: string;
/**
* CPU cores the container may use.
*/
cpus: number;
/**
* The container's memory limit in MB.
*/
memory_mb: number;
};
/**
* 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` (only its workspace, `AppData.ComputerWorkspaceDir`: the rest of its home lives and dies with the container), its browser profile from
* `browser_dir` (at `AppData.ComputerBrowserProfileDir`, which Chrome is
* pointed at, so logins outlive restarts and recreates; the basic password
* store keeps the cookies' key the same in every container), 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,
browser_dir,
cpus,
memory_mb,
}: Params) {
const network = await ensureDockerNetwork();
const host_dir = await resolveHostPath({ local_path: data_dir });
const browser_host_dir = await resolveHostPath({ local_path: browser_dir });
await pullDockerImage({ image: AppData.ComputerImage });
await removeDockerContainer({ container: name });
const created = await dockerRequest<{ Id: string }>({
method: "POST",
path: `/containers/create`,
query: { name },
body: {
Image: AppData.ComputerImage,
Hostname: name,
Labels: {
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
},
Env: [
`BROWSER_EXTRA_ARGS=--user-data-dir=${AppData.ComputerBrowserProfileDir} --password-store=basic`,
],
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.ComputerWorkspaceDir}`,
`${browser_host_dir}:${AppData.ComputerBrowserProfileDir}`,
],
NetworkMode: network,
RestartPolicy: { Name: "unless-stopped" },
SecurityOpt: ["seccomp=unconfined"],
ShmSize: AppData.ComputerShmBytes,
Memory: memory_mb * 1024 * 1024,
NanoCpus: Math.round(cpus * 1e9),
},
NetworkingConfig: {
EndpointsConfig: { [network]: { Aliases: [name] } },
},
},
});
if (!created.ok || !created.data?.Id) {
throw new Error(`Couldn't create the container: ${created.message}`);
}
const started = await dockerRequest({
method: "POST",
path: `/containers/${created.data.Id}/start`,
});
if (!started.ok && started.status != 304) {
throw new Error(`Couldn't start the container: ${started.message}`);
}
const state = await waitForHealthyContainer({ container: created.data.Id });
return {
docker_id: created.data.Id,
network,
status: state.status,
host_dir,
browser_host_dir,
ip_address: state.ip_address,
};
}