import { describe, expect, test } from 'bun:test'; import type { ExecSyncOptions } from 'node:child_process'; /** * The container manager's docker access, driven through the injected * DockerCli — no daemon involved. * * This is the seam lane A1 (ce-h4no) exists to create. Everything before it * was untestable: the cleanup cascade, the exec wrappers and the image * accounting all reached for `execSync`/`spawn` directly, so nothing here * could run under a unit test. The functions that already carry their own * injection (`removeAlienInterface`, proxmox-provisioner.test.ts) or are * pure (`projectTeardownCommands`, exit-cleanup.test.ts) were already covered. */ import { type DockerCli, dockerExec, forceRemoveProject, missingImages, plainDockerExec, projectTeardownCommands, scrubDnsZones, withDockerCli, } from './container-manager'; import { SHARED_PROJECT_NAME } from './docker-compose-generator'; import { SIMULATOR_IPS } from './simulator-ips'; /** * A recording fake: `exec` answers from a script of substring → output, * `spawn` throws (none of the code under test here may spawn). Commands are * recorded in order so tests can assert the sweep's ordering. */ function fakeDockerCli(script: Record): { cli: DockerCli; commands: string[] } { const commands: string[] = []; return { commands, cli: { exec(command: string, _opts?: ExecSyncOptions): string { commands.push(command); for (const [needle, output] of Object.entries(script)) { if (command.includes(needle)) return output; } throw new Error(`fake docker has no answer for: ${command}`); }, spawn(args: string[]): never { throw new Error(`fake docker must not spawn: docker ${args.join(' ')}`); }, }, }; } const TEST_PROJECT = 'celilo-e2e-1726123456789'; describe('forceRemoveProject', () => { test('removes containers, guests, networks and volumes, in that order', () => { const { cli, commands } = fakeDockerCli({ [`--filter name=${TEST_PROJECT}`]: 'c1\nc2\n', 'celilo-e2e.project': 'g1\n', 'network ls': 'celilo-e2e-1726123456789_app\n', 'volume ls': 'v1\n', }); withDockerCli(cli, () => forceRemoveProject(TEST_PROJECT)); // Containers and label-matched guests go in one rm -f, before any network // or volume removal: a guest holds its zone network's endpoint, and a // volume cannot be removed while a container still attaches it. const containerRm = commands.findIndex((c) => c.includes('docker rm -f c1 c2 g1')); const networkRm = commands.findIndex((c) => c.includes('docker network rm')); const volumeRm = commands.findIndex((c) => c.includes('docker volume rm')); expect(containerRm).toBeGreaterThanOrEqual(0); expect(networkRm).toBeGreaterThan(containerRm); expect(volumeRm).toBeGreaterThan(networkRm); // The sweep keys off the per-test project only. Shared infra (DNS, ACME, // registry simulators) must never appear in a removal command. for (const command of commands.filter((c) => c.includes('rm'))) { expect(command).not.toContain(SHARED_PROJECT_NAME); } }); test('removes by label as well as by name, using the shared teardown commands', () => { const { cli, commands } = fakeDockerCli({ [`--filter name=${TEST_PROJECT}`]: '', 'celilo-e2e.project': 'g9\n', }); withDockerCli(cli, () => forceRemoveProject(TEST_PROJECT)); // The label filter is what catches a sim-created guest: its name // (celilo-e2e-lxc-) carries no timestamp, so the name filter // cannot match it, and an unremoved guest pins its zone network. expect(commands).toContain(projectTeardownCommands(TEST_PROJECT).listGuests); expect(commands.some((c) => c.includes('docker rm -f g9'))).toBe(true); }); test('survives docker being down entirely — no throw, no removal attempted', () => { const { cli, commands } = fakeDockerCli({}); // Every listing throws (no script entry matches). The sweep is the exit // handler's backstop: it must complete, not crash the handler. expect(() => withDockerCli(cli, () => forceRemoveProject(TEST_PROJECT))).not.toThrow(); expect(commands.filter((c) => c.includes('rm -f') || c.includes(' rm '))).toEqual([]); }); test('keeps sweeping when one removal fails', () => { const ran: string[] = []; const cli: DockerCli = { exec(command: string) { ran.push(command); // Substring checks run specific-first: listNetworks contains BOTH // 'network ls' and `--filter name=`, so the generic name // check must not win. if (command.includes('docker rm -f')) throw new Error('device or resource busy'); if (command.includes('network ls')) return 'n1\n'; if (command.includes('volume ls')) return 'v1\n'; if (command.includes(`--filter name=${TEST_PROJECT}`)) return 'c1\n'; throw new Error(`no answer for: ${command}`); }, spawn() { throw new Error('must not spawn'); }, }; expect(() => withDockerCli(cli, () => forceRemoveProject(TEST_PROJECT))).not.toThrow(); // The network and volume removals still ran after the container rm failed. expect(ran.some((c) => c.includes('docker network rm n1'))).toBe(true); expect(ran.some((c) => c.includes('docker volume rm v1'))).toBe(true); }); }); describe('dockerExec', () => { test('routes a project container through the per-test compose project', () => { const { cli, commands } = fakeDockerCli({ 'bash -c': 'pong' }); const result = withDockerCli(cli, () => dockerExec(TEST_PROJECT, '/tmp/compose', 'management', 'celilo status', 5_000), ); expect(result).toEqual({ stdout: 'pong', stderr: '', exitCode: 0 }); expect(commands[0]).toContain('-f docker-compose.test.yml'); expect(commands[0]).toContain(`-p ${TEST_PROJECT}`); expect(commands[0]).toContain('exec -T management bash -c'); expect(commands[0]).not.toContain(SHARED_PROJECT_NAME); }); test('routes a shared container through the shared compose project', () => { const { cli, commands } = fakeDockerCli({ 'bash -c': 'pong' }); withDockerCli(cli, () => dockerExec(TEST_PROJECT, '/tmp/compose', 'namecheap-dns', 'true')); expect(commands[0]).toContain('-f docker-compose.shared.yml'); expect(commands[0]).toContain(`-p ${SHARED_PROJECT_NAME}`); }); test('maps a timeout to exitCode 124 and an actionable stderr', () => { const timeoutCli: DockerCli = { exec() { const err = new Error('spawn sync ETIMEDOUT') as Error & { code?: string }; err.code = 'ETIMEDOUT'; throw err; }, spawn() { throw new Error('must not spawn'); }, }; const result = withDockerCli(timeoutCli, () => dockerExec(TEST_PROJECT, '/tmp/compose', 'management', 'celilo init', 8_000), ); expect(result.exitCode).toBe(124); expect(result.stderr).toContain('timed out after 8s'); expect(result.stderr).toContain('celilo init'); }); // Real shape measured 2026-09-06 (ce-013r / celilo#1293): execSync fires its // timer and SIGTERMs the `docker compose exec` client; compose traps the // signal, cleans up, and EXITS 130 by its own convention. The error then // carries status: 130, and `e.status ?? 124` adopted it — the harness // reported its own timeout as a mystery "exit 130" from the exec'd command. // The client's death code is not the command's verdict: a timed-out exec is // always 124, whatever status the dying client left behind. test('a timeout whose compose client exits 130 still reports 124', () => { const timeoutCli: DockerCli = { exec() { const err = new Error('spawn sync ETIMEDOUT') as Error & { code?: string; killed?: boolean; status?: number; }; err.code = 'ETIMEDOUT'; err.killed = true; err.status = 130; throw err; }, spawn() { throw new Error('must not spawn'); }, }; const result = withDockerCli(timeoutCli, () => dockerExec( TEST_PROJECT, '/tmp/compose', 'celilo-mgr-2', 'celilo module import celilo-mgmt', 120_000, ), ); expect(result.exitCode).toBe(124); expect(result.stderr).toContain('timed out after 120s'); }); test('plainDockerExec maps a timeout to exitCode 124, not the client status', () => { const timeoutCli: DockerCli = { exec() { const err = new Error('spawn sync ETIMEDOUT') as Error & { code?: string; killed?: boolean; status?: number; }; err.code = 'ETIMEDOUT'; err.killed = true; err.status = 130; throw err; }, spawn() { throw new Error('must not spawn'); }, }; const result = withDockerCli(timeoutCli, () => plainDockerExec('provisioned-guest', 'ip addr', 10_000), ); expect(result.exitCode).toBe(124); expect(result.stderr).toContain('timed out after 10s'); }); test('carries a non-zero exit status and stderr through', () => { const failingCli: DockerCli = { exec() { const err = new Error('exited 7') as Error & { status?: number; stderr?: string }; err.status = 7; err.stderr = 'no such container'; throw err; }, spawn() { throw new Error('must not spawn'); }, }; const result = withDockerCli(failingCli, () => dockerExec(TEST_PROJECT, '/tmp/compose', 'ghost', 'echo hi'), ); expect(result.exitCode).toBe(7); expect(result.stderr).toBe('no such container'); }); }); describe('missingImages', () => { test('filters tags docker already has, adding :latest where the caller omitted it', () => { const { cli, commands } = fakeDockerCli({ 'docker images': 'celilo-e2e/management:latest\ncelilo-e2e/observer:latest\n', }); const missing = withDockerCli(cli, () => missingImages([ 'celilo-e2e/management', 'celilo-e2e/observer:latest', 'celilo-e2e/target-machine', ]), ); expect(missing).toEqual(['celilo-e2e/target-machine']); expect(commands.length).toBe(1); }); test('answers [] for an empty ask without asking docker', () => { const { cli, commands } = fakeDockerCli({}); expect(withDockerCli(cli, () => missingImages([]))).toEqual([]); expect(commands).toEqual([]); }); }); describe('scrubDnsZones', () => { test('resolves when the running server serves the expected apex address', async () => { const { cli } = fakeDockerCli({ 'kdig @127.0.0.1 celilo.computer A +short': `${SIMULATOR_IPS.WEBSITE}\n`, }); await expect(withDockerCli(cli, () => scrubDnsZones())).resolves.toBeUndefined(); }); test('throws loudly when the reset did not take effect', async () => { const { cli } = fakeDockerCli({ // The reset must succeed so the run reaches the verification query. 'cp -f /seed/*.zone': '', 'kdig @127.0.0.1 celilo.computer A +short': '203.0.113.9\n', }); await expect(withDockerCli(cli, () => scrubDnsZones())).rejects.toThrow(/verification FAILED/); }); test('a failed reset warns instead of aborting the run', async () => { const warn = console.warn; const warnings: string[] = []; console.warn = (message: unknown) => warnings.push(String(message)); try { const deadCli: DockerCli = { exec() { throw new Error('container not running'); }, spawn() { throw new Error('must not spawn'); }, }; await withDockerCli(deadCli, () => scrubDnsZones()); expect(warnings.some((w) => w.includes('[dns-scrub]'))).toBe(true); } finally { console.warn = warn; } }); }); describe('withDockerCli', () => { test('scopes the override: nested scopes see their own runner, and the outer one resumes', () => { const outer = fakeDockerCli({ 'docker images': 'outer-tag:latest\n' }); const inner = fakeDockerCli({ 'docker images': 'inner-tag:latest\n' }); withDockerCli(outer.cli, () => { expect(missingImages(['outer-tag'])).toEqual([]); withDockerCli(inner.cli, () => { expect(missingImages(['inner-tag'])).toEqual([]); }); // The inner scope's fake is gone — only the outer one answers now. // (missingImages answers a docker failure with "all missing", so an // unanswerable tag coming back as itself proves no fake answered.) expect(missingImages(['outer-tag'])).toEqual([]); expect(missingImages(['inner-tag'])).toEqual(['inner-tag']); }); }); test('holds the override across awaits and restores it after', async () => { const fake = fakeDockerCli({ 'docker images': 'async-tag:latest\n' }); await withDockerCli(fake.cli, async () => { await Promise.resolve(); expect(missingImages(['async-tag'])).toEqual([]); }); // Restoration is proven by scoping, not by touching a real daemon: a // fresh override must see no leftover of the previous fake. const probe = fakeDockerCli({ 'docker images': 'probe-tag:latest\n' }); withDockerCli(probe.cli, () => { expect(missingImages(['async-tag'])).toEqual(['async-tag']); }); }); test('restores the previous runner even when the callback throws', () => { const fake = fakeDockerCli({ 'docker images': 'boom-tag:latest\n' }); expect(() => withDockerCli(fake.cli, () => { throw new Error('boom'); }), ).toThrow('boom'); const probe = fakeDockerCli({ 'docker images': 'probe-tag:latest\n' }); withDockerCli(probe.cli, () => { expect(missingImages(['boom-tag'])).toEqual(['boom-tag']); }); }); });