@celilo/e2e 0.7.17 → 0.9.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.
@@ -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,359 +399,132 @@ 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;
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}`,
490
+ );
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));
425
496
  },
426
- 60_000,
427
- 'DNS convergence',
428
- );
429
497
 
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
- const dnsIntReplaced = config.internalMachines.some((m) => m.name === 'dns-int');
438
- if (!dnsIntReplaced) {
439
- console.log('[progress:start] waiting for internal resolver | internal resolver ready');
440
- await waitFor(
441
- async () => {
442
- const result = dockerExec(
443
- projectName,
444
- composeDir,
445
- 'dns-int',
446
- 'dig @127.0.0.1 iamtheinternet.org A +short +timeout=2',
447
- );
448
- return result.exitCode === 0 && result.stdout.trim().length > 0;
449
- },
450
- 30_000,
451
- 'internal DNS resolver',
452
- );
453
- }
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
+ }));
454
507
 
455
- // Optional routing verification
456
- if (config.verifyRouting) {
457
- console.log('[progress:start] verifying routing | routing verified');
458
- await waitFor(
459
- async () => {
460
- const result = dockerExec(
461
- projectName,
462
- composeDir,
463
- 'management',
464
- `ping -c1 -W2 ${SIMULATOR_IPS.ROOT_DNS}`,
465
- );
466
- return result.exitCode === 0;
467
- },
468
- 30_000,
469
- 'routing verification',
470
- );
471
- }
472
-
473
- // Wait for all target machines to complete setup (systemd boot + routing)
474
- const allMachines = [
475
- ...config.dmzMachines,
476
- ...config.appMachines,
477
- ...config.secureMachines,
478
- ...config.internalMachines,
479
- ];
480
- if (allMachines.length > 0) {
481
- console.log('[progress:start] waiting for target machines | target machines ready');
482
- }
483
- for (const machine of allMachines) {
484
- await waitFor(
485
- async () => {
486
- const result = dockerExec(
487
- projectName,
488
- composeDir,
489
- machine.name,
490
- 'systemctl is-active target-setup 2>/dev/null',
491
- );
492
- return result.stdout.trim() === 'active';
493
- },
494
- // App-zone machines use Dockerfile.target-machine-docker which bakes in
495
- // dockerd; systemd boot + dockerd init takes ~25-35s, leaving a tight
496
- // margin against a 30s timeout. 60s covers the observed worst case
497
- // without slowing down dmz/internal-only tests (which still hit this
498
- // in ~2-5s).
499
- 60_000,
500
- `${machine.name} target-setup`,
501
- );
502
- }
503
-
504
- // Wait for DHCP client lease
505
- if (config.dhcpClient) {
506
- console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
507
- await waitFor(
508
- async () => {
509
- const result = dockerExec(
510
- projectName,
511
- composeDir,
512
- 'dhcp-client',
513
- 'test -f /var/lib/dhcp/dhclient.leases && grep -c lease /var/lib/dhcp/dhclient.leases',
514
- );
515
- return result.exitCode === 0 && Number.parseInt(result.stdout.trim()) > 0;
516
- },
517
- 60_000,
518
- 'DHCP client lease',
519
- );
520
- }
521
-
522
- // Wait for routing to Pebble (through shared infra networks)
523
- if (allMachines.length > 0) {
524
- await waitFor(
525
- async () => {
526
- const result = dockerExec(
527
- projectName,
528
- composeDir,
529
- 'management',
530
- `ping -c1 -W2 ${SIMULATOR_IPS.PEBBLE}`,
531
- );
532
- return result.exitCode === 0;
533
- },
534
- 30_000,
535
- 'routing to Pebble',
536
- );
537
- }
538
-
539
- // Initialize celilo. Skipped for the vanilla management variant —
540
- // there's no celilo binary baked into that image; the caller is
541
- // responsible for installing it (typically by running install.sh)
542
- // and then calling `celilo system init` themselves.
543
- if (config.managementVariant !== 'vanilla') {
544
- console.log('[progress:start] initializing celilo | celilo initialized');
545
- // Honest harness (v2/NETWORK_CONFIG_TO_FIREWALL.md): seed ONLY the
546
- // `internal` zone — the network the management box is genuinely on —
547
- // plus DNS. dmz/app/secure are NOT pre-seeded; they come into being
548
- // when a test deploys the firewall (net.deployFirewall()), exactly as
549
- // in production. A test that places services in those zones must call
550
- // deployFirewall first; internal-only tests need nothing more.
551
- const initResult = dockerExec(
552
- projectName,
553
- composeDir,
554
- 'management',
555
- `celilo system init --accept-defaults \
556
- network.internal.subnet=${ZONE_SUBNETS.internal} \
557
- network.internal.gateway=${ZONE_GATEWAYS.internal} \
558
- dns.primary=100.100.0.1 \
559
- dns.fallback=1.0.0.1,8.8.8.8`,
560
- );
561
- if (initResult.exitCode !== 0) {
562
- throw new Error(`Celilo init failed: ${initResult.stderr}`);
563
- }
564
-
565
- // Start the event-bus dispatcher (ISS-0035 / ISS-0042), now that `system
566
- // init` has created the bus DB. Deploys emit bus events —
567
- // public_web.routes_changed (caddy route reconcile), system.created (DNS
568
- // providers) — that only take effect when a dispatcher delivers them.
569
- // Production runs this as a systemd unit; the e2e mgmt box has no systemd,
570
- // so run it detached (nohup + disown, like the cele2e responder). One
571
- // dispatcher per management container serves every deploy in the test, so
572
- // individual tests don't need to start their own (delivery claims are
573
- // atomic, so a test that still does won't double-deliver).
574
- console.log('[progress:start] starting event dispatcher | dispatcher running');
575
- const dispatcherResult = dockerExec(
576
- projectName,
577
- composeDir,
578
- 'management',
579
- 'nohup celilo events run --poll-ms 500 --concurrency 4 > /tmp/cele2e-dispatcher.log 2>&1 < /dev/null & disown',
580
- );
581
- if (dispatcherResult.exitCode !== 0) {
582
- throw new Error(`Failed to start event dispatcher: ${dispatcherResult.stderr}`);
583
- }
584
- }
585
-
586
- console.log('[progress:done] network ready');
587
- // Emit project name so the runner can persist it if --keep is set
588
- console.log(`[e2e:project] ${projectName}`);
589
-
590
- // Track host-side resources spawned via the handle (SOCKS proxies,
591
- // Playwright browsers). stop() tears these down before the docker
592
- // network itself.
593
- const activeProxies: SocksProxyHandle[] = [];
594
- const activeBrowsers: BrowserHandle[] = [];
595
-
596
- const handle: NetworkHandle = {
597
- projectName,
598
-
599
- async celilo(
600
- cmd: string,
601
- optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
602
- ): Promise<ExecResult> {
603
- const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
604
- const timeoutMs =
605
- typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
606
-
607
- const result = await dockerExecAsync(
608
- projectName,
609
- composeDir,
610
- 'management',
611
- `celilo ${cmd}`,
612
- timeoutMs,
613
- );
614
-
615
- if (check && result.exitCode !== 0) {
616
- throw new CeliloCommandError(cmd, result);
617
- }
618
- return result;
619
- },
620
-
621
- exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
622
- return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
623
- },
624
-
625
- async respondWith(values): Promise<void> {
626
- // Write the values JSON inside the management container at a
627
- // stable path, then start a detached `celilo events respond
628
- // --values <path>` process. The responder polls the in-container
629
- // bus and answers config/secret/ensure events as the deploy
630
- // emits them. The responder dies with the container at network
631
- // teardown, so no explicit cleanup hook is needed.
632
- //
633
- // Long timeouts: the responder's --idle-timeout has to outlast
634
- // any quiet stretch between deploys (a long `module deploy`
635
- // might not emit interview events for several minutes if it's
636
- // building images). 1h idle / 2h max-duration covers any single
637
- // test comfortably without leaking. The container goes away at
638
- // network stop regardless.
639
- const valuesJson = JSON.stringify(values).replace(/'/g, "'\\''");
640
- const valuesPath = '/tmp/cele2e-responder-values.json';
641
- const writeCmd = `printf '%s' '${valuesJson}' > ${valuesPath}`;
642
- const writeResult = dockerExec(projectName, composeDir, 'management', writeCmd, 10_000);
643
- if (writeResult.exitCode !== 0) {
644
- throw new Error(
645
- `respondWith: failed to write values file inside management container: ${writeResult.stderr}`,
646
- );
647
- }
648
- // Kill any prior responder (idempotent: replaces prior values map).
649
- dockerExec(
650
- projectName,
651
- composeDir,
652
- 'management',
653
- 'pkill -f "celilo events respond" 2>/dev/null || true',
654
- 5_000,
655
- );
656
- // Spawn detached. nohup + & + disown cleanly survives the
657
- // dockerExec session ending.
658
- const spawnCmd =
659
- `nohup celilo events respond --values ${valuesPath} ` +
660
- '--idle-timeout 1h --max-duration 2h ' +
661
- '--emittedBy cele2e-responder ' +
662
- '> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
663
- const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
664
- if (spawnResult.exitCode !== 0) {
665
- throw new Error(
666
- `respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
667
- );
668
- }
669
- // Brief settle so the responder has registered its watches
670
- // before any deploy fires events. Without this, a fast deploy
671
- // could emit before the responder polls.
672
- await new Promise((r) => setTimeout(r, 500));
673
- },
674
-
675
- async deployFirewall(opts = {}): Promise<void> {
676
- const zones = opts.zones ?? ['dmz', 'app', 'secure'];
677
- const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
678
- const natIp = opts.natIp ?? internalNatIp();
679
- const providedNetworks = zones.map((zone) => ({
680
- zone,
681
- subnet: ZONE_SUBNETS[zone],
682
- gateway: ZONE_GATEWAYS[zone],
683
- }));
508
+ // fw-main is a firewall container, not a target machine, so the
509
+ // network readiness wait (target-setup) does NOT cover its sshd. Poll
510
+ // until the firewall accepts SSH before `machine add` — otherwise a
511
+ // transient first-connect times out (spawnSync ETIMEDOUT) and aborts the
512
+ // whole deploy, cascading into unrelated "module not found" failures
513
+ // (#222). This is a readiness wait on the real prerequisite, not a sleep.
514
+ await waitFor(
515
+ async () => {
516
+ const probe = dockerExec(
517
+ projectName,
518
+ composeDir,
519
+ 'management',
520
+ `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -i /root/.ssh/id_ed25519 root@${firewallIp} hostname`,
521
+ 15_000,
522
+ );
523
+ return probe.exitCode === 0;
524
+ },
525
+ 60_000,
526
+ `firewall ${firewallIp} sshd`,
527
+ );
684
528
 
685
529
  // fw-main is registered as an internal-zone machine; iptables
686
530
  // deploys to it and (Phase 2) writes the provided zones to system
@@ -707,12 +551,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
707
551
  },
708
552
 
709
553
  async browser(options: BrowserOptions): Promise<BrowserHandle> {
710
- const handle = await startBrowser(projectName, options);
711
- activeBrowsers.push(handle);
554
+ const browserHandle = await startBrowser(projectName, options);
555
+ activeBrowsers.push(browserHandle);
712
556
  // browser owns its proxy lifecycle; track it here too so stop()
713
557
  // doesn't try to stop it twice (idempotent stop() handles this)
714
- activeProxies.push(handle.proxy);
715
- return handle;
558
+ activeProxies.push(browserHandle.proxy);
559
+ return browserHandle;
716
560
  },
717
561
 
718
562
  async dig(name: string): Promise<string> {
@@ -833,257 +677,329 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
833
677
  return handle;
834
678
  }
835
679
 
836
- /**
837
- * Reconnect to an existing test network (previously kept alive with --keep).
838
- * Returns a NetworkHandle pointing to the existing Docker project. Skips
839
- * all the setup (image build, container start, celilo init) — assumes the
840
- * network is already running and healthy.
841
- *
842
- * If the project doesn't exist, throws with an actionable error.
843
- */
844
- export function reconnectNetwork(projectName: string): NetworkHandle {
845
- const composeDir = PACKAGE_ROOT;
846
-
847
- // 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.
848
682
  try {
849
- const result = run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`, {
850
- cwd: composeDir,
851
- timeout: 10_000,
852
- });
853
- if (!result.trim()) {
854
- 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
+ );
855
693
  }
856
- } catch (err) {
857
- throw new Error(
858
- `Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
859
- `Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
860
- );
694
+ } catch (e) {
695
+ if (e instanceof Error && e.message.includes('Cannot start e2e tests')) throw e;
861
696
  }
862
697
 
863
- activeProjects.add(projectName);
864
- console.log(`[progress:done] reconnected to existing network (${projectName})`);
865
- console.log(`[e2e:project] ${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();
866
701
 
867
- // Track host-side resources spawned via the handle (mirrors startNetwork).
868
- const activeProxies: SocksProxyHandle[] = [];
869
- const activeBrowsers: BrowserHandle[] = [];
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
+ }
870
708
 
871
- const handle: NetworkHandle = {
872
- projectName,
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 {}
873
719
 
874
- async celilo(
875
- cmd: string,
876
- optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
877
- ): Promise<ExecResult> {
878
- const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
879
- const timeoutMs =
880
- typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
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
+ }
739
+ }
740
+ } catch {}
741
+ } catch {}
881
742
 
882
- const result = await dockerExecAsync(
883
- projectName,
884
- composeDir,
885
- 'management',
886
- `celilo ${cmd}`,
887
- timeoutMs,
888
- );
743
+ const projectName = `celilo-e2e-${Date.now()}`;
744
+ activeProjects.add(projectName);
889
745
 
890
- if (check && result.exitCode !== 0) {
891
- throw new CeliloCommandError(cmd, result);
892
- }
893
- return result;
894
- },
746
+ const composeDir = PACKAGE_ROOT;
747
+ const celiloRoot = config.celiloRoot ?? findCeliloRoot();
895
748
 
896
- exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
897
- return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
898
- },
749
+ // Generate per-test compose (references shared infra networks as external)
750
+ const yaml = generateTestComposeYaml(config, celiloRoot);
751
+ writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
899
752
 
900
- async respondWith(values): Promise<void> {
901
- // Write the values JSON inside the management container at a
902
- // stable path, then start a detached `celilo events respond
903
- // --values <path>` process. The responder polls the in-container
904
- // bus and answers config/secret/ensure events as the deploy
905
- // emits them. The responder dies with the container at network
906
- // teardown, so no explicit cleanup hook is needed.
907
- //
908
- // Long timeouts: the responder's --idle-timeout has to outlast
909
- // any quiet stretch between deploys (a long `module deploy`
910
- // might not emit interview events for several minutes if it's
911
- // building images). 1h idle / 2h max-duration covers any single
912
- // test comfortably without leaking. The container goes away at
913
- // network stop regardless.
914
- const valuesJson = JSON.stringify(values).replace(/'/g, "'\\''");
915
- const valuesPath = '/tmp/cele2e-responder-values.json';
916
- const writeCmd = `printf '%s' '${valuesJson}' > ${valuesPath}`;
917
- const writeResult = dockerExec(projectName, composeDir, 'management', writeCmd, 10_000);
918
- if (writeResult.exitCode !== 0) {
919
- throw new Error(
920
- `respondWith: failed to write values file inside management container: ${writeResult.stderr}`,
921
- );
922
- }
923
- // Kill any prior responder (idempotent: replaces prior values map).
924
- dockerExec(
925
- projectName,
926
- composeDir,
927
- 'management',
928
- 'pkill -f "celilo events respond" 2>/dev/null || true',
929
- 5_000,
930
- );
931
- // Spawn detached. nohup + & + disown cleanly survives the
932
- // dockerExec session ending.
933
- const spawnCmd =
934
- `nohup celilo events respond --values ${valuesPath} ` +
935
- '--idle-timeout 1h --max-duration 2h ' +
936
- '--emittedBy cele2e-responder ' +
937
- '> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
938
- const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
939
- if (spawnResult.exitCode !== 0) {
940
- throw new Error(
941
- `respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
942
- );
943
- }
944
- // Brief settle so the responder has registered its watches
945
- // before any deploy fires events. Without this, a fast deploy
946
- // could emit before the responder polls.
947
- await new Promise((r) => setTimeout(r, 500));
948
- },
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);
949
756
 
950
- async deployFirewall(opts = {}): Promise<void> {
951
- const zones = opts.zones ?? ['dmz', 'app', 'secure'];
952
- const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
953
- const natIp = opts.natIp ?? internalNatIp();
954
- const providedNetworks = zones.map((zone) => ({
955
- zone,
956
- subnet: ZONE_SUBNETS[zone],
957
- gateway: ZONE_GATEWAYS[zone],
958
- }));
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
+ });
959
762
 
960
- await handle.celilo(
961
- `machine add ${firewallIp} --ssh-user root --ssh-key-file /root/.ssh/id_ed25519 --zone internal`,
962
- );
963
- await handle.celilo('module import iptables');
964
- await handle.celilo(`module config set iptables firewall_ip ${firewallIp}`);
965
- await handle.celilo(`module config set iptables nat_ip ${natIp}`);
966
- await handle.celilo(
967
- `module config set iptables zones '${JSON.stringify(['internal', ...zones])}'`,
968
- );
969
- await handle.celilo(
970
- `module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
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',
971
772
  );
972
- await handle.celilo('module deploy iptables', 180_000);
773
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
973
774
  },
775
+ 60_000,
776
+ 'DNS convergence',
777
+ );
974
778
 
975
- async socksProxy(options: ProxyOptions = {}): Promise<SocksProxyHandle> {
976
- const proxy = await startSocksProxy(projectName, options);
977
- activeProxies.push(proxy);
978
- return proxy;
979
- },
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
+ }
980
813
 
981
- async browser(options: BrowserOptions): Promise<BrowserHandle> {
982
- const browserHandle = await startBrowser(projectName, options);
983
- activeBrowsers.push(browserHandle);
984
- activeProxies.push(browserHandle.proxy);
985
- return browserHandle;
986
- },
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
+ }
987
831
 
988
- async dig(name: string): Promise<string> {
989
- const result = dockerExec(projectName, composeDir, 'management', `dig +short ${name}`);
990
- return result.stdout
991
- .split('\n')
992
- .filter((l) => !l.startsWith(';;'))
993
- .join('\n')
994
- .trim();
995
- },
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
+ }
996
862
 
997
- async debug(container = 'management'): Promise<void> {
998
- console.log(`[debug:pause] ${projectName} ${container}`);
999
- const signalFile = join(composeDir, `.debug-resume-${process.pid}`);
1000
- const start = Date.now();
1001
- const timeout = 86_400_000;
1002
- while (!existsSync(signalFile) && Date.now() - start < timeout) {
1003
- await new Promise((r) => setTimeout(r, 500));
1004
- }
1005
- try {
1006
- require('node:fs').unlinkSync(signalFile);
1007
- } catch {}
1008
- console.log('[debug:resumed]');
1009
- },
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
+ }
1010
900
 
1011
- 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
+ }
1012
917
 
1013
- async configureAcme(): Promise<void> {
1014
- await handle.celilo(
1015
- 'module config set caddy acme_ca https://acme-v02.api.letsencrypt.org/dir',
1016
- );
1017
- },
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
+ }
1018
944
 
1019
- async publishModule(localPath: string): Promise<void> {
1020
- const absPath = resolve(localPath);
1021
- const isNetapp = absPath.endsWith('.netapp');
1022
- 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
+ }
1023
965
 
1024
- let netappPath: string;
1025
- 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}`);
1026
969
 
1027
- if (isNetapp) {
1028
- netappPath = absPath;
1029
- } else {
1030
- netappPath = join(tmpdir(), `${moduleId}-${Date.now()}.netapp`);
1031
- cleanup = true;
1032
- const resolvedCeliloRoot = findCeliloRoot();
1033
- const celiloCliPath = join(
1034
- resolvedCeliloRoot ?? PACKAGE_ROOT,
1035
- 'apps/celilo/src/cli/index.ts',
1036
- );
1037
- try {
1038
- run(
1039
- `bun run ${JSON.stringify(celiloCliPath)} package ${JSON.stringify(absPath)} --output ${JSON.stringify(netappPath)}`,
1040
- { timeout: 60_000 },
1041
- );
1042
- } catch (err) {
1043
- throw new Error(`Failed to package module at ${absPath}: ${String(err)}`);
1044
- }
1045
- }
970
+ return buildNetworkHandle(projectName, composeDir, celiloRoot);
971
+ }
1046
972
 
1047
- try {
1048
- run(
1049
- `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} cp ${JSON.stringify(netappPath)} registry:/uploads/${moduleId}.netapp`,
1050
- { cwd: composeDir, timeout: 120_000 },
1051
- );
1052
- } finally {
1053
- if (cleanup)
1054
- try {
1055
- execSync(`rm -f ${JSON.stringify(netappPath)}`, { stdio: 'pipe' });
1056
- } catch {}
1057
- }
1058
- },
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;
1059
983
 
1060
- async stop(): Promise<void> {
1061
- for (const browser of activeBrowsers) {
1062
- try {
1063
- await browser.close();
1064
- } catch {}
1065
- }
1066
- for (const proxy of activeProxies) {
1067
- try {
1068
- await proxy.stop();
1069
- } catch {}
1070
- }
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
+ }
1071
999
 
1072
- // Respect --keep / --reuse: don't tear down
1073
- if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
1074
- console.log(`[progress:done] network kept alive (project: ${projectName})`);
1075
- activeProjects.delete(projectName);
1076
- return;
1077
- }
1078
- try {
1079
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`, {
1080
- cwd: composeDir,
1081
- timeout: 60_000,
1082
- });
1083
- } catch {}
1084
- activeProjects.delete(projectName);
1085
- },
1086
- };
1000
+ activeProjects.add(projectName);
1001
+ console.log(`[progress:done] reconnected to existing network (${projectName})`);
1002
+ console.log(`[e2e:project] ${projectName}`);
1087
1003
 
1088
- return handle;
1004
+ return buildNetworkHandle(projectName, composeDir, findCeliloRoot());
1089
1005
  }