@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.
- package/config/dns/example.net.zone +16 -0
- package/config/routing/fw-main-routes.sh +26 -9
- package/package.json +2 -4
- package/src/cli/command-registry.ts +8 -2
- package/src/cli/completion.ts +7 -2
- package/src/cli/index.ts +55 -2
- package/src/container-manager.ts +32 -1
- package/src/run-lock.test.ts +105 -0
- package/src/run-lock.ts +252 -0
- package/src/runner.ts +47 -13
- package/src/shared-infra.ts +35 -31
- package/AGENTS.md +0 -117
- package/COVERAGE.md +0 -60
|
@@ -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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
package/src/cli/completion.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/container-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/run-lock.ts
ADDED
|
@@ -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
|
|
561
|
-
//
|
|
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 (
|
|
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
|
});
|
package/src/shared-infra.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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.
|