diff --git a/cli/README.md b/cli/README.md index 99bb94af6..8ffb37fa7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,10 +28,12 @@ stops the release rather than moving. The checked-in image manifest is a sentine a deployment overrides with real digests. The packed-artifact test exercises the consumer path locally. -The CLI deploys long-running QM services; it is not the runtime. Docker runs +The CLI deploys long-running QM services; it is not the runtime. The Docker target requires Docker Engine 26 or newer through a local Unix socket. Docker Desktop installations with Enhanced Container Isolation must allow the trusted core image to mount that socket. Docker runs them locally, Fly runs them as Fly apps with Fly Machines for agent computers, and AWS runs digest-pinned ARM64 tasks on ECS Fargate with Lambda MicroVM agent computers. +Local sandbox homes created before org-scoped volume names are migrated automatically while their labeled legacy container still proves ownership. If that container was already removed, startup fails closed and names the legacy source and org-scoped recovery target. Copy the volume deliberately into that target with the reported `qm.org` and `qm.scope` labels, then retry. + ## Deployment directory ```text diff --git a/cli/src/backends/docker.ts b/cli/src/backends/docker.ts index 232f078ab..b656d7868 100644 --- a/cli/src/backends/docker.ts +++ b/cli/src/backends/docker.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { isIP } from "node:net"; import { CliError, bold, die, dim, errMessage, header, note, ok, step, warn } from "../log.ts"; import { capture, @@ -52,6 +53,7 @@ interface DockerCtx { missingSandboxSecrets: string[]; buildFrom: boolean; repoRoot?: string; + dockerSocketPath?: string; } const dockerPrefix = (config: QmConfig): string => `qm-${safe(config.orgId)}`; @@ -112,6 +114,10 @@ function volumeExists(name: string): boolean { return inspectExists(["volume", "inspect", "-f", "{{.Name}}", name], /No such volume|not found/i); } +function ensureVolume(ctx: DockerCtx, name: string): void { + if (!volumeExists(name)) docker(["volume", "create", ...orgLabelArgs(ctx), name]); +} + function pgContainerPassword(ctx: DockerCtx): string | undefined { if (!containerExists(cname(ctx, "pg"))) return undefined; try { @@ -164,7 +170,31 @@ function resolvePluginImage(ctx: DockerCtx, p: ResolvedPlugin): string { } function ensureNetwork(ctx: DockerCtx): void { - docker(["network", "create", ctx.network], /already exists/); + docker(["network", "create", ...orgLabelArgs(ctx), ctx.network], /already exists/); +} + +function effectiveDockerSocket(): string { + const context = process.env.DOCKER_CONTEXT?.trim(); + const endpoint = context + ? docker(["context", "inspect", context, "--format", "{{.Endpoints.docker.Host}}"]).trim() + : process.env.DOCKER_HOST?.trim() || + docker(["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]).trim(); + if (!endpoint.startsWith("unix://")) { + throw new CliError( + `the Docker target requires a local Unix socket context; active endpoint is ${endpoint || "unset"}`, + ); + } + const path = decodeURIComponent(new URL(endpoint).pathname); + if (!path.startsWith("/")) throw new CliError(`the Docker target cannot resolve the active Unix socket: ${endpoint}`); + return path; +} + +function requireVolumeSubpathSupport(): void { + const version = docker(["version", "-f", "{{.Server.Version}}"]).trim(); + const major = Number(version.match(/^(\d+)/)?.[1]); + if (!Number.isFinite(major) || major < 26) { + throw new CliError("Docker Engine 26 or newer is required for containerized core deployments"); + } } function persistRestart(name: string): void { @@ -212,6 +242,7 @@ function ensurePostgres(ctx: DockerCtx, dryRun: boolean): string { if (!containerRunning(pgName)) { step(`Postgres: starting ${pgName}`); + ensureVolume(ctx, pgVolume(ctx)); docker(["rm", "-f", pgName], /No such container|is not running/); const secretFile = writeSecretEnvFile({ POSTGRES_PASSWORD: password }); try { @@ -331,6 +362,26 @@ function serviceEnv(ctx: DockerCtx, service: ServiceName): Record= 64 && ipv4[1]! <= 127) || + (ipv4[0] === 169 && ipv4[1] === 254) || + (ipv4[0] === 172 && ipv4[1]! >= 16 && ipv4[1]! <= 31) || + (ipv4[0] === 192 && (ipv4[1] === 0 || ipv4[1] === 168)) || + (ipv4[0] === 198 && (ipv4[1] === 18 || ipv4[1] === 19 || (ipv4[1] === 51 && ipv4[2] === 100))) || + (ipv4[0] === 203 && ipv4[1] === 0 && ipv4[2] === 113) || + ipv4[0]! >= 224); + const privateIpv6 = + isIP(host) === 6 && + (/^::$/.test(host) || + /^::1$/.test(host) || + /^::ffff:/i.test(host) || + /^(fc|fd|fe8|fe9|fea|feb)/i.test(host) || + /^ff/i.test(host) || + /^2001:db8:/i.test(host)); + const reservedName = + !isIP(host) && + (!host.includes(".") || + ["localhost", "local", "internal", "home", "lan", "test", "invalid", "example"].some( + (suffix) => host === suffix || host.endsWith(`.${suffix}`), + )); + if ( + parsed.protocol !== "https:" || + parsed.username || + parsed.password || + reservedName || + privateIpv4 || + privateIpv6 + ) { + throw new CliError("mixed local and remote sandbox backends require an externally reachable HTTPS PUBLIC_API_URL"); + } +} + function secretEnvKeys(ctx: DockerCtx, service: string): Set { const keys = new Set(Object.keys(secretValues(ctx, service))); if (ctx.signingSecret) keys.add("CORE_SIGNING_SECRET"); @@ -398,15 +499,40 @@ function runArgs(ctx: DockerCtx, service: ServiceName, image: string): { args: s "--restart", "no", ]; - const cleanup = pushEnvArgs(args, serviceEnv(ctx, service), secretEnvKeys(ctx, service)); + const env = serviceEnv(ctx, service); if (service === "core") { args.push("-v", `${ctx.prefix}-coredata:/data`); + if (env.DOCKER_CORE_CONTAINER) { + if (!ctx.dockerSocketPath) throw new CliError("the active Docker socket was not resolved"); + let socketGroup: string; + try { + socketGroup = docker([ + "run", + "--rm", + "-v", + `${ctx.dockerSocketPath}:/var/run/docker.sock`, + "--entrypoint", + "stat", + image, + "-c", + "%g", + "/var/run/docker.sock", + ]).trim(); + } catch { + throw new CliError( + "the core cannot mount the active Docker socket; allow this image when Docker Desktop Enhanced Container Isolation is enabled", + ); + } + if (!/^\d+$/.test(socketGroup)) throw new CliError(`could not resolve the Docker socket group from ${image}`); + args.push("--group-add", socketGroup, "-v", `${ctx.dockerSocketPath}:/var/run/docker.sock`); + } for (const m of layerMounts(ctx)) args.push("-v", m); for (const m of skillMounts(ctx)) args.push("-v", m); } if (def.docker.hostPortOffset !== undefined) { args.push("-p", `${baseHostPort(ctx) + def.docker.hostPortOffset}:${def.docker.internalPort}`); } + const cleanup = pushEnvArgs(args, env, secretEnvKeys(ctx, service)); args.push(image); return { args, cleanup }; } @@ -571,16 +697,20 @@ export async function dockerUp( ); } + const coreEnv = serviceEnv(ctx, "core"); + if (coreEnv.DOCKER_CORE_CONTAINER) ctx.dockerSocketPath = effectiveDockerSocket(); + if (coreEnv.DEPLOY_PROVIDER?.trim() !== "aws") requireVolumeSubpathSupport(); ensureNetwork(ctx); + ensureVolume(ctx, `${ctx.prefix}-coredata`); ctx.databaseUrl = ensurePostgres(ctx, false); if (!externalDatabaseUrl(ctx)) await waitPostgres(ctx); for (const def of ordered(runnableServices(config.services))) { const image = resolveImage(ctx, def.name); - docker(["rm", "-f", cname(ctx, def.name)], /No such container|is not running/); - step(`starting ${def.name}`); const run = runArgs(ctx, def.name, image); try { + docker(["rm", "-f", cname(ctx, def.name)], /No such container|is not running/); + step(`starting ${def.name}`); docker(run.args); } finally { run.cleanup(); @@ -671,6 +801,51 @@ function listDeploymentContainers(orgId: string): string[] { return psNames(["-a", "--filter", `label=${ORG_LABEL_KEY}=${orgId}`]); } +function listMigrationOwnerContainers(orgId: string): string[] { + return psNames(["-a", "--filter", `label=qm.volume-org=${orgId}`]); +} + +function listDeploymentNetworks(orgId: string): string[] { + return docker(["network", "ls", "--filter", `label=${ORG_LABEL_KEY}=${orgId}`, "--format", "{{.Name}}"]) + .split("\n") + .map((name) => name.trim()) + .filter(Boolean); +} + +function listDeploymentVolumes(orgId: string): string[] { + return docker(["volume", "ls", "--filter", `label=${ORG_LABEL_KEY}=${orgId}`, "--format", "{{.Name}}"]) + .split("\n") + .map((name) => name.trim()) + .filter(Boolean); +} + +function legacySandboxResources(name: string): { networks: string[]; volumes: string[] } | null { + if (docker(["inspect", "-f", '{{index .Config.Labels "qm.sandbox"}}', name]).trim() !== "1") return null; + const volumes = docker([ + "inspect", + "-f", + '{{range .Mounts}}{{if eq .Type "volume"}}{{println .Name}}{{end}}{{end}}', + name, + ]) + .split("\n") + .map((volume) => volume.trim()) + .filter((volume) => volume.startsWith("qm-home-")) + .filter( + (volume) => docker(["volume", "inspect", "-f", `{{index .Labels "${ORG_LABEL_KEY}"}}`, volume]).trim() === "", + ); + const networks = docker([ + "inspect", + "-f", + "{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}", + name, + ]) + .split("\n") + .map((network) => network.trim()) + .filter((network) => network.startsWith("qm-net-")); + if (!volumes.length && !networks.length) return null; + return { networks, volumes }; +} + export async function dockerLogs(config: QmConfig, service: string | undefined, opts: LogOpts = {}): Promise { requireDocker(); const prefix = dockerPrefix(config); @@ -696,7 +871,7 @@ export async function dockerLogs(config: QmConfig, service: string | undefined, function streamPrefixedLogs(names: string[], prefix: string, opts: { follow: boolean; tail: string }): Promise { return streamLabeled( names.map((name) => ({ - label: name.slice(prefix.length + 1), + label: name.startsWith(`${prefix}-`) ? name.slice(prefix.length + 1) : name, command: "docker", args: ["logs", "--tail", opts.tail, ...(opts.follow ? ["-f"] : []), name], })), @@ -711,23 +886,50 @@ export async function dockerDown(config: QmConfig, opts: { purge?: boolean } = { const serviceNames = teardownOrdered(runnableServices(config.services)).map((d) => `${prefix}-${d.name}`); const pgName = `${prefix}-pg`; const known = new Set([...serviceNames, pgName]); + const migrationOwners = new Set(listMigrationOwnerContainers(config.orgId)); const pluginNames = [ ...new Set([ ...config.plugins.map((p) => `${prefix}-${p.name}`), ...listDeploymentContainers(config.orgId).filter((n) => !known.has(n)), + ...migrationOwners, ]), ]; const candidates = [...pluginNames, ...serviceNames, pgName]; const present = new Set(psNames(["-a"])); + const legacyNetworks = new Set(); + const legacyVolumes = new Set(); for (const name of candidates) { if (!present.has(name)) continue; + const legacy = legacySandboxResources(name); + for (const network of legacy?.networks ?? []) legacyNetworks.add(network); + for (const volume of legacy?.volumes ?? []) legacyVolumes.add(volume); + if (migrationOwners.has(name) && !opts.purge) continue; + if (legacy?.volumes.length && !opts.purge) { + step(`stopping ${name}`); + docker(["stop", "-t", "2", name], /is not running/); + continue; + } step(`removing ${name}`); docker(["rm", "-f", name], /No such container/); } if (opts.purge) { - warn("purging the network and Postgres volume (durable data will be lost)"); - docker(["network", "rm", prefix], /not found|No such/); - docker(["volume", "rm", `${prefix}-pgdata`, `${prefix}-coredata`], /No such volume|not found|in use/); + warn("purging Docker networks and volumes (durable data will be lost)"); + for (const network of new Set([ + ...listDeploymentNetworks(config.orgId), + ...legacyNetworks, + prefix, + `${prefix}-deployments`, + ])) { + docker(["network", "rm", network], /not found|No such/); + } + for (const volume of new Set([ + ...listDeploymentVolumes(config.orgId), + ...legacyVolumes, + `${prefix}-pgdata`, + `${prefix}-coredata`, + ])) { + docker(["volume", "rm", volume], /No such volume|not found/); + } } ok("down."); } diff --git a/cli/src/config.ts b/cli/src/config.ts index 2277e65cf..c008a6044 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -225,7 +225,7 @@ export function sandboxCoreEnv( if (violation) throw new CliError(violation.message, { clause: violation.clause }); env.FLY_SANDBOX_APP_NAME = sb.app; env.FLY_BASE_IMAGE = sb.image; - const backend = sb.backend ?? (config.target === "fly" ? "sprites" : undefined); + const backend = sb.backend ?? (config.target === "fly" || config.target === "docker" ? "sprites" : undefined); if (backend) env.SANDBOX_BACKEND = backend; } for (const [k, v] of Object.entries(sb.env ?? {})) env[`FLY_RESIDENT_ENV_${k}`] = v; diff --git a/cli/src/secrets.ts b/cli/src/secrets.ts index ed061840b..a8a87488a 100644 --- a/cli/src/secrets.ts +++ b/cli/src/secrets.ts @@ -394,6 +394,9 @@ const AWS_RENDER_ENV_DEFAULTS: Readonly { +function fakeLegacyDocker(dir: string): string { + const argvLog = join(dir, "docker-legacy-argv.log"); + writeFileSync(argvLog, ""); + const bin = join(dir, "docker"); + writeFileSync( + bin, + `#!/usr/bin/env node +const fs = require("node:fs"); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(argvLog)}, JSON.stringify(args) + "\\n"); +if (args[0] === "version") { console.log("29.0"); process.exit(0); } +if (args[0] === "ps") { + if (args.includes("label=qm.volume-org=acme")) console.log("qm-volume-legacy-owner"); + else if (args.includes("label=qm.org=acme")) console.log(["qm-acme-core", "qm-sbx-legacy-abc123", "qm-scratch-legacy-def456"].join("\\n")); + else console.log(["qm-acme-core", "qm-sbx-legacy-abc123", "qm-scratch-legacy-def456", "qm-volume-legacy-owner"].join("\\n")); + process.exit(0); +} +if (args[0] === "inspect") { + const name = args[args.length - 1]; + const format = args[args.indexOf("-f") + 1] || ""; + if (format.includes("qm.sandbox")) { console.log(name.startsWith("qm-sbx-") || name.startsWith("qm-scratch-") ? "1" : ""); process.exit(0); } + if (format.includes(".Mounts")) { console.log(name.startsWith("qm-sbx-") ? "qm-home-legacy-abc123" : ""); process.exit(0); } + if (format.includes("NetworkSettings.Networks")) { console.log(name.startsWith("qm-sbx-") ? "qm-net-legacy-abc123" : name.startsWith("qm-scratch-") ? "qm-net-legacy-def456" : ""); process.exit(0); } +} +if (args[0] === "volume" && args[1] === "inspect") { console.log(""); process.exit(0); } +if ((args[0] === "volume" || args[0] === "network") && args[1] === "ls") { process.exit(0); } +process.exit(0); +`, + ); + chmodSync(bin, 0o755); + return argvLog; +} + +test("docker up delivers secrets via a 0600 env-file, never on the docker argv", { timeout: 120_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), "qm-docker-secrets-")); const priorPath = process.env.PATH; const priorDb = process.env.DATABASE_URL; + const priorContext = process.env.DOCKER_CONTEXT; + const priorHost = process.env.DOCKER_HOST; const priorSecrets = new Map(Object.keys(SECRETS).map((name) => [name, process.env[name]])); const log = console.log, warn = console.warn; @@ -109,6 +162,10 @@ test("docker up delivers secrets via a 0600 env-file, never on the docker argv", env: { core: { HARNESS: "pi", + DEPLOY_PROVIDER: " aws ", + SANDBOX_BACKEND: " sprites ", + SANDBOX_SECONDARY_BACKEND: " local ", + DATA_DIR: "/custom", CORE_SIGNING_SECRET: "config-placeholder", DATABASE_URL: "postgres://config/placeholder", PUBLIC_API_URL: "https://config-placeholder.invalid", @@ -132,6 +189,8 @@ test("docker up delivers secrets via a 0600 env-file, never on the docker argv", const fake = fakeDocker(dir); process.env.PATH = `${dir}:${priorPath}`; process.env.DATABASE_URL = "postgres://external/db"; + process.env.DOCKER_CONTEXT = "fake"; + delete process.env.DOCKER_HOST; for (const name of Object.keys(SECRETS)) delete process.env[name]; process.env.ANTHROPIC_API_KEY = ambientAnthropic; console.log = (...parts: unknown[]): void => void lines.push(parts.join(" ")); @@ -156,6 +215,12 @@ test("docker up delivers secrets via a 0600 env-file, never on the docker argv", assert.ok(argv.includes("SECURITY_SCREEN_PROXY_PROVIDER=example-screen")); assert.ok(argv.includes("SECURITY_SCREEN_PROXY_ENDPOINT=https://screen.example.test/classify")); assert.ok(argv.includes("SECURITY_SCREEN_PROXY_ROLLOUT=enforce")); + assert.ok(argv.includes("DOCKER_CORE_CONTAINER=qm-sekrit-core")); + assert.ok(argv.includes("DOCKER_CORE_DATA_VOLUME=qm-sekrit-coredata")); + assert.ok(argv.includes("DOCKER_DEPLOY_NETWORK=qm-sekrit-deployments")); + assert.ok(argv.includes("DATA_DIR=/data")); + assert.ok(argv.includes(`${join(dir, "docker.sock")}:/var/run/docker.sock`)); + assert.ok(!argv.includes("DATA_DIR=/custom")); assert.ok( argv.includes("WEB_UI_PUBLIC_URL=http://folded.example.com/web-ui"), @@ -225,6 +290,10 @@ test("docker up delivers secrets via a 0600 env-file, never on the docker argv", console.log = log; console.warn = warn; process.env.PATH = priorPath; + if (priorContext === undefined) delete process.env.DOCKER_CONTEXT; + else process.env.DOCKER_CONTEXT = priorContext; + if (priorHost === undefined) delete process.env.DOCKER_HOST; + else process.env.DOCKER_HOST = priorHost; if (priorDb === undefined) delete process.env.DATABASE_URL; else process.env.DATABASE_URL = priorDb; for (const [name, value] of priorSecrets) { @@ -235,9 +304,169 @@ test("docker up delivers secrets via a 0600 env-file, never on the docker argv", } }); +test("a failed Docker socket probe leaves no secret env file behind", { timeout: 120_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), "qm-docker-socket-probe-")); + const priorPath = process.env.PATH; + const priorDb = process.env.DATABASE_URL; + const priorContext = process.env.DOCKER_CONTEXT; + const priorTmp = process.env.TMPDIR; + const priorGid = process.env.FAKE_SOCKET_GID; + const log = console.log; + try { + writeFileSync( + join(dir, CONFIG_FILENAME), + JSON.stringify({ + contract: 1, + orgId: "socket-probe", + publicUrl: "http://localhost:8080", + target: "docker", + services: ["core"], + env: { core: { HARNESS: "mock" } }, + }), + ); + writeFileSync( + join(dir, ".env"), + `CAPABILITY_SECRET=capability\nCONNECTOR_SECRET_KEY=${"connector".repeat(4)}\nCORE_SIGNING_SECRET=${"core".repeat(8)}\nPORTAL_IDENTITY_SECRET=identity\nSKILL_SIGNING_SECRET=${"skill".repeat(8)}\n`, + ); + fakeDocker(dir); + process.env.PATH = `${dir}:${priorPath}`; + process.env.DATABASE_URL = "postgres://external/db"; + process.env.DOCKER_CONTEXT = "fake"; + process.env.TMPDIR = dir; + process.env.FAKE_SOCKET_GID = "invalid"; + console.log = (): void => {}; + const { config } = loadConfigAt(join(dir, CONFIG_FILENAME)); + await assert.rejects(dockerUp(config, dir, {}), /could not resolve the Docker socket group/); + assert.equal( + readdirSync(dir).some((name) => name.startsWith("qm-env-")), + false, + ); + } finally { + console.log = log; + process.env.PATH = priorPath; + if (priorDb === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = priorDb; + if (priorContext === undefined) delete process.env.DOCKER_CONTEXT; + else process.env.DOCKER_CONTEXT = priorContext; + if (priorTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = priorTmp; + if (priorGid === undefined) delete process.env.FAKE_SOCKET_GID; + else process.env.FAKE_SOCKET_GID = priorGid; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Docker context selection rejects remote daemons before stack mutation", { timeout: 120_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), "qm-docker-remote-context-")); + const priorPath = process.env.PATH; + const priorDb = process.env.DATABASE_URL; + const priorContext = process.env.DOCKER_CONTEXT; + const priorHost = process.env.DOCKER_HOST; + const priorEndpoint = process.env.FAKE_DOCKER_ENDPOINT; + const log = console.log; + try { + writeFileSync( + join(dir, CONFIG_FILENAME), + JSON.stringify({ + contract: 1, + orgId: "remote-context", + publicUrl: "http://localhost:8080", + target: "docker", + services: ["core"], + env: { core: { HARNESS: "mock" } }, + }), + ); + writeFileSync( + join(dir, ".env"), + `CAPABILITY_SECRET=capability\nCONNECTOR_SECRET_KEY=${"connector".repeat(4)}\nCORE_SIGNING_SECRET=${"core".repeat(8)}\nPORTAL_IDENTITY_SECRET=identity\nSKILL_SIGNING_SECRET=${"skill".repeat(8)}\n`, + ); + const fake = fakeDocker(dir); + process.env.PATH = `${dir}:${priorPath}`; + process.env.DATABASE_URL = "postgres://external/db"; + process.env.DOCKER_CONTEXT = "remote"; + process.env.DOCKER_HOST = `unix://${join(dir, "wrong.sock")}`; + process.env.FAKE_DOCKER_ENDPOINT = "ssh://builder.example.test"; + console.log = (): void => {}; + const { config } = loadConfigAt(join(dir, CONFIG_FILENAME)); + await assert.rejects(dockerUp(config, dir, {}), /requires a local Unix socket context/); + const calls = readFileSync(fake.argvLog, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + assert.equal( + calls.some((args) => args[0] === "network" && args[1] === "create"), + false, + ); + } finally { + console.log = log; + process.env.PATH = priorPath; + if (priorDb === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = priorDb; + if (priorContext === undefined) delete process.env.DOCKER_CONTEXT; + else process.env.DOCKER_CONTEXT = priorContext; + if (priorHost === undefined) delete process.env.DOCKER_HOST; + else process.env.DOCKER_HOST = priorHost; + if (priorEndpoint === undefined) delete process.env.FAKE_DOCKER_ENDPOINT; + else process.env.FAKE_DOCKER_ENDPOINT = priorEndpoint; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("mixed local and remote sandboxes require an external core URL", async () => { + const dir = mkdtempSync(join(tmpdir(), "qm-docker-mixed-sandbox-")); + const priorPath = process.env.PATH; + const log = console.log, + warn = console.warn; + try { + writeFileSync( + join(dir, CONFIG_FILENAME), + JSON.stringify({ + contract: 1, + orgId: "mixed-sandbox", + publicUrl: "http://localhost:8080", + target: "docker", + services: ["core"], + env: { + core: { + HARNESS: "mock", + DEPLOY_PROVIDER: "aws", + SANDBOX_BACKEND: "aws", + SANDBOX_SECONDARY_BACKEND: "local", + }, + }, + }), + ); + writeFileSync( + join(dir, ".env"), + `CAPABILITY_SECRET=capability\nCONNECTOR_SECRET_KEY=${"connector".repeat(4)}\nCORE_SIGNING_SECRET=${"core".repeat(8)}\nPORTAL_IDENTITY_SECRET=identity\nSKILL_SIGNING_SECRET=${"skill".repeat(8)}\n`, + ); + fakeDocker(dir); + process.env.PATH = `${dir}:${priorPath}`; + console.log = (): void => {}; + console.warn = console.log; + const { config } = loadConfigAt(join(dir, CONFIG_FILENAME)); + await assert.rejects( + dockerUp(config, dir, { dryRun: true }), + /mixed local and remote sandbox backends require an externally reachable PUBLIC_API_URL/, + ); + for (const publicApiUrl of ["http://10.0.0.8:8080", "https://[::1]", "https://localhost."]) { + config.env.core = { ...config.env.core, PUBLIC_API_URL: publicApiUrl }; + await assert.rejects( + dockerUp(config, dir, { dryRun: true }), + /require an externally reachable HTTPS PUBLIC_API_URL/, + ); + } + } finally { + console.log = log; + console.warn = warn; + process.env.PATH = priorPath; + rmSync(dir, { recursive: true, force: true }); + } +}); + test( "managed Postgres: the generated password and DATABASE_URL never reach the docker argv; state.json is 0600", - { timeout: 60_000 }, + { timeout: 120_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), "qm-docker-secrets-pg-")); const xdg = mkdtempSync(join(tmpdir(), "qm-docker-secrets-xdg-")); @@ -302,7 +531,7 @@ test( test( "docker up gates missing required secrets before any container starts while Slack setup remains optional", - { timeout: 60_000 }, + { timeout: 120_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), "qm-docker-secrets-gate-")); const priorPath = process.env.PATH; @@ -351,7 +580,7 @@ test( test( "a multi-line secret value fails loudly, naming the key (docker --env-file cannot carry newlines)", - { timeout: 60_000 }, + { timeout: 120_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), "qm-docker-secrets-nl-")); const priorPath = process.env.PATH; @@ -394,3 +623,45 @@ test( } }, ); + +test("docker down preserves legacy home ownership and purge discovers its unlabeled resources", async () => { + const dir = mkdtempSync(join(tmpdir(), "qm-docker-legacy-down-")); + const priorPath = process.env.PATH; + const log = console.log, + warn = console.warn; + try { + writeFileSync( + join(dir, CONFIG_FILENAME), + JSON.stringify({ + contract: 1, + orgId: "acme", + publicUrl: "http://localhost:8080", + target: "docker", + services: ["core"], + env: { core: { HARNESS: "mock", SANDBOX_BACKEND: "local" } }, + }), + ); + const argvLog = fakeLegacyDocker(dir); + process.env.PATH = `${dir}:${priorPath}`; + console.log = (): void => {}; + console.warn = console.log; + const { config } = loadConfigAt(join(dir, CONFIG_FILENAME)); + await dockerDown(config); + const down = readFileSync(argvLog, "utf8"); + assert.match(down, /\["stop","-t","2","qm-sbx-legacy-abc123"\]/); + assert.doesNotMatch(down, /\["rm","-f","qm-sbx-legacy-abc123"\]/); + assert.doesNotMatch(down, /\["rm","-f","qm-volume-legacy-owner"\]/); + writeFileSync(argvLog, ""); + await dockerDown(config, { purge: true }); + const purge = readFileSync(argvLog, "utf8"); + assert.match(purge, /\["network","rm","qm-net-legacy-abc123"\]/); + assert.match(purge, /\["network","rm","qm-net-legacy-def456"\]/); + assert.match(purge, /\["volume","rm","qm-home-legacy-abc123"\]/); + assert.match(purge, /\["rm","-f","qm-volume-legacy-owner"\]/); + } finally { + console.log = log; + console.warn = warn; + process.env.PATH = priorPath; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/cli/test/e2e/docker-lifecycle.e2e.test.ts b/cli/test/e2e/docker-lifecycle.e2e.test.ts index bc871c279..eea48dbd8 100644 --- a/cli/test/e2e/docker-lifecycle.e2e.test.ts +++ b/cli/test/e2e/docker-lifecycle.e2e.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; +import { createDockerDeployProvider } from "../../../src/deploy/docker-deploy-provider.ts"; +import type { Deployment, DeploymentVersion } from "../../../src/deploy/deploy-store.ts"; +import { signRequest } from "../../../src/auth/source-auth.ts"; +import { mintSignedPayload } from "../../../src/auth/signed-token.ts"; import { runCli, tmp, @@ -17,6 +21,7 @@ import { dockerCleanup, removeStandInImages, preexistingServiceImages, + repoRoot, } from "./harness.ts"; const SERVICES = ["core", "web-ui", "admin", "portal"] as const; @@ -24,6 +29,8 @@ const suffix = (names: string[], end: string): string | undefined => names.find( function lifecycleSkip(): string | false { if (!dockerAvailable()) return "no Docker daemon reachable"; + const engine = execFileSync("docker", ["version", "-f", "{{.Server.Version}}"], { encoding: "utf8" }).trim(); + if (Number(engine.match(/^(\d+)/)?.[1]) < 26) return `Docker Engine 26 or newer is required; found ${engine}`; const pre = preexistingServiceImages(SERVICES); if (pre.length) return `refusing to clobber your local images: ${pre.join(", ")} (docker rmi them to run this test)`; return false; @@ -38,6 +45,10 @@ test( const dep = tmp("dl-dep"); const checkout = standInCheckout(SERVICES); const sentinel = `e2e-${process.pid}-sentinel-${"x".repeat(32)}`; + let nestedProvider: ReturnType | undefined; + let nestedDeployment: Deployment | undefined; + let nestedVersion: DeploymentVersion | undefined; + let nestedEndpointHost: string | undefined; writeFileSync( join(dep, ".env"), @@ -59,7 +70,7 @@ test( target: "docker", basePort, services: [...SERVICES], - env: { core: { HARNESS: "mock" } }, + env: { core: { HARNESS: "mock", SANDBOX_BACKEND: "local" } }, }); standInPlugin(dep, "widget"); @@ -88,6 +99,66 @@ test( assert.equal(got, sentinel); }); + await t.test("core drives an isolated name-routed app through the shared Docker daemon", async () => { + const core = suffix(deploymentContainers(org), "core")!; + assert.ok( + execFileSync("docker", ["exec", core, "docker", "version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + }).trim(), + ); + const env = execFileSync("docker", ["exec", core, "env"], { encoding: "utf8" }); + assert.match(env, new RegExp(`^DOCKER_CORE_CONTAINER=qm-${org}-core$`, "m")); + assert.match(env, new RegExp(`^DOCKER_CORE_DATA_VOLUME=qm-${org}-coredata$`, "m")); + assert.match(env, new RegExp(`^DOCKER_DEPLOY_NETWORK=qm-${org}-deployments$`, "m")); + const id = "12345678-1234-1234-1234-123456789abc"; + const snapshotDir = `/data/deployments/${id}`; + execFileSync("docker", ["exec", core, "mkdir", "-p", snapshotDir]); + execFileSync("docker", [ + "exec", + core, + "sh", + "-c", + `printf '%s' 'console.log("nested app"); require("node:http").createServer((req,res)=>res.end("nested-ok")).listen(process.env.PORT)' > ${snapshotDir}/server.js`, + ]); + nestedVersion = { version: 1, createdAt: 1, entrypoint: "node server.js", snapshotDir }; + nestedDeployment = { + id, + ownerScopeId: "personal:U1", + createdBy: "U1", + currentVersion: 1, + status: "running", + endpoint: null, + versions: [nestedVersion], + }; + nestedProvider = createDockerDeployProvider({ + coreContainer: core, + coreDataVolume: `qm-${org}-coredata`, + coreDataDir: "/data", + network: `qm-${org}-deployments`, + orgId: org, + }); + const endpoint = await nestedProvider.apply(nestedDeployment, nestedVersion); + assert.match(endpoint.host, /^agent-deploy-[a-f0-9]{24}$/); + assert.equal(endpoint.port, 8080); + nestedEndpointHost = endpoint.host; + const app = endpoint.host; + const ports = execFileSync("docker", ["inspect", app, "--format", "{{json .HostConfig.PortBindings}}"], { + encoding: "utf8", + }); + assert.doesNotMatch(ports, /HostPort/); + let body = ""; + for (let i = 0; i < 30 && body !== "nested-ok"; i++) { + try { + body = execFileSync("docker", ["exec", core, "wget", "-qO-", `http://${endpoint.host}:8080`], { + encoding: "utf8", + }); + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + assert.equal(body, "nested-ok"); + }); + await t.test("status enumerates exactly this deployment's containers with ports", () => { const r = runCli(["status"], { cwd: dep }); assert.equal(r.code, 0, r.out); @@ -111,6 +182,8 @@ test( for (const label of [...SERVICES, "widget", "pg"]) { assert.match(all.out, new RegExp(`${label}\\s+\\|`), `interleaved logs missing ${label}`); } + assert.ok(nestedEndpointHost); + assert.match(all.out, new RegExp(`${nestedEndpointHost}\\s+\\|`)); }); await t.test("logs for a non-existent service is a clear error", () => { @@ -122,7 +195,17 @@ test( await t.test("up is idempotent — re-running keeps the stack up", () => { const r = up(); assert.equal(r.code, 0, r.out); - assert.ok(suffix(deploymentContainers(org), "core"), "core still up after re-up"); + const core = suffix(deploymentContainers(org), "core")!; + assert.ok(core, "core still up after re-up"); + assert.ok(nestedProvider && nestedDeployment && nestedVersion); + return nestedProvider.resolveEndpoint!(nestedDeployment, nestedVersion).then((endpoint) => { + assert.equal(endpoint?.host, nestedEndpointHost); + assert.equal(endpoint?.port, 8080); + const body = execFileSync("docker", ["exec", core, "wget", "-qO-", `http://${endpoint!.host}:8080`], { + encoding: "utf8", + }); + assert.equal(body, "nested-ok"); + }); }); await t.test("down (no purge) removes containers but keeps the network + volumes", () => { @@ -148,6 +231,100 @@ test( assert.deepEqual(deploymentVolumes(org), []); assert.deepEqual(deploymentNetworks(org), []); }); + + await t.test("the production core boots with local Docker topology from CLI defaults", async () => { + const actualOrg = `${org}-actual`; + const actualDep = tmp("dl-actual"); + try { + writeFileSync( + join(actualDep, ".env"), + [ + `CORE_SIGNING_SECRET=${sentinel}-actual`, + `CAPABILITY_SECRET=${sentinel}-actual-capability`, + `CONNECTOR_SECRET_KEY=${sentinel}-actual-connector`, + `PORTAL_IDENTITY_SECRET=${sentinel}-actual-identity`, + `SKILL_SIGNING_SECRET=${sentinel}-actual-skill`, + "", + ].join("\n"), + ); + writeConfig(actualDep, { + orgId: actualOrg, + target: "docker", + basePort: basePort + 20, + services: ["core"], + env: { core: { HARNESS: "mock", SANDBOX_BACKEND: "local" } }, + }); + const upActual = runCli(["up", "--build-from", repoRoot], { cwd: actualDep, timeoutMs: 300_000 }); + assert.equal(upActual.code, 0, upActual.out); + const core = suffix(deploymentContainers(actualOrg), "core")!; + assert.equal( + execFileSync("docker", ["exec", core, "printenv", "SANDBOX_BACKEND"], { encoding: "utf8" }).trim(), + "local", + ); + assert.ok( + execFileSync("docker", ["exec", core, "docker", "version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + }).trim(), + ); + const health = execFileSync("docker", ["exec", core, "wget", "-qO-", "http://127.0.0.1:8080/healthz"], { + encoding: "utf8", + }); + assert.match(health, /ok/); + const path = "/v1/deployments"; + const body = JSON.stringify({ + ownerScopeId: "personal:U1", + createdBy: "U1", + entrypoint: "node server.js", + files: [ + { + path: "server.js", + data: 'require("node:http").createServer((req,res)=>res.end("wired-ok")).listen(process.env.PORT)', + }, + ], + name: "wired-app", + }); + const timestamp = Math.floor(Date.now() / 1000); + const published = await fetch(`http://127.0.0.1:${basePort + 20}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-timestamp": String(timestamp), + "x-signature": signRequest(`${sentinel}-actual`, timestamp, `POST\n${path}\n${body}`), + "x-portal-identity": await mintSignedPayload( + { p: "U1", exp: Date.now() + 60_000 }, + `${sentinel}-actual-identity`, + ), + }, + body, + }); + const publishedText = await published.text(); + assert.equal(published.status, 200, publishedText); + const deployed = JSON.parse(publishedText) as { deployment: { endpoint: { host: string; port: number } } }; + assert.match(deployed.deployment.endpoint.host, /^agent-deploy-/); + assert.equal(deployed.deployment.endpoint.port, 8080); + let appBody = ""; + for (let attempt = 0; attempt < 30 && appBody !== "wired-ok"; attempt++) { + try { + appBody = execFileSync( + "docker", + ["exec", core, "wget", "-qO-", `http://${deployed.deployment.endpoint.host}:8080`], + { encoding: "utf8" }, + ); + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + assert.equal(appBody, "wired-ok"); + const downActual = runCli(["down", "--purge"], { cwd: actualDep }); + assert.equal(downActual.code, 0, downActual.out); + assert.deepEqual(deploymentContainers(actualOrg), []); + assert.deepEqual(deploymentVolumes(actualOrg), []); + assert.deepEqual(deploymentNetworks(actualOrg), []); + } finally { + dockerCleanup(actualOrg); + rmDir(actualDep); + } + }); } finally { dockerCleanup(org); removeStandInImages(SERVICES, org); diff --git a/cli/test/e2e/harness.ts b/cli/test/e2e/harness.ts index 7cb88619f..6e814bcea 100644 --- a/cli/test/e2e/harness.ts +++ b/cli/test/e2e/harness.ts @@ -96,12 +96,17 @@ ARG WEB_UI_BASE CMD ["sh","-c","echo 'listening on :8080'; echo 'connected as @e2ebot'; echo 'surface on http://localhost'; echo '[admin-plugin] http'; echo 'public front door on'; echo 'tail sentinel'; while true; do sleep 3600; done"] `; +const STANDIN_CORE_DOCKERFILE = `FROM alpine:latest +RUN apk add --no-cache docker-cli +CMD ["sh","-c","echo 'listening on :8080'; echo 'tail sentinel'; while true; do sleep 3600; done"] +`; + export function standInCheckout(services: readonly string[]): string { const root = tmp("checkout"); for (const svc of services) { const dir = join(root, "deploy", svc); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "Dockerfile"), STANDIN_DOCKERFILE); + writeFileSync(join(dir, "Dockerfile"), svc === "core" ? STANDIN_CORE_DOCKERFILE : STANDIN_DOCKERFILE); } return root; } @@ -149,9 +154,33 @@ const existingDockerNames = (kind: "volume" | "network", candidates: string[]): return []; } }; -export const deploymentVolumes = (orgId: string): string[] => - existingDockerNames("volume", [`qm-${orgId}-pgdata`, `qm-${orgId}-coredata`]); -export const deploymentNetworks = (orgId: string): string[] => existingDockerNames("network", [`qm-${orgId}`]); +export const deploymentVolumes = (orgId: string): string[] => { + try { + const labeled = execFileSync( + "docker", + ["volume", "ls", "--filter", `label=qm.org=${orgId}`, "--format", "{{.Name}}"], + { encoding: "utf8" }, + ) + .split("\n") + .map((name) => name.trim()) + .filter(Boolean); + return [...new Set([...labeled, ...existingDockerNames("volume", [`qm-${orgId}-pgdata`, `qm-${orgId}-coredata`])])]; + } catch { + return existingDockerNames("volume", [`qm-${orgId}-pgdata`, `qm-${orgId}-coredata`]); + } +}; +export const deploymentNetworks = (orgId: string): string[] => { + try { + return execFileSync("docker", ["network", "ls", "--filter", `label=qm.org=${orgId}`, "--format", "{{.Name}}"], { + encoding: "utf8", + }) + .split("\n") + .map((name) => name.trim()) + .filter(Boolean); + } catch { + return []; + } +}; export function preexistingServiceImages(services: readonly string[]): string[] { try { diff --git a/cli/test/secrets.test.ts b/cli/test/secrets.test.ts index e44035be3..49576911a 100644 --- a/cli/test/secrets.test.ts +++ b/cli/test/secrets.test.ts @@ -190,6 +190,12 @@ test("the sprites token is a catalog secret when the sandbox backend is sprites" (secret) => secret.name === "SPRITES_TOKEN", ), ); + for (const target of ["docker", "fly"] as const) { + assert.ok( + secretByName(makeConfig({ target, sandbox: { app: "acme-sb" } }), "SPRITES_TOKEN").required, + `${target} sandbox.app defaults to sprites and requires its token`, + ); + } }); test("naming a base model provider makes that provider's key a required deployment secret", () => { @@ -366,7 +372,7 @@ test("an explicit sandbox.backend wins, and non-fly targets keep their own defau image: "registry.fly.io/acme-sb@sha256:3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c", }, }); - assert.equal(sandboxCoreEnv(docker).env.SANDBOX_BACKEND, undefined); + assert.equal(sandboxCoreEnv(docker).env.SANDBOX_BACKEND, "sprites"); }); test("the .env.example catalog names every secret exactly once", () => { diff --git a/deploy/core/Dockerfile b/deploy/core/Dockerfile index 9be91f94a..bc1ac8623 100644 --- a/deploy/core/Dockerfile +++ b/deploy/core/Dockerfile @@ -1,6 +1,6 @@ FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd -RUN apk add --no-cache ca-certificates curl git git-daemon +RUN apk add --no-cache ca-certificates curl docker-cli git git-daemon WORKDIR /app diff --git a/docs/deploy-directory.md b/docs/deploy-directory.md index a020f7e02..efdda5105 100644 --- a/docs/deploy-directory.md +++ b/docs/deploy-directory.md @@ -107,7 +107,7 @@ Postgres stores create their tables lazily with idempotent DDL through the share | Requirement | Docker | Fly | AWS | | ------------------------------------------------------------------------------------------ | ------------------------------: | ------------------------------: | ----------------------------------------------: | | Node 24 and `qm` CLI | yes | yes | yes | -| Docker daemon | yes | build path | image transfer/build path | +| Docker daemon | local Engine 26+ | build path | image transfer/build path | | Agent-computer image and credentials | Fly app for real execution | Fly app and scoped token | Lambda MicroVM image/version and execution role | | Slack bot app created from generated manifest, bot token, app token | when Slack enabled | when Slack enabled | when Slack enabled | | Admin email, verified sender, and a Resend key or SMTP credentials | with the built-in `auth` broker | with the built-in `auth` broker | with the built-in `auth` broker | diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index e7c0367b0..d06d4996a 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -2905,6 +2905,30 @@ text-overflow: ellipsis; white-space: nowrap; } + .environment-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: 0 0 18px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--warn) 42%, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--warn) 8%, var(--surface)); + } + .environment-notice strong, + .environment-notice p { + display: block; + margin: 0; + } + .environment-notice p { + margin-top: 3px; + color: var(--muted); + font-size: 12px; + } + .environment-notice button { + flex: none; + } .governance-overview { margin: 0 0 22px; padding: 18px 20px; @@ -3987,6 +4011,13 @@

Governance

ScopeOrganization +

Effective state

@@ -5717,6 +5748,7 @@

Confirm governance change

}); let scopeDir = null; + let environmentDir = []; let scopeDirNote = "Loading scopes…"; async function loadScopeDirectory() { const r = await api("GET", "/api/scopes"); @@ -5728,6 +5760,7 @@

Confirm governance change

} viewLoadedAt.history = Date.now(); scopeDir = r.data.scopes || []; + environmentDir = r.data.environments || []; if (SCOPED.has(view) && !urlToState().session) { const memoryEditor = view === "memory" && !(orgWideView() && urlToState().mem !== "edit"); @@ -6244,6 +6277,19 @@

Confirm governance change

); } window.addEventListener("scroll", syncGovernanceSectionNav, { passive: true }); + function renderEnvironmentNotice(data) { + const notice = $("environment-notice"); + const attachment = data?.environmentAttachment; + notice.classList.toggle("hidden", !attachment); + if (!attachment) return; + const name = attachment.environmentName || shortName(attachment.environmentId); + $("environment-notice-title").textContent = "Uses named environment " + name; + $("environment-notice-detail").textContent = + "Computer files and working memory resolve to this environment. Governance and conversation history remain scoped here."; + $("environment-notice-open").textContent = "Open " + name; + $("environment-notice-open").onclick = () => + go({ view: "governance", scope: attachment.environmentId, session: null, page: 1 }); + } let governanceReq = 0; async function loadScope() { const requestedScope = scope; @@ -6259,6 +6305,7 @@

Confirm governance change

); return; } + renderEnvironmentNotice(r.data); renderGovernanceOverview(r.data); syncGovernanceSectionNav(); loadedCommandPolicyPresent = r.data.commandPolicy != null; @@ -11451,6 +11498,21 @@

Confirm governance change

actions: [sortControl], }); const activityTime = (s) => (scopeSort === "human" ? s.lastConversationActivity || 0 : s.lastActivity || 0); + if (environmentDir.length) { + const environments = denseList( + environmentDir, + (environment) => ({ + name: environment.name || shortName(environment.id), + preview: plural(environment.attachedScopes?.length || 0, "attached scope"), + href: stateToUrl({ view: "history", scope: environment.id, historyKind }), + }), + (environment) => selectScope(environment.id), + "No named environments.", + ); + root.appendChild( + dataCard("Named environments", "Named computers and working memory that scopes can share.", environments), + ); + } const t = denseList( activeRows, (s) => { diff --git a/plugins/admin/test/environments.test.ts b/plugins/admin/test/environments.test.ts new file mode 100644 index 000000000..3ec8c9fb8 --- /dev/null +++ b/plugins/admin/test/environments.test.ts @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const html = readFileSync(join(import.meta.dirname, "../public/index.html"), "utf8"); + +test("the admin UI lists named environments and links attachment warnings", () => { + assert.match(html, /Named environments/); + assert.match(html, /id="environment-notice"/); + assert.match(html, /Uses named environment/); + assert.match(html, /scope: attachment\.environmentId/); +}); diff --git a/src/api/routes/admin/scope-config.ts b/src/api/routes/admin/scope-config.ts index 22acc4f7e..17d206168 100644 --- a/src/api/routes/admin/scope-config.ts +++ b/src/api/routes/admin/scope-config.ts @@ -132,10 +132,25 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const crons = await app.listCrons(); const deployments = await app.listDeployments(); const skills = await app.listSkills(); + const environmentRows = await app.listEnvironments(); + const environments = environmentRows.map(({ environment, attachments }) => ({ + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + attachedScopes: attachments.map((attachment) => attachment.scopeId).sort(), + })); + const environmentById = new Map(environments.map((environment) => [environment.id, environment])); + const attachmentByScope = new Map( + environments.flatMap((environment) => + environment.attachedScopes.map((attachedScope) => [attachedScope, environment] as const), + ), + ); const owners = [ ...crons.map((c) => c.ownerScopeId), ...deployments.map((d) => d.ownerScopeId), ...skills.map((s) => s.scopeId), + ...environments.map((environment) => environment.id), + ...environments.flatMap((environment) => environment.attachedScopes), ]; const labels = await discoverScopes(app, deps, owners); const countBy = (ids: string[]): Map => { @@ -171,18 +186,31 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { const cronN = countBy(crons.map((c) => c.ownerScopeId)); const deployN = countBy(deployments.map((d) => d.ownerScopeId)); const skillN = countBy(skills.map((s) => s.scopeId)); - const scopes = [...labels].map(([id, label]) => ({ - scopeId: id, - ...(label ? { label } : {}), - sessions: sessionN.get(id) ?? 0, - backgroundSessions: backgroundN.get(id) ?? 0, - lastActivity: lastActivityBy.get(id) ?? 0, - lastConversationActivity: lastConversationBy.get(id) ?? 0, - lastMessage: lastMessageBy.get(id) ?? "", - crons: cronN.get(id) ?? 0, - deployments: deployN.get(id) ?? 0, - skills: skillN.get(id) ?? 0, - })); + const scopes = [...labels].map(([id, label]) => { + const environment = environmentById.get(id); + const attachment = attachmentByScope.get(id); + return { + scopeId: id, + ...(label ? { label } : {}), + ...(environment?.name ? { environmentName: environment.name } : {}), + ...(attachment + ? { + environmentAttachment: { + environmentId: attachment.id, + environmentName: attachment.name, + }, + } + : {}), + sessions: sessionN.get(id) ?? 0, + backgroundSessions: backgroundN.get(id) ?? 0, + lastActivity: lastActivityBy.get(id) ?? 0, + lastConversationActivity: lastConversationBy.get(id) ?? 0, + lastMessage: lastMessageBy.get(id) ?? "", + crons: cronN.get(id) ?? 0, + deployments: deployN.get(id) ?? 0, + skills: skillN.get(id) ?? 0, + }; + }); scopes.sort( (a, b) => b.lastActivity - a.lastActivity || @@ -190,7 +218,35 @@ export async function listAdminScopes(ctx: ApiCtx): Promise { b.backgroundSessions - a.backgroundSessions || a.scopeId.localeCompare(b.scopeId), ); - return sendJson(res, 200, { scopeId: scope, scopes }); + return sendJson(res, 200, { scopeId: scope, scopes, environments }); +} + +interface ScopeEnvironmentMetadata { + environment?: { id: string; name: string; ownerActorId: string | null }; + environmentAttachment?: { environmentId: string; environmentName: string | null }; +} + +async function scopeEnvironmentMetadata(deps: ApiCtx["deps"], targetScope: string): Promise { + const store = deps.environments; + if (!store) return {}; + + const [environment, attachment] = await Promise.all([store.get(targetScope), store.getAttachment(targetScope)]); + const metadata: ScopeEnvironmentMetadata = {}; + if (environment?.name) { + metadata.environment = { + id: environment.id, + name: environment.name, + ownerActorId: environment.ownerActorId, + }; + } + if (attachment) { + const attachedEnvironment = await store.get(attachment.environmentId); + metadata.environmentAttachment = { + environmentId: attachment.environmentId, + environmentName: attachedEnvironment?.name ?? null, + }; + } + return metadata; } export async function getScopeConfig(ctx: ApiCtx): Promise { @@ -202,6 +258,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { if (!actor) return; await deps.config.refreshScope(targetScope); audit(deps, { principalId: actor.id, action: "config.read", resource: "config", scopeLabel: targetScope }); + const environmentMetadata = await scopeEnvironmentMetadata(deps, targetScope); const serviceCredentials = await Promise.all( (deps.serviceCreds ? await deps.serviceCreds.listServiceCredentials(targetScope) : []).map(async (c) => { const usage = (await deps.credentialUsage?.list({ slug: c.slug, limit: 5000 })) ?? []; @@ -261,6 +318,7 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { }; return sendJson(res, 200, { scopeId: targetScope, + ...environmentMetadata, ...values, soulVersion: deps.config.soulVersion(targetScope), soulHistory: deps.config.soulHistory(targetScope), diff --git a/src/config.ts b/src/config.ts index 16e85a7b6..bf8f8c17d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,9 @@ export interface Config { sandboxBackend: "aws" | "local" | "sprites"; sandboxSecondaryBackend?: "aws" | "local" | "sprites"; deployProvider: "docker" | "aws"; + dockerCoreContainer?: string; + dockerCoreDataVolume?: string; + dockerDeployNetwork?: string; egressServiceHosts?: string[]; brandingDefault?: { accent?: string; mark?: string; selfLabel?: string }; modelId?: string; @@ -245,12 +248,14 @@ interface LocalSandboxEnv { cpus?: number; memoryMb?: number; defaultTimeoutSec?: number; + coreContainer?: string; } function localSandboxEnv(env: NodeJS.ProcessEnv): LocalSandboxEnv { return { ...(env.LOCAL_SANDBOX_IMAGE ? { image: env.LOCAL_SANDBOX_IMAGE } : {}), ...(env.LOCAL_SANDBOX_DOCKER_BIN ? { dockerBin: env.LOCAL_SANDBOX_DOCKER_BIN } : {}), + ...(env.DOCKER_CORE_CONTAINER ? { coreContainer: env.DOCKER_CORE_CONTAINER } : {}), ...(numEnvStrict("LOCAL_SANDBOX_CPUS", env.LOCAL_SANDBOX_CPUS) !== undefined ? { cpus: numEnvStrict("LOCAL_SANDBOX_CPUS", env.LOCAL_SANDBOX_CPUS) } : {}), @@ -553,6 +558,7 @@ function modelProviderEnvStrict(env: NodeJS.ProcessEnv): ModelProvider | undefin } export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { + const deployProvider: Config["deployProvider"] = env.DEPLOY_PROVIDER?.trim() === "aws" ? "aws" : "docker"; const missingSecrets = validateCoreSecretEnv(env); if (missingSecrets.length) { throw new Error(`missing or insecure required core secrets: ${missingSecrets.join(", ")}`); @@ -635,7 +641,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } const publicApiUrl = env.PUBLIC_API_URL ?? env.AGENT_API_URL; const publicUrl = env.PUBLIC_WEB_URL ?? publicApiUrl; - const deployProvider: "aws" | "docker" = env.DEPLOY_PROVIDER === "aws" ? "aws" : "docker"; + if (Boolean(env.DOCKER_CORE_CONTAINER) !== Boolean(env.DOCKER_CORE_DATA_VOLUME)) { + throw new Error("DOCKER_CORE_CONTAINER and DOCKER_CORE_DATA_VOLUME must be set together"); + } + if (env.DOCKER_CORE_CONTAINER && !env.DOCKER_DEPLOY_NETWORK) { + throw new Error("DOCKER_DEPLOY_NETWORK is required with DOCKER_CORE_CONTAINER"); + } let runStore: "memory" | "postgres" = env.SESSION_STORE === "postgres" ? "postgres" : "memory"; if (env.RUN_STORE === "memory" || env.RUN_STORE === "postgres") runStore = env.RUN_STORE; const providerBaseUrls = providerBaseUrlsFromEnv(env); @@ -708,6 +719,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { sandboxBackend, ...(sandboxSecondaryBackend ? { sandboxSecondaryBackend } : {}), deployProvider, + ...(env.DOCKER_CORE_CONTAINER ? { dockerCoreContainer: env.DOCKER_CORE_CONTAINER } : {}), + ...(env.DOCKER_CORE_DATA_VOLUME ? { dockerCoreDataVolume: env.DOCKER_CORE_DATA_VOLUME } : {}), + ...(env.DOCKER_DEPLOY_NETWORK ? { dockerDeployNetwork: env.DOCKER_DEPLOY_NETWORK } : {}), ...(env.EGRESS_SERVICE_HOSTS ? { egressServiceHosts: env.EGRESS_SERVICE_HOSTS.split(",") diff --git a/src/deploy/docker-deploy-provider.ts b/src/deploy/docker-deploy-provider.ts index 885b1b2a2..8e31bff04 100644 --- a/src/deploy/docker-deploy-provider.ts +++ b/src/deploy/docker-deploy-provider.ts @@ -1,6 +1,9 @@ import type { Deployment, DeploymentVersion } from "./deploy-store.ts"; import type { DeployEndpoint, DeployProvider } from "./deploy-provider.ts"; -import { spawnDockerExec } from "../sandbox/docker-exec.ts"; +import { relative } from "node:path/posix"; +import { createHash, randomUUID } from "node:crypto"; +import { spawnDockerExec, type DockerExec } from "../sandbox/docker-exec.ts"; +import { connectDockerNetwork, ensureDockerNetwork, removeDockerNetwork } from "../sandbox/docker-network.ts"; const NETWORK = "agent-deploynet"; const APP_PORT = 8080; @@ -8,59 +11,190 @@ const APP_PORT = 8080; export interface DockerDeployProviderOptions { image?: string; docker?: string; - basePort?: number; + dockerExec?: DockerExec; + coreContainer?: string; + coreDataVolume?: string; + coreDataDir?: string; + network?: string; + orgId?: string; } export function createDockerDeployProvider(opts: DockerDeployProviderOptions = {}): DeployProvider { + if (Boolean(opts.coreDataVolume) !== Boolean(opts.coreDataDir)) { + throw new Error("Docker core data volume and directory must be set together"); + } const docker = opts.docker ?? "docker"; const image = opts.image ?? "node:24-alpine"; - let nextPort = opts.basePort ?? 9200; - const ports = new Map(); - const freed: number[] = []; - const allocPort = (n: string): number => { - const existing = ports.get(n); - if (existing !== undefined) return existing; - const port = freed.pop() ?? nextPort++; - ports.set(n, port); - return port; + const network = opts.network ?? NETWORK; + const owner = opts.orgId ?? "default"; + const dexec = opts.dockerExec ?? spawnDockerExec(docker); + let volumeSubpathSupport: Promise | undefined; + + const resourceKey = (d: Deployment) => createHash("sha256").update(`${owner}\0${d.id}`).digest("hex").slice(0, 24); + const name = (d: Deployment) => `agent-deploy-${resourceKey(d)}`; + const legacyName = (d: Deployment) => `agent-deploy-${d.id.slice(0, 12)}`; + const deploymentNetwork = (d: Deployment) => `${network}-${resourceKey(d)}`; + const ensureNetwork = async (d: Deployment): Promise => { + await ensureDockerNetwork(dexec, deploymentNetwork(d), { + ...(opts.coreContainer ? { member: opts.coreContainer } : {}), + ...(opts.coreContainer ? { memberAlias: "core" } : {}), + labels: { "qm.org": owner, "qm.deploy.id": d.id }, + }); + }; + const containerInfo = async ( + d: Deployment, + ): Promise<{ running: boolean; version: number; orgId: string; deploymentId: string; provision: string } | null> => { + const inspected = await dexec([ + "inspect", + "-f", + '{{.State.Running}} {{index .Config.Labels "qm.deploy.version"}} {{index .Config.Labels "qm.org"}} {{index .Config.Labels "qm.deploy.id"}} {{index .Config.Labels "qm.provision"}}', + name(d), + ]); + if (inspected.code !== 0) return null; + const [running = "", version = "", orgId = "", deploymentId = "", provision = ""] = inspected.stdout + .trim() + .split(/\s+/); + return { running: running === "true", version: Number(version), orgId, deploymentId, provision }; }; - const freePort = (n: string): void => { - const p = ports.get(n); - if (p !== undefined) { - freed.push(p); - ports.delete(n); + const removeOwnedContainer = async (d: Deployment, provision?: string): Promise => { + const info = await containerInfo(d); + if (!info) return true; + if (info.orgId !== owner || info.deploymentId !== d.id) { + throw new Error(`Docker deployment container ${name(d)} is not owned by ${owner}/${d.id}`); } + if (provision && info.provision !== provision) return false; + await dexec(["rm", "-f", name(d)]); + return true; + }; + const rejectAmbiguousLegacyContainer = async (d: Deployment): Promise => { + const mounts = await dexec(["inspect", "-f", "{{json .Mounts}}", legacyName(d)]); + if (mounts.code !== 0) return; + let parsed: Array<{ Source?: string; Destination?: string; Type?: string; RW?: boolean }>; + try { + parsed = JSON.parse(mounts.stdout) as Array<{ + Source?: string; + Destination?: string; + Type?: string; + RW?: boolean; + }>; + } catch { + return; + } + const snapshots = new Set(d.versions.map((version) => version.snapshotDir)); + const exactLegacyBind = parsed.some( + (mount) => + mount.Type === "bind" && + mount.Destination === "/app" && + mount.RW === false && + mount.Source && + snapshots.has(mount.Source), + ); + if (!exactLegacyBind) return; + if (!opts.coreContainer) { + const legacyRemoved = await dexec(["rm", "-f", legacyName(d)]); + if (legacyRemoved.code !== 0 && !/not found|no such/i.test(legacyRemoved.stderr)) { + throw new Error(`docker rm ${legacyName(d)} failed: ${legacyRemoved.stderr.trim()}`); + } + const removed = await dexec(["network", "rm", NETWORK]); + if (removed.code !== 0 && !/active endpoints|not found|no such/i.test(removed.stderr)) { + throw new Error(`docker network rm ${NETWORK} failed: ${removed.stderr.trim()}`); + } + return; + } + throw new Error( + `legacy Docker deployment ${legacyName(d)} has no organization label; verify its owner, remove it explicitly, and retry`, + ); + }; + const cleanup = async (d: Deployment, provision?: string): Promise => { + if (!(await removeOwnedContainer(d, provision))) return; + await removeDockerNetwork(dexec, deploymentNetwork(d), opts.coreContainer); + }; + const ensureVolumeSubpathSupport = async (): Promise => { + if (!opts.coreDataVolume) return; + volumeSubpathSupport ??= (async () => { + const result = await dexec(["version", "-f", "{{.Server.Version}}"]).catch(() => ({ + code: 1, + stdout: "", + stderr: "", + })); + const major = Number(result.stdout.trim().match(/^(\d+)/)?.[1]); + if (result.code !== 0 || !Number.isFinite(major) || major < 26) { + throw new Error("Docker Engine 26 or newer is required for containerized core deployments"); + } + })(); + try { + await volumeSubpathSupport; + } catch (error) { + volumeSubpathSupport = undefined; + throw error; + } + }; + const endpoint = async (d: Deployment, version: DeploymentVersion): Promise => { + const info = await containerInfo(d); + if (!info?.running || info.version !== version.version || info.orgId !== owner || info.deploymentId !== d.id) + return null; + for (const [label, expected] of [ + ["qm.org", owner], + ["qm.deploy.id", d.id], + ] as const) { + const inspected = await dexec(["network", "inspect", "-f", `{{index .Labels "${label}"}}`, deploymentNetwork(d)]); + if (inspected.code !== 0 || inspected.stdout.trim() !== expected) return null; + } + if (opts.coreContainer) { + if (!(await connectDockerNetwork(dexec, deploymentNetwork(d), opts.coreContainer, "core"))) return null; + return { host: name(d), port: APP_PORT }; + } + const port = await dexec(["port", name(d), `${APP_PORT}/tcp`]); + const match = port.stdout + .split("\n")[0] + ?.trim() + .match(/:(\d+)$/); + if (port.code !== 0 || !match) return null; + return { host: "127.0.0.1", port: Number(match[1]) }; + }; + const mount = (version: DeploymentVersion): string[] => { + if (!opts.coreDataVolume || !opts.coreDataDir) return ["-v", `${version.snapshotDir}:/app:ro`]; + const subpath = relative(opts.coreDataDir, version.snapshotDir); + if (!subpath || subpath === ".." || subpath.startsWith("../") || subpath.includes(",")) { + throw new Error(`deploy snapshot is outside Docker core data: ${version.snapshotDir}`); + } + return ["--mount", `type=volume,src=${opts.coreDataVolume},dst=/app,readonly,volume-subpath=${subpath}`]; }; - - const dexec = spawnDockerExec(docker); - - const name = (d: Deployment) => `agent-deploy-${d.id.slice(0, 12)}`; return { profile: { managedScaleToZero: false }, async apply(d: Deployment, version: DeploymentVersion): Promise { - await dexec(["network", "create", NETWORK]); - await dexec(["rm", "-f", name(d)]); - const hostPort = allocPort(name(d)); + const mountArgs = mount(version); + await ensureVolumeSubpathSupport(); + await rejectAmbiguousLegacyContainer(d); + await removeOwnedContainer(d); + await ensureNetwork(d); + const provision = randomUUID(); const envArgs = Object.entries(version.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]); const r = await dexec([ "run", "-d", "--name", name(d), + "--label", + `qm.deploy.version=${version.version}`, + "--label", + `qm.org=${owner}`, + "--label", + `qm.deploy.id=${d.id}`, + "--label", + `qm.provision=${provision}`, "--network", - NETWORK, + deploymentNetwork(d), "--memory", "512m", "--cpus", "1", "--pids-limit", "256", - "-p", - `127.0.0.1:${hostPort}:${APP_PORT}`, - "-v", - `${version.snapshotDir}:/app:ro`, + ...(opts.coreContainer ? [] : ["-p", `127.0.0.1::${APP_PORT}`]), + ...mountArgs, "-w", "/app", "-e", @@ -72,15 +206,20 @@ export function createDockerDeployProvider(opts: DockerDeployProviderOptions = { version.entrypoint, ]); if (r.code !== 0) { - freePort(name(d)); + await cleanup(d, provision); throw new Error(`deploy run failed: ${r.stderr.trim()}`); } - return { host: "127.0.0.1", port: hostPort }; + const resolved = await endpoint(d, version); + if (resolved) return resolved; + await cleanup(d, provision); + throw new Error(`deploy run failed: cannot resolve endpoint for ${name(d)}`); }, async destroy(d: Deployment): Promise { - await dexec(["rm", "-f", name(d)]); - freePort(name(d)); + await rejectAmbiguousLegacyContainer(d); + await cleanup(d); }, + + resolveEndpoint: endpoint, }; } diff --git a/src/sandbox/docker-network.ts b/src/sandbox/docker-network.ts new file mode 100644 index 000000000..bf3c8f047 --- /dev/null +++ b/src/sandbox/docker-network.ts @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; +import type { DockerExec } from "./docker-exec.ts"; + +export interface DockerNetworkOptions { + member?: string; + memberAlias?: string; + labels?: Record; +} + +function subnet(network: string, attempt: number): string { + const value = createHash("sha256").update(`${network}\0${attempt}`).digest().readUInt32BE(0); + const index = value % 16384; + return `198.${18 + (index >>> 13)}.${(index >>> 5) & 255}.${(index & 31) * 8}/29`; +} + +async function networkLabel(dexec: DockerExec, network: string, label: string): Promise { + const inspected = await dexec(["network", "inspect", "-f", `{{index .Labels "${label}"}}`, network]); + return inspected.code === 0 ? inspected.stdout.trim() : null; +} + +export async function connectDockerNetwork( + dexec: DockerExec, + network: string, + member: string, + alias?: string, +): Promise { + const connected = await dexec(["network", "connect", ...(alias ? ["--alias", alias] : []), network, member]); + if (connected.code === 0 || /already exists/i.test(connected.stderr)) return true; + if (/not found|no such/i.test(connected.stderr)) return false; + throw new Error(`docker network connect ${network} ${member} failed: ${connected.stderr.trim()}`); +} + +export async function ensureDockerNetwork( + dexec: DockerExec, + network: string, + opts: DockerNetworkOptions = {}, +): Promise { + if ((await dexec(["network", "inspect", network])).code !== 0) { + for (let attempt = 0; ; attempt++) { + const labels = Object.entries(opts.labels ?? {}).flatMap(([key, value]) => ["--label", `${key}=${value}`]); + const created = await dexec(["network", "create", "--subnet", subnet(network, attempt), ...labels, network]); + if (created.code === 0 || /already exists/i.test(created.stderr)) break; + if (/overlap/i.test(created.stderr) && attempt < 255) continue; + throw new Error(`docker network create ${network} failed: ${created.stderr.trim()}`); + } + } + for (const [label, value] of Object.entries(opts.labels ?? {})) { + if ((await networkLabel(dexec, network, label)) !== value) { + throw new Error(`Docker network ${network} is not owned by ${label}=${value}`); + } + } + if (!opts.member) return; + if (!(await connectDockerNetwork(dexec, network, opts.member, opts.memberAlias))) { + throw new Error(`docker network connect ${network} ${opts.member} failed: network not found`); + } +} + +export async function removeDockerNetwork(dexec: DockerExec, network: string, member?: string): Promise { + if (member) { + const disconnected = await dexec(["network", "disconnect", "-f", network, member]); + if (disconnected.code !== 0 && !/not found|no such|not connected/i.test(disconnected.stderr)) { + throw new Error(`docker network disconnect ${network} ${member} failed: ${disconnected.stderr.trim()}`); + } + } + const removed = await dexec(["network", "rm", network]); + if (removed.code !== 0 && !/not found|no such/i.test(removed.stderr)) { + throw new Error(`docker network rm ${network} failed: ${removed.stderr.trim()}`); + } +} diff --git a/src/sandbox/local-sandbox.ts b/src/sandbox/local-sandbox.ts index ce8c97b4f..4bf7028f8 100644 --- a/src/sandbox/local-sandbox.ts +++ b/src/sandbox/local-sandbox.ts @@ -13,9 +13,9 @@ import { createExecProcessSessions, type ExecProcessIo } from "./exec-process-se import { materializeRoLayers } from "./ro-layers.ts"; import { createExecBackup, createExecFileOps, posixJoin } from "./exec-file-ops.ts"; import { spawnDockerExec, type DockerExec } from "./docker-exec.ts"; +import { connectDockerNetwork, ensureDockerNetwork, removeDockerNetwork } from "./docker-network.ts"; import { ephemeralCredLinkScript } from "../credentials/resident-paths.ts"; import { ephemeralCredLinkPaths } from "../credentials/resident-paths.ts"; -import { shortHash } from "../util/crypto.ts"; import { killableScript, killScript } from "./exec-kill.ts"; import type { AgentComputerProfile, @@ -47,6 +47,8 @@ export interface LocalSandboxOptions { homeDir?: string; repoRoot?: string; dockerExec?: DockerExec; + coreContainer?: string; + orgId?: string; fetchImpl?: typeof fetch; onError?: (e: { category: string; code: string; message: string; scopeLabel?: string }) => void; } @@ -74,18 +76,34 @@ export async function computeSandboxImageFingerprint(repoRoot: string): Promise< } } -export const localContainerName = (scopeId: string): string => `qm-sbx-${localSlug(scopeId)}`; -export const localVolumeName = (scopeId: string): string => `qm-home-${localSlug(scopeId)}`; +export const localContainerName = (scopeId: string, orgId = configOrgId()): string => + `qm-sbx-${localSlug(`${orgId}\0${scopeId}`)}`; +export const localVolumeName = (scopeId: string, orgId = configOrgId()): string => + `qm-home-${localSlug(`${orgId}\0${scopeId}`)}`; export const localNetworkName = (containerName: string): string => `qm-net-${containerName.replace(/^qm-(sbx|scratch)-/, "")}`; -const localScratchName = (key: string): string => `qm-scratch-${localSlug(key)}`; +const localScratchName = (key: string, orgId: string): string => `qm-scratch-${localSlug(`${orgId}\0${key}`)}`; +export const localMigrationOwnerName = (scopeId: string, orgId = configOrgId()): string => + `qm-volume-${localSlug(`${orgId}\0${scopeId}`)}`; +const legacyContainerName = (scopeId: string): string => `qm-sbx-${legacySlug(scopeId)}`; +const legacyVolumeName = (scopeId: string): string => `qm-home-${legacySlug(scopeId)}`; function localSlug(id: string): string { const cleaned = id .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, ""); - return `${cleaned.slice(0, 40).replace(/-+$/, "") || "scope"}-${shortHash(id)}`; + const digest = createHash("sha256").update(id).digest("hex").slice(0, 20); + return `${cleaned.slice(0, 31).replace(/-+$/, "") || "scope"}-${digest}`; +} + +function legacySlug(id: string): string { + const cleaned = id + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const digest = createHash("sha256").update(id).digest("hex").slice(0, 6); + return `${cleaned.slice(0, 40).replace(/-+$/, "") || "scope"}-${digest}`; } export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandboxOptions = {}): Sandbox { @@ -94,11 +112,13 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox const fetchImpl = opts.fetchImpl ?? fetch; const defaultTimeoutSec = opts.defaultTimeoutSec ?? 600; const homeDir = opts.homeDir ?? HOME_DIR; + const orgId = opts.orgId ?? configOrgId(); const workspaceDir = `${homeDir}/${WORKSPACE_BASENAME}`; const provisionQueue = createKeyedQueue(); const portByName = new Map(); const scopeByContainer = new Map(); + const volumeByContainer = new Map(); const scratchByKey = new Map(); const activeByContainer = new Map(); @@ -136,11 +156,149 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox return preflightDone; } - async function containerState(name: string): Promise<{ running: boolean; imageId: string } | null> { - const r = await dexec(["inspect", "-f", "{{.State.Running}} {{.Image}}", name]); + async function createVolume(scope: string, volume: string, migration = false): Promise { + const created = await dexec([ + "volume", + "create", + "--label", + `qm.org=${orgId}`, + "--label", + `qm.scope=${scope}`, + ...(migration ? ["--label", "qm.migration=legacy-v1"] : []), + volume, + ]); + if (created.code !== 0) throw new Error(`docker volume create ${volume} failed: ${created.stderr.trim()}`); + } + + async function finishLegacyMigration(scope: string, name: string): Promise { + await dexec(["rm", "-f", name]); + await removeDockerNetwork(dexec, localNetworkName(name), opts.coreContainer); + const removed = await dexec(["volume", "rm", legacyVolumeName(scope)]); + if (removed.code !== 0) throw new Error(`local sandbox migration source cleanup failed: ${removed.stderr.trim()}`); + } + + async function migrationComplete(scope: string, volume: string): Promise { + const ownerName = localMigrationOwnerName(scope, orgId); + const inspected = await dexec([ + "inspect", + "-f", + '{{.State.Running}} {{index .Config.Labels "qm.volume-owner"}} {{index .Config.Labels "qm.volume-org"}} {{index .Config.Labels "qm.scope"}}', + ownerName, + ]); + if (inspected.code !== 0) return false; + const [running = "", owner = "", inspectedOrg = "", inspectedScope = ""] = inspected.stdout.trim().split(/\s+/); + if (owner !== "1" || inspectedOrg !== orgId || inspectedScope !== scope) return false; + if ((await containerVolume(ownerName)) !== volume) return false; + if (running === "true") return true; + return (await dexec(["start", ownerName])).code === 0; + } + + async function createMigrationOwner(scope: string, volume: string): Promise { + if (await migrationComplete(scope, volume)) return; + const ownerName = localMigrationOwnerName(scope, orgId); + if ((await dexec(["inspect", ownerName])).code === 0) { + throw new Error(`local sandbox migration owner ${ownerName} is not owned by ${orgId}/${scope}`); + } + const created = await dexec([ + "run", + "-d", + "--name", + ownerName, + "--restart", + "unless-stopped", + "--label", + "qm.volume-owner=1", + "--label", + `qm.volume-org=${orgId}`, + "--label", + `qm.scope=${scope}`, + "-v", + `${volume}:${homeDir}:ro`, + image, + "sh", + "-c", + "while :; do sleep 86400; done", + ]); + if (created.code !== 0 || !(await migrationComplete(scope, volume))) { + throw new Error(`local sandbox migration owner create failed: ${created.stderr.trim()}`); + } + } + + async function migrateLegacyVolume(scope: string, name: string, wasRunning: boolean): Promise { + const source = legacyVolumeName(scope); + const target = localVolumeName(scope, orgId); + if ((await dexec(["volume", "inspect", target])).code === 0) { + if (await migrationComplete(scope, target)) { + await finishLegacyMigration(scope, name); + return target; + } + const removed = await dexec(["volume", "rm", target]); + if (removed.code !== 0) throw new Error(`local sandbox migration reset failed: ${removed.stderr.trim()}`); + } + if (wasRunning) { + const stopped = await dexec(["stop", "-t", "2", name], 60_000); + if (stopped.code !== 0) throw new Error(`local sandbox migration stop failed: ${stopped.stderr.trim()}`); + } + await createVolume(scope, target, true); + const copied = await dexec([ + "run", + "--rm", + "-v", + `${source}:/from:ro`, + "-v", + `${target}:/to`, + image, + "sh", + "-c", + "cp -a /from/. /to/", + ]); + if (copied.code !== 0) { + await dexec(["volume", "rm", target]); + if (wasRunning) await dexec(["start", name]); + throw new Error(`local sandbox volume migration failed: ${copied.stderr.trim()}`); + } + await createMigrationOwner(scope, target); + await finishLegacyMigration(scope, name); + return target; + } + + async function volumeLabel(volume: string, label: string): Promise { + const result = await dexec(["volume", "inspect", "-f", `{{index .Labels "${label}"}}`, volume]); + return result.code === 0 ? result.stdout.trim() : ""; + } + + async function assertVolumeOwnership(scope: string, volume: string): Promise { + const [inspectedOrg, inspectedScope] = await Promise.all([ + volumeLabel(volume, "qm.org"), + volumeLabel(volume, "qm.scope"), + ]); + if (inspectedOrg !== orgId || inspectedScope !== scope) { + throw new Error(`local sandbox volume ${volume} is not owned by ${orgId}/${scope}`); + } + } + + async function containerState( + name: string, + ): Promise<{ running: boolean; imageId: string; orgId: string; scope: string; provision: string } | null> { + const r = await dexec([ + "inspect", + "-f", + '{{.State.Running}} {{.Image}} {{index .Config.Labels "qm.org"}} {{index .Config.Labels "qm.scope"}} {{index .Config.Labels "qm.provision"}}', + name, + ]); if (r.code !== 0) return null; - const [running = "", imageId = ""] = r.stdout.trim().split(/\s+/); - return { running: running === "true", imageId }; + const [running = "", imageId = "", inspectedOrgId = "", scope = "", provision = ""] = r.stdout.trim().split(/\s+/); + return { running: running === "true", imageId, orgId: inspectedOrgId, scope, provision }; + } + + async function containerVolume(name: string): Promise { + const r = await dexec([ + "inspect", + "-f", + `{{range .Mounts}}{{if eq .Destination "${homeDir}"}}{{.Name}}{{end}}{{end}}`, + name, + ]); + return r.code === 0 ? r.stdout.trim() : ""; } async function resolvePort(name: string): Promise { @@ -165,9 +323,9 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox timeoutMs?: number, signal?: AbortSignal, ): Promise<{ status: number; text: string }> { - const port = await resolvePort(name); + const address = opts.coreContainer ? `${name}:${AGENT_PORT}` : `127.0.0.1:${await resolvePort(name)}`; const signals = [AbortSignal.timeout(timeoutMs ?? 30_000), ...(signal ? [signal] : [])]; - const res = await fetchImpl(`http://127.0.0.1:${port}${path}`, { + const res = await fetchImpl(`http://${address}${path}`, { method: body === undefined ? "GET" : "POST", ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }), signal: AbortSignal.any(signals), @@ -201,6 +359,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox async function ensureRunning(name: string): Promise { const state = await containerState(name); if (!state) throw new Error(`local sandbox container ${name} is gone`); + await ensureNetwork(name, true); if (!state.running) await startContainer(name); } @@ -225,19 +384,34 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox return Buffer.from((JSON.parse(res.text) as { b64: string }).b64, "base64"); } - async function ensureNetwork(name: string): Promise { + async function ensureNetwork(name: string, connectSandbox = false): Promise { const net = localNetworkName(name); - if ((await dexec(["network", "inspect", net])).code !== 0) { - const r = await dexec(["network", "create", net]); - if (r.code !== 0 && !/already exists/i.test(r.stderr)) { - throw new Error(`docker network create ${net} failed: ${r.stderr.trim()}`); - } + await ensureDockerNetwork(dexec, net, { + ...(opts.coreContainer ? { member: opts.coreContainer } : {}), + ...(opts.coreContainer ? { memberAlias: "core" } : {}), + labels: { "qm.org": orgId }, + }); + if (connectSandbox && !(await connectDockerNetwork(dexec, net, name))) { + throw new Error(`docker network connect ${net} ${name} failed: network not found`); } return net; } - async function runContainer(name: string, scope: string | undefined, withVolume: boolean): Promise { + async function removeNetwork(name: string, disconnectSandbox = false): Promise { + if (disconnectSandbox) { + const disconnected = await dexec(["network", "disconnect", "-f", localNetworkName(name), name]); + if (disconnected.code !== 0 && !/not found|no such|not connected/i.test(disconnected.stderr)) { + throw new Error( + `docker network disconnect ${localNetworkName(name)} ${name} failed: ${disconnected.stderr.trim()}`, + ); + } + } + await removeDockerNetwork(dexec, localNetworkName(name), opts.coreContainer); + } + + async function runContainer(name: string, scope: string | undefined, volume?: string): Promise { const net = await ensureNetwork(name); + const provision = randomUUID(); const args = [ "run", "-d", @@ -247,44 +421,103 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox "qm.sandbox=1", ...(scope ? ["--label", `qm.scope=${scope}`] : []), "--label", - `qm.org=${configOrgId()}`, + `qm.org=${orgId}`, + "--label", + `qm.provision=${provision}`, "--label", "agent_env=dev", "--network", net, - ...(withVolume && scope ? ["-v", `${localVolumeName(scope)}:${homeDir}`] : []), - "-p", - `127.0.0.1:0:${AGENT_PORT}`, + ...(scope && volume ? ["-v", `${volume}:${homeDir}`] : []), + ...(opts.coreContainer ? [] : ["-p", `127.0.0.1:0:${AGENT_PORT}`]), "--add-host=host.docker.internal:host-gateway", ...(opts.cpus ? ["--cpus", String(opts.cpus)] : []), ...(opts.memoryMb ? ["--memory", `${opts.memoryMb}m`] : []), image, ]; const r = await dexec(args, 120_000); - if (r.code !== 0) throw new Error(`docker run ${name} failed: ${r.stderr.trim()}`); + if (r.code !== 0) { + if ((await containerState(name))?.provision === provision) { + await dexec(["rm", "-f", name]); + await removeNetwork(name).catch(swallowAs("local-sandbox: failed run network rm", undefined)); + } + throw new Error(`docker run ${name} failed: ${r.stderr.trim()}`); + } portByName.delete(name); - await waitDaemon(name); + try { + await waitDaemon(name); + } catch (error) { + if ((await containerState(name))?.provision === provision) { + await dexec(["rm", "-f", name]); + await removeNetwork(name).catch(swallowAs("local-sandbox: failed readiness network rm", undefined)); + } + throw error; + } } async function ensureContainer(scope: string): Promise<{ name: string; coldStart: boolean }> { return provisionQueue(scope, async () => { const imageId = await preflight(); - const name = localContainerName(scope); - scopeByContainer.set(name, scope); + const name = localContainerName(scope, orgId); const state = await containerState(name); + let volume = localVolumeName(scope, orgId); + const targetExists = (await dexec(["volume", "inspect", volume])).code === 0; + if (state && (state.orgId !== orgId || state.scope !== scope)) { + throw new Error(`local sandbox container ${name} is not owned by ${orgId}/${scope}`); + } + if (state && !targetExists) throw new Error(`local sandbox container ${name} has no durable volume ${volume}`); + if (targetExists) await assertVolumeOwnership(scope, volume); + if (state && (await containerVolume(name)) !== volume) { + throw new Error(`local sandbox container ${name} does not mount ${volume}`); + } + if (!state) { + const legacyName = legacyContainerName(scope); + const legacyState = await containerState(legacyName); + const legacyVolume = legacyVolumeName(scope); + const legacyExists = (await dexec(["volume", "inspect", legacyVolume])).code === 0; + if ( + targetExists && + (await volumeLabel(volume, "qm.migration")) === "legacy-v1" && + !legacyExists && + !(await migrationComplete(scope, volume)) + ) { + throw new Error(`local sandbox migration target ${volume} is incomplete`); + } + const resumableMigration = targetExists && (await volumeLabel(volume, "qm.migration")) === "legacy-v1"; + if (legacyState?.orgId === orgId && legacyState.scope === scope && (!targetExists || resumableMigration)) { + if ((await containerVolume(legacyName)) !== legacyVolume) { + throw new Error(`legacy local sandbox container ${legacyName} does not mount ${legacyVolume}`); + } + volume = await migrateLegacyVolume(scope, legacyName, legacyState.running); + } else if (targetExists && legacyExists && !legacyState) { + if ((await volumeLabel(volume, "qm.migration")) === "legacy-v1") { + if (!(await migrationComplete(scope, volume))) { + throw new Error(`local sandbox migration target ${volume} is incomplete`); + } + await finishLegacyMigration(scope, legacyName); + } + } else if (legacyState && !targetExists) { + throw new Error(`legacy local sandbox container ${legacyName} is not owned by ${orgId}/${scope}`); + } else if (!legacyState && legacyExists) { + throw new Error( + `legacy local sandbox volume ${legacyVolumeName(scope)} has no owning container; copy it into ${volume} labeled qm.org=${orgId} and qm.scope=${scope}`, + ); + } + } + scopeByContainer.set(name, scope); + volumeByContainer.set(name, volume); if (state && state.imageId === imageId) { + await ensureNetwork(name, true); if (!state.running) await startContainer(name); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); return { name, coldStart: false }; } if (state) await dexec(["rm", "-f", name]); - const volume = localVolumeName(scope); const hadVolume = (await dexec(["volume", "inspect", volume])).code === 0; if (!hadVolume) { - const created = await dexec(["volume", "create", volume]); - if (created.code !== 0) throw new Error(`docker volume create ${volume} failed: ${created.stderr.trim()}`); + await createVolume(scope, volume); } - await runContainer(name, scope, true); + await runContainer(name, scope, volume); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); return { name, coldStart: !hadVolume }; }); @@ -293,15 +526,19 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox async function ensureScratch(key: string): Promise<{ name: string; coldStart: boolean }> { return provisionQueue(`scratch:${key}`, async () => { await preflight(); - const name = localScratchName(key); + const name = localScratchName(key, orgId); scratchByKey.set(key, name); const state = await containerState(name); if (state) { + if (state.orgId !== orgId || state.scope) { + throw new Error(`local scratch container ${name} is not owned by ${orgId}`); + } + await ensureNetwork(name, true); if (!state.running) await startContainer(name); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); return { name, coldStart: false }; } - await runContainer(name, undefined, false); + await runContainer(name, undefined); activeByContainer.set(name, (activeByContainer.get(name) ?? 0) + 1); return { name, coldStart: true }; }); @@ -456,9 +693,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox for (const [k, name] of scratchByKey) if (name === handle.id) scratchByKey.delete(k); if (tdOpts?.destroy) await dexec(["rm", "-f", handle.id]); else await dexec(["rm", "-f", handle.id]).catch(swallowAs("local-sandbox: scratch rm", undefined)); - await dexec(["network", "rm", localNetworkName(handle.id)]).catch( - swallowAs("local-sandbox: scratch network rm", undefined), - ); + await removeNetwork(handle.id).catch(swallowAs("local-sandbox: scratch network rm", undefined)); portByName.delete(handle.id); return; } @@ -467,27 +702,29 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox if (tdOpts?.destroy) { await dexec(["rm", "-f", handle.id]).catch(swallowAs("local-sandbox: destroy rm", undefined)); - await dexec(["network", "rm", localNetworkName(handle.id)]).catch( - swallowAs("local-sandbox: destroy network rm", undefined), - ); + await removeNetwork(handle.id).catch(swallowAs("local-sandbox: destroy network rm", undefined)); + const volume = volumeByContainer.get(handle.id); const scope = scopeByContainer.get(handle.id); - if (scope) - await dexec(["volume", "rm", localVolumeName(scope)]).catch( - swallowAs("local-sandbox: destroy volume rm", undefined), - ); + if (scope) await dexec(["rm", "-f", localMigrationOwnerName(scope, orgId)]); + if (volume) + await dexec(["volume", "rm", volume]).catch(swallowAs("local-sandbox: destroy volume rm", undefined)); scopeByContainer.delete(handle.id); + volumeByContainer.delete(handle.id); portByName.delete(handle.id); return; } const r = await dexec(["stop", "-t", "2", handle.id], 60_000); - if (r.code !== 0) + if (r.code !== 0) { opts.onError?.({ category: "sandbox_park", code: "docker_stop_failed", message: r.stderr.trim(), ...(scopeByContainer.get(handle.id) ? { scopeLabel: scopeByContainer.get(handle.id)! } : {}), }); + } else { + await removeNetwork(handle.id, true).catch(swallowAs("local-sandbox: parked network rm", undefined)); + } portByName.delete(handle.id); }); }, diff --git a/src/wiring.ts b/src/wiring.ts index 83540e3d8..7095951a0 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -570,6 +570,7 @@ export function buildApp( const buildLocal = (): Sandbox => createLocalSandbox(workspace, { ...config.localSandbox, + orgId: config.orgId, onError: sandboxOnError, }); const buildSprites = (): Sandbox => @@ -840,7 +841,14 @@ export function buildApp( advisoryLock, store: artifactMap("aws_deploy_bodies"), }) - : createDockerDeployProvider(); + : createDockerDeployProvider({ + ...(config.dockerCoreContainer ? { coreContainer: config.dockerCoreContainer } : {}), + ...(config.dockerCoreDataVolume + ? { coreDataVolume: config.dockerCoreDataVolume, coreDataDir: config.dataDir } + : {}), + ...(config.dockerDeployNetwork ? { network: config.dockerDeployNetwork } : {}), + orgId: config.orgId, + }); if (config.deployProvider === "aws" && !config.awsDeploy.dataBucket && !config.awsSandbox.s3Bucket) { console.warn( "[wiring] aws deploy: no data bucket resolved (AWS_DEPLOY_DATA_BUCKET unset, sandbox is not aws) — deployed apps have NO durable /data", diff --git a/test/admin-scopes-directory.test.ts b/test/admin-scopes-directory.test.ts index dcb280e50..b604750af 100644 --- a/test/admin-scopes-directory.test.ts +++ b/test/admin-scopes-directory.test.ts @@ -16,6 +16,8 @@ function start() { admin: built.admin, auditLog: built.auditLog, sessions: built.sessions, + config: built.config, + environments: built.environments, }); server.listen(0); const base = `http://localhost:${(server.address() as AddressInfo).port}`; @@ -118,3 +120,34 @@ test("scopes without a session label fall back to the org directory (people's na await s.close(); } }); + +test("the admin scope directory exposes named environments and attached scopes", async () => { + const s = start(); + try { + await s.built.app.upsertChannels([ + { channelId: "A", name: "source" }, + { channelId: "B", name: "attached" }, + ]); + await s.built.app.createEnvironment({ scopeId: "channel:A", name: "A-permanent", actorId: "U1" }); + await s.built.app.attachScope({ scopeId: "channel:B", environmentId: "channel:A", actorId: "U1" }); + + const directory = await json(await fetch(`${s.base}/v1/admin/scopes`, { headers: ALICE_ADMIN })); + const byId = new Map(directory.scopes.map((row: any) => [row.scopeId, row])); + assert.deepEqual(directory.environments, [ + { id: "channel:A", name: "A-permanent", ownerActorId: "U1", attachedScopes: ["channel:B"] }, + ]); + assert.equal((byId.get("channel:A") as any).environmentName, "A-permanent"); + assert.deepEqual((byId.get("channel:B") as any).environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + + const attached = await json(await fetch(`${s.base}/v1/admin/scopes/channel:B`, { headers: ALICE_ADMIN })); + assert.deepEqual(attached.environmentAttachment, { + environmentId: "channel:A", + environmentName: "A-permanent", + }); + } finally { + await s.close(); + } +}); diff --git a/test/config.test.ts b/test/config.test.ts index ee5ba307f..ad86678e8 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -276,6 +276,42 @@ test("deploy proxy dial timeout is parsed once from config", () => { assert.throws(() => loadConfig({ DEPLOY_DIAL_TIMEOUT_MS: "soon" }), /DEPLOY_DIAL_TIMEOUT_MS="soon" is not a number/); }); +test("containerized Docker topology is parsed as one complete contract", () => { + const topology = loadConfig({ + ...productionEnv, + DOCKER_CORE_CONTAINER: "qm-acme-core", + DOCKER_CORE_DATA_VOLUME: "qm-acme-coredata", + DOCKER_DEPLOY_NETWORK: "qm-acme-deployments", + }); + assert.equal(topology.dockerCoreContainer, "qm-acme-core"); + assert.equal(topology.dockerCoreDataVolume, "qm-acme-coredata"); + assert.equal(topology.dockerDeployNetwork, "qm-acme-deployments"); + assert.equal(topology.localSandbox.coreContainer, "qm-acme-core"); + assert.throws( + () => + loadConfig({ + ...productionEnv, + DOCKER_CORE_CONTAINER: "qm-acme-core", + DOCKER_DEPLOY_NETWORK: "qm-acme-deployments", + }), + /DOCKER_CORE_CONTAINER and DOCKER_CORE_DATA_VOLUME must be set together/, + ); + assert.throws( + () => + loadConfig({ + ...productionEnv, + DOCKER_CORE_CONTAINER: "qm-acme-core", + DOCKER_CORE_DATA_VOLUME: "qm-acme-coredata", + }), + /DOCKER_DEPLOY_NETWORK is required with DOCKER_CORE_CONTAINER/, + ); +}); + +test("deploy provider parsing trims known values", () => { + assert.equal(loadConfig({ DEPLOY_PROVIDER: " aws " }).deployProvider, "aws"); + assert.equal(loadConfig({ DEPLOY_PROVIDER: " docker " }).deployProvider, "docker"); +}); + test("PUBLIC_API_URL is not treated as the human-facing web URL", () => { const apiOnly = loadConfig({ PUBLIC_API_URL: "https://agent-api.example" }); assert.equal(apiOnly.apiBaseUrl, "https://agent-api.example"); diff --git a/test/docker-deploy-provider.test.ts b/test/docker-deploy-provider.test.ts new file mode 100644 index 000000000..3f93e3cb9 --- /dev/null +++ b/test/docker-deploy-provider.test.ts @@ -0,0 +1,355 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createDockerDeployProvider } from "../src/deploy/docker-deploy-provider.ts"; +import type { Deployment, DeploymentVersion } from "../src/deploy/deploy-store.ts"; +import type { DockerExec } from "../src/sandbox/docker-exec.ts"; + +function fixture(id: string): { deployment: Deployment; version: DeploymentVersion } { + const version = { + version: 1, + createdAt: 1, + entrypoint: "node server.js", + snapshotDir: `/data/deployments/${id}`, + }; + return { + deployment: { + id, + ownerScopeId: "personal:U1", + createdBy: "U1", + currentVersion: 1, + status: "running", + endpoint: null, + versions: [version], + }, + version, + }; +} + +function fakeDocker() { + const calls: string[][] = []; + const networks = new Set(); + const members = new Set(); + const running = new Set(); + const versions = new Map(); + const labels = new Map>(); + const mountSources = new Map(); + const networkMembers = new Map>(); + const networkLabels = new Map>(); + const ports = new Map(); + const state = { engineVersion: "29.0.0", failNextRun: false, conflictNextRun: false }; + let nextPort = 49152; + const ok = (stdout = "") => ({ code: 0, stdout, stderr: "" }); + const fail = (stderr: string) => ({ code: 1, stdout: "", stderr }); + const exec: DockerExec = async (args) => { + calls.push(args); + if (args[0] === "network" && args[1] === "inspect") { + const network = args.at(-1)!; + if (!networks.has(network)) return fail("not found"); + const format = args[args.indexOf("-f") + 1] ?? ""; + const label = format.match(/\.Labels "([^"]+)"/)?.[1]; + return ok(label ? (networkLabels.get(network)?.[label] ?? "") : network); + } + if (args[0] === "network" && args[1] === "create") { + const network = args.at(-1)!; + networks.add(network); + networkMembers.set(network, new Set()); + const createdLabels: Record = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] !== "--label") continue; + const [key = "", value = ""] = args[++i]!.split("="); + createdLabels[key] = value; + } + networkLabels.set(network, createdLabels); + return ok(network); + } + if (args[0] === "network" && args[1] === "connect") { + const network = args.at(-2)!; + const member = args.at(-1)!; + if (!networks.has(network)) return fail("network not found"); + const key = `${network}:${member}`; + if (members.has(key)) return fail("endpoint already exists in network"); + members.add(key); + networkMembers.get(network)?.add(member); + return ok(); + } + if (args[0] === "network" && args[1] === "disconnect") { + const network = args.at(-2)!; + const member = args.at(-1)!; + members.delete(`${network}:${member}`); + networkMembers.get(network)?.delete(member); + return networks.has(network) ? ok() : fail("network not found"); + } + if (args[0] === "network" && args[1] === "rm") { + const network = args.at(-1)!; + if (networkMembers.get(network)?.size) return fail("active endpoints"); + networkMembers.delete(network); + networkLabels.delete(network); + return networks.delete(network) ? ok() : fail("network not found"); + } + if (args[0] === "version") return ok(state.engineVersion); + if (args[0] === "rm") { + const name = args.at(-1)!; + running.delete(name); + versions.delete(name); + labels.delete(name); + mountSources.delete(name); + for (const networkMembersForName of networkMembers.values()) networkMembersForName.delete(name); + return ok(); + } + if (args[0] === "run") { + const name = args[args.indexOf("--name") + 1]!; + running.add(name); + const versionLabel = args.find((arg) => arg.startsWith("qm.deploy.version=")); + versions.set(name, Number(versionLabel?.split("=")[1])); + const runLabels: Record = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] !== "--label") continue; + const [key = "", value = ""] = args[++i]!.split("="); + runLabels[key] = value; + } + labels.set(name, runLabels); + networkMembers.get(args[args.indexOf("--network") + 1]!)?.add(name); + if (args.includes("-p")) ports.set(name, nextPort++); + if (state.conflictNextRun) { + state.conflictNextRun = false; + runLabels["qm.provision"] = "winning-provision"; + return fail("container name is already in use"); + } + if (state.failNextRun) { + state.failNextRun = false; + return fail("container entered Created state"); + } + return ok("container-id"); + } + if (args[0] === "inspect") { + const name = args.at(-1)!; + const item = labels.get(name); + if (!running.has(name) || !item) return fail("not found"); + const format = args[args.indexOf("-f") + 1] ?? ""; + if (format.includes(".Mounts")) { + return ok( + JSON.stringify( + (mountSources.get(name) ?? []).map((source) => ({ + Source: source, + Destination: "/app", + Type: "bind", + RW: false, + })), + ), + ); + } + if (format === '{{index .Config.Labels "qm.deploy.version"}}') { + return ok(item["qm.deploy.version"] ?? ""); + } + return ok( + `true ${versions.get(name)} ${item["qm.org"] ?? ""} ${item["qm.deploy.id"] ?? ""} ${item["qm.provision"] ?? ""}`, + ); + } + if (args[0] === "port") { + const port = ports.get(args[1]!); + return port ? ok(`127.0.0.1:${port}`) : fail("not published"); + } + return fail(`unexpected command: ${args.join(" ")}`); + }; + return { calls, exec, labels, mountSources, networks, networkMembers, running, state, versions }; +} + +function deploymentKey(orgId: string, id: string): string { + return createHash("sha256").update(`${orgId}\0${id}`).digest("hex").slice(0, 24); +} + +test("containerized core uses isolated name routing and the durable core volume", async () => { + const fake = fakeDocker(); + const provider = createDockerDeployProvider({ + dockerExec: fake.exec, + coreContainer: "qm-acme-core", + coreDataVolume: "qm-acme-coredata", + coreDataDir: "/data", + network: "qm-acme-deployments", + orgId: "acme", + }); + const { deployment, version } = fixture("12345678-1234-1234-1234-123456789abc"); + const key = deploymentKey("acme", deployment.id); + assert.deepEqual(await provider.apply(deployment, version), { host: `agent-deploy-${key}`, port: 8080 }); + const run = fake.calls.find((args) => args[0] === "run")!; + assert.equal(run.includes("-p"), false); + assert.ok(run.includes("qm.org=acme")); + assert.ok( + run.includes( + "type=volume,src=qm-acme-coredata,dst=/app,readonly,volume-subpath=deployments/12345678-1234-1234-1234-123456789abc", + ), + ); + assert.ok( + fake.calls.some( + (args) => args.join(" ") === `network connect --alias core qm-acme-deployments-${key} qm-acme-core`, + ), + ); + assert.deepEqual(await provider.resolveEndpoint!(deployment, version), { + host: `agent-deploy-${key}`, + port: 8080, + }); +}); + +test("host core delegates port allocation to Docker across provider restarts", async () => { + const fake = fakeDocker(); + const first = fixture("aaaaaaaa-1234-1234-1234-123456789abc"); + const second = fixture("bbbbbbbb-1234-1234-1234-123456789abc"); + const provider = createDockerDeployProvider({ dockerExec: fake.exec }); + assert.deepEqual(await provider.apply(first.deployment, first.version), { host: "127.0.0.1", port: 49152 }); + const restarted = createDockerDeployProvider({ dockerExec: fake.exec }); + assert.deepEqual(await restarted.apply(second.deployment, second.version), { host: "127.0.0.1", port: 49153 }); + const runs = fake.calls.filter((args) => args[0] === "run"); + assert.equal(runs.length, 2); + for (const run of runs) assert.ok(run.includes("127.0.0.1::8080")); + assert.equal( + runs.some((run) => run.some((arg) => arg.includes("9200"))), + false, + ); +}); + +test("core volume snapshots cannot escape the configured data directory", async () => { + const fake = fakeDocker(); + const provider = createDockerDeployProvider({ + dockerExec: fake.exec, + coreContainer: "qm-acme-core", + coreDataVolume: "qm-acme-coredata", + coreDataDir: "/data", + }); + const { deployment, version } = fixture("cccccccc-1234-1234-1234-123456789abc"); + version.snapshotDir = "/elsewhere/app"; + await assert.rejects(provider.apply(deployment, version), /outside Docker core data/); +}); + +test("resolve rejects a running container from an interrupted prior version", async () => { + const fake = fakeDocker(); + const provider = createDockerDeployProvider({ + dockerExec: fake.exec, + coreContainer: "qm-acme-core", + coreDataVolume: "qm-acme-coredata", + coreDataDir: "/data", + network: "qm-acme-deployments", + }); + const { deployment, version } = fixture("dddddddd-1234-1234-1234-123456789abc"); + await provider.apply(deployment, version); + const next = { ...version, version: 2 }; + deployment.currentVersion = 2; + deployment.versions.push(next); + assert.equal(await provider.resolveEndpoint!(deployment, next), null); + assert.deepEqual(await provider.apply(deployment, next), { + host: `agent-deploy-${deploymentKey("default", deployment.id)}`, + port: 8080, + }); +}); + +test("containerized core rejects Docker engines without volume subpaths before mutation", async () => { + const fake = fakeDocker(); + fake.state.engineVersion = "25.0.5"; + const provider = createDockerDeployProvider({ + dockerExec: fake.exec, + coreContainer: "qm-acme-core", + coreDataVolume: "qm-acme-coredata", + coreDataDir: "/data", + }); + const { deployment, version } = fixture("eeeeeeee-1234-1234-1234-123456789abc"); + await assert.rejects(provider.apply(deployment, version), /Docker Engine 26 or newer/); + assert.equal( + fake.calls.some((args) => args[0] === "rm" || args[0] === "network"), + false, + ); +}); + +test("a failed Docker run removes its Created container and isolated network", async () => { + const fake = fakeDocker(); + fake.state.failNextRun = true; + const provider = createDockerDeployProvider({ dockerExec: fake.exec, network: "qm-acme-deployments" }); + const { deployment, version } = fixture("ffffffff-1234-1234-1234-123456789abc"); + await assert.rejects(provider.apply(deployment, version), /Created state/); + assert.equal(fake.running.size, 0); + assert.equal(fake.networks.size, 0); +}); + +test("resolve does not recreate topology after destroy", async () => { + const fake = fakeDocker(); + const provider = createDockerDeployProvider({ + dockerExec: fake.exec, + coreContainer: "qm-acme-core", + coreDataVolume: "qm-acme-coredata", + coreDataDir: "/data", + network: "qm-acme-deployments", + }); + const { deployment, version } = fixture("abababab-1234-1234-1234-123456789abc"); + await provider.apply(deployment, version); + await provider.destroy(deployment); + fake.calls.length = 0; + assert.equal(await provider.resolveEndpoint!(deployment, version), null); + assert.equal( + fake.calls.some((args) => args[0] === "network" && args[1] === "create"), + false, + ); +}); + +test("organizations with the same deployment id cannot adopt or remove each other's app", async () => { + const fake = fakeDocker(); + const { deployment, version } = fixture("shared-deployment-id"); + const first = createDockerDeployProvider({ dockerExec: fake.exec, orgId: "org-a" }); + const second = createDockerDeployProvider({ dockerExec: fake.exec, orgId: "org-b" }); + const firstEndpoint = await first.apply(deployment, version); + assert.equal(await second.resolveEndpoint!(deployment, version), null); + await second.destroy(deployment); + assert.deepEqual(await first.resolveEndpoint!(deployment, version), firstEndpoint); +}); + +test("a losing deploy run never removes the winning concurrent container", async () => { + const fake = fakeDocker(); + fake.state.conflictNextRun = true; + const provider = createDockerDeployProvider({ + dockerExec: fake.exec, + orgId: "acme", + network: "qm-acme-deployments", + }); + const { deployment, version } = fixture("concurrent-deployment"); + await assert.rejects(provider.apply(deployment, version), /already in use/); + assert.equal(fake.running.size, 1); + assert.equal(fake.networks.size, 1); +}); + +test("host reapply replaces an exact read-only legacy snapshot bind", async () => { + const fake = fakeDocker(); + const { deployment, version } = fixture("legacy-deployment-id"); + const legacyName = `agent-deploy-${deployment.id.slice(0, 12)}`; + fake.running.add(legacyName); + fake.versions.set(legacyName, version.version); + fake.labels.set(legacyName, {}); + fake.mountSources.set(legacyName, [version.snapshotDir]); + fake.networks.add("agent-deploynet"); + fake.networkMembers.set("agent-deploynet", new Set([legacyName])); + const provider = createDockerDeployProvider({ + dockerExec: fake.exec, + orgId: "acme", + network: "qm-acme-deployments", + }); + const endpoint = await provider.apply(deployment, version); + assert.equal(endpoint.host, "127.0.0.1"); + assert.equal(fake.running.has(legacyName), false); + assert.equal(fake.networks.has("agent-deploynet"), false); +}); + +test("containerized core fails closed on an unlabeled pre-namespace container", async () => { + const fake = fakeDocker(); + const { deployment, version } = fixture("legacy-containerized-id"); + const legacyName = `agent-deploy-${deployment.id.slice(0, 12)}`; + fake.running.add(legacyName); + fake.versions.set(legacyName, version.version); + fake.labels.set(legacyName, {}); + fake.mountSources.set(legacyName, [version.snapshotDir]); + const provider = createDockerDeployProvider({ + dockerExec: fake.exec, + orgId: "acme", + coreContainer: "qm-acme-core", + coreDataVolume: "qm-acme-coredata", + coreDataDir: "/data", + }); + await assert.rejects(provider.apply(deployment, version), /has no organization label/); + assert.equal(fake.running.has(legacyName), true); +}); diff --git a/test/local-sandbox.test.ts b/test/local-sandbox.test.ts index 2e443f586..41912bb0b 100644 --- a/test/local-sandbox.test.ts +++ b/test/local-sandbox.test.ts @@ -8,12 +8,14 @@ import { join } from "node:path"; import { createLocalSandbox, localContainerName, + localMigrationOwnerName, localNetworkName, localVolumeName, } from "../src/sandbox/local-sandbox.ts"; import { createLocalWorkspaceStore } from "../src/workspace/workspace-store.ts"; import { supportsProcessSessions } from "../src/sandbox/sandbox.ts"; import { sleep } from "../src/util/async.ts"; +import { shortHash } from "../src/util/crypto.ts"; import { scopeId } from "../src/types.ts"; import { installFakeDocker, type FakeDocker } from "./support/fake-docker.ts"; @@ -64,6 +66,24 @@ function makeSandbox(fake: FakeDocker, opts: Record = {}) { }); } const rw = (scope: string) => [{ scopeId: scope, mountPath: "", mode: "rw" as const }]; +const legacySlug = (id: string): string => { + const cleaned = id + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `${cleaned.slice(0, 40).replace(/-+$/, "") || "scope"}-${shortHash(id)}`; +}; + +function seedMigrationOwner(fake: FakeDocker, scope: string, volume: string, orgId = "acme"): void { + const name = localMigrationOwnerName(scope, orgId); + fake.containers.set(name, { + name, + imageId: fake.imageId, + running: true, + labels: { "qm.volume-owner": "1", "qm.volume-org": orgId, "qm.scope": scope }, + volume, + }); +} test("profile declares the local Docker substrate honestly", () => { const sb = makeSandbox(installFakeDocker(daemonPort)); @@ -118,6 +138,279 @@ test("cold provision creates volume + container, run() execs over the daemon, by assert.equal(await sb.readFileBytes(h, "bin/missing.dat"), null); }); +test("a containerized core reaches the sandbox by name without publishing a host port", async () => { + const fake = installFakeDocker(daemonPort); + const urls: string[] = []; + const sb = makeSandbox(fake, { + coreContainer: "qm-acme-core", + fetchImpl: async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + urls.push(url.toString()); + url.hostname = "127.0.0.1"; + url.port = String(daemonPort); + return fetch(url, init); + }, + }); + const h = await sb.provision(rw(scopeId("personal", "container-core"))); + const run = fake.commands.find((args) => args[0] === "run")!; + assert.equal(run.includes("-p"), false); + assert.ok( + fake.commands.some( + (args) => + args[0] === "network" && + args[1] === "connect" && + args.at(-2) === localNetworkName(h.id) && + args.at(-1) === "qm-acme-core", + ), + ); + assert.ok(urls.some((url) => url.startsWith(`http://${h.id}:8080/`))); +}); + +test("a replacement core reconnects before using a warm sandbox", async () => { + const fake = installFakeDocker(daemonPort); + const core = "qm-acme-core"; + const sb = makeSandbox(fake, { + coreContainer: core, + orgId: "acme", + fetchImpl: async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + url.hostname = "127.0.0.1"; + url.port = String(daemonPort); + return fetch(url, init); + }, + }); + const handle = await sb.provision(rw(scopeId("personal", "warm-core"))); + const network = localNetworkName(handle.id); + fake.networkMembers.get(network)?.delete(core); + await sb.run(handle, "echo reconnected"); + assert.equal(fake.networkMembers.get(network)?.has(core), true); +}); + +test("sandbox resources are org-scoped and routed names stay within one DNS label", async () => { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", "shared-user"); + const first = await makeSandbox(fake, { orgId: "org-a" }).provision(rw(scope)); + const second = await makeSandbox(fake, { orgId: "org-b" }).provision(rw(scope)); + assert.notEqual(first.id, second.id); + assert.notEqual(localVolumeName(scope, "org-a"), localVolumeName(scope, "org-b")); + assert.ok(localContainerName("s".repeat(200), "o".repeat(200)).length <= 63); + const scratch = await makeSandbox(fake, { orgId: "o".repeat(200) }).provision(rw(scope), { + scratch: { key: "k".repeat(200) }, + }); + assert.ok(scratch.id.length <= 63); +}); + +test("an owned legacy container and home migrate to org-scoped storage", async () => { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", "legacy-running"); + const slug = legacySlug(scope); + const name = `qm-sbx-${slug}`; + const volume = `qm-home-${slug}`; + fake.volumes.add(volume); + fake.containers.set(name, { + name, + imageId: fake.imageId, + running: true, + labels: { "qm.org": "acme", "qm.scope": scope }, + volume, + }); + const handle = await makeSandbox(fake, { orgId: "acme" }).provision(rw(scope)); + assert.equal(handle.id, localContainerName(scope, "acme")); + assert.equal(fake.containers.get(handle.id)?.volume, localVolumeName(scope, "acme")); + assert.equal(handle.coldStart, false); + assert.equal(fake.containers.get(localMigrationOwnerName(scope, "acme"))?.running, true); + assert.equal( + fake.commands.some((args) => args.some((arg) => arg.includes("rm -rf /to/.qm-local-volume-migrated-v1"))), + false, + ); +}); + +test("legacy migration resumes after target creation or completed copy", async () => { + for (const completed of [false, true]) { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", completed ? "legacy-copied" : "legacy-created"); + const slug = legacySlug(scope); + const name = `qm-sbx-${slug}`; + const source = `qm-home-${slug}`; + const target = localVolumeName(scope, "acme"); + fake.volumes.add(source); + fake.volumes.add(target); + fake.volumeLabels.set(target, { + "qm.org": "acme", + "qm.scope": scope, + "qm.migration": "legacy-v1", + }); + if (completed) seedMigrationOwner(fake, scope, target); + fake.containers.set(name, { + name, + imageId: fake.imageId, + running: false, + labels: { "qm.org": "acme", "qm.scope": scope }, + volume: source, + }); + const handle = await makeSandbox(fake, { orgId: "acme" }).provision(rw(scope)); + assert.equal(handle.id, localContainerName(scope, "acme")); + assert.equal(fake.containers.get(handle.id)?.volume, target); + assert.equal(fake.containers.has(localMigrationOwnerName(scope, "acme")), true); + } +}); + +test("legacy migration resumes after the old container was removed", async () => { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", "legacy-removed"); + const source = `qm-home-${legacySlug(scope)}`; + const target = localVolumeName(scope, "acme"); + fake.volumes.add(source); + fake.volumes.add(target); + fake.volumeLabels.set(target, { + "qm.org": "acme", + "qm.scope": scope, + "qm.migration": "legacy-v1", + }); + seedMigrationOwner(fake, scope, target); + fake.containers.get(localMigrationOwnerName(scope, "acme"))!.running = false; + const handle = await makeSandbox(fake, { orgId: "acme" }).provision(rw(scope)); + assert.equal(fake.containers.get(handle.id)?.volume, target); + assert.equal(fake.containers.get(localMigrationOwnerName(scope, "acme"))?.running, true); + assert.equal(handle.coldStart, false); +}); + +test("a migration target without its source requires the external ownership record", async () => { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", "legacy-incomplete-no-source"); + const target = localVolumeName(scope, "acme"); + fake.volumes.add(target); + fake.volumeLabels.set(target, { + "qm.org": "acme", + "qm.scope": scope, + "qm.migration": "legacy-v1", + }); + await assert.rejects(makeSandbox(fake, { orgId: "acme" }).provision(rw(scope)), /migration target .* is incomplete/); +}); + +test("legacy migration refuses to copy a live home when stop fails", async () => { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", "legacy-stop-fails"); + const slug = legacySlug(scope); + const name = `qm-sbx-${slug}`; + const source = `qm-home-${slug}`; + fake.volumes.add(source); + fake.containers.set(name, { + name, + imageId: fake.imageId, + running: true, + labels: { "qm.org": "acme", "qm.scope": scope }, + volume: source, + }); + fake.failStop = true; + await assert.rejects(makeSandbox(fake, { orgId: "acme" }).provision(rw(scope)), /migration stop failed/); + assert.equal(fake.volumes.has(localVolumeName(scope, "acme")), false); +}); + +test("an orphaned legacy home fails closed without durable ownership evidence", async () => { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", "legacy-orphan"); + fake.volumes.add(`qm-home-${legacySlug(scope)}`); + await assert.rejects( + makeSandbox(fake, { orgId: "acme" }).provision(rw(scope)), + /has no owning container; copy it into .* labeled qm.org=acme and qm.scope=/, + ); +}); + +test("an operator-labeled recovery target restores an orphaned legacy home", async () => { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", "legacy-recovered"); + const source = `qm-home-${legacySlug(scope)}`; + const target = localVolumeName(scope, "acme"); + fake.volumes.add(source); + fake.volumes.add(target); + fake.volumeLabels.set(target, { "qm.org": "acme", "qm.scope": scope }); + const handle = await makeSandbox(fake, { orgId: "acme" }).provision(rw(scope)); + assert.equal(fake.containers.get(handle.id)?.volume, target); + assert.equal(handle.coldStart, false); +}); + +test("destroy disconnects the core and removes the isolated sandbox network", async () => { + const fake = installFakeDocker(daemonPort); + const core = "qm-acme-core"; + const sb = makeSandbox(fake, { + coreContainer: core, + orgId: "acme", + fetchImpl: async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + url.hostname = "127.0.0.1"; + url.port = String(daemonPort); + return fetch(url, init); + }, + }); + const handle = await sb.provision(rw(scopeId("personal", "destroy-network"))); + const network = localNetworkName(handle.id); + await sb.teardown(handle, { destroy: true }); + assert.equal(fake.networks.has(network), false); +}); + +test("a failed sandbox run removes its container and core-attached network", async () => { + const fake = installFakeDocker(daemonPort); + fake.failRun = true; + const sb = makeSandbox(fake, { coreContainer: "qm-acme-core", orgId: "acme" }); + await assert.rejects(sb.provision(rw(scopeId("personal", "run-fails"))), /Created state/); + assert.equal(fake.containers.size, 0); + assert.equal(fake.networks.size, 0); +}); + +test("a losing sandbox run never removes the winning concurrent container", async () => { + const fake = installFakeDocker(daemonPort); + fake.conflictOnRun = true; + const scope = scopeId("personal", "run-race"); + const name = localContainerName(scope, "acme"); + await assert.rejects( + makeSandbox(fake, { coreContainer: "qm-acme-core", orgId: "acme" }).provision(rw(scope)), + /already in use/, + ); + assert.equal(fake.containers.get(name)?.labels["qm.provision"], "winning-provision"); + assert.equal(fake.networks.has(localNetworkName(name)), true); +}); + +test("existing sandbox resources must match their organization, scope, and mount", async () => { + const fake = installFakeDocker(daemonPort); + const scope = scopeId("personal", "owned-resource"); + const name = localContainerName(scope, "acme"); + const volume = localVolumeName(scope, "acme"); + fake.volumes.add(volume); + fake.volumeLabels.set(volume, { "qm.org": "other", "qm.scope": scope }); + fake.containers.set(name, { + name, + imageId: fake.imageId, + running: false, + labels: { "qm.org": "acme", "qm.scope": scope }, + volume, + }); + await assert.rejects(makeSandbox(fake, { orgId: "acme" }).provision(rw(scope)), /volume .* is not owned/); +}); + +test("parking removes the isolated bridge and warm reuse rebuilds both attachments", async () => { + const fake = installFakeDocker(daemonPort); + const core = "qm-acme-core"; + const scope = scopeId("personal", "park-network"); + const sb = makeSandbox(fake, { + coreContainer: core, + orgId: "acme", + fetchImpl: async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + url.hostname = "127.0.0.1"; + url.port = String(daemonPort); + return fetch(url, init); + }, + }); + const first = await sb.provision(rw(scope)); + const network = localNetworkName(first.id); + await sb.teardown(first); + assert.equal(fake.networks.has(network), false); + const second = await sb.provision(rw(scope)); + assert.equal(second.coldStart, false); + assert.deepEqual(fake.networkMembers.get(network), new Set([core, first.id])); +}); + test("teardown parks the container and the next provision restarts it warm", async () => { const fake = installFakeDocker(daemonPort); const sb = makeSandbox(fake); diff --git a/test/support/fake-docker.ts b/test/support/fake-docker.ts index 15ea7276a..74621409d 100644 --- a/test/support/fake-docker.ts +++ b/test/support/fake-docker.ts @@ -13,26 +13,46 @@ export interface FakeDocker { containers: Map; volumes: Set; networks: Set; + networkLabels: Map>; + networkMembers: Map>; runCount: number; daemonDown: boolean; imageMissing: boolean; imageId: string; imageFingerprint: string; + commands: string[][]; + migratedVolumes: Set; + volumeLabels: Map>; + failRun: boolean; + conflictOnRun: boolean; + failStop: boolean; } export function installFakeDocker(daemonPort: number): FakeDocker { const containers = new Map(); const volumes = new Set(); const networks = new Set(); + const networkMembers = new Map>(); + const networkLabels = new Map>(); + const migratedVolumes = new Set(); + const volumeLabels = new Map>(); const self: FakeDocker = { containers, volumes, networks, + networkLabels, + networkMembers, runCount: 0, daemonDown: false, imageMissing: false, imageId: "sha256:image-v1", imageFingerprint: "", + commands: [], + migratedVolumes, + volumeLabels, + failRun: false, + conflictOnRun: false, + failStop: false, dockerExec: async (args) => exec(args), }; @@ -54,6 +74,7 @@ export function installFakeDocker(daemonPort: number): FakeDocker { } function exec(args: string[]): { code: number; stdout: string; stderr: string } { + self.commands.push(args); const [cmd, ...rest] = args; if (self.daemonDown) return fail("Cannot connect to the Docker daemon"); switch (cmd) { @@ -67,41 +88,130 @@ export function installFakeDocker(daemonPort: number): FakeDocker { const name = rest[rest.length - 1]!; const c = containers.get(name); if (!c) return fail(`Error: No such object: ${name}`); - return ok(`${c.running} ${c.imageId}`); + const format = rest[rest.indexOf("-f") + 1] ?? ""; + if (format.includes(".Mounts")) return ok(c.volume ?? ""); + if (format.includes("qm.volume-owner")) { + return ok( + `${c.running} ${c.labels["qm.volume-owner"] ?? ""} ${c.labels["qm.volume-org"] ?? ""} ${c.labels["qm.scope"] ?? ""}`, + ); + } + return ok( + `${c.running} ${c.imageId} ${c.labels["qm.org"] ?? ""} ${c.labels["qm.scope"] ?? ""} ${c.labels["qm.provision"] ?? ""}`, + ); } case "network": { - const [sub, name] = rest as [string, string]; - if (sub === "inspect") return networks.has(name) ? ok(name) : fail(`Error: No such network: ${name}`); + const [sub] = rest; + const name = rest[rest.length - 1]!; + if (sub === "inspect") { + if (!networks.has(name)) return fail(`Error: No such network: ${name}`); + const format = rest[rest.indexOf("-f") + 1] ?? ""; + const label = format.match(/\.Labels "([^"]+)"/)?.[1]; + return ok(label ? (networkLabels.get(name)?.[label] ?? "") : name); + } if (sub === "create") { if (networks.has(name)) return fail(`network with name ${name} already exists`); networks.add(name); + networkMembers.set(name, new Set()); + const labels: Record = {}; + for (let i = 0; i < rest.length; i++) { + if (rest[i] !== "--label") continue; + const [key = "", value = ""] = rest[++i]!.split("="); + labels[key] = value; + } + networkLabels.set(name, labels); + return ok(name); + } + if (sub === "connect") { + const network = rest[rest.length - 2]!; + const member = rest[rest.length - 1]!; + const members = networkMembers.get(network); + if (!members) return fail(`Error: No such network: ${network}`); + if (members.has(member)) return fail(`endpoint already exists in network ${network}`); + members.add(member); + return ok(); + } + if (sub === "disconnect") { + const network = rest[rest.length - 2]!; + const member = rest[rest.length - 1]!; + const members = networkMembers.get(network); + if (!members) return fail(`Error: No such network: ${network}`); + members.delete(member); + return ok(); + } + if (sub === "rm") { + if (!networks.has(name)) return fail(`Error: No such network: ${name}`); + if (networkMembers.get(name)?.size) return fail(`network ${name} has active endpoints`); + networks.delete(name); + networkMembers.delete(name); + networkLabels.delete(name); return ok(name); } - if (sub === "rm") return networks.delete(name) ? ok(name) : fail(`Error: No such network: ${name}`); return fail(`unknown network subcommand ${sub}`); } case "volume": { - const [sub, name] = rest as [string, string]; - if (sub === "inspect") return volumes.has(name) ? ok(name) : fail(`Error: no such volume: ${name}`); + const [sub] = rest; + const name = rest[rest.length - 1]!; + if (sub === "inspect") { + if (!volumes.has(name)) return fail(`Error: no such volume: ${name}`); + const format = rest[rest.indexOf("-f") + 1] ?? ""; + const label = format.match(/\.Labels "([^"]+)"/)?.[1]; + return ok(label ? (volumeLabels.get(name)?.[label] ?? "") : name); + } if (sub === "create") { volumes.add(name); + const labels: Record = {}; + for (let i = 0; i < rest.length; i++) { + if (rest[i] !== "--label") continue; + const [key = "", value = ""] = rest[++i]!.split("="); + labels[key] = value; + } + volumeLabels.set(name, labels); return ok(name); } if (sub === "rm") { const attached = [...containers.values()].some((c) => c.volume === name); if (attached) return fail(`volume is in use`); + volumeLabels.delete(name); return volumes.delete(name) ? ok(name) : fail(`Error: no such volume: ${name}`); } return fail(`unknown volume subcommand ${sub}`); } case "run": { + if (!rest.includes("--name")) { + const target = rest.find((arg) => arg.endsWith(":/to") || arg.endsWith(":/to:ro"))?.split(":")[0]; + if (rest.some((arg) => arg.includes("cp -a /from/. /to/") && arg.includes(".qm-local-volume-migrated-v1"))) { + if (target) migratedVolumes.add(target); + return ok("migration"); + } + if (rest.some((arg) => arg.includes("/to/.qm-local-volume-migrated-v1"))) { + return target && migratedVolumes.has(target) ? ok() : fail("not found"); + } + return ok("helper"); + } const c = parseRun(rest); if (self.imageMissing) return fail("Unable to find image"); if (containers.has(c.name)) return fail(`Conflict. The container name "/${c.name}" is already in use`); + if (self.conflictOnRun) { + c.labels["qm.provision"] = "winning-provision"; + containers.set(c.name, c); + const networkIndex = rest.indexOf("--network"); + if (networkIndex !== -1) networkMembers.get(rest[networkIndex + 1]!)?.add(c.name); + return fail(`Conflict. The container name "/${c.name}" is already in use`); + } containers.set(c.name, c); + const networkIndex = rest.indexOf("--network"); + if (networkIndex !== -1) networkMembers.get(rest[networkIndex + 1]!)?.add(c.name); self.runCount++; + if (self.failRun) return fail("container entered Created state"); return ok("deadbeef"); } + case "create": { + const c = parseRun(rest); + if (containers.has(c.name)) return fail(`Conflict. The container name "/${c.name}" is already in use`); + c.running = false; + containers.set(c.name, c); + return ok("owner-container"); + } case "start": { const c = containers.get(rest[0]!); if (!c) return fail("Error: No such container"); @@ -111,12 +221,14 @@ export function installFakeDocker(daemonPort: number): FakeDocker { case "stop": { const c = containers.get(rest[rest.length - 1]!); if (!c) return fail("Error: No such container"); + if (self.failStop) return fail("stop failed"); c.running = false; return ok(); } case "rm": { const name = rest[rest.length - 1]!; containers.delete(name); + for (const members of networkMembers.values()) members.delete(name); return ok(name); } case "port": {