@celilo/e2e 0.11.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +5 -5
  2. package/bin/e2e-status +1 -1
  3. package/bin/e2e-up +2 -2
  4. package/config/dns/tangohost.com.zone +1 -1
  5. package/config/dns/templates/example.net.zone +3 -3
  6. package/config/dns/templates/iamtheinternet.org.zone +2 -2
  7. package/config/resolver/unbound.conf +4 -0
  8. package/config/routing/fw-ext-routes.sh +8 -8
  9. package/config/routing/fw-isp-routes.sh +2 -2
  10. package/config/routing/fw-main-routes.sh +5 -5
  11. package/config/routing/management-routes.sh +1 -1
  12. package/config/routing/observer-setup.sh +1 -1
  13. package/config/routing/public-resolver-routes.sh +33 -0
  14. package/config/routing/public-sim-entrypoint.sh +1 -1
  15. package/config/routing/resolver-internal-routes.sh +7 -2
  16. package/config/routing/resolver-routes.sh +1 -1
  17. package/config/routing/target-routes.sh +1 -1
  18. package/config/routing/target-setup.sh +1 -1
  19. package/config/socks/startup.sh +2 -2
  20. package/docker/Dockerfile.ip-echo +20 -0
  21. package/docker/Dockerfile.resolver +5 -1
  22. package/package.json +5 -4
  23. package/simulators/greenwave/server.ts +35 -0
  24. package/simulators/greenwave/state.ts +79 -7
  25. package/simulators/ip-echo/server.ts +76 -0
  26. package/src/address-plan.test.ts +4 -4
  27. package/src/cli/build.ts +30 -0
  28. package/src/cli/command-registry.ts +19 -0
  29. package/src/cli/completion.ts +16 -1
  30. package/src/cli/index.ts +85 -4
  31. package/src/container-manager.ts +75 -12
  32. package/src/docker-compose-generator.ts +97 -29
  33. package/src/doctor.test.ts +279 -0
  34. package/src/doctor.ts +421 -0
  35. package/src/extract-failure.ts +41 -0
  36. package/src/index.ts +12 -0
  37. package/src/last-run.test.ts +62 -0
  38. package/src/last-run.ts +54 -0
  39. package/src/network-builder.ts +11 -0
  40. package/src/observer.test.ts +2 -2
  41. package/src/observer.ts +1 -1
  42. package/src/router-swap.ts +121 -0
  43. package/src/run-lock.test.ts +73 -2
  44. package/src/run-lock.ts +110 -14
  45. package/src/runner.ts +99 -5
  46. package/src/simulator-ips.ts +21 -0
  47. package/src/socks-proxy.ts +2 -2
  48. package/src/types.ts +34 -4
  49. package/src/vantage.test.ts +1 -1
  50. package/src/zone-classifier.test.ts +11 -16
  51. package/src/zone-classifier.ts +16 -31
@@ -0,0 +1,76 @@
1
+ /**
2
+ * IP echo simulator — the rig's stand-in for api.ipify.org.
3
+ *
4
+ * Reports the source address a request appears to come from, and nothing else.
5
+ * celilo's `public_dns` check uses it for the ONE thing it cannot ask itself:
6
+ * what address the fleet is currently reachable at.
7
+ *
8
+ * Why an independent service rather than the registrar's own answer: the DDNS
9
+ * response carries the address the registrar says it published, so comparing
10
+ * public DNS against it is self-agreement. Worse than useless, in fact —
11
+ * Namecheap returns `ErrCount 0` with the requested address echoed back for
12
+ * `www` updates it does not apply, so the response can be actively false. The
13
+ * expectation has to come from somewhere that has no stake in the answer.
14
+ *
15
+ * This sits on `internet-external`, so a request from the customer fleet
16
+ * arrives having been SNAT'd by the customer firewall — the source address it
17
+ * sees IS the firewall's external address, which is the address the internet
18
+ * must dial. That is the same mechanism DDNS source-IP detection relies on,
19
+ * observed independently.
20
+ *
21
+ * Plain HTTP on purpose. Real ipify is HTTPS, but TLS here would mean an ACME
22
+ * dance (Caddy + Pebble, as the isitup sim does) to test nothing this check
23
+ * cares about: the property under test is "an off-fleet observer reports our
24
+ * egress", not certificate validation. The path from the fleet to here still
25
+ * crosses both firewalls exactly as a real request would.
26
+ */
27
+
28
+ // Marks this file a module rather than a script. Without it the simulators
29
+ // share one global scope at typecheck time, and `PORT`/`server` collide with
30
+ // the identically-named top-level bindings in simulators/isitup/server.ts.
31
+ export {};
32
+
33
+ interface RequestIpProvider {
34
+ requestIP(req: Request): { address: string } | null;
35
+ }
36
+
37
+ const PORT = Number(process.env.ECHO_PORT) || 80;
38
+
39
+ /**
40
+ * Strip the IPv6-mapped-IPv4 prefix Bun returns for IPv4 connections over a
41
+ * dual-stack socket (`::ffff:203.0.113.100` → `203.0.113.100`). Same fix as
42
+ * the namecheap-ddns simulator: the mapped form is not a valid A-record value,
43
+ * and here it would simply never compare equal to what DNS serves.
44
+ */
45
+ function normalizeIp(address: string): string {
46
+ const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
47
+ return mapped ? mapped[1] : address;
48
+ }
49
+
50
+ function sourceIp(req: Request, server: RequestIpProvider): string | null {
51
+ const forwarded = req.headers.get('x-forwarded-for');
52
+ if (forwarded) return normalizeIp(forwarded.split(',')[0].trim());
53
+ const remote = server.requestIP(req);
54
+ return remote?.address ? normalizeIp(remote.address) : null;
55
+ }
56
+
57
+ const server = Bun.serve({
58
+ port: PORT,
59
+ fetch(req, server) {
60
+ const url = new URL(req.url);
61
+
62
+ if (url.pathname === '/health') return Response.json({ ok: true });
63
+
64
+ const ip = sourceIp(req, server);
65
+ if (!ip) {
66
+ // NEVER a sentinel. A prior version of the DDNS simulator returned
67
+ // `0.0.0.0` when it could not determine the source, and that value went
68
+ // straight into a public zone file. An unanswerable probe must read as
69
+ // an error, which celilo counts as *undetermined* — never as a pass.
70
+ return new Response('source address could not be determined', { status: 500 });
71
+ }
72
+ return new Response(ip, { headers: { 'Content-Type': 'text/plain' } });
73
+ },
74
+ });
75
+
76
+ console.log(`[ip-echo] listening on port ${server.port}`);
@@ -17,8 +17,8 @@
17
17
 
18
18
  import { describe, expect, test } from 'bun:test';
19
19
  import { execSync } from 'node:child_process';
20
+ import { subnetContains } from '@celilo/capabilities';
20
21
  import { SIM_PRIVATE_SUPERNET, ZONE_GATEWAYS, ZONE_SUBNETS, type Zone } from './types';
21
- import { inCidr } from './zone-classifier';
22
22
 
23
23
  const REPO_ROOT = execSync('git rev-parse --show-toplevel', {
24
24
  cwd: import.meta.dir,
@@ -73,9 +73,9 @@ describe('simulated address plan (#539)', () => {
73
73
  test('every zone lives inside the sim supernet', () => {
74
74
  for (const zone of Object.keys(ZONE_SUBNETS) as Zone[]) {
75
75
  const base = ZONE_SUBNETS[zone].split('/')[0];
76
- expect(inCidr(base, SIM_PRIVATE_SUPERNET)).toBe(true);
77
- expect(inCidr(ZONE_GATEWAYS[zone], SIM_PRIVATE_SUPERNET)).toBe(true);
78
- expect(inCidr(ZONE_GATEWAYS[zone], ZONE_SUBNETS[zone])).toBe(true);
76
+ expect(subnetContains(SIM_PRIVATE_SUPERNET, base)).toBe(true);
77
+ expect(subnetContains(SIM_PRIVATE_SUPERNET, ZONE_GATEWAYS[zone])).toBe(true);
78
+ expect(subnetContains(ZONE_SUBNETS[zone], ZONE_GATEWAYS[zone])).toBe(true);
79
79
  }
80
80
  });
81
81
 
package/src/cli/build.ts CHANGED
@@ -24,8 +24,16 @@ import { basename, join, resolve } from 'node:path';
24
24
  import { gunzipSync } from 'node:zlib';
25
25
  import { stageAptRepo } from '../../scripts/stage-apt-repo';
26
26
  import { stageLibsignal } from '../../scripts/stage-libsignal';
27
+ import { explainBuildFailure } from '../doctor';
27
28
  import { ensureRegistryServerBundle } from '../registry-bundle';
28
29
 
30
+ /**
31
+ * Ceiling on a single `docker build`. The slowest image here is a few minutes
32
+ * cold; 15 leaves generous headroom while bounding a wedged build, which
33
+ * otherwise hangs the whole invocation with no signal at all.
34
+ */
35
+ const PER_IMAGE_BUILD_TIMEOUT_MS = 15 * 60_000;
36
+
29
37
  /**
30
38
  * Walk up from pkgDir to find the celilo monorepo root.
31
39
  *
@@ -533,12 +541,34 @@ function buildDockerImages(pkgDir: string): void {
533
541
  const result = spawnSync('docker', ['build', '-t', tag, '-f', join(dockerDir, file), context], {
534
542
  cwd: pkgDir,
535
543
  stdio: 'pipe',
544
+ timeout: PER_IMAGE_BUILD_TIMEOUT_MS,
536
545
  });
537
546
  const elapsed = Math.round((Date.now() - start) / 1000);
538
547
 
548
+ // A wedged `docker build` used to hang here forever: spawnSync blocks the
549
+ // event loop, so even the run-lock heartbeat stops — the holder looks alive
550
+ // (its PID is) while beating nothing, and the only way anyone noticed was
551
+ // comparing beatAt to wall-clock by hand. Twice in one evening, ~20 and ~32
552
+ // minutes. A bounded wait turns that into an immediate, nameable failure.
553
+ if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
554
+ console.log(`${red}✗ TIMED OUT${reset} ${dim}${elapsed}s${reset}`);
555
+ console.error(
556
+ `\n${red}Building ${name} exceeded ${Math.round(PER_IMAGE_BUILD_TIMEOUT_MS / 60_000)} minutes and was killed.${reset}`,
557
+ );
558
+ console.error(
559
+ `${dim}Docker itself is usually still responsive. Reproduce it alone with:${reset}`,
560
+ );
561
+ console.error(`${dim} docker build -f docker/${file} -t ${tag} .${reset}`);
562
+ console.error(
563
+ `${dim}If that succeeds, the stall was transient — re-run \`cele2e build-infra\`.${reset}\n`,
564
+ );
565
+ process.exit(1);
566
+ }
567
+
539
568
  if (result.status !== 0) {
540
569
  console.log(`${red}✗${reset} ${dim}${elapsed}s${reset}`);
541
570
  console.error(result.stderr?.toString());
571
+ console.error(explainBuildFailure(result.stderr?.toString() ?? ''));
542
572
  process.exit(1);
543
573
  }
544
574
 
@@ -99,6 +99,22 @@ export const COMMANDS: CommandDef[] = [
99
99
  description: 'Show run-lock holder + network status (exit 3 if busy)',
100
100
  flags: [{ name: '--json', description: 'Emit a machine-readable lock snapshot for polling' }],
101
101
  },
102
+ {
103
+ name: 'doctor',
104
+ description:
105
+ "Check everything a run needs (baked images, base images, lock, disk) — run's implicit preflight",
106
+ flags: [{ name: '--json', description: 'Emit the full report as JSON' }],
107
+ },
108
+ {
109
+ name: 'last',
110
+ description: "Show the most recent run's results dir and counts",
111
+ flags: [
112
+ {
113
+ name: '--json',
114
+ description: 'Emit {runId, resultsDir, total, passed, failed} for scripting',
115
+ },
116
+ ],
117
+ },
102
118
  {
103
119
  name: 'release',
104
120
  description: 'Free a run-lock left by --keep/up (does not tear down)',
@@ -141,4 +157,7 @@ export const RUN_FLAGS = [
141
157
 
142
158
  export const DOWN_FLAGS = ['--keep', '--all'];
143
159
 
160
+ /** Read-only reporters whose only flag is `--json`. */
161
+ export const JSON_FLAG_COMMANDS = ['status', 'doctor', 'last'];
162
+
144
163
  export const BUILD_INFRA_FLAGS = ['--save', '--skip-modules', '--published'];
@@ -3,7 +3,14 @@
3
3
  * All completions are static (no DB lookups needed).
4
4
  */
5
5
 
6
- import { BUILD_INFRA_FLAGS, COMMANDS, DOWN_FLAGS, RUN_FLAGS, UP_PRESETS } from './command-registry';
6
+ import {
7
+ BUILD_INFRA_FLAGS,
8
+ COMMANDS,
9
+ DOWN_FLAGS,
10
+ JSON_FLAG_COMMANDS,
11
+ RUN_FLAGS,
12
+ UP_PRESETS,
13
+ } from './command-registry';
7
14
  import { findAllTestNames } from './module-discovery';
8
15
 
9
16
  export function getCompletions(words: string[], current: number): string[] {
@@ -62,6 +69,14 @@ export function getCompletions(words: string[], current: number): string[] {
62
69
  return filterSuggestions(['zsh', 'bash'], args[1] || '');
63
70
  }
64
71
 
72
+ if (JSON_FLAG_COMMANDS.includes(command) && currentIndex >= 1) {
73
+ const used = new Set(args.slice(1));
74
+ return filterSuggestions(
75
+ ['--json'].filter((f) => !used.has(f)),
76
+ args[currentIndex] || '',
77
+ );
78
+ }
79
+
65
80
  return [];
66
81
  }
67
82
 
package/src/cli/index.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * cele2e version
12
12
  * cele2e shell [container]
13
13
  * cele2e status
14
+ * cele2e doctor
15
+ * cele2e last [--json]
14
16
  * cele2e load
15
17
  * cele2e scaffold <test-name>
16
18
  */
@@ -19,11 +21,13 @@ import { spawnSync } from 'node:child_process';
19
21
  import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
20
22
  import { basename, dirname, join, resolve } from 'node:path';
21
23
  import { parse as parseYaml } from 'yaml';
24
+ import { diagnose, formatHolderLine, formatReport } from '../doctor';
25
+ import { readLastRun } from '../last-run';
22
26
  import {
23
27
  E2eBusyError,
28
+ type LockHolder,
24
29
  acquireRunLock,
25
30
  clearLock,
26
- formatBusy,
27
31
  lockStatus,
28
32
  markKept,
29
33
  } from '../run-lock';
@@ -129,6 +133,19 @@ function discoverSuiteTests(suiteRoot: string): { name: string; file: string; gr
129
133
  return out;
130
134
  }
131
135
 
136
+ /**
137
+ * Say out loud when we cleared this session's own kept stack. Silence would be
138
+ * worse than the old refusal: the operator asked for a kept stack, and it is
139
+ * gone now for a reason they should be able to read.
140
+ */
141
+ function noteAutoRelease(holder: LockHolder | null): void {
142
+ if (!holder) return;
143
+ console.log(
144
+ `Auto-released your own kept stack (${holder.test}, held since ${holder.startedAt}) and continuing.`,
145
+ );
146
+ console.log('Use `cele2e run --reuse` instead if you meant to run against it.');
147
+ }
148
+
132
149
  function runScript(script: string, args: string[], env?: Record<string, string>): never {
133
150
  const result = spawnSync(join(BIN_DIR, script), args, {
134
151
  stdio: 'inherit',
@@ -155,6 +172,8 @@ Commands:
155
172
  version Show cele2e version
156
173
  shell [container] Shell into a running container (default: management)
157
174
  status Show run-lock holder + network status (exit 3 if busy)
175
+ doctor Check the environment a run needs (run's implicit preflight)
176
+ last Show the most recent run's results dir + counts
158
177
  release Free a run-lock left by --keep/up (does not tear down)
159
178
  load Load cached Docker images from tarball
160
179
  scaffold <name> Generate a new test file from template
@@ -180,6 +199,9 @@ Options for \`down\`:
180
199
  --keep Stop containers but preserve volumes
181
200
  --all Also stop competing module containers
182
201
 
202
+ Options for \`doctor\` / \`last\` / \`status\`:
203
+ --json Machine-readable output
204
+
183
205
  Options for \`build-infra\`:
184
206
  --save Save Docker images to tarball after build
185
207
  --skip-modules Skip .netapp packaging (only rebuild Docker images)
@@ -341,7 +363,11 @@ switch (command) {
341
363
  // kept state (cleared by `cele2e down`/`release`) to stop a concurrent run
342
364
  // from wiping it.
343
365
  try {
344
- acquireRunLock({ test: 'up', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
366
+ const outcome = acquireRunLock({
367
+ test: 'up',
368
+ runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID(),
369
+ });
370
+ noteAutoRelease(outcome.autoReleasedOwnKept);
345
371
  markKept();
346
372
  } catch (err) {
347
373
  if (err instanceof E2eBusyError) {
@@ -385,7 +411,19 @@ switch (command) {
385
411
  if (lock.free) {
386
412
  console.log(' free');
387
413
  } else if (lock.holder) {
388
- console.log(` BUSY — ${formatBusy(lock.holder)}`);
414
+ // Heartbeat age is always printed: a wedged holder keeps a live PID, so
415
+ // the only thing separating "slow" from "hung for 32 minutes" is how long
416
+ // ago it last beat — previously visible only by reading beatAt by hand.
417
+ console.log(` BUSY — ${formatHolderLine(lock.holder)}`);
418
+ if (lock.suspect) {
419
+ console.log(
420
+ ' ⚠ SUSPECT: the process is alive but has stopped beating. It is probably wedged;',
421
+ );
422
+ console.log(` inspect or kill pid ${lock.holder.pid}, then \`cele2e down\`.`);
423
+ }
424
+ if (lock.ownKept) {
425
+ console.log(' This is YOUR OWN kept stack — the next `cele2e run` auto-releases it.');
426
+ }
389
427
  }
390
428
  console.log('');
391
429
 
@@ -410,6 +448,48 @@ switch (command) {
410
448
  break;
411
449
  }
412
450
 
451
+ case 'doctor': {
452
+ // The single "is my environment sane?" answer. `run` calls the same
453
+ // diagnose() as its preflight, so what doctor reports is exactly what a run
454
+ // will refuse on — no second, drifting copy of the rules.
455
+ const report = diagnose({ pkgDir: PKG_DIR });
456
+ if (args.includes('--json')) {
457
+ console.log(JSON.stringify(report, null, 2));
458
+ process.exit(report.ok ? 0 : 1);
459
+ }
460
+ console.log('=== cele2e doctor ===');
461
+ for (const line of formatReport(report)) console.log(line);
462
+ console.log('');
463
+ console.log(
464
+ report.ok ? 'Environment looks runnable.' : 'Environment is NOT runnable — see fixes above.',
465
+ );
466
+ process.exit(report.ok ? 0 : 1);
467
+ break;
468
+ }
469
+
470
+ case 'last': {
471
+ // Which results directory was MINE? Inferring it from `ls -t results` returns
472
+ // the newest run, which after a refused start is somebody else's — a suite
473
+ // that never executed then reads as a clean pass.
474
+ const last = readLastRun();
475
+ if (args.includes('--json')) {
476
+ console.log(JSON.stringify(last));
477
+ process.exit(last ? 0 : 1);
478
+ }
479
+ if (!last) {
480
+ console.error('No cele2e run recorded yet.');
481
+ process.exit(1);
482
+ }
483
+ console.log(`runId: ${last.runId}`);
484
+ console.log(`resultsDir: ${last.resultsDir}`);
485
+ console.log(`status: ${last.status}`);
486
+ console.log(
487
+ `results: ${last.passed} passed, ${last.failed} failed${last.skipped > 0 ? `, ${last.skipped} skipped (blocked by an earlier stage)` : ''} of ${last.total}`,
488
+ );
489
+ process.exit(0);
490
+ break;
491
+ }
492
+
413
493
  case 'load':
414
494
  runScript('e2e-load', args);
415
495
  break;
@@ -422,10 +502,11 @@ switch (command) {
422
502
  // build-infra mutates the shared Docker images — serialize against runs.
423
503
  // Released via the lock's exit handler (runBuild may process.exit on failure).
424
504
  try {
425
- acquireRunLock({
505
+ const outcome = acquireRunLock({
426
506
  test: 'build-infra',
427
507
  runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID(),
428
508
  });
509
+ noteAutoRelease(outcome.autoReleasedOwnKept);
429
510
  } catch (err) {
430
511
  if (err instanceof E2eBusyError) {
431
512
  console.error(err.message);
@@ -1,14 +1,17 @@
1
1
  import { type ExecSyncOptions, execSync, spawn } from 'node:child_process';
2
- import { existsSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { basename, join, resolve } from 'node:path';
5
+ import { parse as parseYaml } from 'yaml';
5
6
  import { startBrowser } from './browser';
6
7
  import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
7
8
  import {
8
9
  SHARED_PROJECT_NAME,
10
+ firewallZoneLegs,
9
11
  generateTestComposeYaml,
10
12
  getAllMachines,
11
13
  } from './docker-compose-generator';
14
+ import { explainBuildFailure } from './doctor';
12
15
  import { ensureSharedInfra } from './shared-infra';
13
16
  import { SIMULATOR_IPS } from './simulator-ips';
14
17
  import { startSocksProxy } from './socks-proxy';
@@ -20,8 +23,15 @@ import type {
20
23
  NetworkHandle,
21
24
  ProxyOptions,
22
25
  SocksProxyHandle,
26
+ TopologyPreset,
27
+ } from './types';
28
+ import {
29
+ CeliloCommandError,
30
+ ZONE_GATEWAYS,
31
+ ZONE_SUBNETS,
32
+ greenwaveRouterIp,
33
+ internalNatIp,
23
34
  } from './types';
24
- import { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS, internalNatIp } from './types';
25
35
 
26
36
  /** Package root — where docker/, config/, simulators/ live */
27
37
  const PACKAGE_ROOT = join(__dirname, '..');
@@ -499,7 +509,11 @@ async function streamingBuild(
499
509
  console.log('[progress:done] images built');
500
510
  resolve();
501
511
  } else {
502
- reject(new Error(`docker compose build failed (exit ${code}):\n${stderr.slice(-500)}`));
512
+ reject(
513
+ new Error(
514
+ `docker compose build failed (exit ${code}):\n${stderr.slice(-500)}${explainBuildFailure(stderr)}`,
515
+ ),
516
+ );
503
517
  }
504
518
  });
505
519
  });
@@ -515,6 +529,7 @@ function buildNetworkHandle(
515
529
  projectName: string,
516
530
  composeDir: string,
517
531
  celiloRoot: string | undefined,
532
+ topology: TopologyPreset,
518
533
  ): NetworkHandle {
519
534
  // Track host-side resources spawned via the handle (SOCKS proxies,
520
535
  // Playwright browsers). stop() tears these down before the docker
@@ -598,10 +613,21 @@ function buildNetworkHandle(
598
613
  },
599
614
 
600
615
  async deployFirewall(opts = {}): Promise<void> {
601
- const zones = opts.zones ?? ['dmz', 'app', 'secure'];
616
+ // `opts.zones` says which zones get `provided_networks` written to system
617
+ // config. It does NOT say which NICs the box has — the compose topology
618
+ // decides that, and it wires fw-main to every segmented zone regardless of
619
+ // what a test passed. Roughly twenty call sites passed `['dmz']` against a
620
+ // four-leg firewall, so every one of them under-declared the hardware.
621
+ //
622
+ // Deriving the declared list from the topology makes under-declaring
623
+ // structurally impossible rather than something each future test author has
624
+ // to remember, and removes one more hand-maintained copy of a list the
625
+ // compose generator already owns.
626
+ const providedZones = opts.zones ?? ['dmz', 'app', 'secure'];
627
+ const declaredZones = firewallZoneLegs(topology);
602
628
  const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
603
629
  const natIp = opts.natIp ?? internalNatIp();
604
- const providedNetworks = zones.map((zone) => ({
630
+ const providedNetworks = providedZones.map((zone) => ({
605
631
  zone,
606
632
  subnet: ZONE_SUBNETS[zone],
607
633
  gateway: ZONE_GATEWAYS[zone],
@@ -637,15 +663,23 @@ function buildNetworkHandle(
637
663
  await handle.celilo('module import iptables');
638
664
  await handle.celilo(`module config set iptables firewall_ip ${firewallIp}`);
639
665
  await handle.celilo(`module config set iptables nat_ip ${natIp}`);
640
- await handle.celilo(
641
- `module config set iptables zones '${JSON.stringify(['internal', ...zones])}'`,
642
- );
666
+ await handle.celilo(`module config set iptables zones '${JSON.stringify(declaredZones)}'`);
643
667
  await handle.celilo(
644
668
  `module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
645
669
  );
646
670
  await handle.celilo('module deploy iptables', 180_000);
647
671
  },
648
672
 
673
+ async deployGreenwave(): Promise<void> {
674
+ const routerIp = greenwaveRouterIp();
675
+ await handle.celilo(`machine add ${routerIp} --ssh-user root --earmark greenwave`);
676
+ await handle.celilo('module import greenwave');
677
+ await handle.celilo(`module config set greenwave router_ip ${routerIp}`);
678
+ await handle.celilo('module secret set greenwave router_username admin');
679
+ await handle.celilo('module secret set greenwave router_password admin');
680
+ await handle.celilo('module deploy greenwave', 180_000);
681
+ },
682
+
649
683
  async socksProxy(options: ProxyOptions = {}): Promise<SocksProxyHandle> {
650
684
  const proxy = await startSocksProxy(projectName, options);
651
685
  activeProxies.push(proxy);
@@ -889,7 +923,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
889
923
  // means the test is replacing the infra resolver with a module under test
890
924
  // (e.g., knot-unbound-internal). The bare target machine has no DNS server
891
925
  // until its module deploys, so this wait would always time out. Management
892
- // can still resolve names via the fallback `nameserver 100.100.0.1` in
926
+ // can still resolve names via the fallback `nameserver 203.0.113.1` in
893
927
  // its resolv.conf during deploy.
894
928
  //
895
929
  // The resolver-under-test may sit in ANY zone, not just `internal`: ISS-0156
@@ -1063,7 +1097,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1063
1097
  config.managementZone === 'secure-mgmt' || (config.secureMgmtMachines ?? []).length > 0
1064
1098
  ? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
1065
1099
  : ''
1066
- }dns.primary=100.100.0.1 \
1100
+ }dns.primary=203.0.113.1 \
1067
1101
  dns.fallback=1.0.0.1,8.8.8.8`,
1068
1102
  );
1069
1103
  if (initResult.exitCode !== 0) {
@@ -1095,7 +1129,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1095
1129
  // Emit project name so the runner can persist it if --keep is set
1096
1130
  console.log(`[e2e:project] ${projectName}`);
1097
1131
 
1098
- return buildNetworkHandle(projectName, composeDir, celiloRoot);
1132
+ return buildNetworkHandle(projectName, composeDir, celiloRoot, config.topology);
1099
1133
  }
1100
1134
 
1101
1135
  /**
@@ -1106,6 +1140,26 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
1106
1140
  *
1107
1141
  * If the project doesn't exist, throws with an actionable error.
1108
1142
  */
1143
+ /**
1144
+ * Which topology a already-running stack was built with, read back from its
1145
+ * generated compose file. `direct-internet` is the one where fw-main holds a leg
1146
+ * on `isp-external`; everything else is `default`.
1147
+ */
1148
+ function topologyFromComposeFile(composePath: string): TopologyPreset {
1149
+ try {
1150
+ const compose = parseYaml(readFileSync(composePath, 'utf-8')) as {
1151
+ services?: Record<string, { networks?: Record<string, unknown> }>;
1152
+ };
1153
+ return compose.services?.['fw-main']?.networks?.['isp-external']
1154
+ ? 'direct-internet'
1155
+ : 'default';
1156
+ } catch {
1157
+ // An unreadable compose file means the stack was not built by this
1158
+ // generator; `default` is the topology the fleet runs and the safer guess.
1159
+ return 'default';
1160
+ }
1161
+ }
1162
+
1109
1163
  export function reconnectNetwork(projectName: string): NetworkHandle {
1110
1164
  const composeDir = PACKAGE_ROOT;
1111
1165
 
@@ -1128,5 +1182,14 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
1128
1182
  console.log(`[progress:done] reconnected to existing network (${projectName})`);
1129
1183
  console.log(`[e2e:project] ${projectName}`);
1130
1184
 
1131
- return buildNetworkHandle(projectName, composeDir, findCeliloRoot());
1185
+ // `--reuse` has no NetworkConfig to read, so the topology is recovered from the
1186
+ // compose file that built the running stack — the same artifact the generator
1187
+ // wrote. Guessing a default here would silently mis-declare the firewall's legs
1188
+ // on exactly the runs used for debugging.
1189
+ return buildNetworkHandle(
1190
+ projectName,
1191
+ composeDir,
1192
+ findCeliloRoot(),
1193
+ topologyFromComposeFile(join(composeDir, COMPOSE_FILE)),
1194
+ );
1132
1195
  }