@celilo/e2e 0.8.0 → 0.9.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.
@@ -1,8 +1,9 @@
1
1
  import { type ExecSyncOptions, execSync, spawn } from 'node:child_process';
2
- import { copyFileSync, existsSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
5
  import { startBrowser } from './browser';
6
+ import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
6
7
  import { SHARED_PROJECT_NAME, generateTestComposeYaml } from './docker-compose-generator';
7
8
  import { ensureSharedInfra } from './shared-infra';
8
9
  import { SIMULATOR_IPS } from './simulator-ips';
@@ -16,7 +17,7 @@ import type {
16
17
  ProxyOptions,
17
18
  SocksProxyHandle,
18
19
  } from './types';
19
- import { CeliloCommandError, internalNatIp, ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
20
+ import { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS, internalNatIp } from './types';
20
21
 
21
22
  /** Package root — where docker/, config/, simulators/ live */
22
23
  const PACKAGE_ROOT = join(__dirname, '..');
@@ -126,45 +127,68 @@ const SHARED_CONTAINERS = new Set([
126
127
  ]);
127
128
 
128
129
  /**
129
- * Scrub DNS zone files to baseline templates before a test starts.
130
+ * Reset the SHARED authoritative DNS (namecheap-dns) to its pristine seed
131
+ * before a test starts, so a prior test's DDNS writes don't bleed forward.
130
132
  *
131
- * Copies slim template zones (containing only @, ns1, www) over the
132
- * host-side zone files (which are bind-mounted into namecheap-dns),
133
- * then reloads Knot to pick up the changes. This prevents DDNS-written
134
- * subdomains from one test leaking into the next.
133
+ * namecheap-dns is SHARED across the whole suite. Knot serves the WRITABLE
134
+ * `/config/<zone>.zone` copies, which the DDNS simulator mutates at runtime;
135
+ * `/seed/<zone>.zone` is the read-only pristine mount, copied to `/config` once
136
+ * by namecheap-startup.sh at container start. So the only correct reset is to
137
+ * re-copy `/seed -> /config` inside the container and reload Knot.
135
138
  *
136
- * Called automatically by startNetwork unless config.skipDnsScrub is
137
- * set (useful for --keep debugging).
139
+ * History: the previous implementation rewrote the HOST-side zone file (which
140
+ * is bind-mounted to `/seed`, not `/config`) and reloaded — Knot never re-read
141
+ * it, so the scrub was a silent no-op. iamtheinternet.org survived only because
142
+ * every test re-registers its own hosts; celilo.computer's apex is never
143
+ * re-registered, so once a celilo.computer-managed test clobbered it (apex ->
144
+ * the fw-ext source IP instead of the 100.64.0.58 website-sim) it stayed broken
145
+ * for the rest of the run — silently failing install-sh. (ISS: cele2e DNS bleed.)
146
+ *
147
+ * Called automatically by startNetwork unless config.skipDnsScrub is set
148
+ * (useful for --keep debugging).
149
+ *
150
+ * Exported for the effect-proving integration test (e2e-confidence #256).
138
151
  */
139
- async function scrubDnsZones(): Promise<void> {
140
- const zones = ['iamtheinternet.org', 'example.net'];
141
- const composeDir = PACKAGE_ROOT;
142
-
143
- for (const zone of zones) {
144
- const templatePath = join(PACKAGE_ROOT, 'config', 'dns', 'templates', `${zone}.zone`);
145
- const liveZonePath = join(PACKAGE_ROOT, 'config', 'dns', `${zone}.zone`);
152
+ export async function scrubDnsZones(): Promise<void> {
153
+ const exec = (cmd: string): string =>
154
+ run(
155
+ `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns sh -c ${JSON.stringify(
156
+ cmd,
157
+ )}`,
158
+ { cwd: PACKAGE_ROOT },
159
+ );
146
160
 
147
- try {
148
- // Copy template over the host-side zone file. The file is
149
- // bind-mounted into the container, so overwriting the host file
150
- // makes the new content available inside.
151
- copyFileSync(templatePath, liveZonePath);
152
- } catch (err) {
153
- // Non-fatal: log and continue. The scrub is a hygiene measure;
154
- // if it fails (template missing, permissions), tests can still run
155
- // with stale DNS state.
156
- console.warn(`[dns-scrub] Failed to copy ${zone} template: ${err}`);
157
- }
161
+ try {
162
+ exec('cp -f /seed/*.zone /config/ && knotc zone-reload');
163
+ } catch (err) {
164
+ // Non-fatal hygiene measure: log and continue. A failed reset just means a
165
+ // test may see stale DNS — better than aborting the run.
166
+ console.warn(`[dns-scrub] Failed to reset zones to seed: ${err}`);
167
+ return;
158
168
  }
159
169
 
160
- // Reload all zones in one shot so Knot re-reads from the bind-mount
170
+ // Verify the reset ACTUALLY took effect by querying the RUNNING authoritative
171
+ // server (not a file): the old scrub silently wrote /seed while Knot served
172
+ // /config, so a file check would have looked fine while the server served
173
+ // stale records. Assert celilo.computer's apex now resolves to the website-sim
174
+ // and throw loudly on a confirmed mismatch, so a broken reset can never again
175
+ // silently bleed DNS state across tests. (e2e-confidence #253.)
176
+ const expected = SIMULATOR_IPS.WEBSITE;
177
+ let served: string | null = null;
161
178
  try {
162
- run(
163
- `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns knotc zone-reload`,
164
- { cwd: composeDir },
165
- );
179
+ served = exec('kdig @127.0.0.1 celilo.computer A +short').trim();
166
180
  } catch (err) {
167
- console.warn(`[dns-scrub] Failed to reload zones: ${err}`);
181
+ // Couldn't run the verification query (e.g. the query tool is unavailable).
182
+ // Warn rather than abort the suite over a missing diagnostic — we only fail
183
+ // on a CONFIRMED wrong answer below.
184
+ console.warn(`[dns-scrub] could not verify reset (DNS query failed): ${err}`);
185
+ }
186
+ if (served !== null && !served.split(/\s+/).includes(expected)) {
187
+ throw new Error(
188
+ `[dns-scrub] post-reset verification FAILED: celilo.computer apex should serve ${expected} ` +
189
+ `(website-sim) after scrub, but namecheap-dns returns "${served || '(empty)'}". ` +
190
+ `The DNS reset did not take effect — shared DNS would bleed across tests.`,
191
+ );
168
192
  }
169
193
  }
170
194
 
@@ -187,15 +211,48 @@ function dockerExec(
187
211
  );
188
212
  return { stdout, stderr: '', exitCode: 0 };
189
213
  } catch (err: unknown) {
190
- const e = err as { stdout?: string; stderr?: string; status?: number };
214
+ const e = err as {
215
+ stdout?: string;
216
+ stderr?: string;
217
+ status?: number;
218
+ code?: string;
219
+ signal?: string;
220
+ killed?: boolean;
221
+ };
222
+ // execSync's timeout surfaces as an opaque error (ETIMEDOUT / SIGTERM /
223
+ // Bun's "canceled") with empty stderr — the classic e2e time-sink where a
224
+ // hung command reads as a mystery. Replace it with what actually happened.
225
+ const timedOut = e.code === 'ETIMEDOUT' || e.signal === 'SIGTERM' || e.killed === true;
226
+ const stderr = timedOut
227
+ ? `timed out after ${Math.round(timeoutMs / 1000)}s running: ${cmd}`
228
+ : (e.stderr?.toString() ?? '');
191
229
  return {
192
230
  stdout: e.stdout?.toString() ?? '',
193
- stderr: e.stderr?.toString() ?? '',
194
- exitCode: e.status ?? 1,
231
+ stderr,
232
+ exitCode: e.status ?? (timedOut ? 124 : 1),
195
233
  };
196
234
  }
197
235
  }
198
236
 
237
+ /**
238
+ * Fail fast if the management image's baked celilo CLI is older than the
239
+ * harness needs (ce-5qp). Runs before `system init` so a version mismatch
240
+ * surfaces in <10s with an actionable message instead of a 90s hang ending
241
+ * in "canceled".
242
+ */
243
+ function assertCliVersion(projectName: string, composeDir: string): void {
244
+ const result = dockerExec(projectName, composeDir, 'management', 'celilo --version', 8_000);
245
+ if (result.exitCode !== 0) {
246
+ throw new Error(
247
+ `Could not read celilo version from the management image ` +
248
+ `(harness requires >=${MIN_CLI_VERSION}). ` +
249
+ `\`celilo --version\` exited ${result.exitCode}: ${result.stderr || result.stdout || '(no output)'}`,
250
+ );
251
+ }
252
+ const problem = checkCliVersion(result.stdout);
253
+ if (problem) throw new Error(problem);
254
+ }
255
+
199
256
  function dockerExecAsync(
200
257
  projectName: string,
201
258
  composeDir: string,
@@ -255,6 +312,7 @@ async function waitFor(
255
312
  check: () => Promise<boolean>,
256
313
  timeoutMs: number,
257
314
  label: string,
315
+ onTimeout?: () => string | Promise<string>,
258
316
  ): Promise<void> {
259
317
  const start = Date.now();
260
318
  while (Date.now() - start < timeoutMs) {
@@ -265,7 +323,20 @@ async function waitFor(
265
323
  }
266
324
  await new Promise((r) => setTimeout(r, 2000));
267
325
  }
268
- throw new Error(`Timeout waiting for ${label} after ${timeoutMs}ms`);
326
+ // Self-diagnosing timeout (e2e-confidence #255): a readiness wait must attach
327
+ // EVIDENCE, never speculate ("X likely stalled or Y is down"). When the caller
328
+ // supplies an onTimeout collector, capture the live state and append it so the
329
+ // failure pinpoints the layer on first occurrence — no hand-instrumentation
330
+ // after the fact.
331
+ let diagnostics = '';
332
+ if (onTimeout) {
333
+ try {
334
+ diagnostics = `\n${await onTimeout()}`;
335
+ } catch (err) {
336
+ diagnostics = `\n(onTimeout diagnostics failed: ${err instanceof Error ? err.message : String(err)})`;
337
+ }
338
+ }
339
+ throw new Error(`Timeout waiting for ${label} after ${timeoutMs}ms${diagnostics}`);
269
340
  }
270
341
 
271
342
  /**
@@ -328,369 +399,111 @@ async function streamingBuild(
328
399
  });
329
400
  }
330
401
 
331
- export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle> {
332
- // Pre-flight: fail if live containers are running that will compete for resources.
333
- try {
334
- const running = run('docker ps --format "{{.Names}}" 2>/dev/null').split('\n').filter(Boolean);
335
- const heavyPatterns = ['authentik-', 'caddy-', 'build-your-own-internet-'];
336
- const heavy = running.filter((name) => heavyPatterns.some((p) => name.startsWith(p)));
337
- if (heavy.length > 0) {
338
- const names = heavy.map((c) => ` - ${c}`).join('\n');
339
- throw new Error(
340
- `\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\n` +
341
- 'Live and e2e environments are mutually exclusive.\n' +
342
- 'Stop live containers first: cele2e down --all\n',
343
- );
344
- }
345
- } catch (e) {
346
- if (e instanceof Error && e.message.includes('Cannot start e2e tests')) throw e;
347
- }
402
+ /**
403
+ * Build the NetworkHandle shared by startNetwork (fresh network) and
404
+ * reconnectNetwork (--keep reuse). Both expose the identical method set;
405
+ * only the setup that precedes handle creation differs, so the handle
406
+ * methods live here once instead of being duplicated in both functions.
407
+ */
408
+ function buildNetworkHandle(
409
+ projectName: string,
410
+ composeDir: string,
411
+ celiloRoot: string | undefined,
412
+ ): NetworkHandle {
413
+ // Track host-side resources spawned via the handle (SOCKS proxies,
414
+ // Playwright browsers). stop() tears these down before the docker
415
+ // network itself.
416
+ const activeProxies: SocksProxyHandle[] = [];
417
+ const activeBrowsers: BrowserHandle[] = [];
348
418
 
349
- // Ensure shared infrastructure (DNS, Pebble, registry, etc.) is running.
350
- // This is idempotent — if already up, it just verifies health.
351
- await ensureSharedInfra();
419
+ const handle: NetworkHandle = {
420
+ projectName,
352
421
 
353
- // Scrub DNS zone files to baseline before each test (H5 in E2E_TEST_UPDATES.md).
354
- // Prevents DDNS-written records from one test leaking into the next.
355
- // Tests can opt out with config.skipDnsScrub (e.g., for --reuse debugging).
356
- if (!config.skipDnsScrub) {
357
- await scrubDnsZones();
358
- }
422
+ async celilo(
423
+ cmd: string,
424
+ optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
425
+ ): Promise<ExecResult> {
426
+ const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
427
+ const timeoutMs =
428
+ typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
359
429
 
360
- // Clean up stale per-test containers (self-healing from prior crashes)
361
- console.log('[progress:start] cleaning up stale test resources | cleanup complete');
362
- try {
363
- // Force-kill per-test containers (not shared infra)
364
- try {
365
- const containers = run('docker ps -aq --filter "name=celilo-e2e-1" 2>/dev/null');
366
- if (containers.trim()) {
367
- run(`docker rm -f ${containers.replace(/\n/g, ' ')}`, { timeout: 30_000 });
368
- }
369
- } catch {}
430
+ const result = await dockerExecAsync(
431
+ projectName,
432
+ composeDir,
433
+ 'management',
434
+ `celilo ${cmd}`,
435
+ timeoutMs,
436
+ );
370
437
 
371
- // Remove stale per-test networks
372
- try {
373
- const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
374
- .split('\n')
375
- .filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
376
- if (networks.length > 0) {
377
- const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
378
- for (const project of projects) {
379
- try {
380
- run(`docker compose -p ${project} down --volumes --remove-orphans`, {
381
- timeout: 30_000,
382
- });
383
- } catch {}
384
- }
385
- for (const net of networks) {
386
- try {
387
- run(`docker network rm ${net}`, { timeout: 5_000 });
388
- } catch {}
389
- }
438
+ if (check && result.exitCode !== 0) {
439
+ throw new CeliloCommandError(cmd, result);
390
440
  }
391
- } catch {}
392
- } catch {}
393
-
394
- const projectName = `celilo-e2e-${Date.now()}`;
395
- activeProjects.add(projectName);
396
-
397
- const composeDir = PACKAGE_ROOT;
398
- const celiloRoot = config.celiloRoot ?? findCeliloRoot();
399
-
400
- // Generate per-test compose (references shared infra networks as external)
401
- const yaml = generateTestComposeYaml(config, celiloRoot);
402
- writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
403
-
404
- // Build and start per-test containers only — stream output so the runner
405
- // can show which service is currently being built (can take minutes on cold cache).
406
- await streamingBuild(projectName, composeDir, COMPOSE_FILE);
441
+ return result;
442
+ },
407
443
 
408
- console.log('[progress:start] starting containers | containers running');
409
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, {
410
- cwd: composeDir,
411
- timeout: 120_000,
412
- });
444
+ exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
445
+ return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
446
+ },
413
447
 
414
- // Wait for DNS convergence (comcast-resolver is per-test, needs to reach shared DNS)
415
- console.log('[progress:start] waiting for DNS convergence | DNS converged');
416
- await waitFor(
417
- async () => {
418
- const result = dockerExec(
448
+ async respondWith(values): Promise<void> {
449
+ // Write the values JSON inside the management container at a
450
+ // stable path, then start a detached `celilo events respond
451
+ // --values <path>` process. The responder polls the in-container
452
+ // bus and answers config/secret/ensure events as the deploy
453
+ // emits them. The responder dies with the container at network
454
+ // teardown, so no explicit cleanup hook is needed.
455
+ //
456
+ // Long timeouts: the responder's --idle-timeout has to outlast
457
+ // any quiet stretch between deploys (a long `module deploy`
458
+ // might not emit interview events for several minutes if it's
459
+ // building images). 1h idle / 2h max-duration covers any single
460
+ // test comfortably without leaking. The container goes away at
461
+ // network stop regardless.
462
+ const valuesJson = JSON.stringify(values).replace(/'/g, "'\\''");
463
+ const valuesPath = '/tmp/cele2e-responder-values.json';
464
+ const writeCmd = `printf '%s' '${valuesJson}' > ${valuesPath}`;
465
+ const writeResult = dockerExec(projectName, composeDir, 'management', writeCmd, 10_000);
466
+ if (writeResult.exitCode !== 0) {
467
+ throw new Error(
468
+ `respondWith: failed to write values file inside management container: ${writeResult.stderr}`,
469
+ );
470
+ }
471
+ // Kill any prior responder (idempotent: replaces prior values map).
472
+ dockerExec(
419
473
  projectName,
420
474
  composeDir,
421
- 'comcast-resolver',
422
- 'dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2',
475
+ 'management',
476
+ 'pkill -f "celilo events respond" 2>/dev/null || true',
477
+ 5_000,
423
478
  );
424
- return result.exitCode === 0 && result.stdout.trim().length > 0;
425
- },
426
- 60_000,
427
- 'DNS convergence',
428
- );
429
-
430
- // Wait for internal resolver (split-horizon DNS for management container).
431
- // Skipped when the test specifies its own machine named `dns-int` — that
432
- // means the test is replacing the infra resolver with a module under test
433
- // (e.g., knot-unbound-internal). The bare target machine has no DNS server
434
- // until its module deploys, so this wait would always time out. Management
435
- // can still resolve names via the fallback `nameserver 100.100.0.1` in
436
- // its resolv.conf during deploy.
437
- //
438
- // The resolver-under-test may sit in ANY zone, not just `internal`: ISS-0156
439
- // places the dns_internal provider in a PROTECTED zone (dmz) so it can see
440
- // protected-zone query sources for split-horizon views. So check every zone's
441
- // machines for a `dns-int`, not only internalMachines.
442
- const dnsIntReplaced = [
443
- ...config.internalMachines,
444
- ...config.dmzMachines,
445
- ...config.appMachines,
446
- ...config.secureMachines,
447
- ].some((m) => m.name === 'dns-int');
448
- if (!dnsIntReplaced) {
449
- console.log('[progress:start] waiting for internal resolver | internal resolver ready');
450
- await waitFor(
451
- async () => {
452
- const result = dockerExec(
453
- projectName,
454
- composeDir,
455
- 'dns-int',
456
- 'dig @127.0.0.1 iamtheinternet.org A +short +timeout=2',
479
+ // Spawn detached. nohup + & + disown cleanly survives the
480
+ // dockerExec session ending.
481
+ const spawnCmd =
482
+ `nohup celilo events respond --values ${valuesPath} ` +
483
+ '--idle-timeout 1h --max-duration 2h ' +
484
+ '--emittedBy cele2e-responder ' +
485
+ '> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
486
+ const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
487
+ if (spawnResult.exitCode !== 0) {
488
+ throw new Error(
489
+ `respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
457
490
  );
458
- return result.exitCode === 0 && result.stdout.trim().length > 0;
459
- },
460
- 30_000,
461
- 'internal DNS resolver',
462
- );
463
- }
491
+ }
492
+ // Brief settle so the responder has registered its watches
493
+ // before any deploy fires events. Without this, a fast deploy
494
+ // could emit before the responder polls.
495
+ await new Promise((r) => setTimeout(r, 500));
496
+ },
464
497
 
465
- // Optional routing verification
466
- if (config.verifyRouting) {
467
- console.log('[progress:start] verifying routing | routing verified');
468
- await waitFor(
469
- async () => {
470
- const result = dockerExec(
471
- projectName,
472
- composeDir,
473
- 'management',
474
- `ping -c1 -W2 ${SIMULATOR_IPS.ROOT_DNS}`,
475
- );
476
- return result.exitCode === 0;
477
- },
478
- 30_000,
479
- 'routing verification',
480
- );
481
- }
482
-
483
- // Wait for all target machines to complete setup (systemd boot + routing)
484
- const allMachines = [
485
- ...config.dmzMachines,
486
- ...config.appMachines,
487
- ...config.secureMachines,
488
- ...config.internalMachines,
489
- ];
490
- if (allMachines.length > 0) {
491
- console.log('[progress:start] waiting for target machines | target machines ready');
492
- }
493
- for (const machine of allMachines) {
494
- await waitFor(
495
- async () => {
496
- const result = dockerExec(
497
- projectName,
498
- composeDir,
499
- machine.name,
500
- 'systemctl is-active target-setup 2>/dev/null',
501
- );
502
- return result.stdout.trim() === 'active';
503
- },
504
- // App-zone machines use Dockerfile.target-machine-docker which bakes in
505
- // dockerd; systemd boot + dockerd init takes ~25-35s, leaving a tight
506
- // margin against a 30s timeout. 60s covers the observed worst case
507
- // without slowing down dmz/internal-only tests (which still hit this
508
- // in ~2-5s).
509
- 60_000,
510
- `${machine.name} target-setup`,
511
- );
512
- }
513
-
514
- // Wait for DHCP client lease
515
- if (config.dhcpClient) {
516
- console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
517
- await waitFor(
518
- async () => {
519
- const result = dockerExec(
520
- projectName,
521
- composeDir,
522
- 'dhcp-client',
523
- 'test -f /var/lib/dhcp/dhclient.leases && grep -c lease /var/lib/dhcp/dhclient.leases',
524
- );
525
- return result.exitCode === 0 && Number.parseInt(result.stdout.trim()) > 0;
526
- },
527
- 60_000,
528
- 'DHCP client lease',
529
- );
530
- }
531
-
532
- // Wait for routing to Pebble (through shared infra networks)
533
- if (allMachines.length > 0) {
534
- await waitFor(
535
- async () => {
536
- const result = dockerExec(
537
- projectName,
538
- composeDir,
539
- 'management',
540
- `ping -c1 -W2 ${SIMULATOR_IPS.PEBBLE}`,
541
- );
542
- return result.exitCode === 0;
543
- },
544
- 30_000,
545
- 'routing to Pebble',
546
- );
547
- }
548
-
549
- // Initialize celilo. Skipped for the vanilla management variant —
550
- // there's no celilo binary baked into that image; the caller is
551
- // responsible for installing it (typically by running install.sh)
552
- // and then calling `celilo system init` themselves.
553
- if (config.managementVariant !== 'vanilla') {
554
- console.log('[progress:start] initializing celilo | celilo initialized');
555
- // Honest harness (v2/NETWORK_CONFIG_TO_FIREWALL.md): seed ONLY the
556
- // `internal` zone — the network the management box is genuinely on —
557
- // plus DNS. dmz/app/secure are NOT pre-seeded; they come into being
558
- // when a test deploys the firewall (net.deployFirewall()), exactly as
559
- // in production. A test that places services in those zones must call
560
- // deployFirewall first; internal-only tests need nothing more.
561
- const initResult = dockerExec(
562
- projectName,
563
- composeDir,
564
- 'management',
565
- `celilo system init --accept-defaults \
566
- network.internal.subnet=${ZONE_SUBNETS.internal} \
567
- network.internal.gateway=${ZONE_GATEWAYS.internal} \
568
- dns.primary=100.100.0.1 \
569
- dns.fallback=1.0.0.1,8.8.8.8`,
570
- );
571
- if (initResult.exitCode !== 0) {
572
- throw new Error(`Celilo init failed: ${initResult.stderr}`);
573
- }
574
-
575
- // Start the event-bus dispatcher (ISS-0035 / ISS-0042), now that `system
576
- // init` has created the bus DB. Deploys emit bus events —
577
- // public_web.routes_changed (caddy route reconcile), system.created (DNS
578
- // providers) — that only take effect when a dispatcher delivers them.
579
- // Production runs this as a systemd unit; the e2e mgmt box has no systemd,
580
- // so run it detached (nohup + disown, like the cele2e responder). One
581
- // dispatcher per management container serves every deploy in the test, so
582
- // individual tests don't need to start their own (delivery claims are
583
- // atomic, so a test that still does won't double-deliver).
584
- console.log('[progress:start] starting event dispatcher | dispatcher running');
585
- const dispatcherResult = dockerExec(
586
- projectName,
587
- composeDir,
588
- 'management',
589
- 'nohup celilo events run --poll-ms 500 --concurrency 4 > /tmp/cele2e-dispatcher.log 2>&1 < /dev/null & disown',
590
- );
591
- if (dispatcherResult.exitCode !== 0) {
592
- throw new Error(`Failed to start event dispatcher: ${dispatcherResult.stderr}`);
593
- }
594
- }
595
-
596
- console.log('[progress:done] network ready');
597
- // Emit project name so the runner can persist it if --keep is set
598
- console.log(`[e2e:project] ${projectName}`);
599
-
600
- // Track host-side resources spawned via the handle (SOCKS proxies,
601
- // Playwright browsers). stop() tears these down before the docker
602
- // network itself.
603
- const activeProxies: SocksProxyHandle[] = [];
604
- const activeBrowsers: BrowserHandle[] = [];
605
-
606
- const handle: NetworkHandle = {
607
- projectName,
608
-
609
- async celilo(
610
- cmd: string,
611
- optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
612
- ): Promise<ExecResult> {
613
- const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
614
- const timeoutMs =
615
- typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
616
-
617
- const result = await dockerExecAsync(
618
- projectName,
619
- composeDir,
620
- 'management',
621
- `celilo ${cmd}`,
622
- timeoutMs,
623
- );
624
-
625
- if (check && result.exitCode !== 0) {
626
- throw new CeliloCommandError(cmd, result);
627
- }
628
- return result;
629
- },
630
-
631
- exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
632
- return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
633
- },
634
-
635
- async respondWith(values): Promise<void> {
636
- // Write the values JSON inside the management container at a
637
- // stable path, then start a detached `celilo events respond
638
- // --values <path>` process. The responder polls the in-container
639
- // bus and answers config/secret/ensure events as the deploy
640
- // emits them. The responder dies with the container at network
641
- // teardown, so no explicit cleanup hook is needed.
642
- //
643
- // Long timeouts: the responder's --idle-timeout has to outlast
644
- // any quiet stretch between deploys (a long `module deploy`
645
- // might not emit interview events for several minutes if it's
646
- // building images). 1h idle / 2h max-duration covers any single
647
- // test comfortably without leaking. The container goes away at
648
- // network stop regardless.
649
- const valuesJson = JSON.stringify(values).replace(/'/g, "'\\''");
650
- const valuesPath = '/tmp/cele2e-responder-values.json';
651
- const writeCmd = `printf '%s' '${valuesJson}' > ${valuesPath}`;
652
- const writeResult = dockerExec(projectName, composeDir, 'management', writeCmd, 10_000);
653
- if (writeResult.exitCode !== 0) {
654
- throw new Error(
655
- `respondWith: failed to write values file inside management container: ${writeResult.stderr}`,
656
- );
657
- }
658
- // Kill any prior responder (idempotent: replaces prior values map).
659
- dockerExec(
660
- projectName,
661
- composeDir,
662
- 'management',
663
- 'pkill -f "celilo events respond" 2>/dev/null || true',
664
- 5_000,
665
- );
666
- // Spawn detached. nohup + & + disown cleanly survives the
667
- // dockerExec session ending.
668
- const spawnCmd =
669
- `nohup celilo events respond --values ${valuesPath} ` +
670
- '--idle-timeout 1h --max-duration 2h ' +
671
- '--emittedBy cele2e-responder ' +
672
- '> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
673
- const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
674
- if (spawnResult.exitCode !== 0) {
675
- throw new Error(
676
- `respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
677
- );
678
- }
679
- // Brief settle so the responder has registered its watches
680
- // before any deploy fires events. Without this, a fast deploy
681
- // could emit before the responder polls.
682
- await new Promise((r) => setTimeout(r, 500));
683
- },
684
-
685
- async deployFirewall(opts = {}): Promise<void> {
686
- const zones = opts.zones ?? ['dmz', 'app', 'secure'];
687
- const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
688
- const natIp = opts.natIp ?? internalNatIp();
689
- const providedNetworks = zones.map((zone) => ({
690
- zone,
691
- subnet: ZONE_SUBNETS[zone],
692
- gateway: ZONE_GATEWAYS[zone],
693
- }));
498
+ async deployFirewall(opts = {}): Promise<void> {
499
+ const zones = opts.zones ?? ['dmz', 'app', 'secure'];
500
+ const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
501
+ const natIp = opts.natIp ?? internalNatIp();
502
+ const providedNetworks = zones.map((zone) => ({
503
+ zone,
504
+ subnet: ZONE_SUBNETS[zone],
505
+ gateway: ZONE_GATEWAYS[zone],
506
+ }));
694
507
 
695
508
  // fw-main is a firewall container, not a target machine, so the
696
509
  // network readiness wait (target-setup) does NOT cover its sshd. Poll
@@ -738,12 +551,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
738
551
  },
739
552
 
740
553
  async browser(options: BrowserOptions): Promise<BrowserHandle> {
741
- const handle = await startBrowser(projectName, options);
742
- activeBrowsers.push(handle);
554
+ const browserHandle = await startBrowser(projectName, options);
555
+ activeBrowsers.push(browserHandle);
743
556
  // browser owns its proxy lifecycle; track it here too so stop()
744
557
  // doesn't try to stop it twice (idempotent stop() handles this)
745
- activeProxies.push(handle.proxy);
746
- return handle;
558
+ activeProxies.push(browserHandle.proxy);
559
+ return browserHandle;
747
560
  },
748
561
 
749
562
  async dig(name: string): Promise<string> {
@@ -864,257 +677,329 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
864
677
  return handle;
865
678
  }
866
679
 
867
- /**
868
- * Reconnect to an existing test network (previously kept alive with --keep).
869
- * Returns a NetworkHandle pointing to the existing Docker project. Skips
870
- * all the setup (image build, container start, celilo init) — assumes the
871
- * network is already running and healthy.
872
- *
873
- * If the project doesn't exist, throws with an actionable error.
874
- */
875
- export function reconnectNetwork(projectName: string): NetworkHandle {
876
- const composeDir = PACKAGE_ROOT;
877
-
878
- // Verify the project actually exists
680
+ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle> {
681
+ // Pre-flight: fail if live containers are running that will compete for resources.
879
682
  try {
880
- const result = run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`, {
881
- cwd: composeDir,
882
- timeout: 10_000,
883
- });
884
- if (!result.trim()) {
885
- throw new Error(`No containers found for project ${projectName}`);
683
+ const running = run('docker ps --format "{{.Names}}" 2>/dev/null').split('\n').filter(Boolean);
684
+ const heavyPatterns = ['authentik-', 'caddy-', 'build-your-own-internet-'];
685
+ const heavy = running.filter((name) => heavyPatterns.some((p) => name.startsWith(p)));
686
+ if (heavy.length > 0) {
687
+ const names = heavy.map((c) => ` - ${c}`).join('\n');
688
+ throw new Error(
689
+ `\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\n` +
690
+ 'Live and e2e environments are mutually exclusive.\n' +
691
+ 'Stop live containers first: cele2e down --all\n',
692
+ );
886
693
  }
887
- } catch (err) {
888
- throw new Error(
889
- `Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
890
- `Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
891
- );
694
+ } catch (e) {
695
+ if (e instanceof Error && e.message.includes('Cannot start e2e tests')) throw e;
892
696
  }
893
697
 
894
- activeProjects.add(projectName);
895
- console.log(`[progress:done] reconnected to existing network (${projectName})`);
896
- console.log(`[e2e:project] ${projectName}`);
897
-
898
- // Track host-side resources spawned via the handle (mirrors startNetwork).
899
- const activeProxies: SocksProxyHandle[] = [];
900
- const activeBrowsers: BrowserHandle[] = [];
901
-
902
- const handle: NetworkHandle = {
903
- projectName,
698
+ // Ensure shared infrastructure (DNS, Pebble, registry, etc.) is running.
699
+ // This is idempotent — if already up, it just verifies health.
700
+ await ensureSharedInfra();
904
701
 
905
- async celilo(
906
- cmd: string,
907
- optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
908
- ): Promise<ExecResult> {
909
- const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
910
- const timeoutMs =
911
- typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
702
+ // Scrub DNS zone files to baseline before each test (H5 in E2E_TEST_UPDATES.md).
703
+ // Prevents DDNS-written records from one test leaking into the next.
704
+ // Tests can opt out with config.skipDnsScrub (e.g., for --reuse debugging).
705
+ if (!config.skipDnsScrub) {
706
+ await scrubDnsZones();
707
+ }
912
708
 
913
- const result = await dockerExecAsync(
914
- projectName,
915
- composeDir,
916
- 'management',
917
- `celilo ${cmd}`,
918
- timeoutMs,
919
- );
709
+ // Clean up stale per-test containers (self-healing from prior crashes)
710
+ console.log('[progress:start] cleaning up stale test resources | cleanup complete');
711
+ try {
712
+ // Force-kill per-test containers (not shared infra)
713
+ try {
714
+ const containers = run('docker ps -aq --filter "name=celilo-e2e-1" 2>/dev/null');
715
+ if (containers.trim()) {
716
+ run(`docker rm -f ${containers.replace(/\n/g, ' ')}`, { timeout: 30_000 });
717
+ }
718
+ } catch {}
920
719
 
921
- if (check && result.exitCode !== 0) {
922
- throw new CeliloCommandError(cmd, result);
720
+ // Remove stale per-test networks
721
+ try {
722
+ const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
723
+ .split('\n')
724
+ .filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
725
+ if (networks.length > 0) {
726
+ const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
727
+ for (const project of projects) {
728
+ try {
729
+ run(`docker compose -p ${project} down --volumes --remove-orphans`, {
730
+ timeout: 30_000,
731
+ });
732
+ } catch {}
733
+ }
734
+ for (const net of networks) {
735
+ try {
736
+ run(`docker network rm ${net}`, { timeout: 5_000 });
737
+ } catch {}
738
+ }
923
739
  }
924
- return result;
925
- },
740
+ } catch {}
741
+ } catch {}
926
742
 
927
- exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
928
- return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
929
- },
743
+ const projectName = `celilo-e2e-${Date.now()}`;
744
+ activeProjects.add(projectName);
930
745
 
931
- async respondWith(values): Promise<void> {
932
- // Write the values JSON inside the management container at a
933
- // stable path, then start a detached `celilo events respond
934
- // --values <path>` process. The responder polls the in-container
935
- // bus and answers config/secret/ensure events as the deploy
936
- // emits them. The responder dies with the container at network
937
- // teardown, so no explicit cleanup hook is needed.
938
- //
939
- // Long timeouts: the responder's --idle-timeout has to outlast
940
- // any quiet stretch between deploys (a long `module deploy`
941
- // might not emit interview events for several minutes if it's
942
- // building images). 1h idle / 2h max-duration covers any single
943
- // test comfortably without leaking. The container goes away at
944
- // network stop regardless.
945
- const valuesJson = JSON.stringify(values).replace(/'/g, "'\\''");
946
- const valuesPath = '/tmp/cele2e-responder-values.json';
947
- const writeCmd = `printf '%s' '${valuesJson}' > ${valuesPath}`;
948
- const writeResult = dockerExec(projectName, composeDir, 'management', writeCmd, 10_000);
949
- if (writeResult.exitCode !== 0) {
950
- throw new Error(
951
- `respondWith: failed to write values file inside management container: ${writeResult.stderr}`,
952
- );
953
- }
954
- // Kill any prior responder (idempotent: replaces prior values map).
955
- dockerExec(
956
- projectName,
957
- composeDir,
958
- 'management',
959
- 'pkill -f "celilo events respond" 2>/dev/null || true',
960
- 5_000,
961
- );
962
- // Spawn detached. nohup + & + disown cleanly survives the
963
- // dockerExec session ending.
964
- const spawnCmd =
965
- `nohup celilo events respond --values ${valuesPath} ` +
966
- '--idle-timeout 1h --max-duration 2h ' +
967
- '--emittedBy cele2e-responder ' +
968
- '> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
969
- const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
970
- if (spawnResult.exitCode !== 0) {
971
- throw new Error(
972
- `respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
973
- );
974
- }
975
- // Brief settle so the responder has registered its watches
976
- // before any deploy fires events. Without this, a fast deploy
977
- // could emit before the responder polls.
978
- await new Promise((r) => setTimeout(r, 500));
979
- },
746
+ const composeDir = PACKAGE_ROOT;
747
+ const celiloRoot = config.celiloRoot ?? findCeliloRoot();
980
748
 
981
- async deployFirewall(opts = {}): Promise<void> {
982
- const zones = opts.zones ?? ['dmz', 'app', 'secure'];
983
- const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
984
- const natIp = opts.natIp ?? internalNatIp();
985
- const providedNetworks = zones.map((zone) => ({
986
- zone,
987
- subnet: ZONE_SUBNETS[zone],
988
- gateway: ZONE_GATEWAYS[zone],
989
- }));
749
+ // Generate per-test compose (references shared infra networks as external)
750
+ const yaml = generateTestComposeYaml(config, celiloRoot);
751
+ writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
990
752
 
991
- await handle.celilo(
992
- `machine add ${firewallIp} --ssh-user root --ssh-key-file /root/.ssh/id_ed25519 --zone internal`,
993
- );
994
- await handle.celilo('module import iptables');
995
- await handle.celilo(`module config set iptables firewall_ip ${firewallIp}`);
996
- await handle.celilo(`module config set iptables nat_ip ${natIp}`);
997
- await handle.celilo(
998
- `module config set iptables zones '${JSON.stringify(['internal', ...zones])}'`,
999
- );
1000
- await handle.celilo(
1001
- `module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
753
+ // Build and start per-test containers only — stream output so the runner
754
+ // can show which service is currently being built (can take minutes on cold cache).
755
+ await streamingBuild(projectName, composeDir, COMPOSE_FILE);
756
+
757
+ console.log('[progress:start] starting containers | containers running');
758
+ run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, {
759
+ cwd: composeDir,
760
+ timeout: 120_000,
761
+ });
762
+
763
+ // Wait for DNS convergence (comcast-resolver is per-test, needs to reach shared DNS)
764
+ console.log('[progress:start] waiting for DNS convergence | DNS converged');
765
+ await waitFor(
766
+ async () => {
767
+ const result = dockerExec(
768
+ projectName,
769
+ composeDir,
770
+ 'comcast-resolver',
771
+ 'dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2',
1002
772
  );
1003
- await handle.celilo('module deploy iptables', 180_000);
773
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
1004
774
  },
775
+ 60_000,
776
+ 'DNS convergence',
777
+ );
1005
778
 
1006
- async socksProxy(options: ProxyOptions = {}): Promise<SocksProxyHandle> {
1007
- const proxy = await startSocksProxy(projectName, options);
1008
- activeProxies.push(proxy);
1009
- return proxy;
1010
- },
779
+ // Wait for internal resolver (split-horizon DNS for management container).
780
+ // Skipped when the test specifies its own machine named `dns-int` — that
781
+ // means the test is replacing the infra resolver with a module under test
782
+ // (e.g., knot-unbound-internal). The bare target machine has no DNS server
783
+ // until its module deploys, so this wait would always time out. Management
784
+ // can still resolve names via the fallback `nameserver 100.100.0.1` in
785
+ // its resolv.conf during deploy.
786
+ //
787
+ // The resolver-under-test may sit in ANY zone, not just `internal`: ISS-0156
788
+ // places the dns_internal provider in a PROTECTED zone (dmz) so it can see
789
+ // protected-zone query sources for split-horizon views. So check every zone's
790
+ // machines for a `dns-int`, not only internalMachines.
791
+ const dnsIntReplaced = [
792
+ ...config.internalMachines,
793
+ ...config.dmzMachines,
794
+ ...config.appMachines,
795
+ ...config.secureMachines,
796
+ ].some((m) => m.name === 'dns-int');
797
+ if (!dnsIntReplaced) {
798
+ console.log('[progress:start] waiting for internal resolver | internal resolver ready');
799
+ await waitFor(
800
+ async () => {
801
+ const result = dockerExec(
802
+ projectName,
803
+ composeDir,
804
+ 'dns-int',
805
+ 'dig @127.0.0.1 iamtheinternet.org A +short +timeout=2',
806
+ );
807
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
808
+ },
809
+ 30_000,
810
+ 'internal DNS resolver',
811
+ );
812
+ }
1011
813
 
1012
- async browser(options: BrowserOptions): Promise<BrowserHandle> {
1013
- const browserHandle = await startBrowser(projectName, options);
1014
- activeBrowsers.push(browserHandle);
1015
- activeProxies.push(browserHandle.proxy);
1016
- return browserHandle;
1017
- },
814
+ // Optional routing verification
815
+ if (config.verifyRouting) {
816
+ console.log('[progress:start] verifying routing | routing verified');
817
+ await waitFor(
818
+ async () => {
819
+ const result = dockerExec(
820
+ projectName,
821
+ composeDir,
822
+ 'management',
823
+ `ping -c1 -W2 ${SIMULATOR_IPS.ROOT_DNS}`,
824
+ );
825
+ return result.exitCode === 0;
826
+ },
827
+ 30_000,
828
+ 'routing verification',
829
+ );
830
+ }
1018
831
 
1019
- async dig(name: string): Promise<string> {
1020
- const result = dockerExec(projectName, composeDir, 'management', `dig +short ${name}`);
1021
- return result.stdout
1022
- .split('\n')
1023
- .filter((l) => !l.startsWith(';;'))
1024
- .join('\n')
1025
- .trim();
1026
- },
832
+ // Wait for all target machines to complete setup (systemd boot + routing)
833
+ const allMachines = [
834
+ ...config.dmzMachines,
835
+ ...config.appMachines,
836
+ ...config.secureMachines,
837
+ ...config.internalMachines,
838
+ ];
839
+ if (allMachines.length > 0) {
840
+ console.log('[progress:start] waiting for target machines | target machines ready');
841
+ }
842
+ for (const machine of allMachines) {
843
+ await waitFor(
844
+ async () => {
845
+ const result = dockerExec(
846
+ projectName,
847
+ composeDir,
848
+ machine.name,
849
+ 'systemctl is-active target-setup 2>/dev/null',
850
+ );
851
+ return result.stdout.trim() === 'active';
852
+ },
853
+ // App-zone machines use Dockerfile.target-machine-docker which bakes in
854
+ // dockerd; systemd boot + dockerd init takes ~25-35s, leaving a tight
855
+ // margin against a 30s timeout. 60s covers the observed worst case
856
+ // without slowing down dmz/internal-only tests (which still hit this
857
+ // in ~2-5s).
858
+ 60_000,
859
+ `${machine.name} target-setup`,
860
+ );
861
+ }
1027
862
 
1028
- async debug(container = 'management'): Promise<void> {
1029
- console.log(`[debug:pause] ${projectName} ${container}`);
1030
- const signalFile = join(composeDir, `.debug-resume-${process.pid}`);
1031
- const start = Date.now();
1032
- const timeout = 86_400_000;
1033
- while (!existsSync(signalFile) && Date.now() - start < timeout) {
1034
- await new Promise((r) => setTimeout(r, 500));
1035
- }
1036
- try {
1037
- require('node:fs').unlinkSync(signalFile);
1038
- } catch {}
1039
- console.log('[debug:resumed]');
1040
- },
863
+ // Wait for DHCP client lease
864
+ if (config.dhcpClient) {
865
+ console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
866
+ // Self-diagnosing via waitFor's onTimeout (e2e-confidence #255): a DHCP-lease
867
+ // timeout is an intermittent in-suite flake (passes solo). On timeout, dump
868
+ // the dhcp-client's interface + dhclient transcript so the failure shows
869
+ // whether DISCOVER/OFFER/REQUEST/ACK completed (server silent) vs. a lease
870
+ // that landed but wasn't recorded.
871
+ await waitFor(
872
+ async () => {
873
+ const result = dockerExec(
874
+ projectName,
875
+ composeDir,
876
+ 'dhcp-client',
877
+ 'test -f /var/lib/dhcp/dhclient.leases && grep -c lease /var/lib/dhcp/dhclient.leases',
878
+ );
879
+ return result.exitCode === 0 && Number.parseInt(result.stdout.trim()) > 0;
880
+ },
881
+ 60_000,
882
+ 'DHCP client lease',
883
+ () => {
884
+ const dump = (label: string, cmd: string): string => {
885
+ const r = dockerExec(projectName, composeDir, 'dhcp-client', cmd);
886
+ return `--- ${label} ---\n${(r.stdout || r.stderr || '(no output)').trim()}`;
887
+ };
888
+ return [
889
+ '=== DHCP diagnostics (dhcp-client) ===',
890
+ dump('ip addr', 'ip -o addr show 2>&1'),
891
+ dump('dhclient.leases', 'cat /var/lib/dhcp/dhclient.leases 2>&1 | tail -25'),
892
+ dump(
893
+ 'dhclient transcript',
894
+ "journalctl -u dhclient --no-pager 2>/dev/null | tail -25 || cat /var/log/dhclient.log 2>/dev/null | tail -25 || echo '(no dhclient log)'",
895
+ ),
896
+ ].join('\n');
897
+ },
898
+ );
899
+ }
1041
900
 
1042
- waitFor,
901
+ // Wait for routing to Pebble (through shared infra networks)
902
+ if (allMachines.length > 0) {
903
+ await waitFor(
904
+ async () => {
905
+ const result = dockerExec(
906
+ projectName,
907
+ composeDir,
908
+ 'management',
909
+ `ping -c1 -W2 ${SIMULATOR_IPS.PEBBLE}`,
910
+ );
911
+ return result.exitCode === 0;
912
+ },
913
+ 30_000,
914
+ 'routing to Pebble',
915
+ );
916
+ }
1043
917
 
1044
- async configureAcme(): Promise<void> {
1045
- await handle.celilo(
1046
- 'module config set caddy acme_ca https://acme-v02.api.letsencrypt.org/dir',
1047
- );
1048
- },
918
+ // Initialize celilo. Skipped for the vanilla management variant —
919
+ // there's no celilo binary baked into that image; the caller is
920
+ // responsible for installing it (typically by running install.sh)
921
+ // and then calling `celilo system init` themselves.
922
+ if (config.managementVariant !== 'vanilla') {
923
+ assertCliVersion(projectName, composeDir);
924
+ console.log('[progress:start] initializing celilo | celilo initialized');
925
+ // Honest harness (v2/NETWORK_CONFIG_TO_FIREWALL.md): seed ONLY the
926
+ // `internal` zone — the network the management box is genuinely on —
927
+ // plus DNS. dmz/app/secure are NOT pre-seeded; they come into being
928
+ // when a test deploys the firewall (net.deployFirewall()), exactly as
929
+ // in production. A test that places services in those zones must call
930
+ // deployFirewall first; internal-only tests need nothing more.
931
+ const initResult = dockerExec(
932
+ projectName,
933
+ composeDir,
934
+ 'management',
935
+ `celilo system init --accept-defaults \
936
+ network.internal.subnet=${ZONE_SUBNETS.internal} \
937
+ network.internal.gateway=${ZONE_GATEWAYS.internal} \
938
+ dns.primary=100.100.0.1 \
939
+ dns.fallback=1.0.0.1,8.8.8.8`,
940
+ );
941
+ if (initResult.exitCode !== 0) {
942
+ throw new Error(`Celilo init failed: ${initResult.stderr}`);
943
+ }
1049
944
 
1050
- async publishModule(localPath: string): Promise<void> {
1051
- const absPath = resolve(localPath);
1052
- const isNetapp = absPath.endsWith('.netapp');
1053
- const moduleId = isNetapp ? basename(absPath).slice(0, -7) : basename(absPath);
945
+ // Start the event-bus dispatcher (ISS-0035 / ISS-0042), now that `system
946
+ // init` has created the bus DB. Deploys emit bus events —
947
+ // public_web.routes_changed (caddy route reconcile), system.created (DNS
948
+ // providers) — that only take effect when a dispatcher delivers them.
949
+ // Production runs this as a systemd unit; the e2e mgmt box has no systemd,
950
+ // so run it detached (nohup + disown, like the cele2e responder). One
951
+ // dispatcher per management container serves every deploy in the test, so
952
+ // individual tests don't need to start their own (delivery claims are
953
+ // atomic, so a test that still does won't double-deliver).
954
+ console.log('[progress:start] starting event dispatcher | dispatcher running');
955
+ const dispatcherResult = dockerExec(
956
+ projectName,
957
+ composeDir,
958
+ 'management',
959
+ 'nohup celilo events run --poll-ms 500 --concurrency 4 > /tmp/cele2e-dispatcher.log 2>&1 < /dev/null & disown',
960
+ );
961
+ if (dispatcherResult.exitCode !== 0) {
962
+ throw new Error(`Failed to start event dispatcher: ${dispatcherResult.stderr}`);
963
+ }
964
+ }
1054
965
 
1055
- let netappPath: string;
1056
- let cleanup = false;
966
+ console.log('[progress:done] network ready');
967
+ // Emit project name so the runner can persist it if --keep is set
968
+ console.log(`[e2e:project] ${projectName}`);
1057
969
 
1058
- if (isNetapp) {
1059
- netappPath = absPath;
1060
- } else {
1061
- netappPath = join(tmpdir(), `${moduleId}-${Date.now()}.netapp`);
1062
- cleanup = true;
1063
- const resolvedCeliloRoot = findCeliloRoot();
1064
- const celiloCliPath = join(
1065
- resolvedCeliloRoot ?? PACKAGE_ROOT,
1066
- 'apps/celilo/src/cli/index.ts',
1067
- );
1068
- try {
1069
- run(
1070
- `bun run ${JSON.stringify(celiloCliPath)} package ${JSON.stringify(absPath)} --output ${JSON.stringify(netappPath)}`,
1071
- { timeout: 60_000 },
1072
- );
1073
- } catch (err) {
1074
- throw new Error(`Failed to package module at ${absPath}: ${String(err)}`);
1075
- }
1076
- }
970
+ return buildNetworkHandle(projectName, composeDir, celiloRoot);
971
+ }
1077
972
 
1078
- try {
1079
- run(
1080
- `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} cp ${JSON.stringify(netappPath)} registry:/uploads/${moduleId}.netapp`,
1081
- { cwd: composeDir, timeout: 120_000 },
1082
- );
1083
- } finally {
1084
- if (cleanup)
1085
- try {
1086
- execSync(`rm -f ${JSON.stringify(netappPath)}`, { stdio: 'pipe' });
1087
- } catch {}
1088
- }
1089
- },
973
+ /**
974
+ * Reconnect to an existing test network (previously kept alive with --keep).
975
+ * Returns a NetworkHandle pointing to the existing Docker project. Skips
976
+ * all the setup (image build, container start, celilo init) — assumes the
977
+ * network is already running and healthy.
978
+ *
979
+ * If the project doesn't exist, throws with an actionable error.
980
+ */
981
+ export function reconnectNetwork(projectName: string): NetworkHandle {
982
+ const composeDir = PACKAGE_ROOT;
1090
983
 
1091
- async stop(): Promise<void> {
1092
- for (const browser of activeBrowsers) {
1093
- try {
1094
- await browser.close();
1095
- } catch {}
1096
- }
1097
- for (const proxy of activeProxies) {
1098
- try {
1099
- await proxy.stop();
1100
- } catch {}
1101
- }
984
+ // Verify the project actually exists
985
+ try {
986
+ const result = run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`, {
987
+ cwd: composeDir,
988
+ timeout: 10_000,
989
+ });
990
+ if (!result.trim()) {
991
+ throw new Error(`No containers found for project ${projectName}`);
992
+ }
993
+ } catch (err) {
994
+ throw new Error(
995
+ `Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
996
+ `Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
997
+ );
998
+ }
1102
999
 
1103
- // Respect --keep / --reuse: don't tear down
1104
- if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
1105
- console.log(`[progress:done] network kept alive (project: ${projectName})`);
1106
- activeProjects.delete(projectName);
1107
- return;
1108
- }
1109
- try {
1110
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`, {
1111
- cwd: composeDir,
1112
- timeout: 60_000,
1113
- });
1114
- } catch {}
1115
- activeProjects.delete(projectName);
1116
- },
1117
- };
1000
+ activeProjects.add(projectName);
1001
+ console.log(`[progress:done] reconnected to existing network (${projectName})`);
1002
+ console.log(`[e2e:project] ${projectName}`);
1118
1003
 
1119
- return handle;
1004
+ return buildNetworkHandle(projectName, composeDir, findCeliloRoot());
1120
1005
  }