@celilo/e2e 0.19.2 → 0.20.0

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.
Files changed (50) hide show
  1. package/README.md +30 -13
  2. package/bin/e2e-bake-management +171 -12
  3. package/bin/e2e-infra +0 -1
  4. package/bin/e2e-up +14 -3
  5. package/docker/Dockerfile.observer +12 -1
  6. package/docker/Dockerfile.target-machine +22 -1
  7. package/npm-registry-server/package.json +1 -1
  8. package/package.json +3 -3
  9. package/registry-server/package.json +1 -1
  10. package/scripts/pack-celilo-packages.ts +15 -0
  11. package/src/block-timing.test.ts +559 -0
  12. package/src/block-timing.ts +366 -0
  13. package/src/cli/build.test.ts +54 -4
  14. package/src/cli/build.ts +204 -88
  15. package/src/cli/command-registry.ts +21 -0
  16. package/src/cli/command-tree-parser.ts +11 -2
  17. package/src/cli/completion.ts +9 -0
  18. package/src/cli/host.ts +252 -0
  19. package/src/cli/index.ts +78 -51
  20. package/src/cli/module-discovery.ts +108 -13
  21. package/src/cli/scaffold.ts +18 -26
  22. package/src/container-manager.cleanup.test.ts +284 -0
  23. package/src/container-manager.runner.test.ts +351 -0
  24. package/src/container-manager.test.ts +84 -0
  25. package/src/container-manager.ts +721 -185
  26. package/src/docker-compose-generator.ts +135 -61
  27. package/src/doctor.test.ts +259 -4
  28. package/src/doctor.ts +276 -3
  29. package/src/exit-cleanup.test.ts +83 -1
  30. package/src/fleet-nameserver-gate.test.ts +45 -0
  31. package/src/host-vm.test.ts +156 -0
  32. package/src/host-vm.ts +230 -0
  33. package/src/index.ts +11 -0
  34. package/src/live-stack.test.ts +184 -0
  35. package/src/live-stack.ts +145 -0
  36. package/src/no-unjustified-sleep.test.ts +90 -0
  37. package/src/proxmox-provisioner.test.ts +18 -2
  38. package/src/proxmox-provisioner.ts +22 -0
  39. package/src/public-sim-routes.test.ts +9 -2
  40. package/src/repo-root.ts +33 -0
  41. package/src/run-args.test.ts +76 -0
  42. package/src/run-args.ts +89 -0
  43. package/src/runner.ts +213 -8
  44. package/src/shared-infra.ts +83 -32
  45. package/src/socks-proxy.ts +2 -0
  46. package/src/source-fingerprint.test.ts +213 -0
  47. package/src/source-fingerprint.ts +201 -0
  48. package/src/stage-simulator-inputs.ts +93 -0
  49. package/src/stages.ts +133 -0
  50. package/src/wait-for-run.ts +1 -0
@@ -1,19 +1,29 @@
1
- import { type ExecSyncOptions, execSync, spawn } from 'node:child_process';
1
+ import {
2
+ type ChildProcessWithoutNullStreams,
3
+ type ExecSyncOptions,
4
+ type SpawnOptionsWithoutStdio,
5
+ execSync,
6
+ spawn,
7
+ } from 'node:child_process';
2
8
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
9
  import { tmpdir } from 'node:os';
4
10
  import { basename, join, resolve } from 'node:path';
11
+ import { parseIpv4, subnetContains } from '@celilo/capabilities';
5
12
  import { parse as parseYaml } from 'yaml';
6
13
  import { startBrowser } from './browser';
7
14
  import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
8
15
  import {
16
+ type FirewallLeg,
9
17
  SHARED_PROJECT_NAME,
10
18
  firewallZoneLegs,
19
+ generateSharedInfraYaml,
11
20
  generateTestComposeYaml,
12
21
  getAllMachines,
22
+ referencedImages,
13
23
  registryUploadsHostDir,
14
24
  } from './docker-compose-generator';
15
- import { explainBuildFailure } from './doctor';
16
25
  import { type ModuleHost, parseModuleHost } from './module-host';
26
+ import { CONTAINER_PREFIX, GUEST_PROJECT_LABEL } from './proxmox-provisioner';
17
27
  import { ensureSharedInfra } from './shared-infra';
18
28
  import { SIMULATOR_IPS } from './simulator-ips';
19
29
  import { startSocksProxy } from './socks-proxy';
@@ -35,9 +45,114 @@ import {
35
45
  internalNatIp,
36
46
  } from './types';
37
47
 
48
+ /**
49
+ * Every way this module reaches the docker CLI, injectable so the cleanup
50
+ * sweeps, the exec wrappers and the image accounting are unit-testable
51
+ * without a daemon (ce-h4no, lane A1 of openspec/changes/e2e-suite-recovery).
52
+ *
53
+ * Follows the DoctorProbe pattern in doctor.ts: an interface of typed
54
+ * operations with the real implementation as the default, so no caller
55
+ * changes. proxmox-provisioner.ts owns the narrower `DockerRunner` name
56
+ * (argv-only, sync, no streaming), so this one is named for what it wraps:
57
+ * the docker CLI itself. Tests substitute a fake via `withDockerCli`.
58
+ */
59
+ export interface DockerCli {
60
+ /** Synchronous `docker ...` shell command. Throws on non-zero exit, like execSync. */
61
+ exec(command: string, opts?: ExecSyncOptions): string;
62
+ /**
63
+ * Streaming `docker ...` for long-running compose build and exec. Callers
64
+ * never pass stdio (pipe default), so the streams are non-null.
65
+ */
66
+ spawn(args: string[], opts?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams;
67
+ }
68
+
69
+ export const realDockerCli: DockerCli = {
70
+ exec(command, opts) {
71
+ // Encoding is pinned last so TS narrows the return to `string` even if
72
+ // a caller passes an opts object that could in principle override it.
73
+ return execSync(command, { ...opts, encoding: 'utf-8' });
74
+ },
75
+ spawn(args, opts) {
76
+ return spawn('docker', args, opts);
77
+ },
78
+ };
79
+
80
+ let dockerCli: DockerCli = realDockerCli;
81
+
82
+ /**
83
+ * Run `fn` with `runner` as this module's docker access, restoring the
84
+ * previous runner afterwards — even when `fn` throws. A sync callback runs
85
+ * and restores synchronously; a promise callback keeps the override in force
86
+ * across its awaits and restores it when it settles. Concurrent async scopes
87
+ * would restore out of order, so tests await one scope at a time.
88
+ */
89
+ export function withDockerCli<T>(runner: DockerCli, fn: () => T): T;
90
+ export function withDockerCli<T>(runner: DockerCli, fn: () => Promise<T>): Promise<T>;
91
+ export function withDockerCli<T>(runner: DockerCli, fn: () => T | Promise<T>): T | Promise<T> {
92
+ const previous = dockerCli;
93
+ dockerCli = runner;
94
+ try {
95
+ const result = fn();
96
+ if (result instanceof Promise) {
97
+ return result.finally(() => {
98
+ dockerCli = previous;
99
+ });
100
+ }
101
+ dockerCli = previous;
102
+ return result;
103
+ } catch (err) {
104
+ dockerCli = previous;
105
+ throw err;
106
+ }
107
+ }
108
+
38
109
  /** Package root — where docker/, config/, simulators/ live */
39
110
  const PACKAGE_ROOT = join(__dirname, '..');
40
111
 
112
+ /**
113
+ * The `.netapp` build-infra already staged for this module, if nothing in the
114
+ * source has changed since.
115
+ *
116
+ * `publishModule` used to repackage from source unconditionally, inside a
117
+ * running stack, under a 60s cap. Measured 2026-09-04: build-infra packaged
118
+ * `celilo-registry` (an 82MB netapp) in 11 seconds before any stack existed,
119
+ * then publishModule exceeded 60 seconds on identical work because the stack was
120
+ * up and eating the host. `registry-pipeline` failed on exactly that, and it is
121
+ * not one of the three flakes that suite is quarantined for (celilo#1258).
122
+ *
123
+ * `find -newer -quit` asks the filesystem instead of walking in JS and stops at
124
+ * the first newer file. A staleness check that cannot answer repackages: reusing
125
+ * on an inconclusive result is how a test silently runs against a stale module.
126
+ */
127
+ function stagedNetappIfCurrent(moduleDir: string, moduleId: string): string | null {
128
+ const staged = join(PACKAGE_ROOT, 'netapps', `${moduleId}.netapp`);
129
+ if (!existsSync(staged)) return null;
130
+
131
+ // Only paths the package actually contains can make it stale. `e2e/` is
132
+ // excluded from a module package wholesale and `node_modules/.bin` is excluded
133
+ // from the hook runtime closure — see `classifyModulePath` and
134
+ // `includeNodeModulesPath` in apps/celilo/src/module/packaging/, which are the
135
+ // authority. They are restated rather than imported because packages/e2e has
136
+ // no import path into apps/celilo, and registry-server's bootstrap.ts already
137
+ // carries the same duplication for the same reason.
138
+ //
139
+ // Getting these wrong is safe in one direction only, and it is this one:
140
+ // counting a non-packaged path makes us repackage needlessly (slow), while
141
+ // MISSING a packaged path would reuse a stale netapp (wrong). Every exclusion
142
+ // here is a path the packager does not ship, so it cannot cause the latter.
143
+ // Without them nothing is ever reused: `bun install` touches
144
+ // `e2e/node_modules/.bin/*` and every module looks permanently dirty.
145
+ try {
146
+ const newer = execSync(
147
+ `find ${JSON.stringify(moduleDir)} -newer ${JSON.stringify(staged)} -not -path '*/e2e/*' -not -path '*/node_modules/.bin/*' -print -quit`,
148
+ { encoding: 'utf-8', timeout: 30_000 },
149
+ ).trim();
150
+ return newer === '' ? staged : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
41
156
  /**
42
157
  * Find the Celilo project root by walking up from the package directory.
43
158
  * Looks for apps/celilo/ as the marker.
@@ -104,11 +219,14 @@ const activeProjects: Set<string> = new Set();
104
219
  */
105
220
  export function projectTeardownCommands(project: string): {
106
221
  listContainers: string;
222
+ /** Sim-created LXC guests of this project — by label, since their names carry no timestamp. */
223
+ listGuests: string;
107
224
  listNetworks: string;
108
225
  listVolumes: string;
109
226
  } {
110
227
  return {
111
228
  listContainers: `docker ps -aq --filter name=${project}`,
229
+ listGuests: `docker ps -aq --filter label=${GUEST_PROJECT_LABEL}=${project}`,
112
230
  listNetworks: `docker network ls --format {{.Name}} --filter name=${project}`,
113
231
  // Volumes were missing here, and the compose path that would have removed
114
232
  // them (`down --volumes`) is the same best-effort call that silently does
@@ -121,42 +239,220 @@ export function projectTeardownCommands(project: string): {
121
239
  };
122
240
  }
123
241
 
124
- /** Force-remove a project's containers, networks and volumes by name. Never throws. */
125
- function forceRemoveProject(project: string): void {
242
+ /**
243
+ * Command listing the containers attached to one of the project's networks.
244
+ *
245
+ * A Proxmox guest is named `celilo-e2e-lxc-<vmid>` and carries NO project name
246
+ * (it is created by `docker run`, not compose), so the project-scoped container
247
+ * sweep never sees it. A live guest keeps `docker network rm` failing forever,
248
+ * and the leaked network holds the sim's subnet — every later run then dies at
249
+ * `docker compose up` with `invalid pool request: Pool overlaps` (ce-ywix:
250
+ * three full regressions lost to one crashed suite's guest).
251
+ */
252
+ export function networkContainersInspectCommand(network: string): string {
253
+ return `docker network inspect ${network} --format {{json .Containers}}`;
254
+ }
255
+
256
+ /**
257
+ * One best-effort cleanup step that failed and was carried past. A sweep step
258
+ * never aborts its sweep (the next step's resources are no less leaked for
259
+ * it), but it is also never silent: each failure is logged as a
260
+ * `[cleanup:failed]` line and counted in the sweep's return value, so the
261
+ * progress line cannot claim "cleanup complete" over a sweep that did not
262
+ * complete (ce-yuzi, lane A2 of openspec/changes/e2e-suite-recovery).
263
+ */
264
+ export interface CleanupFailure {
265
+ /** The step that failed, named the way the sweep runs it, e.g. `docker rm -f ...`. */
266
+ step: string;
267
+ /** The error's own message, not a restatement of it. */
268
+ detail: string;
269
+ }
270
+
271
+ /** Log one best-effort step that failed where there is no sweep to count it. */
272
+ function logCleanupFailure(step: string, error: unknown): void {
273
+ const detail = error instanceof Error ? error.message : String(error);
274
+ console.error(`[cleanup:failed] ${step} | ${detail}`);
275
+ }
276
+
277
+ /** Record a carried-past failure: log it AND count it, so the caller's
278
+ * progress line reflects reality. */
279
+ function recordFailure(failures: CleanupFailure[], step: string, error: unknown): void {
280
+ failures.push({ step, detail: error instanceof Error ? error.message : String(error) });
281
+ logCleanupFailure(step, error);
282
+ }
283
+
284
+ /**
285
+ * The done-side of the stale-resource sweep's progress line. Honest by
286
+ * construction: "complete" appears only when the sweep returned zero
287
+ * failures.
288
+ */
289
+ export function cleanupProgress(failures: CleanupFailure[]): string {
290
+ if (failures.length === 0) return 'cleanup complete';
291
+ return `cleanup incomplete: ${failures.length} step(s) failed (see [cleanup:failed] lines above)`;
292
+ }
293
+
294
+ /**
295
+ * Force-remove a project's containers, networks and volumes by name. Never
296
+ * throws. Returns one CleanupFailure per step that failed and was carried
297
+ * past, so callers can report what the sweep could not do.
298
+ */
299
+ export function forceRemoveProject(project: string): CleanupFailure[] {
300
+ const failures: CleanupFailure[] = [];
126
301
  const cmds = projectTeardownCommands(project);
127
- try {
128
- const ids = execSync(cmds.listContainers, { timeout: 15_000, stdio: 'pipe' })
129
- .toString()
130
- .split('\n')
131
- .filter(Boolean);
132
- if (ids.length > 0) {
133
- execSync(`docker rm -f ${ids.join(' ')}`, { timeout: 30_000, stdio: 'pipe' });
302
+ const listed = (cmd: string, step: string): string[] => {
303
+ try {
304
+ return run(cmd, { timeout: 15_000, stdio: 'pipe' }).split('\n').filter(Boolean);
305
+ } catch (err) {
306
+ // A listing that fails must not look like an empty result: an empty
307
+ // answer and a broken docker are indistinguishable to the sweep, and
308
+ // the sweep would skip what it cannot see.
309
+ recordFailure(failures, step, err);
310
+ return [];
311
+ }
312
+ };
313
+ // Guests are listed by LABEL, not only by name: a sim-created guest is named
314
+ // celilo-e2e-lxc-<vmid>, which the name filter cannot match, and a guest left
315
+ // running holds its zone network's endpoint — the network rm below then fails
316
+ // with "has active endpoints" and the subnet stays allocated, colliding with
317
+ // every later suite in the run (celilo#1247).
318
+ const ids = [
319
+ ...new Set([
320
+ ...listed(cmds.listContainers, `docker ps name=${project}`),
321
+ ...listed(cmds.listGuests, `docker ps label=${GUEST_PROJECT_LABEL}=${project}`),
322
+ ]),
323
+ ];
324
+ if (ids.length > 0) {
325
+ try {
326
+ run(`docker rm -f ${ids.join(' ')}`, { timeout: 30_000, stdio: 'pipe' });
327
+ } catch (err) {
328
+ recordFailure(failures, `docker rm -f ${project} containers`, err);
134
329
  }
135
- } catch {}
330
+ }
136
331
  try {
137
- const nets = execSync(cmds.listNetworks, { timeout: 15_000, stdio: 'pipe' })
138
- .toString()
332
+ const nets = run(cmds.listNetworks, { timeout: 15_000, stdio: 'pipe' })
139
333
  .split('\n')
140
334
  .filter(Boolean);
141
335
  for (const net of nets) {
336
+ // Remove the e2e containers ON the network first, or the rm below fails
337
+ // while a guest is attached. Only ever touches containers carrying the
338
+ // provisioner's own CONTAINER_PREFIX containment contract, so anything
339
+ // else attached by hand is left alone and the network rm fails (the
340
+ // doctor's leaked-stacks check is what names that case).
341
+ try {
342
+ // Through the seam (run/dockerCli), not raw execSync, so the sweep is
343
+ // unit-testable and every failure lands in the same channel.
344
+ const attached = JSON.parse(
345
+ run(networkContainersInspectCommand(net), { timeout: 15_000, stdio: 'pipe' }) || '{}',
346
+ ) as Record<string, { Name?: string }>;
347
+ const ours = Object.values(attached)
348
+ .map((c) => c.Name ?? '')
349
+ .filter((n) => n.startsWith(CONTAINER_PREFIX));
350
+ if (ours.length > 0) {
351
+ run(`docker rm -f ${ours.join(' ')}`, { timeout: 30_000, stdio: 'pipe' });
352
+ }
353
+ } catch (err) {
354
+ recordFailure(failures, `attached-container sweep on ${net}`, err);
355
+ }
142
356
  try {
143
- execSync(`docker network rm ${net}`, { timeout: 10_000, stdio: 'pipe' });
144
- } catch {}
357
+ run(`docker network rm ${net}`, { timeout: 10_000, stdio: 'pipe' });
358
+ } catch (err) {
359
+ // A network that fails to rm must not stop the volume sweep below.
360
+ recordFailure(failures, `docker network rm ${net}`, err);
361
+ }
145
362
  }
146
- } catch {}
363
+ } catch (err) {
364
+ recordFailure(failures, `network sweep for ${project}`, err);
365
+ }
147
366
  // Volumes last: a volume still attached to a container cannot be removed, so
148
367
  // this must follow the container sweep above.
149
368
  try {
150
- const vols = execSync(cmds.listVolumes, { timeout: 15_000, stdio: 'pipe' })
151
- .toString()
369
+ const vols = run(cmds.listVolumes, { timeout: 15_000, stdio: 'pipe' })
370
+ .split('\n')
371
+ .filter(Boolean);
372
+ for (const vol of vols) {
373
+ try {
374
+ run(`docker volume rm ${vol}`, { timeout: 10_000, stdio: 'pipe' });
375
+ } catch (err) {
376
+ recordFailure(failures, `docker volume rm ${vol}`, err);
377
+ }
378
+ }
379
+ } catch (err) {
380
+ recordFailure(failures, `volume sweep for ${project}`, err);
381
+ }
382
+ return failures;
383
+ }
384
+
385
+ /**
386
+ * The start-of-run stale-resource sweep: per-test containers, sim-created
387
+ * guests of ANY project (their names carry no timestamp, celilo#1247), whole
388
+ * per-test projects found via their networks, and stale volumes. Moved
389
+ * verbatim out of startNetwork so its reach and its failure behavior are
390
+ * unit-testable (ce-yuzi). Returns the failures it carried past; the caller
391
+ * turns that into the progress line.
392
+ */
393
+ export function sweepStaleTestResources(): CleanupFailure[] {
394
+ const failures: CleanupFailure[] = [];
395
+ // Force-kill per-test containers (not shared infra)
396
+ try {
397
+ const containers = run('docker ps -aq --filter "name=celilo-e2e-1" 2>/dev/null');
398
+ if (containers.trim()) {
399
+ run(`docker rm -f ${containers.replace(/\n/g, ' ')}`, { timeout: 30_000 });
400
+ }
401
+ } catch (err) {
402
+ recordFailure(failures, 'stale container sweep', err);
403
+ }
404
+
405
+ // Sim-created LXC guests. The name filter above cannot match them (their
406
+ // names are celilo-e2e-lxc-<vmid>, with no timestamp), and a guest left
407
+ // running holds its zone network's endpoint — so the network sweep below
408
+ // cannot free that subnet either, and every later suite in the run dies
409
+ // creating it (celilo#1247). Any container carrying the guest label is
410
+ // ours by construction: only the provisioner sets it.
411
+ try {
412
+ const guests = run(`docker ps -aq --filter label=${GUEST_PROJECT_LABEL}`);
413
+ if (guests.trim()) {
414
+ run(`docker rm -f ${guests.replace(/\n/g, ' ')}`, { timeout: 30_000 });
415
+ }
416
+ } catch (err) {
417
+ recordFailure(failures, 'stale guest sweep', err);
418
+ }
419
+
420
+ // Remove stale per-test networks AND their volumes, via the same
421
+ // by-name teardown the exit handler uses. This used to lean on
422
+ // `docker compose -p <project> down --volumes` for the volume half, which
423
+ // has no `-f` and no reliable cwd here either — compose exits "no
424
+ // configuration file provided" and the catch swallows it, so volumes were
425
+ // never removed on this path either. Reusing forceRemoveProject keeps the
426
+ // crash-recovery sweep and the exit handler from drifting apart.
427
+ try {
428
+ const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
429
+ .split('\n')
430
+ .filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
431
+ const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
432
+ for (const project of projects) {
433
+ failures.push(...forceRemoveProject(project));
434
+ }
435
+ } catch (err) {
436
+ recordFailure(failures, 'stale project sweep', err);
437
+ }
438
+ // Volumes can outlive every container and network of their project — that
439
+ // is exactly the 58-volume pile — so sweep by prefix too, not only for
440
+ // projects that still have a network to be discovered by.
441
+ try {
442
+ const vols = run('docker volume ls -q --filter name=celilo-e2e-1 2>/dev/null')
152
443
  .split('\n')
153
444
  .filter(Boolean);
154
445
  for (const vol of vols) {
155
446
  try {
156
- execSync(`docker volume rm ${vol}`, { timeout: 10_000, stdio: 'pipe' });
157
- } catch {}
447
+ run(`docker volume rm ${vol}`, { timeout: 5_000 });
448
+ } catch (err) {
449
+ recordFailure(failures, `docker volume rm ${vol}`, err);
450
+ }
158
451
  }
159
- } catch {}
452
+ } catch (err) {
453
+ recordFailure(failures, 'stale volume sweep', err);
454
+ }
455
+ return failures;
160
456
  }
161
457
 
162
458
  function cleanupOnExit() {
@@ -169,19 +465,27 @@ function cleanupOnExit() {
169
465
  // Compose first when it can work — it also drops volumes and orphans — but
170
466
  // it is best-effort, so never rely on it having done anything.
171
467
  try {
172
- execSync(`docker compose -f ${COMPOSE_FILE} -p ${project} down --volumes --remove-orphans`, {
468
+ run(`docker compose -f ${COMPOSE_FILE} -p ${project} down --volumes --remove-orphans`, {
173
469
  cwd: PACKAGE_ROOT,
174
470
  timeout: 30_000,
175
471
  stdio: 'pipe',
176
472
  });
177
- } catch {}
473
+ } catch (err) {
474
+ // Best effort: an exit handler has no reliable cwd, so compose can fail
475
+ // to find the file. forceRemoveProject below is the authoritative,
476
+ // compose-free teardown, but the compose attempt still gets logged so a
477
+ // systematic failure is visible instead of absorbed.
478
+ logCleanupFailure(`compose down ${project}`, err);
479
+ }
178
480
  // Authoritative: needs no compose file, no cwd, no working directory state.
179
481
  forceRemoveProject(project);
180
482
  }
181
483
  activeProjects.clear();
182
484
  try {
183
- execSync('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
184
- } catch {}
485
+ run('docker network prune -f', { timeout: 10_000, stdio: 'pipe' });
486
+ } catch (err) {
487
+ logCleanupFailure('docker network prune', err);
488
+ }
185
489
  }
186
490
 
187
491
  process.on('SIGTERM', () => {
@@ -219,12 +523,14 @@ process.on('exit', () => {
219
523
  function run(cmd: string, opts?: ExecSyncOptions): string {
220
524
  // Encoding is pinned last so TS narrows the return to `string` even if
221
525
  // a caller passes an opts object that could in principle override it.
222
- return execSync(cmd, {
223
- timeout: COMPOSE_TIMEOUT,
224
- stdio: ['pipe', 'pipe', 'pipe'],
225
- ...opts,
226
- encoding: 'utf-8',
227
- }).trim();
526
+ return dockerCli
527
+ .exec(cmd, {
528
+ timeout: COMPOSE_TIMEOUT,
529
+ stdio: ['pipe', 'pipe', 'pipe'],
530
+ ...opts,
531
+ encoding: 'utf-8',
532
+ })
533
+ .trim();
228
534
  }
229
535
 
230
536
  const COMPOSE_FILE = 'docker-compose.test.yml';
@@ -312,7 +618,8 @@ export async function scrubDnsZones(): Promise<void> {
312
618
  }
313
619
  }
314
620
 
315
- function dockerExec(
621
+ /** Exported for the unit tests that drive it through a fake DockerCli. */
622
+ export function dockerExec(
316
623
  projectName: string,
317
624
  composeDir: string,
318
625
  container: string,
@@ -349,11 +656,96 @@ function dockerExec(
349
656
  return {
350
657
  stdout: e.stdout?.toString() ?? '',
351
658
  stderr,
352
- exitCode: e.status ?? (timedOut ? 124 : 1),
659
+ // The timeout verdict wins over any status the dying client left behind.
660
+ // `docker compose exec` traps the SIGTERM execSync sends on timeout and
661
+ // exits 130 by its own convention (ce-013r, celilo#1293), so the real
662
+ // error carries status: 130 — the client's death code, not the command's
663
+ // verdict. Adopting it read every harness timeout as a mystery exit 130.
664
+ exitCode: timedOut ? 124 : (e.status ?? 1),
353
665
  };
354
666
  }
355
667
  }
356
668
 
669
+ /**
670
+ * Subnets the simulated internet actually hosts. A nameserver outside all of
671
+ * them blackholes: nothing in the sim owns the address, so every query to it
672
+ * eats its full timeout. ZONE_SUBNETS covers machines a test deploys (a
673
+ * dns_internal provider among them); the three external networks cover the
674
+ * sim's public edge — isp-external (comcast-resolver, the fleet's
675
+ * `dns.primary`), internet-external (the public simulators), and the
676
+ * real-internet transit network behind fw-ext.
677
+ */
678
+ const SIM_ROUTABLE_SUBNETS: ReadonlyArray<{ cidr: string; label: string }> = [
679
+ ...Object.entries(ZONE_SUBNETS).map(([zone, cidr]) => ({ cidr, label: `${zone} zone` })),
680
+ { cidr: '203.0.113.0/24', label: 'isp-external' },
681
+ { cidr: '100.64.0.0/24', label: 'internet-external' },
682
+ { cidr: '172.30.0.0/24', label: 'real-internet' },
683
+ ];
684
+
685
+ /**
686
+ * The nameserver problems in `celilo system config get` output, one string
687
+ * each. Pure so the gate is unit-testable without a daemon. `dns.fallback` is
688
+ * comma-separated; `dns.primary` a single IP — both split on either separator.
689
+ * A missing `dns.primary` is itself a problem: the birth nameserver list would
690
+ * be empty.
691
+ */
692
+ export function fleetNameserverProblems(configOutput: string): string[] {
693
+ const entries = [...configOutput.matchAll(/^dns\.(primary|fallback) = (.+)$/gm)].map(
694
+ ([, key, value]) => ({ key: key as 'primary' | 'fallback', value: value.trim() }),
695
+ );
696
+ const problems: string[] = [];
697
+ if (!entries.some((e) => e.key === 'primary')) {
698
+ problems.push('dns.primary is not set — the birth nameserver list would be empty');
699
+ }
700
+ for (const { key, value } of entries) {
701
+ for (const ip of value.split(/[\s,]+/).filter(Boolean)) {
702
+ if (parseIpv4(ip) === null) {
703
+ problems.push(`dns.${key} entry "${ip}" is not an IPv4 address`);
704
+ continue;
705
+ }
706
+ const routable = SIM_ROUTABLE_SUBNETS.some((s) => subnetContains(s.cidr, ip));
707
+ if (!routable) {
708
+ problems.push(
709
+ `dns.${key} nameserver ${ip} is in no simulated subnet (${SIM_ROUTABLE_SUBNETS.map((s) => s.cidr).join(', ')}) — the sim cannot route to it, so every lookup to it blackholes`,
710
+ );
711
+ }
712
+ }
713
+ }
714
+ return problems;
715
+ }
716
+
717
+ /**
718
+ * Recurrence gate for the Gathering Facts hang (celilo#1290). A nameserver in
719
+ * fleet config that the sim cannot route blackholes exactly the lookups
720
+ * ansible's Gathering Facts performs, and the failure used to surface minutes
721
+ * later as an unrelated 600s command timeout. Read back what `system init`
722
+ * actually stored — core's own resolver discovery falls back to 1.1.1.1
723
+ * (dns-discovery.ts), which is unroutable here — and fail in seconds, naming
724
+ * the address.
725
+ */
726
+ function assertFleetNameserversAreSimRoutable(projectName: string, composeDir: string): void {
727
+ const result = dockerExec(
728
+ projectName,
729
+ composeDir,
730
+ 'management',
731
+ 'celilo system config get dns.primary; celilo system config get dns.fallback',
732
+ 30_000,
733
+ );
734
+ // A missing dns.fallback exits non-zero ("key not found"); that is fine and
735
+ // expected. Only treat the read as failed when even dns.primary is absent.
736
+ if (result.exitCode !== 0 && !result.stdout.includes('dns.primary = ')) {
737
+ throw new Error(
738
+ `Could not read fleet DNS config from the management container: ${result.stderr || result.stdout || '(no output)'}`,
739
+ );
740
+ }
741
+ const problems = fleetNameserverProblems(result.stdout);
742
+ if (problems.length > 0) {
743
+ throw new Error(
744
+ `Fleet DNS config names nameserver(s) the simulated internet cannot reach — the celilo#1290 failure shape (600s Gathering Facts hang):\n ${problems.join('\n ')}`,
745
+ );
746
+ }
747
+ }
748
+
357
749
  /**
358
750
  * Fail fast if the management image's baked celilo CLI is older than the
359
751
  * harness needs (ce-5qp). Runs before `system init` so a version mismatch
@@ -378,22 +770,94 @@ function assertCliVersion(projectName: string, composeDir: string): void {
378
770
  * knows nothing about it, so `docker compose exec` cannot see it. Same wrapping
379
771
  * as `dockerExec` so both behave identically from a test's point of view.
380
772
  */
381
- function plainDockerExec(container: string, cmd: string, timeoutMs = 60_000): ExecResult {
773
+ export function plainDockerExec(container: string, cmd: string, timeoutMs = 60_000): ExecResult {
382
774
  try {
383
775
  const stdout = run(`docker exec ${container} bash -c ${JSON.stringify(cmd)}`, {
384
776
  timeout: timeoutMs,
385
777
  });
386
778
  return { stdout, stderr: '', exitCode: 0 };
387
779
  } catch (err: unknown) {
388
- const e = err as { stdout?: string; stderr?: string; status?: number };
780
+ const e = err as {
781
+ stdout?: string;
782
+ stderr?: string;
783
+ status?: number;
784
+ code?: string;
785
+ signal?: string;
786
+ killed?: boolean;
787
+ };
788
+ // Same timeout mapping as dockerExec: the harness's clock ended the exec,
789
+ // so it reports 124 with the actionable stderr, never the client's own
790
+ // exit status (ce-013r).
791
+ const timedOut = e.code === 'ETIMEDOUT' || e.signal === 'SIGTERM' || e.killed === true;
389
792
  return {
390
793
  stdout: e.stdout ?? '',
391
- stderr: e.stderr ?? String(err),
392
- exitCode: e.status ?? 1,
794
+ stderr: timedOut
795
+ ? `timed out after ${Math.round(timeoutMs / 1000)}s running: ${cmd}`
796
+ : (e.stderr ?? String(err)),
797
+ exitCode: timedOut ? 124 : (e.status ?? 1),
393
798
  };
394
799
  }
395
800
  }
396
801
 
802
+ /**
803
+ * Finds the interface still carrying an address in `subnet` (the alien
804
+ * segment's subnet) inside container `service`, deletes it, and verifies the
805
+ * deletion by re-reading the interface table. Throws naming the surviving
806
+ * interface and container when the interface is still there — the detach path
807
+ * of attachAlienSegment, which re-reads and throws for the same reason:
808
+ * neither docker's exit code nor its own view can be believed for veth
809
+ * surgery, and a surviving alien interface is exactly what makes a later
810
+ * converge refuse somewhere far from the cause (celilo#1261).
811
+ *
812
+ * `exec` is injected so unit tests can drive this against a faked interface
813
+ * table with no docker at all.
814
+ */
815
+ export function removeAlienInterface(opts: {
816
+ service: string;
817
+ subnet: string;
818
+ exec: (cmd: string) => ExecResult;
819
+ /** Captured failure of `docker network disconnect -f`, carried into the
820
+ * throw's report when the repair does not hold. It is evidence, not a
821
+ * verdict: disconnect legitimately fails on a container docker already
822
+ * considers unattached. */
823
+ disconnectError?: string;
824
+ }): void {
825
+ const prefix = opts.subnet.replace(/\.\d+\/\d+$/, '').replace(/\./g, '\\.');
826
+ const orphanRegex = new RegExp(`(\\S+)\\s+inet\\s+${prefix}\\.\\d+`);
827
+ const orphanIn = (table: string) => orphanRegex.exec(table)?.[1];
828
+
829
+ const check = opts.exec('ip -o addr show scope global');
830
+ const orphan = orphanIn(check.stdout);
831
+ if (!orphan) return;
832
+
833
+ const del = opts.exec(`ip link del ${orphan}`);
834
+ const recheck = opts.exec('ip -o addr show scope global');
835
+ const survivor = orphanIn(recheck.stdout);
836
+ if (survivor) {
837
+ throw new Error(
838
+ [
839
+ `detachAlienSegment: '${opts.service}' still has interface ${survivor} on ${opts.subnet} after repair:`,
840
+ ` ip link del ${orphan} exited ${del.exitCode}, stderr: ${del.stderr || '(empty)'}`,
841
+ opts.disconnectError
842
+ ? ` docker network disconnect failed: ${opts.disconnectError}`
843
+ : undefined,
844
+ ` interface table after repair:\n${recheck.stdout}`,
845
+ ]
846
+ .filter((line) => line !== undefined)
847
+ .join('\n'),
848
+ );
849
+ }
850
+ // The delete reported failure but the interface is gone. docker's exit code
851
+ // is not to be believed for this operation (see attachAlienSegment), so this
852
+ // is surfaced rather than swallowed — and not fatal, since the actual goal
853
+ // state (no alien interface) holds.
854
+ if (del.exitCode !== 0) {
855
+ console.error(
856
+ `detachAlienSegment: 'ip link del ${orphan}' on '${opts.service}' exited ${del.exitCode} but the interface is gone. stderr: ${del.stderr || '(empty)'}`,
857
+ );
858
+ }
859
+ }
860
+
397
861
  function dockerExecAsync(
398
862
  projectName: string,
399
863
  composeDir: string,
@@ -420,7 +884,7 @@ function dockerExecAsync(
420
884
  '-c',
421
885
  cmd,
422
886
  ];
423
- const child = spawn('docker', args, { cwd: composeDir });
887
+ const child = dockerCli.spawn(args, { cwd: composeDir });
424
888
 
425
889
  let stdout = '';
426
890
  let stderr = '';
@@ -462,6 +926,7 @@ async function waitFor(
462
926
  } catch {
463
927
  // retry
464
928
  }
929
+ // e2e-sleep-ok: poll cadence inside waitFor; the loop re-checks the condition each iteration.
465
930
  await new Promise((r) => setTimeout(r, 2000));
466
931
  }
467
932
  // Self-diagnosing timeout (e2e-confidence #255): a readiness wait must attach
@@ -481,67 +946,28 @@ async function waitFor(
481
946
  }
482
947
 
483
948
  /**
484
- * Run `docker compose build` with per-service progress signals.
485
- * Uses --progress=plain so output is parseable on non-TTY.
486
- * Emits one [progress:start] per service being built so the runner
487
- * shows "building <service>" as each image is compiled.
949
+ * Which of these image tags docker does not have locally.
950
+ *
951
+ * One `docker images` listing rather than a per-image `inspect`, because this
952
+ * runs on the startup path of every test and forty process spawns there is its
953
+ * own cost. A docker failure returns "all of them", so the caller builds —
954
+ * being wrong in the direction of doing the work.
488
955
  */
489
- async function streamingBuild(
490
- projectName: string,
491
- composeDir: string,
492
- composeFile: string,
493
- ): Promise<void> {
494
- return new Promise<void>((resolve, reject) => {
495
- const proc = spawn(
496
- 'docker',
497
- ['compose', '-f', composeFile, '-p', projectName, 'build', '--progress=plain'],
498
- { cwd: composeDir },
956
+ /** Exported for the unit tests that drive it through a fake DockerCli. */
957
+ export function missingImages(tags: string[]): string[] {
958
+ if (tags.length === 0) return [];
959
+ let present: Set<string>;
960
+ try {
961
+ present = new Set(
962
+ run('docker images --format "{{.Repository}}:{{.Tag}}"', { timeout: 15_000 })
963
+ .split('\n')
964
+ .map((line) => line.trim())
965
+ .filter(Boolean),
499
966
  );
500
-
501
- const seenServices = new Set<string>();
502
- let stderr = '';
503
-
504
- function handleLine(line: string) {
505
- // --progress=plain lines look like: #5 [management 2/4] RUN apt-get ...
506
- const svcMatch = line.match(/\[([a-z][a-z0-9-]*)\s+\d+\/\d+\]/);
507
- if (svcMatch) {
508
- const svc = svcMatch[1];
509
- if (!seenServices.has(svc)) {
510
- seenServices.add(svc);
511
- // Each new service auto-finalizes the previous [progress:start] in the runner.
512
- console.log(`[progress:start] building image: ${svc} | ${svc} image ready`);
513
- }
514
- }
515
- }
516
-
517
- let buf = '';
518
- function onData(chunk: Buffer) {
519
- buf += chunk.toString();
520
- const lines = buf.split('\n');
521
- buf = lines.pop() ?? '';
522
- for (const line of lines) handleLine(line.trim());
523
- }
524
-
525
- proc.stdout.on('data', onData);
526
- proc.stderr.on('data', (chunk: Buffer) => {
527
- stderr += chunk.toString();
528
- onData(chunk);
529
- });
530
-
531
- proc.on('close', (code) => {
532
- if (buf.trim()) handleLine(buf.trim());
533
- if (code === 0) {
534
- console.log('[progress:done] images built');
535
- resolve();
536
- } else {
537
- reject(
538
- new Error(
539
- `docker compose build failed (exit ${code}):\n${stderr.slice(-500)}${explainBuildFailure(stderr)}`,
540
- ),
541
- );
542
- }
543
- });
544
- });
967
+ } catch {
968
+ return tags;
969
+ }
970
+ return tags.filter((tag) => !present.has(tag.includes(':') ? tag : `${tag}:latest`));
545
971
  }
546
972
 
547
973
  /**
@@ -739,10 +1165,40 @@ function buildNetworkHandle(
739
1165
  `respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
740
1166
  );
741
1167
  }
742
- // Brief settle so the responder has registered its watches
743
- // before any deploy fires events. Without this, a fast deploy
744
- // could emit before the responder polls.
745
- await new Promise((r) => setTimeout(r, 500));
1168
+ // The spawn returns as soon as the shell backgrounds the process; bun
1169
+ // then has to boot inside the container and register the responder's
1170
+ // bus watches. A fixed sleep both wastes time on an idle host and is
1171
+ // not enough on a loaded one: dns-replication failed stage 1 when its
1172
+ // deploy's first interview fired before the responder polled, and the
1173
+ // missing-responder error read as a missing fixture value. The log
1174
+ // line below is printed only after startProgrammaticResponder has
1175
+ // registered every watch, so waiting for it is a readiness wait on the
1176
+ // real prerequisite, not a sleep (same pattern as deployFirewall).
1177
+ const responderLog = '/tmp/cele2e-responder.log';
1178
+ await waitFor(
1179
+ async () => {
1180
+ const probe = dockerExec(
1181
+ projectName,
1182
+ composeDir,
1183
+ 'management',
1184
+ `grep -q "programmatic responder running" ${responderLog}`,
1185
+ 5_000,
1186
+ );
1187
+ return probe.exitCode === 0;
1188
+ },
1189
+ 60_000,
1190
+ 'the e2e responder to register its bus watches in the management container',
1191
+ async () => {
1192
+ const log = dockerExec(
1193
+ projectName,
1194
+ composeDir,
1195
+ 'management',
1196
+ `cat ${responderLog} 2>/dev/null || echo '(no responder log)'`,
1197
+ 5_000,
1198
+ );
1199
+ return `responder log (${responderLog}):\n${log.stdout || log.stderr}`;
1200
+ },
1201
+ );
746
1202
  },
747
1203
 
748
1204
  async deployFirewall(opts = {}): Promise<ExecResult> {
@@ -769,7 +1225,19 @@ function buildNetworkHandle(
769
1225
  //
770
1226
  // `external` is excluded: it is the residual, has no subnet, and is not a
771
1227
  // zone modules are placed in.
772
- const declaredZones = firewallZoneLegs(topology);
1228
+ //
1229
+ // `firewallZoneLegs` knows the topology's legs, but not the control-plane
1230
+ // one: the generator adds fw-main's `secure-mgmt` leg as a post-step
1231
+ // (when celilo-mgr lives off the LAN, when a machine declares the zone,
1232
+ // or when the proxmox sim is on), so it never appears in a
1233
+ // topology-derived list. A sim fleet's fw-main then held an address on a
1234
+ // segment with no declared subnet, eth4 classified alien, and the
1235
+ // iptables converge refused (ce-fvxd). The compose file is the wiring and
1236
+ // the wiring decides — the same artifact `topologyFromComposeFile` reads.
1237
+ const baseLegs = firewallZoneLegs(topology);
1238
+ const declaredZones: FirewallLeg[] = fwMainHasSecureMgmtLeg(join(composeDir, COMPOSE_FILE))
1239
+ ? [...baseLegs, 'secure-mgmt']
1240
+ : baseLegs;
773
1241
  const providedZones = declaredZones.filter((zone) => zone !== 'external');
774
1242
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
775
1243
  const natIp = opts.natIp ?? internalNatIp();
@@ -881,7 +1349,11 @@ function buildNetworkHandle(
881
1349
  // would make a classification test pass for the wrong reason.
882
1350
  try {
883
1351
  run(`docker network connect --ip ${ip} ${netName} ${id}`, { timeout: 20_000 });
884
- } catch {}
1352
+ } catch {
1353
+ // Ignorable: failure here is the COMMON outcome (the gateway-exists
1354
+ // case documented above, hit by every attach), and a connect that
1355
+ // genuinely failed cannot hide — the address check below throws.
1356
+ }
885
1357
 
886
1358
  const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
887
1359
  if (!check.stdout.includes(ip)) {
@@ -902,33 +1374,44 @@ function buildNetworkHandle(
902
1374
  cwd: composeDir,
903
1375
  timeout: 20_000,
904
1376
  });
905
- if (id) {
906
- try {
907
- run(`docker network disconnect -f ${netName} ${id}`, { timeout: 20_000 });
908
- } catch {}
1377
+ if (!id) continue;
1378
+ // `-f` makes disconnect report failure on a container docker considers
1379
+ // unattached, which after a half-completed attach is exactly the state
1380
+ // being cleaned up here. So the failure is captured for the repair step
1381
+ // to carry in its report, not obeyed — whether the interface actually
1382
+ // left is decided by the interface table, not by docker's exit code.
1383
+ let disconnectError: string | undefined;
1384
+ try {
1385
+ run(`docker network disconnect -f ${netName} ${id}`, { timeout: 20_000 });
1386
+ } catch (err: unknown) {
1387
+ disconnectError = err instanceof Error ? err.message : String(err);
909
1388
  }
1389
+ removeAlienInterface({
1390
+ service,
1391
+ subnet: alienSegmentSubnet,
1392
+ disconnectError,
1393
+ exec: (cmd) => dockerExec(projectName, composeDir, service, cmd),
1394
+ });
910
1395
  }
911
1396
  try {
912
1397
  run(`docker network rm ${netName}`, { timeout: 20_000 });
913
- } catch {}
914
-
915
- // VERIFY, for the same reason attach does. A lingering address on a dead
916
- // wire is still an interface celilo cannot attribute, so a test that goes
917
- // on to expect a clean converge would fail somewhere far from the cause.
918
- for (const service of attachedToAlienSegment) {
919
- const check = dockerExec(projectName, composeDir, service, 'ip -o addr show scope global');
920
- const prefix = alienSegmentSubnet.replace(/\.\d+\/\d+$/, '').replace(/\./g, '\\.');
921
- const orphan = new RegExp(`(\\S+)\\s+inet\\s+${prefix}\\.\\d+`).exec(check.stdout);
922
- if (orphan) {
923
- dockerExec(projectName, composeDir, service, `ip link del ${orphan[1]}`);
924
- }
1398
+ } catch (err: unknown) {
1399
+ console.error(
1400
+ `detachAlienSegment: docker network rm ${netName} failed: ${err instanceof Error ? err.message : String(err)}`,
1401
+ );
925
1402
  }
926
1403
  attachedToAlienSegment.clear();
927
1404
  },
928
1405
 
929
1406
  async deployGreenwave(): Promise<void> {
930
1407
  const routerIp = greenwaveRouterIp();
931
- await handle.celilo(`machine add ${routerIp} --ssh-user root --earmark greenwave`);
1408
+ // No `machine add` for the router, and do not restore one: greenwave is an
1409
+ // appliance module (schema.ts `apiOnly`, ISP router) that talks to it over
1410
+ // HTTPS only (scripts/router-api.ts), deploys onto no system
1411
+ // (manifest `requires: capabilities: []`, no `system:`), and never enters
1412
+ // infrastructure selection. Adding the router as a root machine only ran
1413
+ // the fleet aspects against a simulated ISP router over SSH.
1414
+ // @psbanka - 2026-09: removed in ce-5b1v; see celilo#1263.
932
1415
  await handle.celilo('module import greenwave');
933
1416
  await handle.celilo(`module config set greenwave router_ip ${routerIp}`);
934
1417
  await handle.celilo('module secret set greenwave router_username admin');
@@ -973,13 +1456,18 @@ function buildNetworkHandle(
973
1456
  const start = Date.now();
974
1457
  const timeout = 86_400_000; // 24 hour max debug session
975
1458
  while (!existsSync(signalFile) && Date.now() - start < timeout) {
1459
+ // e2e-sleep-ok: waits on a human exiting the debug shell; the signal file is re-checked above.
976
1460
  await new Promise((r) => setTimeout(r, 500));
977
1461
  }
978
1462
 
979
- // Clean up
1463
+ // Clean up. Ignorable: the file only ends THIS pause's wait loop, its
1464
+ // name is pid-unique so a leftover can never satisfy another session's
1465
+ // wait, and the next debug pause writes its own.
980
1466
  try {
981
1467
  require('node:fs').unlinkSync(signalFile);
982
- } catch {}
1468
+ } catch {
1469
+ // deliberately ignored, see above
1470
+ }
983
1471
 
984
1472
  console.log('[debug:resumed]');
985
1473
  },
@@ -997,17 +1485,29 @@ function buildNetworkHandle(
997
1485
  if (isNetapp) {
998
1486
  netappPath = absPath;
999
1487
  } else {
1000
- // Package the module source directory using the celilo CLI
1001
- netappPath = join(tmpdir(), `${moduleId}-${Date.now()}.netapp`);
1002
- cleanup = true;
1003
- const celiloCliPath = join(celiloRoot ?? PACKAGE_ROOT, 'apps/celilo/src/cli/index.ts');
1004
- try {
1005
- run(
1006
- `bun run ${JSON.stringify(celiloCliPath)} package ${JSON.stringify(absPath)} --output ${JSON.stringify(netappPath)}`,
1007
- { timeout: 60_000 },
1008
- );
1009
- } catch (err) {
1010
- throw new Error(`Failed to package module at ${absPath}: ${String(err)}`);
1488
+ const staged = stagedNetappIfCurrent(absPath, moduleId);
1489
+ if (staged) {
1490
+ // build-infra already produced this, before the stack was competing
1491
+ // for the host. Repackaging it here is the work that times out.
1492
+ netappPath = staged;
1493
+ } else {
1494
+ netappPath = join(tmpdir(), `${moduleId}-${Date.now()}.netapp`);
1495
+ cleanup = true;
1496
+ const celiloCliPath = join(celiloRoot ?? PACKAGE_ROOT, 'apps/celilo/src/cli/index.ts');
1497
+ const startedAt = Date.now();
1498
+ try {
1499
+ // 180s, not 60s: this runs while the stack is up, and the same work
1500
+ // that takes 11s on an idle host went past 60s under a live stack.
1501
+ run(
1502
+ `bun run ${JSON.stringify(celiloCliPath)} package ${JSON.stringify(absPath)} --output ${JSON.stringify(netappPath)}`,
1503
+ { timeout: 180_000 },
1504
+ );
1505
+ } catch (err) {
1506
+ const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
1507
+ throw new Error(
1508
+ `Failed to package module ${moduleId} at ${absPath} after ${seconds}s (no current staged netapp, so it was rebuilt inside a live stack): ${String(err)}`,
1509
+ );
1510
+ }
1011
1511
  }
1012
1512
  }
1013
1513
 
@@ -1029,7 +1529,9 @@ function buildNetworkHandle(
1029
1529
  if (cleanup)
1030
1530
  try {
1031
1531
  execSync(`rm -f ${JSON.stringify(netappPath)}`, { stdio: 'pipe' });
1032
- } catch {}
1532
+ } catch (err) {
1533
+ logCleanupFailure(`rm staged netapp ${netappPath}`, err);
1534
+ }
1033
1535
  }
1034
1536
  },
1035
1537
 
@@ -1046,12 +1548,18 @@ function buildNetworkHandle(
1046
1548
  for (const browser of activeBrowsers) {
1047
1549
  try {
1048
1550
  await browser.close();
1049
- } catch {}
1551
+ } catch (err) {
1552
+ // A browser that refuses to close must not stop the network teardown
1553
+ // below — the leaked resource is the network, not the browser.
1554
+ logCleanupFailure('browser close', err);
1555
+ }
1050
1556
  }
1051
1557
  for (const proxy of activeProxies) {
1052
1558
  try {
1053
1559
  await proxy.stop();
1054
- } catch {}
1560
+ } catch (err) {
1561
+ logCleanupFailure('proxy stop', err);
1562
+ }
1055
1563
  }
1056
1564
 
1057
1565
  // Respect --keep / --reuse: don't tear down the network
@@ -1061,13 +1569,30 @@ function buildNetworkHandle(
1061
1569
  return;
1062
1570
  }
1063
1571
  console.log('[progress:start] stopping network | network stopped');
1572
+ // Kill this project's sim-created guests BEFORE compose down. A guest
1573
+ // holds its zone network's endpoint and the ssh-keys volume, so compose
1574
+ // down with the guest alive fails both removals, the error is swallowed
1575
+ // below, and the subnet stays allocated for every later suite
1576
+ // (celilo#1247).
1577
+ try {
1578
+ const guests = run(projectTeardownCommands(projectName).listGuests);
1579
+ if (guests.trim()) {
1580
+ run(`docker rm -f ${guests.replace(/\n/g, ' ')}`, { timeout: 30_000 });
1581
+ }
1582
+ } catch (err) {
1583
+ // Compose down below still runs; without the guest gone it fails both
1584
+ // removals (celilo#1247), so the failure is worth naming, not absorbing.
1585
+ logCleanupFailure(`guest sweep ${projectName}`, err);
1586
+ }
1064
1587
  try {
1065
1588
  run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`, {
1066
1589
  cwd: composeDir,
1067
1590
  timeout: 60_000,
1068
1591
  });
1069
- } catch {
1070
- // Best effort cleanup
1592
+ } catch (err) {
1593
+ // Best effort cleanup: stop() must never fail the caller over teardown.
1594
+ // The failure is logged so a systematically broken compose is visible.
1595
+ logCleanupFailure(`compose down ${projectName}`, err);
1071
1596
  }
1072
1597
  activeProjects.delete(projectName);
1073
1598
  },
@@ -1103,47 +1628,15 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1103
1628
  await scrubDnsZones();
1104
1629
  }
1105
1630
 
1106
- // Clean up stale per-test containers (self-healing from prior crashes)
1107
- console.log('[progress:start] cleaning up stale test resources | cleanup complete');
1108
- try {
1109
- // Force-kill per-test containers (not shared infra)
1110
- try {
1111
- const containers = run('docker ps -aq --filter "name=celilo-e2e-1" 2>/dev/null');
1112
- if (containers.trim()) {
1113
- run(`docker rm -f ${containers.replace(/\n/g, ' ')}`, { timeout: 30_000 });
1114
- }
1115
- } catch {}
1116
-
1117
- // Remove stale per-test networks AND their volumes, via the same
1118
- // by-name teardown the exit handler uses. This used to lean on
1119
- // `docker compose -p <project> down --volumes` for the volume half, which
1120
- // has no `-f` and no reliable cwd here either — compose exits "no
1121
- // configuration file provided" and the catch swallows it, so volumes were
1122
- // never removed on this path either. Reusing forceRemoveProject keeps the
1123
- // crash-recovery sweep and the exit handler from drifting apart.
1124
- try {
1125
- const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
1126
- .split('\n')
1127
- .filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
1128
- const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
1129
- for (const project of projects) {
1130
- forceRemoveProject(project);
1131
- }
1132
- } catch {}
1133
- // Volumes can outlive every container and network of their project — that
1134
- // is exactly the 58-volume pile — so sweep by prefix too, not only for
1135
- // projects that still have a network to be discovered by.
1136
- try {
1137
- const vols = run('docker volume ls -q --filter name=celilo-e2e-1 2>/dev/null')
1138
- .split('\n')
1139
- .filter(Boolean);
1140
- for (const vol of vols) {
1141
- try {
1142
- run(`docker volume rm ${vol}`, { timeout: 5_000 });
1143
- } catch {}
1144
- }
1145
- } catch {}
1146
- } catch {}
1631
+ // Clean up stale per-test containers (self-healing from prior crashes).
1632
+ // The sweep is extracted so it is testable; the progress line is honest by
1633
+ // construction: "cleanup complete" only when the sweep reported zero
1634
+ // failures (ce-yuzi).
1635
+ console.log('[progress:start] cleaning up stale test resources | sweeping');
1636
+ const cleanupFailures = sweepStaleTestResources();
1637
+ console.log(
1638
+ `[progress:start] cleaning up stale test resources | ${cleanupProgress(cleanupFailures)}`,
1639
+ );
1147
1640
 
1148
1641
  const projectName = `celilo-e2e-${Date.now()}`;
1149
1642
  activeProjects.add(projectName);
@@ -1155,9 +1648,21 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1155
1648
  const yaml = generateTestComposeYaml(config, celiloRoot);
1156
1649
  writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
1157
1650
 
1158
- // Build and start per-test containers only — stream output so the runner
1159
- // can show which service is currently being built (can take minutes on cold cache).
1160
- await streamingBuild(projectName, composeDir, COMPOSE_FILE);
1651
+ // The compose carries `image:` only (never `build:`), so the run-time path
1652
+ // cannot build and cannot resolve a FROM from docker.io — the fetch that
1653
+ // made 30 outputs fail with `lookup auth.docker.io` (tasks.md 5b.2). A
1654
+ // missing baked tag is a loud failure naming the remedy. Checked for BOTH
1655
+ // compose files here, before shared infra comes up, so an unpopulated
1656
+ // machine is named in seconds rather than discovered mid-`up`.
1657
+ const missing = missingImages([
1658
+ ...referencedImages(yaml),
1659
+ ...referencedImages(generateSharedInfraYaml()),
1660
+ ]);
1661
+ if (missing.length > 0) {
1662
+ throw new Error(
1663
+ `${missing.length} baked image(s) missing from the local store, and suite time never builds: ${missing.join(', ')}.\nRemedy: cele2e build-infra (it owns the network phase — docker base images, apt, caddy, docker-ce). The suite run stays hermetic.`,
1664
+ );
1665
+ }
1161
1666
 
1162
1667
  console.log('[progress:start] starting containers | containers running');
1163
1668
  run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, {
@@ -1346,6 +1851,15 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1346
1851
  if (config.managementVariant !== 'vanilla') {
1347
1852
  assertCliVersion(projectName, composeDir);
1348
1853
  console.log('[progress:start] initializing celilo | celilo initialized');
1854
+ // dns.fallback is deliberately NOT set. The sim hosts exactly one resolver
1855
+ // the fleet may name — comcast-resolver at 203.0.113.1 — so the
1856
+ // pre-resolver birth list is just that address. The former value
1857
+ // (1.0.0.1,8.8.8.8) named addresses that exist nowhere in the sim: every
1858
+ // lookup to them ate its full timeout, and ansible's Gathering Facts does
1859
+ // reverse lookups, which is the 600s hang in celilo#1290. The sim's
1860
+ // second public resolver (SIMULATOR_IPS.PUBLIC_RESOLVER) stays OUT of
1861
+ // fleet config on purpose: the public_dns check needs a resolver celilo
1862
+ // does not itself use.
1349
1863
  // Honest harness (openspec/specs/progressive-zone-disclosure/spec.md): seed ONLY the
1350
1864
  // `internal` zone — the network the management box is genuinely on —
1351
1865
  // plus DNS. dmz/app/secure are NOT pre-seeded; they come into being
@@ -1366,12 +1880,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1366
1880
  config.managementZone === 'secure-mgmt' || (config.secureMgmtMachines ?? []).length > 0
1367
1881
  ? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
1368
1882
  : ''
1369
- }dns.primary=203.0.113.1 \
1370
- dns.fallback=1.0.0.1,8.8.8.8`,
1883
+ }dns.primary=203.0.113.1`,
1371
1884
  );
1372
1885
  if (initResult.exitCode !== 0) {
1373
1886
  throw new Error(`Celilo init failed: ${initResult.stderr}`);
1374
1887
  }
1888
+ assertFleetNameserversAreSimRoutable(projectName, composeDir);
1375
1889
 
1376
1890
  // Start the event-bus dispatcher (ISS-0035 / ISS-0042), now that `system
1377
1891
  // init` has created the bus DB. Deploys emit bus events —
@@ -1429,6 +1943,28 @@ function topologyFromComposeFile(composePath: string): TopologyPreset {
1429
1943
  }
1430
1944
  }
1431
1945
 
1946
+ /**
1947
+ * Whether the generated compose wires fw-main to the secure-mgmt control-plane
1948
+ * network. The generator adds that leg as a post-step after building the
1949
+ * topology services, so it is invisible to `firewallZoneLegs(topology)` — which
1950
+ * is how a wired-but-undeclared eth4 reached the iptables converge and got
1951
+ * refused (ce-fvxd). Exported for the leg-coherence test.
1952
+ */
1953
+ export function fwMainHasSecureMgmtLeg(composePath: string): boolean {
1954
+ try {
1955
+ const compose = parseYaml(readFileSync(composePath, 'utf-8')) as {
1956
+ services?: Record<string, { networks?: Record<string, unknown> }>;
1957
+ };
1958
+ return Boolean(compose.services?.['fw-main']?.networks?.['secure-mgmt']);
1959
+ } catch {
1960
+ // An unreadable compose file means the stack was not built by this
1961
+ // generator. Reporting "no leg" reproduces the under-declaration, but the
1962
+ // converge refuses loudly on the unaccounted interface, so the failure is
1963
+ // not silent — the same trade `topologyFromComposeFile` makes.
1964
+ return false;
1965
+ }
1966
+ }
1967
+
1432
1968
  export function reconnectNetwork(projectName: string): NetworkHandle {
1433
1969
  const composeDir = PACKAGE_ROOT;
1434
1970