@celilo/e2e 0.19.3 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -13
- package/bin/e2e-bake-management +196 -12
- package/bin/e2e-infra +0 -1
- package/bin/e2e-up +14 -3
- package/docker/Dockerfile.observer +12 -1
- package/docker/Dockerfile.target-machine +22 -1
- package/npm-registry-server/package.json +1 -1
- package/package.json +3 -3
- package/registry-server/package.json +1 -1
- package/scripts/pack-celilo-packages.ts +15 -0
- package/src/block-timing.test.ts +559 -0
- package/src/block-timing.ts +366 -0
- package/src/cli/build.test.ts +135 -4
- package/src/cli/build.ts +237 -94
- package/src/cli/command-registry.ts +21 -0
- package/src/cli/command-tree-parser.ts +11 -2
- package/src/cli/completion.ts +9 -0
- package/src/cli/host.ts +252 -0
- package/src/cli/index.ts +78 -51
- package/src/cli/module-discovery.ts +108 -13
- package/src/cli/scaffold.ts +18 -26
- package/src/container-manager.cleanup.test.ts +284 -0
- package/src/container-manager.runner.test.ts +351 -0
- package/src/container-manager.test.ts +84 -0
- package/src/container-manager.ts +721 -185
- package/src/docker-compose-generator.ts +135 -61
- package/src/doctor.test.ts +259 -4
- package/src/doctor.ts +276 -3
- package/src/exit-cleanup.test.ts +83 -1
- package/src/fleet-nameserver-gate.test.ts +45 -0
- package/src/host-vm.test.ts +156 -0
- package/src/host-vm.ts +230 -0
- package/src/index.ts +11 -0
- package/src/live-stack.test.ts +300 -0
- package/src/live-stack.ts +355 -0
- package/src/no-unjustified-sleep.test.ts +90 -0
- package/src/proxmox-provisioner.test.ts +18 -2
- package/src/proxmox-provisioner.ts +22 -0
- package/src/public-sim-routes.test.ts +9 -2
- package/src/repo-root.ts +33 -0
- package/src/run-args.test.ts +76 -0
- package/src/run-args.ts +89 -0
- package/src/runner.ts +213 -8
- package/src/shared-infra.ts +88 -32
- package/src/socks-proxy.ts +2 -0
- package/src/source-fingerprint.test.ts +213 -0
- package/src/source-fingerprint.ts +212 -0
- package/src/stage-simulator-inputs.ts +93 -0
- package/src/stages.ts +133 -0
- package/src/wait-for-run.ts +1 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lane A2 of openspec/changes/e2e-suite-recovery (ce-yuzi): the best-effort
|
|
3
|
+
* cleanup sweeps must not report success they did not achieve.
|
|
4
|
+
*
|
|
5
|
+
* Every sweep step that fails is recorded as a CleanupFailure and logged as a
|
|
6
|
+
* `[cleanup:failed]` line; the sweep itself still never throws (the exit
|
|
7
|
+
* handler and the pre-suite recovery sweep must run to completion even when
|
|
8
|
+
* docker is gone). Reach is measured, not reasoned about: a fake DockerCli
|
|
9
|
+
* emulates docker's own filter semantics over a planted world, the REAL
|
|
10
|
+
* unmodified sweep runs against it, and the rm commands it issues are compared
|
|
11
|
+
* against the set of resources the sweep was supposed to reach.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import {
|
|
19
|
+
cleanupProgress,
|
|
20
|
+
forceRemoveProject,
|
|
21
|
+
sweepStaleTestResources,
|
|
22
|
+
withDockerCli,
|
|
23
|
+
} from './container-manager';
|
|
24
|
+
|
|
25
|
+
const PROJECT = 'celilo-e2e-1788449289917';
|
|
26
|
+
|
|
27
|
+
/** A planted docker world keyed by resource kind. Guest carries its label;
|
|
28
|
+
* `attached` names the containers docker reports on each network. */
|
|
29
|
+
interface DockerWorld {
|
|
30
|
+
containers: Record<string, string>; // name -> id
|
|
31
|
+
guests: Record<string, { id: string; project?: string }>; // name -> guest
|
|
32
|
+
networks: string[];
|
|
33
|
+
attached: Record<string, string[]>; // network -> attached container names
|
|
34
|
+
volumes: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A fake DockerCli.exec that answers docker listing commands from `world`
|
|
38
|
+
* using docker's own filter semantics (name filter = substring match, label
|
|
39
|
+
* filter = exact), records every `rm` it is asked to run, and throws on
|
|
40
|
+
* `failOn` commands. Anything it cannot answer throws — a fixture that
|
|
41
|
+
* silently answers `''` would understate reach exactly like the real sweep
|
|
42
|
+
* skipping a directory does. */
|
|
43
|
+
function fakeDocker(world: DockerWorld, failOn?: (cmd: string) => boolean) {
|
|
44
|
+
const rms: string[] = [];
|
|
45
|
+
const exec = (cmd: string): string => {
|
|
46
|
+
if (failOn?.(cmd)) throw new Error(`docker: ${cmd.split(' ').slice(0, 3).join(' ')} failed`);
|
|
47
|
+
if (
|
|
48
|
+
cmd.startsWith('docker rm -f ') ||
|
|
49
|
+
cmd.startsWith('docker network rm ') ||
|
|
50
|
+
cmd.startsWith('docker volume rm ')
|
|
51
|
+
) {
|
|
52
|
+
rms.push(cmd);
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
const nameFilter = (prefix: string): string | undefined => {
|
|
56
|
+
const m = cmd.slice(prefix.length).match(/--filter "?name=([^\s"']+)/);
|
|
57
|
+
return m && cmd.startsWith(prefix) ? m[1] : undefined;
|
|
58
|
+
};
|
|
59
|
+
// Container listing (by name and by guest label)
|
|
60
|
+
if (cmd.startsWith('docker ps -aq')) {
|
|
61
|
+
const label = cmd.match(/--filter label=(\S+?)(?:=(\S+))?$/);
|
|
62
|
+
if (label) {
|
|
63
|
+
return Object.entries(world.guests)
|
|
64
|
+
.filter(([, g]) => label[2] === undefined || g.project === label[2])
|
|
65
|
+
.map(([, g]) => g.id)
|
|
66
|
+
.join('\n');
|
|
67
|
+
}
|
|
68
|
+
const nf = nameFilter('docker ps');
|
|
69
|
+
if (nf === undefined) throw new Error(`fakeDocker: unanswerable: ${cmd}`);
|
|
70
|
+
return Object.entries(world.containers)
|
|
71
|
+
.filter(([name]) => name.includes(nf))
|
|
72
|
+
.map(([, id]) => id)
|
|
73
|
+
.join('\n');
|
|
74
|
+
}
|
|
75
|
+
if (cmd.includes('docker network ls')) {
|
|
76
|
+
const nf = cmd.match(/--filter name=([^\s"']+)/)?.[1];
|
|
77
|
+
return world.networks.filter((n) => (nf === undefined ? true : n.includes(nf))).join('\n');
|
|
78
|
+
}
|
|
79
|
+
if (cmd.startsWith('docker network inspect ')) {
|
|
80
|
+
const net = cmd.match(/docker network inspect (\S+)/)?.[1] ?? '';
|
|
81
|
+
const attached = (world.attached[net] ?? []).map((name, i) => [
|
|
82
|
+
`deadbeef${i}`,
|
|
83
|
+
{ Name: name },
|
|
84
|
+
]);
|
|
85
|
+
return JSON.stringify(Object.fromEntries(attached));
|
|
86
|
+
}
|
|
87
|
+
if (cmd.startsWith('docker volume ls -q')) {
|
|
88
|
+
const nf = cmd.match(/--filter name=([^\s"']+)/)?.[1] ?? '';
|
|
89
|
+
return world.volumes.filter((v) => v.includes(nf)).join('\n');
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`fakeDocker: unanswerable: ${cmd}`);
|
|
92
|
+
};
|
|
93
|
+
return { exec, rms };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function plantedWorld(): DockerWorld {
|
|
97
|
+
return {
|
|
98
|
+
containers: {
|
|
99
|
+
[`${PROJECT}_fw-main_1`]: 'cid-fw',
|
|
100
|
+
[`${PROJECT}_caddy_1`]: 'cid-caddy',
|
|
101
|
+
// A live non-e2e container: no filter may ever reach it.
|
|
102
|
+
'nginx-live': 'cid-nginx',
|
|
103
|
+
},
|
|
104
|
+
guests: {
|
|
105
|
+
'celilo-e2e-lxc-7001': { id: 'cid-guest', project: PROJECT },
|
|
106
|
+
},
|
|
107
|
+
networks: [`${PROJECT}_zone-a`, `${PROJECT}_zone-b`],
|
|
108
|
+
attached: {
|
|
109
|
+
[`${PROJECT}_zone-a`]: ['celilo-e2e-lxc-7001', 'hand-attached'],
|
|
110
|
+
[`${PROJECT}_zone-b`]: [],
|
|
111
|
+
},
|
|
112
|
+
volumes: [`${PROJECT}_ssh-keys`],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('forceRemoveProject reach (measured, not reasoned)', () => {
|
|
117
|
+
test('removes every planted container, guest, network and volume of the project', () => {
|
|
118
|
+
const world = plantedWorld();
|
|
119
|
+
const { exec, rms } = fakeDocker(world);
|
|
120
|
+
withDockerCli({ exec, spawn: undefined as never }, () => {
|
|
121
|
+
forceRemoveProject(PROJECT);
|
|
122
|
+
});
|
|
123
|
+
const removed = rms.join('\n');
|
|
124
|
+
// Containers and the label-listed guest (celilo#1247).
|
|
125
|
+
expect(removed).toContain('cid-fw');
|
|
126
|
+
expect(removed).toContain('cid-caddy');
|
|
127
|
+
expect(removed).toContain('cid-guest');
|
|
128
|
+
// Both networks.
|
|
129
|
+
expect(removed).toContain(`${PROJECT}_zone-a`);
|
|
130
|
+
expect(removed).toContain(`${PROJECT}_zone-b`);
|
|
131
|
+
// The volume.
|
|
132
|
+
expect(removed).toContain(`${PROJECT}_ssh-keys`);
|
|
133
|
+
// Containers attached to a project network that carry the provisioner's
|
|
134
|
+
// CONTAINER_PREFIX containment contract (the guest does) get their own rm.
|
|
135
|
+
expect(rms.some((c) => c.startsWith('docker rm -f') && c.includes('celilo-e2e-lxc-7001'))).toBe(
|
|
136
|
+
true,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('never reaches a decoy: shared infra, hand-attached containers, non-e2e', () => {
|
|
141
|
+
const world = plantedWorld();
|
|
142
|
+
const { exec, rms } = fakeDocker(world);
|
|
143
|
+
withDockerCli({ exec, spawn: undefined as never }, () => {
|
|
144
|
+
forceRemoveProject(PROJECT);
|
|
145
|
+
});
|
|
146
|
+
const removed = rms.join('\n');
|
|
147
|
+
expect(removed).not.toContain('nginx-live');
|
|
148
|
+
expect(removed).not.toContain('hand-attached');
|
|
149
|
+
expect(removed).not.toContain('celilo-e2e-shared');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('forceRemoveProject failures surface (ce-yuzi)', () => {
|
|
154
|
+
test('a docker rm that throws is reported, not swallowed', () => {
|
|
155
|
+
const world = plantedWorld();
|
|
156
|
+
const { exec } = fakeDocker(world, (cmd) => cmd.startsWith('docker rm -f'));
|
|
157
|
+
const failures = withDockerCli({ exec, spawn: undefined as never }, () =>
|
|
158
|
+
forceRemoveProject(PROJECT),
|
|
159
|
+
);
|
|
160
|
+
expect(failures.length).toBeGreaterThan(0);
|
|
161
|
+
expect(failures.some((f) => f.step.includes('docker rm -f'))).toBe(true);
|
|
162
|
+
expect(failures.some((f) => f.detail.includes('failed'))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a network rm that throws is reported, and later steps still ran', () => {
|
|
166
|
+
const world = plantedWorld();
|
|
167
|
+
const { exec, rms } = fakeDocker(world, (cmd) => cmd.startsWith('docker network rm'));
|
|
168
|
+
const failures = withDockerCli({ exec, spawn: undefined as never }, () =>
|
|
169
|
+
forceRemoveProject(PROJECT),
|
|
170
|
+
);
|
|
171
|
+
expect(failures.some((f) => f.step.includes('docker network rm'))).toBe(true);
|
|
172
|
+
// Volumes come after networks: a failed network rm must not stop them.
|
|
173
|
+
expect(rms.some((c) => c.startsWith('docker volume rm'))).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the sweep never throws, even when every docker command fails', () => {
|
|
177
|
+
const { exec } = fakeDocker(plantedWorld(), () => true);
|
|
178
|
+
const failures = withDockerCli({ exec, spawn: undefined as never }, () =>
|
|
179
|
+
forceRemoveProject(PROJECT),
|
|
180
|
+
);
|
|
181
|
+
expect(failures.length).toBeGreaterThan(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a LISTING that fails is recorded, not read as an empty result', () => {
|
|
185
|
+
// An empty answer and a broken docker look identical to a sweep that
|
|
186
|
+
// filters; the list failure must be counted or the sweep reports success
|
|
187
|
+
// over resources it never saw.
|
|
188
|
+
const { exec } = fakeDocker(plantedWorld(), (cmd) =>
|
|
189
|
+
cmd.startsWith('docker ps -aq --filter name='),
|
|
190
|
+
);
|
|
191
|
+
const failures = withDockerCli({ exec, spawn: undefined as never }, () =>
|
|
192
|
+
forceRemoveProject(PROJECT),
|
|
193
|
+
);
|
|
194
|
+
expect(failures.some((f) => f.step.startsWith('docker ps name='))).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('sweepStaleTestResources reach (measured, not reasoned)', () => {
|
|
199
|
+
function staleWorld(): DockerWorld {
|
|
200
|
+
return {
|
|
201
|
+
containers: {
|
|
202
|
+
[`${PROJECT}_fw-main_1`]: 'cid-fw',
|
|
203
|
+
'celilo-e2e-shared-namecheap-dns-1': 'cid-shared-dns',
|
|
204
|
+
'nginx-live': 'cid-nginx',
|
|
205
|
+
},
|
|
206
|
+
guests: {
|
|
207
|
+
'celilo-e2e-lxc-7001': { id: 'cid-guest' },
|
|
208
|
+
},
|
|
209
|
+
networks: [`${PROJECT}_zone-a`, 'celilo-e2e-shared_namecheap'],
|
|
210
|
+
attached: {},
|
|
211
|
+
volumes: [`${PROJECT}_ssh-keys`, 'celilo-e2e-shared_dns'],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
test('reaches per-test containers, guests of any project, project networks and volumes', () => {
|
|
216
|
+
const { exec, rms } = fakeDocker(staleWorld());
|
|
217
|
+
const failures = withDockerCli({ exec, spawn: undefined as never }, () =>
|
|
218
|
+
sweepStaleTestResources(),
|
|
219
|
+
);
|
|
220
|
+
expect(failures).toEqual([]);
|
|
221
|
+
const removed = rms.join('\n');
|
|
222
|
+
expect(removed).toContain('cid-fw');
|
|
223
|
+
expect(removed).toContain('cid-guest');
|
|
224
|
+
expect(removed).toContain(`${PROJECT}_zone-a`);
|
|
225
|
+
expect(removed).toContain(`${PROJECT}_ssh-keys`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('never reaches shared infra or non-e2e resources', () => {
|
|
229
|
+
const { exec, rms } = fakeDocker(staleWorld());
|
|
230
|
+
withDockerCli({ exec, spawn: undefined as never }, () => sweepStaleTestResources());
|
|
231
|
+
const removed = rms.join('\n');
|
|
232
|
+
expect(removed).not.toContain('cid-shared-dns');
|
|
233
|
+
expect(removed).not.toContain('nginx-live');
|
|
234
|
+
expect(removed).not.toContain('celilo-e2e-shared');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('sweep failures are returned, not swallowed', () => {
|
|
238
|
+
const { exec } = fakeDocker(staleWorld(), (cmd) => cmd.startsWith('docker rm -f'));
|
|
239
|
+
const failures = withDockerCli({ exec, spawn: undefined as never }, () =>
|
|
240
|
+
sweepStaleTestResources(),
|
|
241
|
+
);
|
|
242
|
+
expect(failures.length).toBeGreaterThan(0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('cleanupProgress is honest by construction', () => {
|
|
247
|
+
test('says complete only on zero failures', () => {
|
|
248
|
+
expect(cleanupProgress([])).toBe('cleanup complete');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('says incomplete and names the count when steps failed', () => {
|
|
252
|
+
const line = cleanupProgress([
|
|
253
|
+
{ step: 'docker rm -f x', detail: 'boom' },
|
|
254
|
+
{ step: 'docker network rm y', detail: 'boom' },
|
|
255
|
+
]);
|
|
256
|
+
expect(line).not.toContain('cleanup complete');
|
|
257
|
+
expect(line).toContain('2');
|
|
258
|
+
expect(line).toContain('[cleanup:failed]');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('the honest progress line is actually wired into startNetwork', () => {
|
|
263
|
+
// Same source-assertion style as exit-cleanup.test.ts: behavior tests above
|
|
264
|
+
// prove the helpers, this proves startNetwork really uses them.
|
|
265
|
+
const SRC = readFileSync(
|
|
266
|
+
join(dirname(fileURLToPath(import.meta.url)), 'container-manager.ts'),
|
|
267
|
+
'utf-8',
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
test('startNetwork prints the sweep result after sweeping, via cleanupProgress', () => {
|
|
271
|
+
const body = SRC.slice(SRC.indexOf('export async function startNetwork'));
|
|
272
|
+
const sweepCall = body.indexOf('sweepStaleTestResources()');
|
|
273
|
+
const progressCall = body.indexOf('cleanupProgress(cleanupFailures)');
|
|
274
|
+
expect(sweepCall).toBeGreaterThan(-1);
|
|
275
|
+
expect(progressCall).toBeGreaterThan(sweepCall);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('no path prints "cleanup complete" except through cleanupProgress', () => {
|
|
279
|
+
// The only literal left in the file is inside cleanupProgress itself.
|
|
280
|
+
const idx = SRC.indexOf("'cleanup complete'");
|
|
281
|
+
expect(idx).toBeGreaterThan(-1);
|
|
282
|
+
expect(SRC.lastIndexOf('export function cleanupProgress', idx)).toBeGreaterThan(-1);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ExecSyncOptions } from 'node:child_process';
|
|
3
|
+
/**
|
|
4
|
+
* The container manager's docker access, driven through the injected
|
|
5
|
+
* DockerCli — no daemon involved.
|
|
6
|
+
*
|
|
7
|
+
* This is the seam lane A1 (ce-h4no) exists to create. Everything before it
|
|
8
|
+
* was untestable: the cleanup cascade, the exec wrappers and the image
|
|
9
|
+
* accounting all reached for `execSync`/`spawn` directly, so nothing here
|
|
10
|
+
* could run under a unit test. The functions that already carry their own
|
|
11
|
+
* injection (`removeAlienInterface`, proxmox-provisioner.test.ts) or are
|
|
12
|
+
* pure (`projectTeardownCommands`, exit-cleanup.test.ts) were already covered.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
type DockerCli,
|
|
16
|
+
dockerExec,
|
|
17
|
+
forceRemoveProject,
|
|
18
|
+
missingImages,
|
|
19
|
+
plainDockerExec,
|
|
20
|
+
projectTeardownCommands,
|
|
21
|
+
scrubDnsZones,
|
|
22
|
+
withDockerCli,
|
|
23
|
+
} from './container-manager';
|
|
24
|
+
import { SHARED_PROJECT_NAME } from './docker-compose-generator';
|
|
25
|
+
import { SIMULATOR_IPS } from './simulator-ips';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A recording fake: `exec` answers from a script of substring → output,
|
|
29
|
+
* `spawn` throws (none of the code under test here may spawn). Commands are
|
|
30
|
+
* recorded in order so tests can assert the sweep's ordering.
|
|
31
|
+
*/
|
|
32
|
+
function fakeDockerCli(script: Record<string, string>): { cli: DockerCli; commands: string[] } {
|
|
33
|
+
const commands: string[] = [];
|
|
34
|
+
return {
|
|
35
|
+
commands,
|
|
36
|
+
cli: {
|
|
37
|
+
exec(command: string, _opts?: ExecSyncOptions): string {
|
|
38
|
+
commands.push(command);
|
|
39
|
+
for (const [needle, output] of Object.entries(script)) {
|
|
40
|
+
if (command.includes(needle)) return output;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`fake docker has no answer for: ${command}`);
|
|
43
|
+
},
|
|
44
|
+
spawn(args: string[]): never {
|
|
45
|
+
throw new Error(`fake docker must not spawn: docker ${args.join(' ')}`);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TEST_PROJECT = 'celilo-e2e-1726123456789';
|
|
52
|
+
|
|
53
|
+
describe('forceRemoveProject', () => {
|
|
54
|
+
test('removes containers, guests, networks and volumes, in that order', () => {
|
|
55
|
+
const { cli, commands } = fakeDockerCli({
|
|
56
|
+
[`--filter name=${TEST_PROJECT}`]: 'c1\nc2\n',
|
|
57
|
+
'celilo-e2e.project': 'g1\n',
|
|
58
|
+
'network ls': 'celilo-e2e-1726123456789_app\n',
|
|
59
|
+
'volume ls': 'v1\n',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
withDockerCli(cli, () => forceRemoveProject(TEST_PROJECT));
|
|
63
|
+
|
|
64
|
+
// Containers and label-matched guests go in one rm -f, before any network
|
|
65
|
+
// or volume removal: a guest holds its zone network's endpoint, and a
|
|
66
|
+
// volume cannot be removed while a container still attaches it.
|
|
67
|
+
const containerRm = commands.findIndex((c) => c.includes('docker rm -f c1 c2 g1'));
|
|
68
|
+
const networkRm = commands.findIndex((c) => c.includes('docker network rm'));
|
|
69
|
+
const volumeRm = commands.findIndex((c) => c.includes('docker volume rm'));
|
|
70
|
+
expect(containerRm).toBeGreaterThanOrEqual(0);
|
|
71
|
+
expect(networkRm).toBeGreaterThan(containerRm);
|
|
72
|
+
expect(volumeRm).toBeGreaterThan(networkRm);
|
|
73
|
+
// The sweep keys off the per-test project only. Shared infra (DNS, ACME,
|
|
74
|
+
// registry simulators) must never appear in a removal command.
|
|
75
|
+
for (const command of commands.filter((c) => c.includes('rm'))) {
|
|
76
|
+
expect(command).not.toContain(SHARED_PROJECT_NAME);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('removes by label as well as by name, using the shared teardown commands', () => {
|
|
81
|
+
const { cli, commands } = fakeDockerCli({
|
|
82
|
+
[`--filter name=${TEST_PROJECT}`]: '',
|
|
83
|
+
'celilo-e2e.project': 'g9\n',
|
|
84
|
+
});
|
|
85
|
+
withDockerCli(cli, () => forceRemoveProject(TEST_PROJECT));
|
|
86
|
+
// The label filter is what catches a sim-created guest: its name
|
|
87
|
+
// (celilo-e2e-lxc-<vmid>) carries no timestamp, so the name filter
|
|
88
|
+
// cannot match it, and an unremoved guest pins its zone network.
|
|
89
|
+
expect(commands).toContain(projectTeardownCommands(TEST_PROJECT).listGuests);
|
|
90
|
+
expect(commands.some((c) => c.includes('docker rm -f g9'))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('survives docker being down entirely — no throw, no removal attempted', () => {
|
|
94
|
+
const { cli, commands } = fakeDockerCli({});
|
|
95
|
+
// Every listing throws (no script entry matches). The sweep is the exit
|
|
96
|
+
// handler's backstop: it must complete, not crash the handler.
|
|
97
|
+
expect(() => withDockerCli(cli, () => forceRemoveProject(TEST_PROJECT))).not.toThrow();
|
|
98
|
+
expect(commands.filter((c) => c.includes('rm -f') || c.includes(' rm '))).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('keeps sweeping when one removal fails', () => {
|
|
102
|
+
const ran: string[] = [];
|
|
103
|
+
const cli: DockerCli = {
|
|
104
|
+
exec(command: string) {
|
|
105
|
+
ran.push(command);
|
|
106
|
+
// Substring checks run specific-first: listNetworks contains BOTH
|
|
107
|
+
// 'network ls' and `--filter name=<project>`, so the generic name
|
|
108
|
+
// check must not win.
|
|
109
|
+
if (command.includes('docker rm -f')) throw new Error('device or resource busy');
|
|
110
|
+
if (command.includes('network ls')) return 'n1\n';
|
|
111
|
+
if (command.includes('volume ls')) return 'v1\n';
|
|
112
|
+
if (command.includes(`--filter name=${TEST_PROJECT}`)) return 'c1\n';
|
|
113
|
+
throw new Error(`no answer for: ${command}`);
|
|
114
|
+
},
|
|
115
|
+
spawn() {
|
|
116
|
+
throw new Error('must not spawn');
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
expect(() => withDockerCli(cli, () => forceRemoveProject(TEST_PROJECT))).not.toThrow();
|
|
120
|
+
// The network and volume removals still ran after the container rm failed.
|
|
121
|
+
expect(ran.some((c) => c.includes('docker network rm n1'))).toBe(true);
|
|
122
|
+
expect(ran.some((c) => c.includes('docker volume rm v1'))).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('dockerExec', () => {
|
|
127
|
+
test('routes a project container through the per-test compose project', () => {
|
|
128
|
+
const { cli, commands } = fakeDockerCli({ 'bash -c': 'pong' });
|
|
129
|
+
const result = withDockerCli(cli, () =>
|
|
130
|
+
dockerExec(TEST_PROJECT, '/tmp/compose', 'management', 'celilo status', 5_000),
|
|
131
|
+
);
|
|
132
|
+
expect(result).toEqual({ stdout: 'pong', stderr: '', exitCode: 0 });
|
|
133
|
+
expect(commands[0]).toContain('-f docker-compose.test.yml');
|
|
134
|
+
expect(commands[0]).toContain(`-p ${TEST_PROJECT}`);
|
|
135
|
+
expect(commands[0]).toContain('exec -T management bash -c');
|
|
136
|
+
expect(commands[0]).not.toContain(SHARED_PROJECT_NAME);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('routes a shared container through the shared compose project', () => {
|
|
140
|
+
const { cli, commands } = fakeDockerCli({ 'bash -c': 'pong' });
|
|
141
|
+
withDockerCli(cli, () => dockerExec(TEST_PROJECT, '/tmp/compose', 'namecheap-dns', 'true'));
|
|
142
|
+
expect(commands[0]).toContain('-f docker-compose.shared.yml');
|
|
143
|
+
expect(commands[0]).toContain(`-p ${SHARED_PROJECT_NAME}`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('maps a timeout to exitCode 124 and an actionable stderr', () => {
|
|
147
|
+
const timeoutCli: DockerCli = {
|
|
148
|
+
exec() {
|
|
149
|
+
const err = new Error('spawn sync ETIMEDOUT') as Error & { code?: string };
|
|
150
|
+
err.code = 'ETIMEDOUT';
|
|
151
|
+
throw err;
|
|
152
|
+
},
|
|
153
|
+
spawn() {
|
|
154
|
+
throw new Error('must not spawn');
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const result = withDockerCli(timeoutCli, () =>
|
|
158
|
+
dockerExec(TEST_PROJECT, '/tmp/compose', 'management', 'celilo init', 8_000),
|
|
159
|
+
);
|
|
160
|
+
expect(result.exitCode).toBe(124);
|
|
161
|
+
expect(result.stderr).toContain('timed out after 8s');
|
|
162
|
+
expect(result.stderr).toContain('celilo init');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Real shape measured 2026-09-06 (ce-013r / celilo#1293): execSync fires its
|
|
166
|
+
// timer and SIGTERMs the `docker compose exec` client; compose traps the
|
|
167
|
+
// signal, cleans up, and EXITS 130 by its own convention. The error then
|
|
168
|
+
// carries status: 130, and `e.status ?? 124` adopted it — the harness
|
|
169
|
+
// reported its own timeout as a mystery "exit 130" from the exec'd command.
|
|
170
|
+
// The client's death code is not the command's verdict: a timed-out exec is
|
|
171
|
+
// always 124, whatever status the dying client left behind.
|
|
172
|
+
test('a timeout whose compose client exits 130 still reports 124', () => {
|
|
173
|
+
const timeoutCli: DockerCli = {
|
|
174
|
+
exec() {
|
|
175
|
+
const err = new Error('spawn sync ETIMEDOUT') as Error & {
|
|
176
|
+
code?: string;
|
|
177
|
+
killed?: boolean;
|
|
178
|
+
status?: number;
|
|
179
|
+
};
|
|
180
|
+
err.code = 'ETIMEDOUT';
|
|
181
|
+
err.killed = true;
|
|
182
|
+
err.status = 130;
|
|
183
|
+
throw err;
|
|
184
|
+
},
|
|
185
|
+
spawn() {
|
|
186
|
+
throw new Error('must not spawn');
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const result = withDockerCli(timeoutCli, () =>
|
|
190
|
+
dockerExec(
|
|
191
|
+
TEST_PROJECT,
|
|
192
|
+
'/tmp/compose',
|
|
193
|
+
'celilo-mgr-2',
|
|
194
|
+
'celilo module import celilo-mgmt',
|
|
195
|
+
120_000,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
expect(result.exitCode).toBe(124);
|
|
199
|
+
expect(result.stderr).toContain('timed out after 120s');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('plainDockerExec maps a timeout to exitCode 124, not the client status', () => {
|
|
203
|
+
const timeoutCli: DockerCli = {
|
|
204
|
+
exec() {
|
|
205
|
+
const err = new Error('spawn sync ETIMEDOUT') as Error & {
|
|
206
|
+
code?: string;
|
|
207
|
+
killed?: boolean;
|
|
208
|
+
status?: number;
|
|
209
|
+
};
|
|
210
|
+
err.code = 'ETIMEDOUT';
|
|
211
|
+
err.killed = true;
|
|
212
|
+
err.status = 130;
|
|
213
|
+
throw err;
|
|
214
|
+
},
|
|
215
|
+
spawn() {
|
|
216
|
+
throw new Error('must not spawn');
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
const result = withDockerCli(timeoutCli, () =>
|
|
220
|
+
plainDockerExec('provisioned-guest', 'ip addr', 10_000),
|
|
221
|
+
);
|
|
222
|
+
expect(result.exitCode).toBe(124);
|
|
223
|
+
expect(result.stderr).toContain('timed out after 10s');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('carries a non-zero exit status and stderr through', () => {
|
|
227
|
+
const failingCli: DockerCli = {
|
|
228
|
+
exec() {
|
|
229
|
+
const err = new Error('exited 7') as Error & { status?: number; stderr?: string };
|
|
230
|
+
err.status = 7;
|
|
231
|
+
err.stderr = 'no such container';
|
|
232
|
+
throw err;
|
|
233
|
+
},
|
|
234
|
+
spawn() {
|
|
235
|
+
throw new Error('must not spawn');
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
const result = withDockerCli(failingCli, () =>
|
|
239
|
+
dockerExec(TEST_PROJECT, '/tmp/compose', 'ghost', 'echo hi'),
|
|
240
|
+
);
|
|
241
|
+
expect(result.exitCode).toBe(7);
|
|
242
|
+
expect(result.stderr).toBe('no such container');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('missingImages', () => {
|
|
247
|
+
test('filters tags docker already has, adding :latest where the caller omitted it', () => {
|
|
248
|
+
const { cli, commands } = fakeDockerCli({
|
|
249
|
+
'docker images': 'celilo-e2e/management:latest\ncelilo-e2e/observer:latest\n',
|
|
250
|
+
});
|
|
251
|
+
const missing = withDockerCli(cli, () =>
|
|
252
|
+
missingImages([
|
|
253
|
+
'celilo-e2e/management',
|
|
254
|
+
'celilo-e2e/observer:latest',
|
|
255
|
+
'celilo-e2e/target-machine',
|
|
256
|
+
]),
|
|
257
|
+
);
|
|
258
|
+
expect(missing).toEqual(['celilo-e2e/target-machine']);
|
|
259
|
+
expect(commands.length).toBe(1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('answers [] for an empty ask without asking docker', () => {
|
|
263
|
+
const { cli, commands } = fakeDockerCli({});
|
|
264
|
+
expect(withDockerCli(cli, () => missingImages([]))).toEqual([]);
|
|
265
|
+
expect(commands).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('scrubDnsZones', () => {
|
|
270
|
+
test('resolves when the running server serves the expected apex address', async () => {
|
|
271
|
+
const { cli } = fakeDockerCli({
|
|
272
|
+
'kdig @127.0.0.1 celilo.computer A +short': `${SIMULATOR_IPS.WEBSITE}\n`,
|
|
273
|
+
});
|
|
274
|
+
await expect(withDockerCli(cli, () => scrubDnsZones())).resolves.toBeUndefined();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('throws loudly when the reset did not take effect', async () => {
|
|
278
|
+
const { cli } = fakeDockerCli({
|
|
279
|
+
// The reset must succeed so the run reaches the verification query.
|
|
280
|
+
'cp -f /seed/*.zone': '',
|
|
281
|
+
'kdig @127.0.0.1 celilo.computer A +short': '203.0.113.9\n',
|
|
282
|
+
});
|
|
283
|
+
await expect(withDockerCli(cli, () => scrubDnsZones())).rejects.toThrow(/verification FAILED/);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('a failed reset warns instead of aborting the run', async () => {
|
|
287
|
+
const warn = console.warn;
|
|
288
|
+
const warnings: string[] = [];
|
|
289
|
+
console.warn = (message: unknown) => warnings.push(String(message));
|
|
290
|
+
try {
|
|
291
|
+
const deadCli: DockerCli = {
|
|
292
|
+
exec() {
|
|
293
|
+
throw new Error('container not running');
|
|
294
|
+
},
|
|
295
|
+
spawn() {
|
|
296
|
+
throw new Error('must not spawn');
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
await withDockerCli(deadCli, () => scrubDnsZones());
|
|
300
|
+
expect(warnings.some((w) => w.includes('[dns-scrub]'))).toBe(true);
|
|
301
|
+
} finally {
|
|
302
|
+
console.warn = warn;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('withDockerCli', () => {
|
|
308
|
+
test('scopes the override: nested scopes see their own runner, and the outer one resumes', () => {
|
|
309
|
+
const outer = fakeDockerCli({ 'docker images': 'outer-tag:latest\n' });
|
|
310
|
+
const inner = fakeDockerCli({ 'docker images': 'inner-tag:latest\n' });
|
|
311
|
+
|
|
312
|
+
withDockerCli(outer.cli, () => {
|
|
313
|
+
expect(missingImages(['outer-tag'])).toEqual([]);
|
|
314
|
+
withDockerCli(inner.cli, () => {
|
|
315
|
+
expect(missingImages(['inner-tag'])).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
// The inner scope's fake is gone — only the outer one answers now.
|
|
318
|
+
// (missingImages answers a docker failure with "all missing", so an
|
|
319
|
+
// unanswerable tag coming back as itself proves no fake answered.)
|
|
320
|
+
expect(missingImages(['outer-tag'])).toEqual([]);
|
|
321
|
+
expect(missingImages(['inner-tag'])).toEqual(['inner-tag']);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('holds the override across awaits and restores it after', async () => {
|
|
326
|
+
const fake = fakeDockerCli({ 'docker images': 'async-tag:latest\n' });
|
|
327
|
+
await withDockerCli(fake.cli, async () => {
|
|
328
|
+
await Promise.resolve();
|
|
329
|
+
expect(missingImages(['async-tag'])).toEqual([]);
|
|
330
|
+
});
|
|
331
|
+
// Restoration is proven by scoping, not by touching a real daemon: a
|
|
332
|
+
// fresh override must see no leftover of the previous fake.
|
|
333
|
+
const probe = fakeDockerCli({ 'docker images': 'probe-tag:latest\n' });
|
|
334
|
+
withDockerCli(probe.cli, () => {
|
|
335
|
+
expect(missingImages(['async-tag'])).toEqual(['async-tag']);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('restores the previous runner even when the callback throws', () => {
|
|
340
|
+
const fake = fakeDockerCli({ 'docker images': 'boom-tag:latest\n' });
|
|
341
|
+
expect(() =>
|
|
342
|
+
withDockerCli(fake.cli, () => {
|
|
343
|
+
throw new Error('boom');
|
|
344
|
+
}),
|
|
345
|
+
).toThrow('boom');
|
|
346
|
+
const probe = fakeDockerCli({ 'docker images': 'probe-tag:latest\n' });
|
|
347
|
+
withDockerCli(probe.cli, () => {
|
|
348
|
+
expect(missingImages(['boom-tag'])).toEqual(['boom-tag']);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|