@celilo/e2e 0.7.17 → 0.8.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.
@@ -0,0 +1,16 @@
1
+ $ORIGIN example.net.
2
+ $TTL 300
3
+
4
+ @ IN SOA ns1.example.net. admin.example.net. (
5
+ 2024020108 300 60 604800 300
6
+ )
7
+
8
+ ; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
9
+ ; the e2e simulation is to model real internet connectivity, where
10
+ ; private IPs are unreachable from outside. All public-facing names
11
+ ; resolve to the firewall's external IP (100.100.0.100), which DNATs
12
+ ; inbound to the right internal host.
13
+ @ IN NS ns1.example.net.
14
+ ns1 IN A 100.64.0.55
15
+ @ IN A 100.100.0.100
16
+ www IN A 100.100.0.100
@@ -8,22 +8,39 @@ chmod 700 /root/.ssh
8
8
  chmod 600 /root/.ssh/authorized_keys
9
9
  /usr/sbin/sshd
10
10
 
11
- # Routing — detect if we have an isp-external interface (direct-internet topology)
11
+ # IP forwarding is set via sysctls in docker-compose
12
+ iptables -P FORWARD ACCEPT
13
+
14
+ # Routing + egress NAT — depends on topology (detected by the presence of an
15
+ # isp-external interface).
12
16
  ip route del default 2>/dev/null || true
13
17
  if ip -o addr show | grep -q '100.100.0'; then
14
- # Direct internet: route via fw-ext on isp-external
18
+ # Direct-internet: fw-main has its own external (WAN) interface.
15
19
  ip route add default via 100.100.0.101
20
+ # Egress NAT SCOPED to the external/WAN interface (the one with the public
21
+ # 100.100.0.x address) — NOT a blanket MASQUERADE (ISS-0156). Scoping to the WAN
22
+ # means inter-zone traffic (protected↔protected, protected↔internal) keeps its
23
+ # source, so the dmz-resident resolver sees each client's real zone for
24
+ # source-based DNS views. This boot-time rule is what target machines need for
25
+ # egress/DNS BEFORE any module deploys; celilo's iptables module applies the
26
+ # same WAN-scoped MASQUERADE on deploy (idempotent — the production code path).
27
+ EXT_IFACE=$(ip -o addr show | awk '/100\.100\.0\./{print $2; exit}')
28
+ iptables -t nat -A POSTROUTING -o "$EXT_IFACE" -j MASQUERADE
16
29
  else
17
- # Default: route via fw-isp on internal
30
+ # Two-layer: no external interface on fw-main; it routes outbound to the
31
+ # upstream firewall (fw-isp) and MASQUERADEs toward it (egress leaves via the
32
+ # internal-facing interface, so there is no single WAN interface to scope to).
18
33
  ip route add default via 192.168.0.1
34
+ # Protected↔protected (dmz/app/secure ↔ each other, all in 10/8) is NOT NAT'd —
35
+ # a real firewall routes between its segmented zones without NAT, preserving the
36
+ # client source so the dmz-resident resolver can serve source-based split-horizon
37
+ # (ISS-0156). Only protected↔internal and egress get MASQUERADE'd below. Without
38
+ # this RETURN, app→dmz and internal→dmz would both be SNAT'd to the dmz gateway
39
+ # and become indistinguishable (confirmed via live probe).
40
+ iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -d 10.0.0.0/8 -j RETURN
41
+ iptables -t nat -A POSTROUTING -j MASQUERADE
19
42
  fi
20
43
 
21
- # IP forwarding is set via sysctls in docker-compose
22
-
23
- # NAT all outbound traffic from dmz/app/secure
24
- iptables -t nat -A POSTROUTING -j MASQUERADE
25
- iptables -P FORWARD ACCEPT
26
-
27
44
  # DNS
28
45
  echo "nameserver 100.100.0.1" > /etc/resolv.conf
29
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.7.17",
3
+ "version": "0.8.0",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,9 +34,7 @@
34
34
  "npm-registry-server/tsconfig.json",
35
35
  "npm-registry-server/src/",
36
36
  "scripts/",
37
- "README.md",
38
- "AGENTS.md",
39
- "COVERAGE.md"
37
+ "README.md"
40
38
  ],
41
39
  "dependencies": {
42
40
  "@celilo/cli-display": "^0.1.9",
@@ -33,6 +33,7 @@ export const COMMANDS: CommandDef[] = [
33
33
  { name: '--reuse', description: 'Reuse existing network if running' },
34
34
  { name: '--live', description: 'Use live (non-simulated) internet' },
35
35
  { name: '--published', description: 'Use published .netapp packages' },
36
+ { name: '--notify', description: 'Desktop notification when the run finishes' },
36
37
  ],
37
38
  },
38
39
  {
@@ -75,7 +76,12 @@ export const COMMANDS: CommandDef[] = [
75
76
  },
76
77
  {
77
78
  name: 'status',
78
- description: 'Show network status and DNS health',
79
+ description: 'Show run-lock holder + network status (exit 3 if busy)',
80
+ flags: [{ name: '--json', description: 'Emit a machine-readable lock snapshot for polling' }],
81
+ },
82
+ {
83
+ name: 'release',
84
+ description: 'Free a run-lock left by --keep/up (does not tear down)',
79
85
  },
80
86
  {
81
87
  name: 'load',
@@ -101,7 +107,7 @@ export const COMMANDS: CommandDef[] = [
101
107
 
102
108
  export const UP_PRESETS = ['--caddy', '--full-stack', '--infrastructure'];
103
109
 
104
- export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published'];
110
+ export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published', '--notify'];
105
111
 
106
112
  export const DOWN_FLAGS = ['--keep', '--all'];
107
113
 
@@ -119,6 +119,7 @@ _cele2e() {
119
119
  '--reuse[Reuse existing network if running]' \\
120
120
  '--live[Use live (non-simulated) internet]' \\
121
121
  '--published[Use published .netapp packages]' \\
122
+ '--notify[Desktop notification when the run finishes]' \\
122
123
  '--ci[Plain log output: one line per step, no spinner]' \\
123
124
  '--no-ci[Force spinner even when a CI env var is set]' \\
124
125
  '*::test name or module path:_cele2e_run_args'
@@ -148,7 +149,10 @@ _cele2e() {
148
149
  completion)
149
150
  _arguments '1: :_cele2e_completion_shells'
150
151
  ;;
151
- status|load|clear-timing|list)
152
+ status)
153
+ _arguments '--json[Emit a machine-readable lock snapshot for polling]'
154
+ ;;
155
+ release|load|clear-timing)
152
156
  ;;
153
157
  esac
154
158
  ;;
@@ -164,7 +168,8 @@ _cele2e_commands() {
164
168
  'build-infra:Rebuild @celilo/e2e Docker images and standard module .netapps'
165
169
  'clear-timing:Clear saved timing history for test ETAs'
166
170
  'shell:Shell into a running container (default: management)'
167
- 'status:Show network status and DNS health'
171
+ 'status:Show run-lock holder + network status (exit 3 if busy)'
172
+ 'release:Free a run-lock left by --keep/up (does not tear down)'
168
173
  'load:Load cached Docker images from tarball'
169
174
  'scaffold:Generate a new test file from template'
170
175
  'version:Show cele2e version'
package/src/cli/index.ts CHANGED
@@ -23,6 +23,7 @@ import { runBuild } from './build';
23
23
  import { runScaffold } from './scaffold';
24
24
  import { generateBashCompletion, generateZshCompletion, getCompletions } from './completion';
25
25
  import { findAllModules, findModulesWithMatchingTest } from './module-discovery';
26
+ import { acquireRunLock, clearLock, E2eBusyError, formatBusy, lockStatus, markKept } from '../run-lock';
26
27
 
27
28
  const PKG_DIR = resolve(import.meta.dir, '../..');
28
29
  const BIN_DIR = join(PKG_DIR, 'bin');
@@ -127,7 +128,8 @@ Commands:
127
128
  clear-timing Clear saved timing history used for test ETAs
128
129
  version Show cele2e version
129
130
  shell [container] Shell into a running container (default: management)
130
- status Show network status and DNS health
131
+ status Show run-lock holder + network status (exit 3 if busy)
132
+ release Free a run-lock left by --keep/up (does not tear down)
131
133
  load Load cached Docker images from tarball
132
134
  scaffold <name> Generate a new test file from template
133
135
  completion <shell> Generate shell completion script (zsh|bash)
@@ -139,6 +141,7 @@ Options for \`run\`:
139
141
  --reuse Reuse existing network if running
140
142
  --live Use live (non-simulated) internet
141
143
  --published Use published .netapp packages
144
+ --notify Desktop notification when the run finishes (best-effort)
142
145
  --ci Plain log output: one ✔/✗ line per step, no spinner
143
146
  (auto-on when a CI env var is set; --no-ci forces off)
144
147
 
@@ -297,16 +300,55 @@ switch (command) {
297
300
  process.exit(0);
298
301
  }
299
302
 
300
- case 'up':
303
+ case 'up': {
304
+ // `up` leaves an interactive network running, so it holds the run-lock in a
305
+ // kept state (cleared by `cele2e down`/`release`) to stop a concurrent run
306
+ // from wiping it.
307
+ try {
308
+ acquireRunLock({ test: 'up', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
309
+ markKept();
310
+ } catch (err) {
311
+ if (err instanceof E2eBusyError) {
312
+ console.error(err.message);
313
+ process.exit(3);
314
+ }
315
+ throw err;
316
+ }
301
317
  runScript('e2e-up', args);
318
+ }
302
319
 
303
320
  case 'down':
321
+ // Tearing the network down frees the (possibly kept) lock.
322
+ clearLock();
304
323
  runScript('e2e-down', args, { E2E_TEST_DIR: stateDir });
305
324
 
306
325
  case 'shell':
307
326
  runScript('e2e-shell', args);
308
327
 
328
+ case 'release': {
329
+ // Free a lock left by `--keep`/`up` (a `kept` stack) so the next run can
330
+ // proceed. Does not tear the stack down — use `cele2e down` for that.
331
+ const had = clearLock();
332
+ console.log(had ? 'Released the e2e run-lock.' : 'No e2e run-lock held.');
333
+ process.exit(0);
334
+ }
335
+
309
336
  case 'status': {
337
+ // Run-lock is the contention surface other sessions poll. Report it first;
338
+ // exit 0 = free, 3 = busy. `--json` emits a machine-readable snapshot.
339
+ const lock = lockStatus();
340
+ if (args.includes('--json')) {
341
+ console.log(JSON.stringify(lock));
342
+ process.exit(lock.free ? 0 : 3);
343
+ }
344
+ console.log('=== Run Lock ===');
345
+ if (lock.free) {
346
+ console.log(' free');
347
+ } else if (lock.holder) {
348
+ console.log(` BUSY — ${formatBusy(lock.holder)}`);
349
+ }
350
+ console.log('');
351
+
310
352
  const persistentFile = join(stateDir, '.e2e-persistent.json');
311
353
  let projectName: string | undefined;
312
354
  if (existsSync(persistentFile)) {
@@ -332,6 +374,17 @@ switch (command) {
332
374
  const save = args.includes('--save');
333
375
  const skipModules = args.includes('--skip-modules');
334
376
  const moduleDirs = args.filter((a) => !a.startsWith('--'));
377
+ // build-infra mutates the shared Docker images — serialize against runs.
378
+ // Released via the lock's exit handler (runBuild may process.exit on failure).
379
+ try {
380
+ acquireRunLock({ test: 'build-infra', runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID() });
381
+ } catch (err) {
382
+ if (err instanceof E2eBusyError) {
383
+ console.error(err.message);
384
+ process.exit(3);
385
+ }
386
+ throw err;
387
+ }
335
388
  runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules });
336
389
  break;
337
390
  }
@@ -434,7 +434,17 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
434
434
  // until its module deploys, so this wait would always time out. Management
435
435
  // can still resolve names via the fallback `nameserver 100.100.0.1` in
436
436
  // its resolv.conf during deploy.
437
- const dnsIntReplaced = config.internalMachines.some((m) => m.name === 'dns-int');
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');
438
448
  if (!dnsIntReplaced) {
439
449
  console.log('[progress:start] waiting for internal resolver | internal resolver ready');
440
450
  await waitFor(
@@ -682,6 +692,27 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
682
692
  gateway: ZONE_GATEWAYS[zone],
683
693
  }));
684
694
 
695
+ // fw-main is a firewall container, not a target machine, so the
696
+ // network readiness wait (target-setup) does NOT cover its sshd. Poll
697
+ // until the firewall accepts SSH before `machine add` — otherwise a
698
+ // transient first-connect times out (spawnSync ETIMEDOUT) and aborts the
699
+ // whole deploy, cascading into unrelated "module not found" failures
700
+ // (#222). This is a readiness wait on the real prerequisite, not a sleep.
701
+ await waitFor(
702
+ async () => {
703
+ const probe = dockerExec(
704
+ projectName,
705
+ composeDir,
706
+ 'management',
707
+ `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -i /root/.ssh/id_ed25519 root@${firewallIp} hostname`,
708
+ 15_000,
709
+ );
710
+ return probe.exitCode === 0;
711
+ },
712
+ 60_000,
713
+ `firewall ${firewallIp} sshd`,
714
+ );
715
+
685
716
  // fw-main is registered as an internal-zone machine; iptables
686
717
  // deploys to it and (Phase 2) writes the provided zones to system
687
718
  // config from its on_install hook.
@@ -0,0 +1,105 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test';
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { hostname, tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ acquireRunLock,
7
+ clearLock,
8
+ E2eBusyError,
9
+ type LockHolder,
10
+ lockStatus,
11
+ markKept,
12
+ readHolder,
13
+ releaseRunLock,
14
+ } from './run-lock';
15
+
16
+ // Isolate every test on its own lock file (machine-global path is overridable).
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ dir = mkdtempSync(join(tmpdir(), 'e2e-lock-'));
21
+ process.env.CELILO_E2E_LOCK_PATH = join(dir, 'run.lock');
22
+ });
23
+
24
+ afterEach(() => {
25
+ clearLock();
26
+ rmSync(dir, { recursive: true, force: true });
27
+ delete process.env.CELILO_E2E_LOCK_PATH;
28
+ });
29
+
30
+ function writeRawHolder(h: Partial<LockHolder>): void {
31
+ const full: LockHolder = {
32
+ pid: 123,
33
+ hostname: hostname(),
34
+ session: 'other',
35
+ test: 'foo',
36
+ runId: 'r',
37
+ startedAt: new Date().toISOString(),
38
+ beatAt: Date.now(),
39
+ state: 'running',
40
+ ...h,
41
+ };
42
+ writeFileSync(process.env.CELILO_E2E_LOCK_PATH as string, JSON.stringify(full));
43
+ }
44
+
45
+ test('acquire writes a holder for this process; release removes it', () => {
46
+ acquireRunLock({ test: 'mytest', runId: 'run-1' });
47
+ const h = readHolder();
48
+ expect(h?.pid).toBe(process.pid);
49
+ expect(h?.test).toBe('mytest');
50
+ expect(lockStatus().free).toBe(false);
51
+
52
+ releaseRunLock();
53
+ expect(readHolder()).toBeNull();
54
+ expect(lockStatus().free).toBe(true);
55
+ });
56
+
57
+ test('a live holder makes a second acquire throw E2eBusyError', () => {
58
+ acquireRunLock({ test: 't1', runId: 'r1' });
59
+ // The on-disk holder is us (a live pid) → busy.
60
+ expect(() => acquireRunLock({ test: 't2', runId: 'r2' })).toThrow(E2eBusyError);
61
+ releaseRunLock();
62
+ });
63
+
64
+ test('a dead-pid holder on the same host is stale and reclaimed', () => {
65
+ // 2e9 is far above any real pid → process.kill(pid, 0) throws ESRCH → dead.
66
+ writeRawHolder({ pid: 2_000_000_000 });
67
+ expect(lockStatus().free).toBe(true); // stale → reported free
68
+
69
+ acquireRunLock({ test: 'reclaimer', runId: 'r' }); // reclaims the stale lock
70
+ expect(readHolder()?.pid).toBe(process.pid);
71
+ releaseRunLock();
72
+ });
73
+
74
+ test('--keep leaves a kept lock that survives release and blocks the next run', () => {
75
+ acquireRunLock({ test: 'kept-test', runId: 'r' });
76
+ markKept();
77
+ releaseRunLock();
78
+
79
+ const h = readHolder();
80
+ expect(h?.state).toBe('kept');
81
+ expect(lockStatus().free).toBe(false); // kept is never stale
82
+
83
+ // A plain run is refused...
84
+ expect(() => acquireRunLock({ test: 'next', runId: 'r2' })).toThrow(E2eBusyError);
85
+ // ...but a --reuse run (allowKept) takes it over.
86
+ acquireRunLock({ test: 'reuse', runId: 'r3', allowKept: true });
87
+ expect(readHolder()?.pid).toBe(process.pid);
88
+ releaseRunLock();
89
+ });
90
+
91
+ test('clearLock frees a kept lock (the `cele2e release` path)', () => {
92
+ writeRawHolder({ state: 'kept', pid: 2_000_000_000 });
93
+ expect(lockStatus().free).toBe(false);
94
+ expect(clearLock()).toBe(true);
95
+ expect(existsSync(process.env.CELILO_E2E_LOCK_PATH as string)).toBe(false);
96
+ expect(lockStatus().free).toBe(true);
97
+ });
98
+
99
+ test('a corrupt lock file is reclaimed, not fatal', () => {
100
+ writeFileSync(process.env.CELILO_E2E_LOCK_PATH as string, 'not json{');
101
+ expect(readHolder()).toBeNull();
102
+ acquireRunLock({ test: 'survivor', runId: 'r' });
103
+ expect(readHolder()?.pid).toBe(process.pid);
104
+ releaseRunLock();
105
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * cele2e run-lock — cross-platform mutual exclusion for the shared e2e infra.
3
+ *
4
+ * cele2e is a single shared resource: the shared infra is one global compose
5
+ * project (celilo-e2e-shared) and start-of-run cleanup tears down every
6
+ * celilo-e2e-* container regardless of who started it. So two actors at once
7
+ * (two Claude sessions, or a session + an operator at the terminal) clobber
8
+ * each other. This lock serializes runs across processes — no external tools,
9
+ * identical on macOS and Linux.
10
+ *
11
+ * Design (Forgejo #243):
12
+ * - Acquire via fs.openSync(path, 'wx') — POSIX-atomic exclusive create,
13
+ * same behavior on mac+linux. The lockfile lives at a MACHINE-GLOBAL path
14
+ * (~/.cache/celilo-e2e/run.lock), NOT under any repo/worktree, because the
15
+ * Docker infra is global regardless of which checkout started it.
16
+ * - Fail fast on contention with a message naming the holder. Sessions poll
17
+ * `cele2e status` and decide for themselves whether to wait (no --wait).
18
+ * - Staleness: on the SAME host, PID-liveness is authoritative (process.kill
19
+ * (pid, 0)); a dead holder PID → reclaim. Cross-host we can't check the PID,
20
+ * so fall back to a heartbeat TTL (beatAt older than STALE_TTL_MS).
21
+ * - A `kept` lock (left by `--keep` / `up`) is NEVER auto-reclaimed — it
22
+ * guards a stack that outlives the process and is cleared only by
23
+ * `cele2e release` / `cele2e down`. This enforces the honor-system rule.
24
+ */
25
+
26
+ import { execFileSync } from 'node:child_process';
27
+ import { mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
28
+ import { homedir, hostname } from 'node:os';
29
+ import { basename, dirname, join } from 'node:path';
30
+
31
+ const STALE_TTL_MS = 90_000;
32
+ const HEARTBEAT_MS = 30_000;
33
+
34
+ /**
35
+ * Machine-global lock path — NOT under any repo/worktree, because the Docker
36
+ * infra is global regardless of which checkout started it. Overridable via
37
+ * CELILO_E2E_LOCK_PATH (used by tests; also a handy operator escape hatch).
38
+ */
39
+ export function lockPath(): string {
40
+ return process.env.CELILO_E2E_LOCK_PATH || join(homedir(), '.cache', 'celilo-e2e', 'run.lock');
41
+ }
42
+
43
+ export interface LockHolder {
44
+ pid: number;
45
+ hostname: string;
46
+ session: string;
47
+ test: string;
48
+ runId: string;
49
+ startedAt: string;
50
+ beatAt: number;
51
+ state: 'running' | 'kept';
52
+ }
53
+
54
+ interface Held {
55
+ keepOnRelease: boolean;
56
+ heartbeat: ReturnType<typeof setInterval> | null;
57
+ released: boolean;
58
+ }
59
+
60
+ /** Set once this process owns the lock; null otherwise. */
61
+ let held: Held | null = null;
62
+
63
+ export class E2eBusyError extends Error {
64
+ constructor(public holder: LockHolder) {
65
+ super(formatBusy(holder));
66
+ this.name = 'E2eBusyError';
67
+ }
68
+ }
69
+
70
+ /** Human label for who's running, e.g. "e2e busy: <session> running <test>, started 3m ago (pid 1234)". */
71
+ export function formatBusy(h: LockHolder): string {
72
+ const age = ageString(Date.parse(h.startedAt));
73
+ const verb = h.state === 'kept' ? 'holds a kept stack from' : 'running';
74
+ const what = h.state === 'kept' ? `(run \`cele2e release\` to free it)` : `${h.test}, started ${age} ago (pid ${h.pid})`;
75
+ return `e2e busy: ${h.session} ${verb} ${what}`;
76
+ }
77
+
78
+ function ageString(since: number): string {
79
+ const s = Math.max(0, Math.floor((Date.now() - since) / 1000));
80
+ if (s < 60) return `${s}s`;
81
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
82
+ return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
83
+ }
84
+
85
+ /** Branch + worktree path so a holder maps back to a specific session/thread. */
86
+ function deriveSession(): string {
87
+ if (process.env.CELILO_E2E_SESSION) return process.env.CELILO_E2E_SESSION;
88
+ const cwd = process.cwd();
89
+ const git = (args: string[]): string => {
90
+ try {
91
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
92
+ } catch {
93
+ return '';
94
+ }
95
+ };
96
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
97
+ const top = git(['rev-parse', '--show-toplevel']);
98
+ const base = top ? basename(top) : basename(cwd);
99
+ if (branch && top) return `${branch} (${top})`;
100
+ return branch || base;
101
+ }
102
+
103
+ function pidAlive(pid: number): boolean {
104
+ try {
105
+ process.kill(pid, 0);
106
+ return true;
107
+ } catch (err) {
108
+ // ESRCH = no such process (dead). EPERM = alive but not ours (still alive).
109
+ return (err as NodeJS.ErrnoException).code === 'EPERM';
110
+ }
111
+ }
112
+
113
+ export function readHolder(): LockHolder | null {
114
+ try {
115
+ return JSON.parse(readFileSync(lockPath(), 'utf-8')) as LockHolder;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Is the on-disk holder stale (safe to reclaim)? A `kept` lock is never stale —
123
+ * it deliberately outlives its process. On the same host the PID is the source
124
+ * of truth; cross-host we can only use the heartbeat TTL.
125
+ */
126
+ function isStale(h: LockHolder): boolean {
127
+ if (h.state === 'kept') return false;
128
+ if (h.hostname === hostname()) return !pidAlive(h.pid);
129
+ return Date.now() - h.beatAt > STALE_TTL_MS;
130
+ }
131
+
132
+ function writeHolder(fd: number, h: LockHolder): void {
133
+ writeFileSync(fd, JSON.stringify(h, null, 2));
134
+ }
135
+
136
+ /**
137
+ * Acquire the run lock for this process. Throws E2eBusyError if another live
138
+ * (non-stale) holder owns it. Registers an exit handler so the lock is released
139
+ * on any process.exit() path (normal, exception, or a SIGINT handler that
140
+ * exits). A signal-killed process with no exit handler leaks the lock, but the
141
+ * next run reclaims it via PID-liveness — that's exactly what staleness is for.
142
+ */
143
+ export function acquireRunLock(opts: { test: string; runId: string; allowKept?: boolean }): void {
144
+ mkdirSync(dirname(lockPath()), { recursive: true });
145
+
146
+ const holder: LockHolder = {
147
+ pid: process.pid,
148
+ hostname: hostname(),
149
+ session: deriveSession(),
150
+ test: opts.test,
151
+ runId: opts.runId,
152
+ startedAt: new Date().toISOString(),
153
+ beatAt: Date.now(),
154
+ state: 'running',
155
+ };
156
+
157
+ for (let attempt = 0; attempt < 2; attempt++) {
158
+ let fd: number;
159
+ try {
160
+ fd = openSync(lockPath(), 'wx');
161
+ } catch (err) {
162
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
163
+ const existing = readHolder();
164
+ // Reclaim when: corrupt/unreadable, the holder is stale, OR this is a
165
+ // `--reuse` run taking over a `kept` stack it's deliberately reusing.
166
+ if (!existing || isStale(existing) || (opts.allowKept && existing.state === 'kept')) {
167
+ try { unlinkSync(lockPath()); } catch {}
168
+ continue;
169
+ }
170
+ throw new E2eBusyError(existing);
171
+ }
172
+ writeHolder(fd, holder);
173
+ closeSync(fd);
174
+
175
+ const heartbeat = setInterval(() => {
176
+ // Only refresh while running; a kept lock's beatAt is frozen by design.
177
+ if (!held || held.released || held.keepOnRelease) return;
178
+ try {
179
+ const cur = readHolder();
180
+ if (cur && cur.pid === process.pid) {
181
+ writeFileSync(lockPath(), JSON.stringify({ ...cur, beatAt: Date.now() }, null, 2));
182
+ }
183
+ } catch {}
184
+ }, HEARTBEAT_MS);
185
+ heartbeat.unref();
186
+
187
+ held = { keepOnRelease: false, heartbeat, released: false };
188
+ process.on('exit', releaseRunLock);
189
+ return;
190
+ }
191
+ // Two reclaim attempts both lost the race → someone else won fair and square.
192
+ const existing = readHolder();
193
+ throw new E2eBusyError(existing ?? holderUnknown());
194
+ }
195
+
196
+ function holderUnknown(): LockHolder {
197
+ return {
198
+ pid: 0,
199
+ hostname: hostname(),
200
+ session: 'unknown',
201
+ test: '?',
202
+ runId: '?',
203
+ startedAt: new Date().toISOString(),
204
+ beatAt: Date.now(),
205
+ state: 'running',
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Mark the lock to survive process exit in a `kept` state — call when a stack
211
+ * (network/containers) will outlive this process (`--keep`, `up`). The next run
212
+ * then refuses to clobber it until `cele2e release` / `cele2e down`.
213
+ */
214
+ export function markKept(): void {
215
+ if (held) held.keepOnRelease = true;
216
+ }
217
+
218
+ /** Release the lock. Idempotent. Honors markKept() (kept-state) vs delete. */
219
+ export function releaseRunLock(): void {
220
+ if (!held || held.released) return;
221
+ held.released = true;
222
+ if (held.heartbeat) clearInterval(held.heartbeat);
223
+
224
+ if (held.keepOnRelease) {
225
+ const cur = readHolder();
226
+ if (cur && cur.pid === process.pid) {
227
+ try { writeFileSync(lockPath(), JSON.stringify({ ...cur, state: 'kept', beatAt: Date.now() }, null, 2)); } catch {}
228
+ }
229
+ return;
230
+ }
231
+ try {
232
+ const cur = readHolder();
233
+ if (!cur || cur.pid === process.pid) unlinkSync(lockPath());
234
+ } catch {}
235
+ }
236
+
237
+ /** Forcibly clear the lock regardless of holder — used by `cele2e release`/`down`. */
238
+ export function clearLock(): boolean {
239
+ try {
240
+ unlinkSync(lockPath());
241
+ return true;
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /** Current lock state for `cele2e status` (and any poller). */
248
+ export function lockStatus(): { free: boolean; holder: LockHolder | null } {
249
+ const h = readHolder();
250
+ if (!h || isStale(h)) return { free: true, holder: null };
251
+ return { free: false, holder: h };
252
+ }
package/src/runner.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  emitTestStarted,
31
31
  } from './bus-events';
32
32
  import { parseLine } from './parse-line';
33
+ import { acquireRunLock, E2eBusyError, markKept } from './run-lock';
33
34
 
34
35
  // ─── Config ──────────────────────────────────────────────────────────
35
36
 
@@ -87,6 +88,26 @@ const patterns = rawArgs.filter((a) => !a.startsWith('--'));
87
88
  // run everything. Passed through from the CLI (not stripped) so the runner can
88
89
  // apply the exclusion.
89
90
  const flagCiSafe = rawArgs.includes('--ci-safe');
91
+ // --notify: fire a desktop notification when the run finishes (handy for long
92
+ // detached/background runs). Best-effort — never affects the exit code.
93
+ const flagNotify = rawArgs.includes('--notify');
94
+
95
+ function notify(title: string, message: string): void {
96
+ if (!flagNotify) return;
97
+ try {
98
+ if (process.platform === 'darwin') {
99
+ const r = spawnSync('terminal-notifier', ['-title', title, '-message', message, '-sound', 'Glass'], { stdio: 'ignore' });
100
+ if (r.error) {
101
+ spawnSync('osascript', ['-e', `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)} sound name "Glass"`], { stdio: 'ignore' });
102
+ }
103
+ spawnSync('say', [message], { stdio: 'ignore' });
104
+ } else if (process.platform === 'linux') {
105
+ spawnSync('notify-send', [title, message], { stdio: 'ignore' });
106
+ }
107
+ } catch {
108
+ // ponytail: notification is a nicety; its failure must never break a run.
109
+ }
110
+ }
90
111
 
91
112
  // ─── Colors ──────────────────────────────────────────────────────────
92
113
 
@@ -94,7 +115,6 @@ const bold = '\x1b[1m';
94
115
  const dim = '\x1b[2m';
95
116
  const green = '\x1b[32m';
96
117
  const red = '\x1b[31m';
97
- const cyan = '\x1b[36m';
98
118
  const reset = '\x1b[0m';
99
119
 
100
120
  const interactive =
@@ -386,14 +406,6 @@ function dockerComposeShared(cmd: string): string {
386
406
  }
387
407
  }
388
408
 
389
- function isSharedInfraHealthy(): boolean {
390
- const containers = dockerComposeShared('ps -q');
391
- if (!containers) return false;
392
- return dockerComposeShared(
393
- 'exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2',
394
- ).length > 0;
395
- }
396
-
397
409
  function stopSharedInfra(): void {
398
410
  dockerComposeShared('down --volumes --remove-orphans');
399
411
  try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
@@ -417,6 +429,24 @@ function assertDockerAvailable(): void {
417
429
  async function main() {
418
430
  assertDockerAvailable();
419
431
 
432
+ // Serialize against other cele2e runs sharing the global Docker infra. Fail
433
+ // fast naming the holder; pollers use `cele2e status`. Acquire BEFORE any
434
+ // docker mutation so a competing run can't wipe our shared infra mid-setup.
435
+ const lockLabel = patterns.length ? patterns.join(',') : moduleDirs.length ? 'modules' : 'all';
436
+ try {
437
+ acquireRunLock({ test: lockLabel, runId: process.env.CELE2E_RUN_ID ?? ambientRunId, allowKept: flagReuse });
438
+ // A --reuse run keeps using a kept stack that stays up afterwards — hold the
439
+ // lock in kept state so it isn't freed out from under the reused network.
440
+ if (flagReuse) markKept();
441
+ } catch (err) {
442
+ if (err instanceof E2eBusyError) {
443
+ console.error(`\n${red}${err.message}${reset}`);
444
+ console.error(`${dim}Poll with: cele2e status (machine-readable: cele2e status --json)${reset}\n`);
445
+ process.exit(3);
446
+ }
447
+ throw err;
448
+ }
449
+
420
450
  let testFiles: string[] = [];
421
451
 
422
452
  if (moduleDirs.length > 0) {
@@ -557,9 +587,8 @@ async function main() {
557
587
  mkdirSync(resultsDir, { recursive: true });
558
588
 
559
589
  // runId scopes every event in this suite invocation. Read from env to
560
- // let callers (CI, the Claude tool wrapper) inject a known id and
561
- // subscribe to it before launching cele2e; otherwise generate one and
562
- // print it so the operator can correlate.
590
+ // let callers (CI) inject a known id and subscribe to it before launching
591
+ // cele2e; otherwise generate one and print it so the operator can correlate.
563
592
  const runId = process.env.CELE2E_RUN_ID || crypto.randomUUID();
564
593
  if (!process.env.CELE2E_RUN_ID) {
565
594
  console.log(`${dim}runId: ${runId}${reset}`);
@@ -574,7 +603,7 @@ async function main() {
574
603
 
575
604
  // 'auto' resolves to 'render' when isTTY is true (operator at a
576
605
  // terminal sees animated spinners) and 'protocol' when isTTY is
577
- // false (cele2e-run.sh wrapper / any redirected stdout gets ASCII
606
+ // false (a backgrounded run / any redirected stdout gets ASCII
578
607
  // `[progress:*]` markers — grep-friendly, no braille noise).
579
608
  // Prior to this change the mode was hardcoded 'render', so non-TTY
580
609
  // callers got `⣾ phase started` / `✔ phase done` lines per phase
@@ -663,6 +692,9 @@ async function main() {
663
692
  console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
664
693
  console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
665
694
  console.log(`${dim} Tear down: cele2e down${reset}`);
695
+ // The kept network outlives this process — hold the lock in a `kept`
696
+ // state so the next run refuses to clobber it (cleared by `cele2e down`).
697
+ markKept();
666
698
  }
667
699
 
668
700
  if (result.status === 'pass') {
@@ -745,6 +777,7 @@ async function main() {
745
777
  resultsDir,
746
778
  });
747
779
 
780
+ notify('cele2e', `${passed} passed, ${failed} failed — ${formatDuration(suiteDuration)}`);
748
781
  process.exit(failed > 0 ? 1 : 0);
749
782
  }
750
783
 
@@ -762,5 +795,6 @@ main().catch((err) => {
762
795
  error: err instanceof Error ? err.message : String(err),
763
796
  });
764
797
  console.error(err);
798
+ notify('cele2e', 'run failed');
765
799
  process.exit(1);
766
800
  });
@@ -30,6 +30,34 @@ function run(cmd: string, opts?: { cwd?: string; timeout?: number }): string {
30
30
 
31
31
  let sharedInfraRunning = false;
32
32
 
33
+ /**
34
+ * Remove ALL celilo-e2e-* Docker resources by name prefix (#212). Used at
35
+ * start-of-run, where the run-lock guarantees no other session's stack is live.
36
+ * Graceful compose-down of the shared project first (clean network detach),
37
+ * then a force sweep that catches orphans from any prior invocation regardless
38
+ * of compose-project name or the path that created them.
39
+ */
40
+ function nukeE2eResources(e2eDir: string): void {
41
+ try {
42
+ run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
43
+ { cwd: e2eDir, timeout: 30_000 });
44
+ } catch {}
45
+ try {
46
+ const ids = run('docker ps -aq --filter name=celilo-e2e', { timeout: 10_000 });
47
+ if (ids.trim()) run(`docker rm -f ${ids.replace(/\n/g, ' ')}`, { timeout: 60_000 });
48
+ } catch {}
49
+ try {
50
+ const nets = run('docker network ls --format "{{.Name}}"')
51
+ .split('\n')
52
+ .filter((n) => n.startsWith('celilo-e2e'));
53
+ for (const net of nets) {
54
+ try { run(`docker network rm ${net}`, { timeout: 5_000 }); } catch {}
55
+ }
56
+ } catch {}
57
+ try { run('docker network prune -f', { timeout: 10_000 }); } catch {}
58
+ try { run('docker volume prune -f', { timeout: 10_000 }); } catch {}
59
+ }
60
+
33
61
  /**
34
62
  * Check if the shared infrastructure is already running and healthy.
35
63
  *
@@ -104,38 +132,14 @@ export async function ensureSharedInfra(): Promise<void> {
104
132
 
105
133
  const e2eDir = getE2eDir();
106
134
 
107
- // Clean up any stale shared infra resources
135
+ // Start-of-run cleanup. We hold the run-lock here (acquired in the runner /
136
+ // build path before any docker mutation), so NO other session's stack is
137
+ // live — it is safe to remove EVERY celilo-e2e-* resource by name prefix.
138
+ // This self-heals orphans left by a crashed / cross-path / cross-checkout
139
+ // prior run (e.g. a botched-mount container that wedges the next `up`),
140
+ // which the old compose-project-scoped `down` couldn't see (#212).
108
141
  console.log('[progress:start] cleaning up stale shared infrastructure | shared infra cleanup complete');
109
- try {
110
- run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} down --volumes --remove-orphans`,
111
- { cwd: e2eDir, timeout: 30_000 });
112
- } catch {}
113
-
114
- // Also clean up any per-test projects that reference our networks
115
- try {
116
- const networks = run('docker network ls --format "{{.Name}}"')
117
- .split('\n')
118
- .filter(n => n.startsWith('celilo-e2e-') && !n.startsWith(`${SHARED_PROJECT_NAME}_`));
119
- if (networks.length > 0) {
120
- const projects = [...new Set(networks.map(n => n.replace(/_[^_]+$/, '')))];
121
- for (const project of projects) {
122
- try { run(`docker compose -p ${project} down --volumes --remove-orphans`, { timeout: 30_000 }); } catch {}
123
- }
124
- }
125
- } catch {}
126
-
127
- try { run('docker network prune -f', { timeout: 10_000 }); } catch {}
128
- try { run('docker volume prune -f', { timeout: 10_000 }); } catch {}
129
-
130
- // Force-remove any leftover networks by name
131
- try {
132
- const staleNets = run('docker network ls --format "{{.Name}}"')
133
- .split('\n')
134
- .filter(n => n.startsWith('celilo-e2e'));
135
- for (const net of staleNets) {
136
- try { run(`docker network rm ${net}`, { timeout: 5_000 }); } catch {}
137
- }
138
- } catch {}
142
+ nukeE2eResources(e2eDir);
139
143
 
140
144
  // Refresh the bundled registry-server source so Dockerfile.registry's
141
145
  // COPY resolves whether we're in the monorepo (regenerated from the
package/AGENTS.md DELETED
@@ -1,117 +0,0 @@
1
- # AGENTS.md — `@celilo/e2e`
2
-
3
- `@celilo/e2e` is a **simulated internet in Docker** for end-to-end testing of
4
- [celilo](https://celilo.computer)-deployed apps. It stands up a DNS hierarchy
5
- (root → TLD → registrar → recursive resolver), an ACME server (Pebble, standing
6
- in for Let's Encrypt), an ISP router + customer firewall with real iptables NAT,
7
- zoned LAN networks, and systemd target machines — then runs celilo *inside* it as
8
- if it were deploying to a real home lab. This file orients an AI agent to write
9
- and run those tests. **Read the in-package docs below before searching the web** —
10
- they are the source of truth.
11
-
12
- This file ships **inside the npm package**: the `./`-prefixed docs in the doc map
13
- are installed alongside it (grep them offline). Repo-relative entries point at the
14
- source repository, which the tarball does not carry.
15
-
16
- ## Mental model (learn these five things)
17
-
18
- - **The sim is a faithful internet, not a convenience harness.** Public DNS only
19
- ever answers with the customer firewall's *external* IP; the firewall DNATs
20
- inbound to the right internal host. ACME challenges validate over the public
21
- path. A test that "passes" by short-circuiting any of this is testing a placebo
22
- — see **Inviolable rules** below.
23
- - **`network()` builder** — declare zones and the machines in them, `.start()` the
24
- topology, drive celilo with `.celilo(...)`, assert, then `.stop()`. This is the
25
- whole library surface (`import { network } from '@celilo/e2e'`).
26
- - **Zones** — `internal` (home LAN), `dmz`, `app`, `secure` are the customer-side
27
- networks; `isp-external` / `internet-external` model the public internet. Public
28
- IPs live only on the public networks; the firewall containers are the only place
29
- the two address spaces meet (via NAT/DNAT).
30
- - **Two consumption modes** — as a **library** (`network()` in a `bun test` file)
31
- or via the **`cele2e` CLI** (`cele2e run <test>`, `build-infra`, `list`). The
32
- shared infra (`celilo-e2e-shared`) is a *global* Docker compose project,
33
- independent of which checkout started it.
34
- - **`build-infra` gate** — anything baked into a Docker image (`docker/`,
35
- `config/`, `simulators/`, installed package tarballs) needs `cele2e build-infra`
36
- before it takes effect. Pure `src/*.ts` changes do not (bun runs TS directly).
37
-
38
- ## Writing a test (the happy path)
39
-
40
- ```ts
41
- import { test, expect } from 'bun:test';
42
- import { network } from '@celilo/e2e';
43
-
44
- test('caddy serves TLS in the dmz', async () => {
45
- const net = await network()
46
- .dmz({ caddy: '10.0.10.10' }) // machine name → zone-side IP
47
- .start();
48
- try {
49
- await net.celilo('module import caddy');
50
- await net.celilo('module deploy caddy --no-interactive');
51
- // assert against the deployed service via the public path, not the container IP
52
- } finally {
53
- await net.stop();
54
- }
55
- });
56
- ```
57
-
58
- - Prefer the **fixtures** in `./src/fixtures.ts` (`CADDY_DEPLOYMENT`,
59
- `FULL_STACK`, `TECHNITIUM_STACK`, …) over hand-rolling topology.
60
- - Drive **everything through the celilo CLI** or the module's documented surface —
61
- never `docker exec` to hand-edit a deployed container's config (that's the bug
62
- you're trying to catch).
63
- - Poll on the **observable condition** the deploy is meant to satisfy
64
- (`VantageProbe`, DNS resolution, an HTTP header) — never `setTimeout` to mask a
65
- race.
66
-
67
- ## CLI you'll use
68
-
69
- ```
70
- cele2e list # enumerate every test (module suites + top-level)
71
- cele2e build-infra # rebuild Docker images (required after image-baked changes)
72
- cele2e run <test> [--keep] # run one test; --keep leaves the network up for debugging
73
- cele2e run --all # full regression (every suite); --all-modules = modules only
74
- ```
75
-
76
- For Claude-driven / detached runs, fire via the repo wrapper
77
- `infra/scripts/cele2e-run.sh <test> [--keep|--build]` (fire-and-forget RUNID/LOG
78
- contract) rather than calling `cele2e run` from a long-lived watcher.
79
-
80
- ## Inviolable rules (breaking any turns the suite into a placebo)
81
-
82
- 1. **No RFC 1918 leaks past the public boundary.** Public DNS never serves an A
83
- record pointing at `10/8`, `172.16/12`, or `192.168/16` — only the firewall's
84
- external IP.
85
- 2. **Don't collapse the topology to fix a timing/connectivity bug.** Fix the
86
- *deployment ordering* (DNS record / DNAT rule / firewall opening in place
87
- before the dependent step), not the simulation.
88
- 3. **Public IPs only on the public networks.** Firewall containers bridge public
89
- and private — the only place those spaces meet, and only via NAT/DNAT.
90
- 4. **ACME challenges go through the public path** to the firewall's external IP.
91
- 5. **DDNS updates modify *public* DNS** with the firewall's external IP, never an
92
- internal target IP.
93
-
94
- Cheats (`/etc/hosts` overrides, `iptables -F`, `--insecure` in module code,
95
- hardcoded internal IPs, `|| true` on a celilo command, sleeping past a race) are
96
- fine for **bisecting** a failure but are never a **final solution** — confirm the
97
- hypothesis, then ship the real fix. Full list + rationale in `./README.md` and the
98
- repo `CLAUDE.md`.
99
-
100
- ## Doc map (read in this order)
101
-
102
- Shipped **inside this package** (offline, grep-friendly):
103
-
104
- - `./README.md` — network architecture, machines, routing, DNS, simulators,
105
- manual usage. Start here.
106
- - `./COVERAGE.md` — the array/object/map coverage audit (where N>1 paths are or
107
- aren't exercised). Consult before adding a multi-element feature test.
108
- - `./src/index.ts` — the public library exports (`network`, types, probes).
109
- - `./src/fixtures.ts` — ready-made topology presets to build tests on.
110
- - `./src/types.ts` — `NetworkHandle`, `Zone`, `MachineSpec`, `Vantage`, and the
111
- zone subnet/gateway constants.
112
-
113
- In the **source repo** (not carried in the tarball):
114
-
115
- - `infra/docs/RUNNING_CELE2E_TESTS.md` — the full operator guide; read before
116
- running the suite for real.
117
- - `https://celilo.computer/docs` — hosted docs (LAN; the repo docs lead).
package/COVERAGE.md DELETED
@@ -1,60 +0,0 @@
1
- # E2E Test Coverage Audit
2
-
3
- This document tracks how well the e2e suite exercises capability and module
4
- fields whose type is `array`, `object`, or `map` — anywhere the framework's
5
- public API admits "more than one of X."
6
-
7
- ## Why this exists
8
-
9
- A test that only ever uses N=1 will pass even when the multi-element path is
10
- broken. The bug fixed by `dns_registrar` 4.0.0 (see
11
- `apps/celilo/designs/DNS_REGISTRAR_MULTI_DOMAIN.md`) escaped the existing e2e
12
- suite for exactly this reason: the website-deploy-simple test put caddy, namecheap,
13
- and the website all on a single domain, and the multi-domain code path was
14
- never exercised.
15
-
16
- ## Audit checklist
17
-
18
- For every capability and every module manifest, identify each field whose
19
- type is `array`, `object`, or `map`. For each one, ask:
20
-
21
- 1. Does at least one e2e test exercise this field with **N > 1** distinct
22
- values?
23
- 2. Are those values **meaningfully different** (different domains, different
24
- zones, different ports — not just "two of the same kind of thing")?
25
- 3. Does the test assert behavior that would be **wrong if only the first
26
- element worked**?
27
-
28
- Anywhere all three answers aren't "yes," that's a gap to either close (write
29
- the test) or document why it's intentionally deferred.
30
-
31
- ## Coverage table
32
-
33
- | Field | N>1? | Meaningfully different? | Asserts non-first works? | Status |
34
- |---|---|---|---|---|
35
- | `namecheap.domains` | yes (`website-deploy-cross-domain`) | yes (different TLDs: `iamtheinternet.org` + `example.net`) | yes (verifies example.net DNS+TLS while iamtheinternet.org is canonical) | covered |
36
- | `namecheap.ddns_passwords` (per-domain map) | yes (same test) | yes (distinct passwords per domain) | yes (uses example.net's own password to update its A record) | covered |
37
- | `caddy.hostnames` | yes (`website-deploy-simple`) | partial (same domain in `website-deploy-simple.test.ts`); yes (cross-TLD in cross-domain test) | yes (cross-domain test serves a hostname that isn't `hostnames[0]`) | covered |
38
- | `iptables.exposed_services` | unclear — needs audit | — | — | **GAP** |
39
- | `public_web` registrations across modules | partial (`website-deploy-simple.test.ts` registers auth + website on the same domain) | needs cross-domain variant | partial | **GAP** |
40
- | `<every secret: object field>` | typically only one key | usually no | — | **AUDIT EACH** |
41
-
42
- ## Closing the gaps
43
-
44
- - **`iptables.exposed_services`**: write an e2e that exposes two services
45
- on the same machine via different ports and verifies both are reachable
46
- from outside.
47
- - **`public_web` cross-domain**: extend `website-deploy-simple.test.ts` (or a new
48
- test) to register routes on two different domains and verify each is
49
- served by the right backend.
50
- - **secret object fields**: as modules using these grow (e.g. third-party
51
- API integrations with multiple keys), add per-key tests.
52
-
53
- ## New-module checklist
54
-
55
- When authoring a module, list every field whose type permits multiple
56
- values. For each, write at least one e2e or integration test that uses
57
- **N ≥ 2** with meaningfully different values. Add a row to the table
58
- above (or document why you've deferred it).
59
-
60
- See `design/MODULE_DEVELOPMENT_GUIDE.md` for the full new-module checklist.