// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * Container-image helpers (project `image`): run the final image as a uniquely named, * labelled container with the Docker socket and a data directory mounted, drive the CLI * through `docker exec`, read logs/exit codes, and inspect the root-owned database. */ import fs from 'node:fs'; import path from 'node:path'; import { ALPINE_IMAGE, imageRef, LABEL_RUN, LABEL_TEST, RESOURCE_PREFIX, runId } from './env'; import { HarnessError } from './errors'; import { FakeDocker } from './fake-docker'; import { buildRawRequest, rawHttp } from './http'; import { parseLogs, type LogSet } from './logs'; import { runProcess, sleep, toolEnv, type ProcResult } from './proc'; import { dockerCli } from './docker'; import { forceRemoveDir, type Workspace } from './workspace'; import { printsTokens, type CliResult } from './cli'; import type { Recorder } from './recorder'; import type { SecretRegistry } from './secrets'; /** Socket mounted at /var/run/docker.sock in the container. */ export type ImageDockerBackend = 'real' | 'none' | FakeDocker | { socketPath: string }; export interface ImageStartOptions { /** LOG_LEVEL (default 'INFO'; null = unset). */ logLevel?: string | null; /** HTTP_ADDR inside the container (default unset → 0.0.0.0). */ addr?: string | null; /** HTTP_PORT inside the container (default unset → 80); the published port follows it. */ containerPort?: number; /** DB_PATH inside the container (default '/data/dpsapi.db'; null = unset). */ dbPath?: string | null; /** Extra env (undefined removes). */ env?: Record; /** Default: a new FakeDocker (empty) — pass 'real' for the host Engine. */ docker?: ImageDockerBackend; /** Host directory mounted at /data (default: a new workspace dir). Reuse it to replace a container. */ dataDir?: string; /** docker run --user. */ user?: string; /** Publish the container port on 127.0.0.1: (default true). */ publish?: boolean; /** Extra `docker run` arguments (before the image). */ extraArgs?: string[]; /** Arguments after the image (default none → server mode). */ args?: string[]; /** Wait for HTTP readiness (default true when publishing and no args). */ waitReady?: boolean; readyTimeoutMs?: number; /** Name suffix (default img). */ suffix?: string; } export interface ImageExecOptions { /** `docker exec -e K=V` (undefined values are skipped). */ env?: Record; user?: string; timeoutMs?: number; } export interface ContainerExit { exitCode: number; oomKilled: boolean; stdout: string; stderr: string; } export interface ImageContext { workspace: Workspace; recorder: Recorder; secrets: SecretRegistry; testKey: string; newFakeDocker: () => Promise; containers: ImageContainer[]; } let seq = 0; export class ImageContainer { /** Published host port (0 when not published). */ hostPort = 0; /** http://127.0.0.1: */ baseURL = ''; id = ''; constructor( private readonly ctx: ImageContext, readonly name: string, readonly image: string, /** Host directory mounted at /data (files inside are root-owned). */ readonly dataDir: string, readonly options: ImageStartOptions, readonly fakeDocker?: FakeDocker, ) {} /** DB_PATH inside the container. */ get containerDbPath(): string | undefined { return this.options.dbPath === null ? undefined : (this.options.dbPath ?? '/data/dpsapi.db'); } url(p = '/api/v1/containers'): string { return this.baseURL + p; } /** Polls `OPTIONS *` through the published port until the app answers (docker-proxy accepts early, so a real HTTP response is required). */ async waitReady(timeoutMs = this.options.readyTimeoutMs ?? 20_000): Promise { if (!this.hostPort) throw new HarnessError('infrastructure', `container ${this.name} has no published port`); const deadline = performance.now() + timeoutMs; let n = 0; while (true) { const r = await rawHttp({ host: '127.0.0.1', port: this.hostPort, data: buildRawRequest({ method: 'OPTIONS', target: '*' }), timeoutMs: 2000, allowTimeout: true }).catch(() => undefined); if (r?.outcome === 'response') return; if (++n % 10 === 0 && !(await this.isRunning())) { const l = await this.logs(); throw new Error(`container ${this.name} exited during startup\n--- stderr\n${l.stderr}`); } if (performance.now() > deadline) { const l = await this.logs(); throw new HarnessError('timeout', `container ${this.name} not ready on ${this.baseURL} within ${timeoutMs} ms\n--- stderr\n${l.stderr}`); } await sleep(50); } } /** * `docker exec [-e ..] /app ...args` with the same result shape as CliRunner.run. * Exit codes >= 125 (docker exec failures, signal deaths reported as 128+n) and daemon-refused execs are HarnessErrors. */ async exec(args: string[], opts: ImageExecOptions = {}): Promise { const r = await this.execRaw(['/app', ...args], opts); const res: CliResult = { ...r, args: [...args], via: 'docker-exec' }; if (printsTokens(args)) this.ctx.secrets.registerAllIn(r.stdout); this.ctx.recorder.addCli({ via: 'docker-exec', command: `docker exec ${this.name} /app`, args, env: execEnv(opts.env), exitCode: r.exitCode, signal: r.signal, timedOut: r.timedOut, durationMs: Math.round(r.durationMs), stdout: r.stdout, stderr: r.stderr }); // The daemon can refuse an exec with exit 1 (e.g. unknown --user), which must not pass for an app failure. const daemonRefused = r.exitCode !== 0 && /^(Error response from daemon:|OCI runtime exec failed)/.test(r.stderr + r.stdout); if ((r.exitCode !== null && r.exitCode >= 125) || daemonRefused) { throw new HarnessError('infrastructure', `docker exec ${this.name} /app ${args.slice(0, 2).join(' ')} failed at the docker level (exit ${r.exitCode}): ${(r.stderr + r.stdout).trim()}`, r); } return res; } /** `docker exec` of an arbitrary argv (e.g. ['sh'] to prove there is no shell). Never throws for exit codes. */ async execRaw(argv: string[], opts: ImageExecOptions = {}): Promise { const a = ['exec']; if (opts.user) a.push('--user', opts.user); for (const [k, v] of Object.entries(opts.env ?? {})) if (v !== undefined) a.push('-e', `${k}=${v}`); a.push(this.name, ...argv); return runProcess('docker', a, { env: toolEnv(), timeoutMs: opts.timeoutMs ?? 30_000, label: `docker exec ${argv[0]}` }); } /** `docker logs`: the container's stdout and stderr separately. */ async logs(): Promise<{ stdout: string; stderr: string }> { const r = await dockerCli(['logs', this.name], { allowFailure: true }); return { stdout: r.stdout, stderr: r.stderr }; } /** Parsed application logs (container stderr). */ async parsedLogs(): Promise { return parseLogs((await this.logs()).stderr); } async inspect(): Promise> { const r = await dockerCli(['inspect', this.name]); return (JSON.parse(r.stdout) as Array>)[0]; } async isRunning(): Promise { const r = await dockerCli(['inspect', '-f', '{{.State.Running}}', this.name], { allowFailure: true }); return r.stdout.trim() === 'true'; } /** * Stops the container and returns the application's exit code. * Default: `docker stop -t ` (SIGTERM, then SIGKILL after the grace period). * With `signal`: `docker kill -s ` and wait up to timeoutSec. * A SIGKILL by docker after the grace period shows as exitCode 137. */ async stop({ signal, timeoutSec = 20 }: { signal?: NodeJS.Signals; timeoutSec?: number } = {}): Promise { if (signal) await dockerCli(['kill', '-s', signal, this.name]); else await dockerCli(['stop', '-t', String(timeoutSec), this.name], { timeoutMs: (timeoutSec + 30) * 1000 }); return this.waitForExit((timeoutSec + 10) * 1000); } /** Waits for the container to exit (`docker wait`), then returns exit code and logs. */ async waitForExit(timeoutMs = 30_000): Promise { const r = await runProcess('docker', ['wait', this.name], { env: toolEnv(), timeoutMs, label: 'docker wait' }); if (r.exitCode !== 0) throw new HarnessError('infrastructure', `docker wait ${this.name}: ${r.stderr}`); const st = (await this.inspect()).State as { ExitCode: number; OOMKilled: boolean }; const l = await this.logs(); return { exitCode: st.ExitCode, oomKilled: st.OOMKilled, stdout: l.stdout, stderr: l.stderr }; } /** docker rm -f -v. */ async remove(): Promise { await dockerCli(['rm', '-f', '-v', this.name], { allowFailure: true }); } /** * Copies the data directory (db + -wal, not -shm) into a new host-owned workspace directory and * returns the path of the copied database file — inspect it with readTokens/assertDbInvariants. * Works whether the app container is running, stopped or removed. */ async snapshotDb(): Promise { return snapshotDataDir(this.ctx, this.dataDir, path.basename(this.containerDbPath ?? '/data/dpsapi.db')); } } function execEnv(env: Record = {}): Record { const o: Record = {}; for (const [k, v] of Object.entries(env)) if (v !== undefined) o[k] = v; return o; } let snapSeq = 0; /** Copies a (root-owned) data dir into a host-owned directory using a throwaway alpine container. Returns the copied db path. */ export async function snapshotDataDir(ctx: Pick, dataDir: string, dbFile = 'dpsapi.db'): Promise { const out = ctx.workspace.mkdir(`db-snapshot-${++snapSeq}`); const uid = process.getuid!(); const gid = process.getgid!(); const name = `${RESOURCE_PREFIX}${runId()}-${ctx.testKey}-snap${snapSeq}`; await dockerCli([ 'run', '--rm', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${ctx.testKey}`, '--network', 'none', '-v', `${dataDir}:/data:ro`, '-v', `${out}:/out`, ALPINE_IMAGE, 'sh', '-c', `cp -a /data/. /out/ && rm -f /out/*-shm && chown -R ${uid}:${gid} /out && chmod -R u+rwX /out`, ]); const p = path.join(out, dbFile); if (!fs.existsSync(p)) throw new HarnessError('infrastructure', `no ${dbFile} in ${dataDir}`); return p; } export class ImageRunner { constructor(private readonly ctx: ImageContext) {} /** The image under test ($DPSAPI_IMAGE). */ get image(): string { const i = imageRef(); if (!i) throw new HarnessError('prerequisite', 'DPSAPI_IMAGE is not set (use `make docker-e2e` or export DPSAPI_IMAGE=)'); return i; } /** Name for a suite-owned container: dpsapi-e2e---. */ containerName(suffix: string): string { return `${RESOURCE_PREFIX}${runId()}-${this.ctx.testKey}-${suffix}`; } /** docker run -d the image (server mode by default) and wait for readiness. */ async start(opts: ImageStartOptions = {}): Promise { const image = this.image; const suffix = opts.suffix ?? `img${++seq}`; const name = this.containerName(suffix); const dataDir = opts.dataDir ?? this.ctx.workspace.mkdir(`data-${suffix}`); let fake: FakeDocker | undefined; let sock: string | undefined; const backend = opts.docker ?? (fake = await this.ctx.newFakeDocker()); if (backend === 'real') sock = '/var/run/docker.sock'; else if (backend instanceof FakeDocker) { fake = backend; sock = backend.socketPath; } else if (backend !== 'none') sock = backend.socketPath; if (sock && !fs.existsSync(sock)) throw new HarnessError('infrastructure', `socket ${sock} does not exist (docker would create a root-owned directory there)`); const c = new ImageContainer(this.ctx, name, image, dataDir, opts, fake); const containerPort = opts.containerPort ?? 80; const args = ['run', '-d', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${this.ctx.testKey}`]; if (opts.publish ?? true) args.push('-p', `127.0.0.1::${containerPort}`); if (sock) args.push('-v', `${sock}:/var/run/docker.sock`); args.push('-v', `${dataDir}:/data`); const env: Record = { DB_PATH: opts.dbPath === null ? undefined : (opts.dbPath ?? '/data/dpsapi.db'), LOG_LEVEL: opts.logLevel === null ? undefined : (opts.logLevel ?? 'INFO'), HTTP_ADDR: opts.addr ?? undefined, HTTP_PORT: opts.containerPort !== undefined ? String(opts.containerPort) : undefined, ...(opts.env ?? {}), }; for (const [k, v] of Object.entries(env)) if (v !== undefined) args.push('-e', `${k}=${v}`); if (opts.user) args.push('--user', opts.user); args.push(...(opts.extraArgs ?? []), image, ...(opts.args ?? [])); this.ctx.containers.push(c); const r = await dockerCli(args); c.id = r.stdout.trim(); this.ctx.recorder.note(`image container ${name} started: docker ${JSON.stringify(args)}`); if (opts.publish ?? true) { const p = await dockerCli(['port', name, `${containerPort}/tcp`], { allowFailure: true }); const m = /127\.0\.0\.1:(\d+)/.exec(p.stdout); if (m) { c.hostPort = Number(m[1]); c.baseURL = `http://127.0.0.1:${c.hostPort}`; } else if (opts.waitReady ?? !opts.args?.length) { const l = await c.logs(); throw new Error(`container ${name} has no published port (exited?): ${p.stderr}\n--- stderr\n${l.stderr}`); } } if ((opts.waitReady ?? !opts.args?.length) && (opts.publish ?? true)) await c.waitReady(); return c; } /** * One-shot `docker run --rm` of the image with args (CLI mode / startup-failure checks). * Returns the CliResult shape (via 'docker-run'); exit codes 125–127 are HarnessErrors. */ async run( args: string[], opts: { env?: Record; dataDir?: string; docker?: ImageDockerBackend; user?: string; timeoutMs?: number; extraArgs?: string[]; suffix?: string } = {}, ): Promise { const name = this.containerName(opts.suffix ?? `run${++seq}`); const dataDir = opts.dataDir ?? this.ctx.workspace.mkdir(`data-${name.slice(-12)}`); const a = ['run', '--rm', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${this.ctx.testKey}`, '-v', `${dataDir}:/data`]; const backend = opts.docker ?? 'none'; if (backend === 'real') a.push('-v', '/var/run/docker.sock:/var/run/docker.sock'); else if (backend instanceof FakeDocker) a.push('-v', `${backend.socketPath}:/var/run/docker.sock`); else if (backend !== 'none') a.push('-v', `${backend.socketPath}:/var/run/docker.sock`); const env = { DB_PATH: '/data/dpsapi.db', ...(opts.env ?? {}) }; for (const [k, v] of Object.entries(env)) if (v !== undefined) a.push('-e', `${k}=${v}`); if (opts.user) a.push('--user', opts.user); a.push(...(opts.extraArgs ?? []), this.image, ...args); const r = await runProcess('docker', a, { env: toolEnv(), timeoutMs: opts.timeoutMs ?? 60_000, label: `docker run ${args.slice(0, 2).join(' ')}` }); if (printsTokens(args)) this.ctx.secrets.registerAllIn(r.stdout); this.ctx.recorder.addCli({ via: 'docker-run', command: `docker run ${this.image}`, args, env: execEnv(env), exitCode: r.exitCode, signal: r.signal, timedOut: r.timedOut, durationMs: Math.round(r.durationMs), stdout: r.stdout, stderr: r.stderr }); if (r.exitCode !== null && r.exitCode >= 125 && r.exitCode <= 127) { throw new HarnessError('infrastructure', `docker run failed at the docker level (exit ${r.exitCode}): ${r.stderr.trim()}`, r); } return { ...r, args: [...args], via: 'docker-run' }; } /** * Copies /app out of the image (docker create + docker cp, no container is started) into the * workspace and returns its host path — the static binary can then be driven on the host with * cli.withBinary(path) / startServer({ binary: path }) for multi-transport comparisons. */ async extractBinary(): Promise { const name = this.containerName(`extract${++seq}`); const dest = this.ctx.workspace.path(`app-from-image-${seq}`); await dockerCli(['create', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${this.ctx.testKey}`, this.image]); try { await dockerCli(['cp', `${name}:/app`, dest]); } finally { await dockerCli(['rm', '-f', name], { allowFailure: true }); } fs.chmodSync(dest, 0o755); return dest; } /** Copies a data dir to a readable location; returns the db path (see ImageContainer.snapshotDb). */ snapshotDataDir(dataDir: string, dbFile = 'dpsapi.db'): Promise { return snapshotDataDir(this.ctx, dataDir, dbFile); } /** Deletes a (root-owned) data directory via a throwaway container. */ removeDataDir(dataDir: string): Promise { return forceRemoveDir(dataDir); } }