@celilo/e2e 0.7.16 → 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.
package/bin/e2e-build CHANGED
@@ -22,6 +22,7 @@ NETAPPS_DIR="$E2E_DIR/netapps"
22
22
  cd "$E2E_DIR"
23
23
 
24
24
  GREEN='\033[0;32m'
25
+ YELLOW='\033[0;33m'
25
26
  DIM='\033[2m'
26
27
  BOLD='\033[1m'
27
28
  NC='\033[0m'
@@ -160,6 +161,25 @@ BAKE_END=$(date +%s)
160
161
  echo -e "${GREEN}Baked in $((BAKE_END - BAKE_START))s${NC}"
161
162
  echo ""
162
163
 
164
+ # ── Step 2d: Pre-seed heavy app images into the app-zone preload cache ─────────
165
+ #
166
+ # Heavy application images (authentik server, postgres, redis) are pulled ONCE
167
+ # on the host here and saved to docker-image-cache/, which app-zone target
168
+ # machines bind-mount and load at startup via docker-image-preload.service.
169
+ # Without this the cache is empty on a fresh checkout / the builder, so every
170
+ # app-module deploy pulls GB through the simulated internet — slow enough to
171
+ # time the authentik deploy out (exit 124) and take down forgejo-deploy +
172
+ # full-stack-pipeline (ISS-0154). Best-effort: e2e-cache-images is per-image
173
+ # resilient and the preload service skips a missing tarball, so a transient
174
+ # registry hiccup degrades to a slow pull rather than failing build-infra.
175
+ echo -e "${BOLD}Pre-seeding app-zone image cache...${NC}"
176
+ if bash "$E2E_DIR/bin/e2e-cache-images"; then
177
+ :
178
+ else
179
+ echo -e "${YELLOW}⚠ image cache step failed — app-zone deploys may pull over the sim${NC}" >&2
180
+ fi
181
+ echo ""
182
+
163
183
  # ── Step 3: Optionally save images to tarball ─────────────────────────────────
164
184
 
165
185
  if [ "$1" = "--save" ]; then
@@ -23,6 +23,7 @@ mkdir -p "$CACHE_DIR"
23
23
 
24
24
  BOLD='\033[1m'
25
25
  GREEN='\033[0;32m'
26
+ YELLOW='\033[0;33m'
26
27
  DIM='\033[2m'
27
28
  NC='\033[0m'
28
29
 
@@ -44,12 +45,18 @@ for filename in "${!IMAGES[@]}"; do
44
45
  printf " %-50s " "$image"
45
46
  START=$(date +%s)
46
47
 
47
- docker pull "$image" > /dev/null 2>&1
48
- docker save "$image" -o "$tarball"
49
-
50
- END=$(date +%s)
51
- SIZE=$(du -h "$tarball" | cut -f1)
52
- echo -e "${GREEN}✔${NC} ${DIM}$((END - START))s ${SIZE}${NC}"
48
+ # Per-image best-effort: a transient registry failure for one image must not
49
+ # abort caching the others (this runs inside build-infra now). A guarded
50
+ # command does not trip `set -e`. The preload service skips a missing tarball
51
+ # gracefully — that module just pulls over the sim at deploy time.
52
+ if docker pull "$image" > /dev/null 2>&1 && docker save "$image" -o "$tarball"; then
53
+ END=$(date +%s)
54
+ SIZE=$(du -h "$tarball" | cut -f1)
55
+ echo -e "${GREEN}✔${NC} ${DIM}$((END - START))s ${SIZE}${NC}"
56
+ else
57
+ rm -f "$tarball"
58
+ echo -e "${YELLOW}✘ failed — will pull over the sim at deploy time${NC}"
59
+ fi
53
60
  done
54
61
 
55
62
  echo ""
@@ -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
@@ -0,0 +1,16 @@
1
+ $ORIGIN iamtheinternet.org.
2
+ $TTL 300
3
+
4
+ @ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
5
+ 2024020247 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.iamtheinternet.org.
14
+ ns1 IN A 100.64.0.55
15
+ @ IN A 0.0.0.0
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.16",
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",
@@ -19,7 +19,8 @@
19
19
  },
20
20
  "scripts": {
21
21
  "test": "bun test tests/",
22
- "test:completion": "bun test tests/completion"
22
+ "test:completion": "bun test tests/completion",
23
+ "test:integration": "bun test tests-integration/"
23
24
  },
24
25
  "files": [
25
26
  "src/",
@@ -65,11 +65,24 @@ export function stageAptRepo(repoRoot: string, pkgDir: string): boolean {
65
65
  process.stdout.write(` ${'apt-repo (build debs)'.padEnd(28)} `);
66
66
  const t0 = Date.now();
67
67
 
68
+ // The deb must install the EXACT version the npm-registry-sim serves locally
69
+ // (= apps/celilo/package.json). Pin CELILO_VERSION so the postinst runs
70
+ // `bun add @celilo/cli@<version>` rather than `@celilo/cli@alpha` (build-deb.sh's
71
+ // default): the sim has no local `alpha` dist-tag, so an `@alpha` install leaks
72
+ // to the real npm uplink and pulls a stale published alpha (ISS-0153). Pinning
73
+ // makes the deb version, the installed npm version, and the test expectation a
74
+ // single source of truth.
75
+ const version = currentCeliloVersion(repoRoot);
76
+
68
77
  // Reuse the canonical build-deb scripts so versioning/contents match a
69
78
  // real release exactly. build:deb builds both arches; build:deb:bootstrap
70
79
  // builds the arch-all meta-package.
71
80
  for (const script of ['build:deb', 'build:deb:bootstrap']) {
72
- const result = spawnSync('bun', ['run', script], { cwd: repoRoot, stdio: 'pipe' });
81
+ const result = spawnSync('bun', ['run', script], {
82
+ cwd: repoRoot,
83
+ stdio: 'pipe',
84
+ env: { ...process.env, CELILO_VERSION: version },
85
+ });
73
86
  if (result.status !== 0) {
74
87
  console.log(`${red}✗${reset}`);
75
88
  console.error(result.stderr?.toString());
@@ -81,7 +94,6 @@ export function stageAptRepo(repoRoot: string, pkgDir: string): boolean {
81
94
  // Stage every freshly-built .deb into the pool. dist/ may also hold older
82
95
  // versions; copy only the current ones so the repo doesn't advertise
83
96
  // stale celilo versions the npm-registry-sim no longer serves.
84
- const version = currentCeliloVersion(repoRoot);
85
97
  const distDir = join(repoRoot, 'dist');
86
98
 
87
99
  const debs = readdirSync(distDir).filter((f) => f.endsWith('.deb') && f.includes(version));
package/src/cli/build.ts CHANGED
@@ -48,6 +48,7 @@ const bold = '\x1b[1m';
48
48
  const dim = '\x1b[2m';
49
49
  const green = '\x1b[32m';
50
50
  const red = '\x1b[31m';
51
+ const yellow = '\x1b[33m';
51
52
  const reset = '\x1b[0m';
52
53
 
53
54
  interface BuildOptions {
@@ -320,6 +321,30 @@ function bakeManagement(pkgDir: string): void {
320
321
  console.log(`\n${green}Baked in ${elapsed}s${reset}\n`);
321
322
  }
322
323
 
324
+ /**
325
+ * Pre-seed heavy application images (authentik server, postgres, redis) into
326
+ * the app-zone preload cache. Without this the `docker-image-cache` is empty on
327
+ * a fresh checkout / the builder, so every app-module deploy pulls GB through
328
+ * the simulated internet — slow enough to time the authentik deploy out
329
+ * (exit 124) and take down forgejo-deploy + full-stack-pipeline (ISS-0154).
330
+ * Best-effort: `e2e-cache-images` is per-image resilient and the preload
331
+ * service skips a missing tarball, so a transient registry hiccup degrades to a
332
+ * slow pull rather than failing build-infra.
333
+ */
334
+ function cacheAppImages(pkgDir: string): void {
335
+ const cacheScript = join(pkgDir, 'bin', 'e2e-cache-images');
336
+ if (!existsSync(cacheScript)) return;
337
+
338
+ console.log(`${bold}Pre-seeding app-zone image cache...${reset}\n`);
339
+ const result = spawnSync('bash', [cacheScript], { stdio: 'inherit' });
340
+ if (result.status !== 0) {
341
+ console.error(
342
+ `${yellow}⚠ image cache step failed — app-zone deploys may pull over the sim${reset}`,
343
+ );
344
+ }
345
+ console.log('');
346
+ }
347
+
323
348
  function saveImages(pkgDir: string): void {
324
349
  const cacheDir = join(pkgDir, '.docker-cache');
325
350
  mkdirSync(cacheDir, { recursive: true });
@@ -406,6 +431,10 @@ export function runBuild(options: BuildOptions): void {
406
431
  // install.sh, rather than a parallel `bun add -g` pinning.
407
432
  bakeManagement(pkgDir);
408
433
 
434
+ // Pre-seed heavy app images so app-zone deploys load them from the cache
435
+ // instead of pulling GB through the sim (ISS-0154).
436
+ cacheAppImages(pkgDir);
437
+
409
438
  if (save) {
410
439
  saveImages(pkgDir);
411
440
  }
@@ -26,11 +26,14 @@ export const COMMANDS: CommandDef[] = [
26
26
  description: 'Run e2e tests — pass a module path or pattern, or --all / --all-modules',
27
27
  flags: [
28
28
  { name: '--all', description: 'Run the full regression: every module suite + top-level e2e/tests/' },
29
+ { name: '--complete', description: 'Alias of --all: run every test, including ci-unsafe (quarantined) ones' },
30
+ { name: '--ci-safe', description: 'Full regression EXCEPT tests marked cele2e-ci-unsafe (used by the nightly)' },
29
31
  { name: '--all-modules', description: 'Run e2e tests for all modules with e2e/ directories' },
30
32
  { name: '--keep', description: 'Keep network running after tests' },
31
33
  { name: '--reuse', description: 'Reuse existing network if running' },
32
34
  { name: '--live', description: 'Use live (non-simulated) internet' },
33
35
  { name: '--published', description: 'Use published .netapp packages' },
36
+ { name: '--notify', description: 'Desktop notification when the run finishes' },
34
37
  ],
35
38
  },
36
39
  {
@@ -73,7 +76,12 @@ export const COMMANDS: CommandDef[] = [
73
76
  },
74
77
  {
75
78
  name: 'status',
76
- 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)',
77
85
  },
78
86
  {
79
87
  name: 'load',
@@ -99,7 +107,7 @@ export const COMMANDS: CommandDef[] = [
99
107
 
100
108
  export const UP_PRESETS = ['--caddy', '--full-stack', '--infrastructure'];
101
109
 
102
- export const RUN_FLAGS = ['--all', '--all-modules', '--keep', '--reuse', '--live', '--published'];
110
+ export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published', '--notify'];
103
111
 
104
112
  export const DOWN_FLAGS = ['--keep', '--all'];
105
113
 
@@ -119,6 +119,9 @@ _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]' \\
123
+ '--ci[Plain log output: one line per step, no spinner]' \\
124
+ '--no-ci[Force spinner even when a CI env var is set]' \\
122
125
  '*::test name or module path:_cele2e_run_args'
123
126
  ;;
124
127
  list)
@@ -146,7 +149,10 @@ _cele2e() {
146
149
  completion)
147
150
  _arguments '1: :_cele2e_completion_shells'
148
151
  ;;
149
- status|load|clear-timing|list)
152
+ status)
153
+ _arguments '--json[Emit a machine-readable lock snapshot for polling]'
154
+ ;;
155
+ release|load|clear-timing)
150
156
  ;;
151
157
  esac
152
158
  ;;
@@ -162,7 +168,8 @@ _cele2e_commands() {
162
168
  'build-infra:Rebuild @celilo/e2e Docker images and standard module .netapps'
163
169
  'clear-timing:Clear saved timing history for test ETAs'
164
170
  'shell:Shell into a running container (default: management)'
165
- '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)'
166
173
  'load:Load cached Docker images from tarball'
167
174
  'scaffold:Generate a new test file from template'
168
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,9 @@ 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)
145
+ --ci Plain log output: one ✔/✗ line per step, no spinner
146
+ (auto-on when a CI env var is set; --no-ci forces off)
142
147
 
143
148
  Options for \`up\`:
144
149
  --caddy Start with caddy machine in DMZ
@@ -187,8 +192,17 @@ switch (command) {
187
192
  // repo's top-level e2e/tests/ in one invocation; `--all-modules` runs just
188
193
  // the module suites. Both resolve the repo root via resolveSuiteRoot() so
189
194
  // they work through the `./cele2e` wrapper's cd into packages/e2e.
190
- if (args.includes('--all') || args.includes('--all-modules')) {
191
- const wantTopLevel = args.includes('--all');
195
+ if (
196
+ args.includes('--all') ||
197
+ args.includes('--all-modules') ||
198
+ args.includes('--complete') ||
199
+ args.includes('--ci-safe')
200
+ ) {
201
+ // --complete and --ci-safe both run the full discovery (every module +
202
+ // the repo's top-level e2e/tests/). --ci-safe additionally has the runner
203
+ // skip tests marked `cele2e-ci-unsafe`. --all-modules stays modules-only.
204
+ const wantTopLevel =
205
+ args.includes('--all') || args.includes('--complete') || args.includes('--ci-safe');
192
206
  const suiteRoot = resolveSuiteRoot();
193
207
  const allModules = findAllModules(suiteRoot);
194
208
  const env: Record<string, string> = {
@@ -206,7 +220,9 @@ switch (command) {
206
220
  }
207
221
  runScript(
208
222
  'e2e-run',
209
- args.filter((a) => a !== '--all' && a !== '--all-modules'),
223
+ // Strip the discovery aliases; KEEP --ci-safe so the runner applies the
224
+ // `cele2e-ci-unsafe` quarantine exclusion.
225
+ args.filter((a) => a !== '--all' && a !== '--all-modules' && a !== '--complete'),
210
226
  env,
211
227
  );
212
228
  }
@@ -284,16 +300,55 @@ switch (command) {
284
300
  process.exit(0);
285
301
  }
286
302
 
287
- 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
+ }
288
317
  runScript('e2e-up', args);
318
+ }
289
319
 
290
320
  case 'down':
321
+ // Tearing the network down frees the (possibly kept) lock.
322
+ clearLock();
291
323
  runScript('e2e-down', args, { E2E_TEST_DIR: stateDir });
292
324
 
293
325
  case 'shell':
294
326
  runScript('e2e-shell', args);
295
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
+
296
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
+
297
352
  const persistentFile = join(stateDir, '.e2e-persistent.json');
298
353
  let projectName: string | undefined;
299
354
  if (existsSync(persistentFile)) {
@@ -319,6 +374,17 @@ switch (command) {
319
374
  const save = args.includes('--save');
320
375
  const skipModules = args.includes('--skip-modules');
321
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
+ }
322
388
  runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules });
323
389
  break;
324
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.
@@ -396,6 +396,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
396
396
  variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
397
397
  networks: { internal: { ipv4_address: ip } },
398
398
  cap_add: ['NET_ADMIN'],
399
+ // ISS-0157: `module import`'s `bun install` hard-links packages from the
400
+ // bun cache into node_modules, which on overlayfs forces a copy-up + fsync
401
+ // per file. On a slow/contended builder disk that storm blows past the
402
+ // 120s import timeout (intermittently). Put the celilo data dir (the
403
+ // node_modules install target) on tmpfs (RAM): bun then copies cache→tmpfs
404
+ // (cross-fs, no overlay copy-up) at RAM speed, immune to host disk. NOT
405
+ // /root/.bun itself — that holds the baked `celilo` CLI (`bun add -g`).
406
+ // Tmpfs the bun *cache* subdir too: the copy-up + fsync storm is the
407
+ // overlay upper layer under /root/.bun/install/cache. RAM-backing it (cold
408
+ // → re-fetch over the fast sim/net) keeps the CLI intact. Ephemeral e2e.
409
+ tmpfs: ['/root/.local/share/celilo', '/root/.bun/install/cache'],
399
410
  volumes: [
400
411
  ...(celiloRoot ? [`${celiloRoot}:/celilo`] : []),
401
412
  'ssh-keys:/ssh-keys',
@@ -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
@@ -19,9 +19,9 @@
19
19
 
20
20
  import { spawn, spawnSync, execSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
22
- import { basename, join, resolve } from 'node:path';
22
+ import { basename, dirname, join, resolve } from 'node:path';
23
23
  import { parse as parseYaml } from 'yaml';
24
- import { ProgressDisplay } from '@celilo/cli-display';
24
+ import { type DisplayMode, ProgressDisplay } from '@celilo/cli-display';
25
25
  import {
26
26
  emitRunCompleted,
27
27
  emitRunFailed,
@@ -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
 
@@ -70,7 +71,43 @@ const flagReuse = rawArgs.includes('--reuse');
70
71
  const flagLive = rawArgs.includes('--live');
71
72
  const flagPublished = rawArgs.includes('--published');
72
73
  const flagVerbose = rawArgs.includes('--verbose');
74
+ // CI mode: no animated spinner, no [progress:*] markers — just one clean
75
+ // ✔/✗ line per step (plus sub-events) written straight to the log. Forgejo's
76
+ // runner allocates a PTY, so process.stdout.isTTY is truthy there and the
77
+ // interactive footer (redrawn via `\r` on every spinner tick) gets linearized
78
+ // into one log line per tick — dozens of identical braille lines per step.
79
+ // `--ci` (or any standard `CI` env var, e.g. Forgejo/GitHub Actions) forces
80
+ // the non-interactive render path that sidesteps that entirely. `--no-ci`
81
+ // opts back out when a CI env var is set but an operator wants the spinner.
82
+ const flagCi = rawArgs.includes('--ci');
83
+ const flagNoCi = rawArgs.includes('--no-ci');
84
+ const ci = flagCi || (!flagNoCi && !!process.env.CI);
73
85
  const patterns = rawArgs.filter((a) => !a.startsWith('--'));
86
+ // --ci-safe: skip tests marked `cele2e-ci-unsafe` in their file (quarantined —
87
+ // known-failing or builder-flaky, each tracked by an ISS). `--complete`/`--all`
88
+ // run everything. Passed through from the CLI (not stripped) so the runner can
89
+ // apply the exclusion.
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
+ }
74
111
 
75
112
  // ─── Colors ──────────────────────────────────────────────────────────
76
113
 
@@ -78,10 +115,10 @@ const bold = '\x1b[1m';
78
115
  const dim = '\x1b[2m';
79
116
  const green = '\x1b[32m';
80
117
  const red = '\x1b[31m';
81
- const cyan = '\x1b[36m';
82
118
  const reset = '\x1b[0m';
83
119
 
84
- const interactive = process.stdout.isTTY && !process.argv.includes('--no-interactive') && !flagVerbose;
120
+ const interactive =
121
+ process.stdout.isTTY && !process.argv.includes('--no-interactive') && !flagVerbose && !ci;
85
122
 
86
123
  // ─── Timing ──────────────────────────────────────────────────────────
87
124
 
@@ -369,14 +406,6 @@ function dockerComposeShared(cmd: string): string {
369
406
  }
370
407
  }
371
408
 
372
- function isSharedInfraHealthy(): boolean {
373
- const containers = dockerComposeShared('ps -q');
374
- if (!containers) return false;
375
- return dockerComposeShared(
376
- 'exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2',
377
- ).length > 0;
378
- }
379
-
380
409
  function stopSharedInfra(): void {
381
410
  dockerComposeShared('down --volumes --remove-orphans');
382
411
  try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
@@ -400,6 +429,24 @@ function assertDockerAvailable(): void {
400
429
  async function main() {
401
430
  assertDockerAvailable();
402
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
+
403
450
  let testFiles: string[] = [];
404
451
 
405
452
  if (moduleDirs.length > 0) {
@@ -454,12 +501,45 @@ async function main() {
454
501
  .filter((f) => f.endsWith('.test.ts'))
455
502
  .sort()
456
503
  .map((f) => resolve(testsPath, f));
504
+ // Docker-backed integration tests live in a sibling `tests-integration/`
505
+ // so they stay OUT of the hermetic `bun test tests/` gate. cele2e is the
506
+ // heavy runner (Docker + build-infra present), so it covers BOTH tiers —
507
+ // this is what keeps the documented `cele2e run ansible-output` working
508
+ // after the test moved out of tests/.
509
+ const integrationPath = join(dirname(testsPath), 'tests-integration');
510
+ if (existsSync(integrationPath)) {
511
+ testFiles.push(
512
+ ...readdirSync(integrationPath)
513
+ .filter((f) => f.endsWith('.test.ts'))
514
+ .sort()
515
+ .map((f) => resolve(integrationPath, f)),
516
+ );
517
+ }
457
518
  }
458
519
 
459
520
  if (patterns.length > 0) {
460
521
  testFiles = testFiles.filter((f) => patterns.some((p) => basename(f, '.test.ts').includes(p)));
461
522
  }
462
523
 
524
+ // --ci-safe: drop quarantined tests — those whose file contains the marker
525
+ // `cele2e-ci-unsafe` (a comment naming the reason + ISS). Keeps the nightly
526
+ // green on the CI-safe subset while the quarantined tests are tracked + fixed
527
+ // separately. `--complete`/`--all` run everything (no exclusion).
528
+ if (flagCiSafe) {
529
+ const before = testFiles.length;
530
+ testFiles = testFiles.filter((f) => {
531
+ try {
532
+ return !readFileSync(f, 'utf-8').includes('cele2e-ci-unsafe');
533
+ } catch {
534
+ return true;
535
+ }
536
+ });
537
+ const skipped = before - testFiles.length;
538
+ if (skipped > 0) {
539
+ console.log(`${dim}--ci-safe: skipped ${skipped} quarantined (cele2e-ci-unsafe) test(s)${reset}`);
540
+ }
541
+ }
542
+
463
543
  if (testFiles.length === 0) {
464
544
  console.error(`${red}No test files matched${reset}`);
465
545
  process.exit(1);
@@ -507,9 +587,8 @@ async function main() {
507
587
  mkdirSync(resultsDir, { recursive: true });
508
588
 
509
589
  // runId scopes every event in this suite invocation. Read from env to
510
- // let callers (CI, the Claude tool wrapper) inject a known id and
511
- // subscribe to it before launching cele2e; otherwise generate one and
512
- // 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.
513
592
  const runId = process.env.CELE2E_RUN_ID || crypto.randomUUID();
514
593
  if (!process.env.CELE2E_RUN_ID) {
515
594
  console.log(`${dim}runId: ${runId}${reset}`);
@@ -524,14 +603,21 @@ async function main() {
524
603
 
525
604
  // 'auto' resolves to 'render' when isTTY is true (operator at a
526
605
  // terminal sees animated spinners) and 'protocol' when isTTY is
527
- // false (CI / cele2e-run.sh wrapper / any redirected stdout gets
528
- // ASCII `[progress:*]` markers — grep-friendly, no braille noise).
606
+ // false (a backgrounded run / any redirected stdout gets ASCII
607
+ // `[progress:*]` markers — grep-friendly, no braille noise).
529
608
  // Prior to this change the mode was hardcoded 'render', so non-TTY
530
609
  // callers got `⣾ phase started` / `✔ phase done` lines per phase
531
610
  // (e.g. ~29 braille codepoints in a typical test log) — cosmetic
532
611
  // but actively unhelpful for log scanning and tooling.
612
+ //
613
+ // CI mode forces 'render' with isTTY:false (non-interactive render):
614
+ // one ✔/✗ line per step, sub-events emitted inline, NO spinner and
615
+ // NO `[progress:*]` markers. We can't rely on 'auto' here because
616
+ // Forgejo's PTY makes isTTY truthy — which would pick the animated
617
+ // footer and spam the log with one braille line per spinner tick.
618
+ const displayMode: DisplayMode = ci ? 'render' : 'auto';
533
619
  const display = new ProgressDisplay({
534
- mode: 'auto',
620
+ mode: displayMode,
535
621
  out: { write: process.stdout.write.bind(process.stdout), isTTY: interactive },
536
622
  });
537
623
  let passed = 0;
@@ -606,6 +692,9 @@ async function main() {
606
692
  console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
607
693
  console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
608
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();
609
698
  }
610
699
 
611
700
  if (result.status === 'pass') {
@@ -688,6 +777,7 @@ async function main() {
688
777
  resultsDir,
689
778
  });
690
779
 
780
+ notify('cele2e', `${passed} passed, ${failed} failed — ${formatDuration(suiteDuration)}`);
691
781
  process.exit(failed > 0 ? 1 : 0);
692
782
  }
693
783
 
@@ -705,5 +795,6 @@ main().catch((err) => {
705
795
  error: err instanceof Error ? err.message : String(err),
706
796
  });
707
797
  console.error(err);
798
+ notify('cele2e', 'run failed');
708
799
  process.exit(1);
709
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
@@ -172,11 +176,16 @@ export async function ensureSharedInfra(): Promise<void> {
172
176
  run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`,
173
177
  { cwd: e2eDir, timeout: 120_000 });
174
178
 
175
- // Wait for DNS convergence
179
+ // Wait for DNS convergence — robustly. The old single 60s window with no
180
+ // recovery was an intermittent build-infra failure on the builder (#319:
181
+ // namecheap-dns hadn't served its zone within 60s, and the bare throw gave
182
+ // no clue why). Now: a longer window, RESTART the DNS container if it's
183
+ // stuck (re-triggers the zone load), and DUMP diagnostics before giving up.
176
184
  console.log('[progress:start] waiting for DNS convergence | DNS converged');
185
+ const dnsTimeout = 180_000;
177
186
  const start = Date.now();
178
- const timeout = 60_000;
179
- while (Date.now() - start < timeout) {
187
+ let lastRestart = start;
188
+ while (Date.now() - start < dnsTimeout) {
180
189
  try {
181
190
  const result = run(
182
191
  `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2`,
@@ -190,9 +199,34 @@ export async function ensureSharedInfra(): Promise<void> {
190
199
  } catch {
191
200
  // retry
192
201
  }
202
+ // Recovery: if ~45s have passed since the last (re)start without
203
+ // converging, the DNS container likely didn't load its zone — restart
204
+ // it to re-trigger the load instead of waiting out the whole window.
205
+ if (Date.now() - lastRestart > 45_000) {
206
+ console.log('[progress:sub] DNS not converged yet — restarting namecheap-dns');
207
+ try {
208
+ run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} restart namecheap-dns`,
209
+ { cwd: e2eDir, timeout: 30_000 });
210
+ } catch {}
211
+ lastRestart = Date.now();
212
+ }
193
213
  await new Promise(r => setTimeout(r, 2000));
194
214
  }
195
- throw new Error('Timeout waiting for shared infrastructure DNS convergence');
215
+ // Diagnostics before failing, so the log shows WHY it didn't converge.
216
+ let psOut = '';
217
+ try {
218
+ psOut = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps`,
219
+ { cwd: e2eDir, timeout: 10_000 });
220
+ } catch {}
221
+ let dnsLog = '';
222
+ try {
223
+ dnsLog = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} logs --tail 40 namecheap-dns`,
224
+ { cwd: e2eDir, timeout: 10_000 });
225
+ } catch {}
226
+ throw new Error(
227
+ `Timeout waiting for shared infrastructure DNS convergence (${dnsTimeout / 1000}s)\n` +
228
+ `--- compose ps ---\n${psOut}\n--- namecheap-dns logs (tail 40) ---\n${dnsLog}`,
229
+ );
196
230
  }
197
231
 
198
232
  /**