Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions cli/src/backends/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,17 +346,23 @@ function secretValues(ctx: DockerCtx, service: string): Record<string, string> {
return out;
}

export function localDockerEnv(config: QmConfig, prefix: string): Record<string, string> {
if (!localSandboxActive(config)) return {};
return {
DOCKER_HOST: "unix:///var/run/docker.sock",
QM_CORE_CONTAINER: `${prefix}-core`,
DEPLOY_DOCKER_NETWORK: prefix,
};
}

export function dockerServiceEnv(config: QmConfig, service: ServiceName): Record<string, string> {
const def = serviceDef(service);
const out: Record<string, string> = {
[def.docker.portEnv]: String(def.docker.internalPort),
CORE_API_URL: "http://core:8080",
...orgEnv(service, config.orgId, config.publicUrl, config.services.includes("portal"), brandEnvOf(config)),
};
if (service === "core" && localSandboxActive(config)) {
out.DOCKER_HOST = "unix:///var/run/docker.sock";
out.QM_CORE_CONTAINER = `${dockerPrefix(config)}-core`;
}
if (service === "core") Object.assign(out, localDockerEnv(config, dockerPrefix(config)));
if (service === "portal") {
if (config.services.includes("web-ui")) out.WEB_UI_UPSTREAM = "http://web-ui:8080";
if (config.services.includes("admin")) out.ADMIN_UPSTREAM = "http://admin:8080";
Expand Down Expand Up @@ -395,10 +401,7 @@ function serviceEnv(ctx: DockerCtx, service: ServiceName): Record<string, string
const layerSubs = existingLayerSubdirs(ctx);
if (layerSubs.length) out.DEPLOYMENT_LAYER = "/layer";
Object.assign(out, ctx.sandboxEnv);
if (localSandboxActive(config)) {
out.DOCKER_HOST = "unix:///var/run/docker.sock";
out.QM_CORE_CONTAINER = `${ctx.prefix}-core`;
}
Object.assign(out, localDockerEnv(config, ctx.prefix));
} else {
Object.assign(out, dockerServiceEnv(config, service));
}
Expand Down
2 changes: 2 additions & 0 deletions cli/test/auth-broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ test("docker local wires the host daemon coordinates only into core", () => {
);
assert.equal(dockerServiceEnv(local, "core").DOCKER_HOST, "unix:///var/run/docker.sock");
assert.equal(dockerServiceEnv(local, "core").QM_CORE_CONTAINER, "qm-acme-core");
assert.equal(dockerServiceEnv(local, "core").DEPLOY_DOCKER_NETWORK, "qm-acme");
assert.equal(dockerServiceEnv(local, "portal").DOCKER_HOST, undefined);
assert.equal(dockerServiceEnv(local, "portal").DEPLOY_DOCKER_NETWORK, undefined);
});

test("the broker's generated secrets reach both sides under the right names", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface Config {
s3Prefix?: string;
deployIdleTtlMs?: number;
deployGitDir: string;
deployDockerNetwork?: string;
deployDialTimeoutMs: number;
deployAppsSessionSecret?: string;
deployAppsLoginUrl?: string;
Expand Down Expand Up @@ -895,6 +896,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
? { deployIdleTtlMs: numEnvStrict("DEPLOY_IDLE_TTL_MS", env.DEPLOY_IDLE_TTL_MS) }
: {}),
deployGitDir: env.DEPLOY_GIT_DIR ? resolve(env.DEPLOY_GIT_DIR) : join(dataDir, "deploy-git"),
...(env.DEPLOY_DOCKER_NETWORK ? { deployDockerNetwork: env.DEPLOY_DOCKER_NETWORK } : {}),
deployDialTimeoutMs:
numEnvStrict("DEPLOY_DIAL_TIMEOUT_MS", env.DEPLOY_DIAL_TIMEOUT_MS) ?? CONFIG_DEFAULTS.deployDialTimeoutMs,
...deployAppsEnv(env),
Expand Down
77 changes: 53 additions & 24 deletions src/deploy/docker-deploy-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ export interface DockerDeployProviderOptions {
docker?: string;
basePort?: number;
dockerExec?: DockerExec;
network?: string;
}

export function createDockerDeployProvider(opts: DockerDeployProviderOptions = {}): DeployProvider {
const docker = opts.docker ?? "docker";
const image = opts.image ?? "node:24-alpine";
const sharedNetwork = opts.network;
let nextPort = opts.basePort ?? 9200;
const ports = new Map<string, number>();
const freed: number[] = [];
Expand Down Expand Up @@ -80,31 +82,41 @@ export function createDockerDeployProvider(opts: DockerDeployProviderOptions = {
}
};

const containerPresent = async (container: string): Promise<boolean> => {
const r = await dexec(["inspect", "--format", "{{.Id}}", container]);
if (r.code === 0) return true;
if (/no such (?:object|container)|not found/i.test(r.stderr)) return false;
throw new Error(`docker inspect ${container} failed: ${r.stderr.trim()}`);
};

return {
profile: { managedScaleToZero: false },

async apply(d: Deployment, version: DeploymentVersion): Promise<DeployEndpoint> {
const net = await ensureNetwork(network(d));
await dexec(["rm", "-f", name(d)]);
const hostPort = allocPort(name(d));
const container = name(d);
const net = sharedNetwork ?? (await ensureNetwork(network(d)));
await dexec(["rm", "-f", container]);
const hostPort = sharedNetwork ? undefined : allocPort(container);
const envArgs = Object.entries(version.env ?? {}).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
const r = await dexec([
"run",
"-d",
const cleanup = async (): Promise<void> => {
await dexec(["rm", "-f", container]);
if (!sharedNetwork) await dexec(["network", "rm", net]);
if (hostPort !== undefined) freePort(container);
};
const create = await dexec([
"create",
"--name",
name(d),
container,
"--network",
net,
...(sharedNetwork ? ["--network-alias", container] : []),
"--memory",
"512m",
"--cpus",
"1",
"--pids-limit",
"256",
"-p",
`127.0.0.1:${hostPort}:${APP_PORT}`,
"-v",
`${version.snapshotDir}:/app:ro`,
...(hostPort !== undefined ? ["-p", `127.0.0.1:${hostPort}:${APP_PORT}`] : []),
"-w",
"/app",
"-e",
Expand All @@ -115,30 +127,47 @@ export function createDockerDeployProvider(opts: DockerDeployProviderOptions = {
"-c",
version.entrypoint,
]);
if (r.code !== 0) {
await dexec(["rm", "-f", name(d)]);
await dexec(["network", "rm", net]);
freePort(name(d));
throw new Error(`deploy run failed: ${r.stderr.trim()}`);
if (create.code !== 0) {
await cleanup();
throw new Error(`deploy create failed: ${create.stderr.trim()}`);
}
const copy = await dexec(["cp", `${version.snapshotDir}/.`, `${container}:/app`], 600_000);
if (copy.code !== 0) {
await cleanup();
throw new Error(`deploy snapshot copy failed: ${copy.stderr.trim()}`);
}
return { host: "127.0.0.1", port: hostPort };
const start = await dexec(["start", container]);
if (start.code !== 0) {
await cleanup();
throw new Error(`deploy start failed: ${start.stderr.trim()}`);
}
return sharedNetwork ? { host: container, port: APP_PORT } : { host: "127.0.0.1", port: hostPort! };
},

async logs(d: Deployment, opts: { tailLines: number }): Promise<string | null> {
if (!(await migrateTarget(name(d)))) return null;
const lines = Math.max(1, Math.min(2000, Math.floor(opts.tailLines)));
const r = await dexec(["logs", "--tail", String(lines), name(d)]);
async logs(d: Deployment, logOpts: { tailLines: number }): Promise<string | null> {
const container = name(d);
const ready = sharedNetwork
? await containerPresent(container).catch(() => false)
: await migrateTarget(container);
if (!ready) return null;
const lines = Math.max(1, Math.min(2000, Math.floor(logOpts.tailLines)));
const r = await dexec(["logs", "--tail", String(lines), container]);
if (r.code !== 0) return null;
return `${r.stdout}${r.stderr}`;
},

async destroy(d: Deployment): Promise<void> {
await dexec(["rm", "-f", name(d)]);
await dexec(["network", "rm", network(d)]);
freePort(name(d));
const container = name(d);
await dexec(["rm", "-f", container]);
if (!sharedNetwork) await dexec(["network", "rm", network(d)]);
freePort(container);
},

async resolveEndpoint(d): Promise<DeployEndpoint | null> {
if (sharedNetwork) {
if (d.endpoint?.host === "127.0.0.1") return null;
return (await containerPresent(name(d))) ? d.endpoint : null;
}
return (await migrateTarget(name(d))) ? d.endpoint : null;
},
};
Expand Down
2 changes: 1 addition & 1 deletion src/wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,7 @@ export function buildApp(
store: artifactMap<StoredDeployBody>("aws_deploy_bodies"),
});
if (config.deployProvider === "fly") return createFlyDeployProvider(config.flyDeploy);
return createDockerDeployProvider();
return createDockerDeployProvider(config.deployDockerNetwork ? { network: config.deployDockerNetwork } : {});
})();
if (config.deployProvider === "aws" && !config.awsDeploy.dataBucket && !config.awsSandbox.s3Bucket) {
console.warn(
Expand Down
106 changes: 106 additions & 0 deletions test/docker-deploy-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ test("Docker deployments use isolated networks and remove them on destroy", asyn
assert.ok(calls.some((args) => args.join(" ") === `network create ${secondName}-net`));
assert.ok(calls.some((args) => args.join(" ").includes(`--name ${firstName} --network ${firstName}-net`)));
assert.ok(calls.some((args) => args.join(" ").includes(`--name ${secondName} --network ${secondName}-net`)));
assert.ok(
calls.some((args) => args[0] === "create" && args.includes("-p") && args.some((a) => a.startsWith("127.0.0.1:"))),
"fallback mode still publishes a loopback host port",
);
assert.ok(calls.some((args) => args[0] === "cp" && args[1] === "/snap/one/."), "snapshot is copied in");
assert.ok(calls.some((args) => args.join(" ") === `network rm ${firstName}-net`));
});

Expand Down Expand Up @@ -144,3 +149,104 @@ test("a transient target inspection failure does not report the deployment missi

await assert.rejects(provider.resolveEndpoint!(running, running.versions[0]!), /daemon unavailable/);
});

test("shared-network mode copies the snapshot in and reaches the app by container alias", async () => {
const calls: string[][] = [];
const dockerExec: DockerExec = async (args) => {
calls.push(args);
return { code: 0, stdout: "", stderr: "" };
};
const store = createDeployStore();
const deployment = await store.create({
ownerScopeId: scopeId("personal", "U1"),
createdBy: "U1",
entrypoint: "node server.js",
snapshotDir: "/snap/shared",
});
const provider = createDockerDeployProvider({ dockerExec, network: "qm-default" });

const endpoint = await provider.apply(deployment, deployment.versions[0]!);
const container = `agent-deploy-${deployment.id.slice(0, 12)}`;
assert.deepEqual(endpoint, { host: container, port: 8080 });

const flat = calls.map((args) => args.join(" "));
const createIdx = flat.findIndex((c) => c.startsWith("create "));
const cpIdx = flat.findIndex((c) => c.startsWith("cp "));
const startIdx = flat.findIndex((c) => c.startsWith("start "));
assert.ok(createIdx !== -1, "container is created, not run");
assert.equal(cpIdx, createIdx + 1, "snapshot is copied right after create");
assert.equal(startIdx, cpIdx + 1, "container starts only after the copy");
assert.equal(flat[cpIdx], `cp /snap/shared/. ${container}:/app`);
assert.ok(flat[createIdx]!.includes(`--network qm-default`));
assert.ok(flat[createIdx]!.includes(`--network-alias ${container}`));
assert.ok(!flat[createIdx]!.includes(" -v "), "no bind mount across the core boundary");
assert.ok(!flat[createIdx]!.includes(" -p "), "no host port publishing in shared-network mode");
assert.ok(!flat.some((c) => c.startsWith("network ")), "shared network is never created or removed");

await provider.destroy(deployment);
assert.ok(!calls.some((args) => args[0] === "network"), "destroy leaves the shared network alone");

await store.setEndpoint(deployment.id, endpoint);
const stored = (await store.get(deployment.id))!;
const resolved = await provider.resolveEndpoint!(stored, stored.versions[0]!);
assert.deepEqual(resolved, { host: container, port: 8080 });
});

test("shared-network mode cleans up the container when the snapshot copy fails", async () => {
const calls: string[][] = [];
const dockerExec: DockerExec = async (args) => {
calls.push(args);
if (args[0] === "cp") return { code: 1, stdout: "", stderr: "no such directory" };
return { code: 0, stdout: "", stderr: "" };
};
const store = createDeployStore();
const deployment = await store.create({
ownerScopeId: scopeId("personal", "U1"),
createdBy: "U1",
entrypoint: "node server.js",
snapshotDir: "/snap/broken",
});
const provider = createDockerDeployProvider({ dockerExec, network: "qm-default" });

await assert.rejects(provider.apply(deployment, deployment.versions[0]!), /snapshot copy failed/);
assert.ok(calls.some((args) => args[0] === "rm" && args.includes(`agent-deploy-${deployment.id.slice(0, 12)}`)));
assert.ok(!calls.some((args) => args[0] === "network"), "cleanup never touches the shared network");
});

test("shared-network mode cleans up the container when start fails", async () => {
const calls: string[][] = [];
const dockerExec: DockerExec = async (args) => {
calls.push(args);
if (args[0] === "start") return { code: 1, stdout: "", stderr: "start failed" };
return { code: 0, stdout: "", stderr: "" };
};
const store = createDeployStore();
const deployment = await store.create({
ownerScopeId: scopeId("personal", "U1"),
createdBy: "U1",
entrypoint: "node server.js",
snapshotDir: "/snap/no-start",
});
const provider = createDockerDeployProvider({ dockerExec, network: "qm-default" });

await assert.rejects(provider.apply(deployment, deployment.versions[0]!), /deploy start failed/);
assert.ok(calls.some((args) => args[0] === "cp" && args[1] === "/snap/no-start/."), "copy happened before start");
assert.ok(calls.some((args) => args[0] === "rm" && args[1] === "-f"));
assert.ok(!calls.some((args) => args[0] === "network"));
});

test("shared-network mode re-materializes deployments stored with a loopback endpoint", async () => {
const dockerExec: DockerExec = async () => ({ code: 0, stdout: "", stderr: "" });
const store = createDeployStore();
const deployment = await store.create({
ownerScopeId: scopeId("personal", "U1"),
createdBy: "U1",
entrypoint: "node server.js",
snapshotDir: "/snap/stale",
});
await store.setEndpoint(deployment.id, { host: "127.0.0.1", port: 9200 });
const stale = (await store.get(deployment.id))!;
const provider = createDockerDeployProvider({ dockerExec, network: "qm-default" });

assert.equal(await provider.resolveEndpoint!(stale, stale.versions[0]!), null);
});