@celilo/e2e 0.7.17 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/bin/e2e-bake-management +214 -85
- package/config/routing/fw-main-routes.sh +26 -9
- package/package.json +3 -5
- package/simulators/namecheap-ddns/zone-updater.test.ts +31 -0
- package/simulators/namecheap-ddns/zone-updater.ts +26 -0
- package/src/cli/build.test.ts +47 -0
- package/src/cli/build.ts +283 -11
- package/src/cli/command-registry.ts +11 -3
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +59 -3
- package/src/cli-version-contract.test.ts +22 -0
- package/src/cli-version-contract.ts +37 -0
- package/src/container-manager.ts +522 -606
- package/src/docker-compose-generator.ts +5 -3
- package/src/network-builder.ts +20 -28
- package/src/run-lock.test.ts +105 -0
- package/src/run-lock.ts +252 -0
- package/src/runner.ts +121 -13
- package/src/shared-infra.ts +36 -58
- package/src/types.ts +16 -1
- package/AGENTS.md +0 -117
- package/COVERAGE.md +0 -60
- package/config/dns/iamtheinternet.org.zone +0 -16
|
@@ -18,9 +18,11 @@ import { ZONE_GATEWAYS } from './types';
|
|
|
18
18
|
* - **npm-installed consumer** (lunacycle and friends) — `../../modules`
|
|
19
19
|
* would resolve to `<consumer>/node_modules/modules`, which doesn't
|
|
20
20
|
* exist. Docker fails the mount with "permission denied" trying to
|
|
21
|
-
* create that path. Instead we bind the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* create that path. Instead we bind the local `./netapps/` dir to
|
|
22
|
+
* `/uploads`, and the registry's scanUploadsDir picks up the .netapp
|
|
23
|
+
* files. That dir is populated at `cele2e build-infra` time by fetching
|
|
24
|
+
* the standard-module netapps from the public registry (see
|
|
25
|
+
* stageNetappsFromRegistry in cli/build.ts) — consumers vendor nothing.
|
|
24
26
|
*
|
|
25
27
|
* Detection: check whether the would-be monorepo modules dir is real.
|
|
26
28
|
* `import.meta.dir` is `<pkgDir>/src/`, so `../../../modules` from there
|
package/src/network-builder.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { startNetwork } from './container-manager';
|
|
5
|
-
import type { NetworkConfig, NetworkHandle, TopologyPreset, Vantage } from './types';
|
|
5
|
+
import type { MachineSpec, NetworkConfig, NetworkHandle, TopologyPreset, Vantage, Zone } from './types';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* If a mounted path looks like a celilo module (has scripts/package.json),
|
|
@@ -51,53 +51,45 @@ export class NetworkBuilder {
|
|
|
51
51
|
return this;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */
|
|
55
|
+
private addMachines(
|
|
56
|
+
target: MachineSpec[],
|
|
57
|
+
zone: Zone,
|
|
58
|
+
machines: Record<string, string>,
|
|
59
|
+
extra?: Pick<MachineSpec, 'docker' | 'dockerfile'>,
|
|
60
|
+
): this {
|
|
55
61
|
for (const [name, ip] of Object.entries(machines)) {
|
|
56
|
-
|
|
62
|
+
target.push({ name, ip, zone, ...extra });
|
|
57
63
|
}
|
|
58
64
|
return this;
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
dmz(machines: Record<string, string>): this {
|
|
68
|
+
return this.addMachines(this.config.dmzMachines, 'dmz', machines);
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
app(machines: Record<string, string>): this {
|
|
62
|
-
|
|
63
|
-
this.config.appMachines.push({ name, ip, zone: 'app' });
|
|
64
|
-
}
|
|
65
|
-
return this;
|
|
72
|
+
return this.addMachines(this.config.appMachines, 'app', machines);
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
secure(machines: Record<string, string>): this {
|
|
69
|
-
|
|
70
|
-
this.config.secureMachines.push({ name, ip, zone: 'secure' });
|
|
71
|
-
}
|
|
72
|
-
return this;
|
|
76
|
+
return this.addMachines(this.config.secureMachines, 'secure', machines);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
internal(machines: Record<string, string>): this {
|
|
76
|
-
|
|
77
|
-
this.config.internalMachines.push({ name, ip, zone: 'internal' });
|
|
78
|
-
}
|
|
79
|
-
return this;
|
|
80
|
+
return this.addMachines(this.config.internalMachines, 'internal', machines);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */
|
|
83
84
|
internalDocker(machines: Record<string, string>): this {
|
|
84
|
-
|
|
85
|
-
this.config.internalMachines.push({ name, ip, zone: 'internal', docker: true });
|
|
86
|
-
}
|
|
87
|
-
return this;
|
|
85
|
+
return this.addMachines(this.config.internalMachines, 'internal', machines, { docker: true });
|
|
88
86
|
}
|
|
89
87
|
|
|
90
88
|
/** Add a Technitium DNS machine using the dedicated pre-installed image */
|
|
91
89
|
technitium(machines: Record<string, string>): this {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
ip,
|
|
96
|
-
zone: 'internal',
|
|
97
|
-
dockerfile: 'docker/Dockerfile.target-technitium',
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
return this;
|
|
90
|
+
return this.addMachines(this.config.internalMachines, 'internal', machines, {
|
|
91
|
+
dockerfile: 'docker/Dockerfile.target-technitium',
|
|
92
|
+
});
|
|
101
93
|
}
|
|
102
94
|
|
|
103
95
|
withDhcpClient(): this {
|
|
@@ -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,8 @@ import {
|
|
|
30
30
|
emitTestStarted,
|
|
31
31
|
} from './bus-events';
|
|
32
32
|
import { parseLine } from './parse-line';
|
|
33
|
+
import { acquireRunLock, E2eBusyError, markKept } from './run-lock';
|
|
34
|
+
import { SIMULATOR_IPS } from './simulator-ips';
|
|
33
35
|
|
|
34
36
|
// ─── Config ──────────────────────────────────────────────────────────
|
|
35
37
|
|
|
@@ -87,6 +89,52 @@ const patterns = rawArgs.filter((a) => !a.startsWith('--'));
|
|
|
87
89
|
// run everything. Passed through from the CLI (not stripped) so the runner can
|
|
88
90
|
// apply the exclusion.
|
|
89
91
|
const flagCiSafe = rawArgs.includes('--ci-safe');
|
|
92
|
+
// --notify: fire a desktop notification when the run finishes (handy for long
|
|
93
|
+
// detached/background runs). Best-effort — never affects the exit code.
|
|
94
|
+
const flagNotify = rawArgs.includes('--notify');
|
|
95
|
+
// --shuffle [--seed=N]: randomize test order to surface order-dependent bugs
|
|
96
|
+
// (e2e-confidence #258). The install-sh DNS-pollution bug only manifested in a
|
|
97
|
+
// specific order — a shuffle lane flushes that class out. Seeded + logged so a
|
|
98
|
+
// failing order is reproducible: replay with the printed --seed=N.
|
|
99
|
+
const flagShuffle = rawArgs.includes('--shuffle');
|
|
100
|
+
const seedFlag = rawArgs.find((a) => a.startsWith('--seed='));
|
|
101
|
+
const seedArg = seedFlag ? Number.parseInt(seedFlag.split('=')[1] ?? '', 10) : undefined;
|
|
102
|
+
|
|
103
|
+
/** Deterministic Fisher-Yates shuffle seeded by a 32-bit value (mulberry32), so
|
|
104
|
+
* a randomized test order is reproducible via --seed=<n>. */
|
|
105
|
+
function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
|
|
106
|
+
let s = seed >>> 0;
|
|
107
|
+
const rand = (): number => {
|
|
108
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
109
|
+
let t = s;
|
|
110
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
111
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
112
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
113
|
+
};
|
|
114
|
+
const out = [...arr];
|
|
115
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
116
|
+
const j = Math.floor(rand() * (i + 1));
|
|
117
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function notify(title: string, message: string): void {
|
|
123
|
+
if (!flagNotify) return;
|
|
124
|
+
try {
|
|
125
|
+
if (process.platform === 'darwin') {
|
|
126
|
+
const r = spawnSync('terminal-notifier', ['-title', title, '-message', message, '-sound', 'Glass'], { stdio: 'ignore' });
|
|
127
|
+
if (r.error) {
|
|
128
|
+
spawnSync('osascript', ['-e', `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)} sound name "Glass"`], { stdio: 'ignore' });
|
|
129
|
+
}
|
|
130
|
+
spawnSync('say', [message], { stdio: 'ignore' });
|
|
131
|
+
} else if (process.platform === 'linux') {
|
|
132
|
+
spawnSync('notify-send', [title, message], { stdio: 'ignore' });
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// ponytail: notification is a nicety; its failure must never break a run.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
90
138
|
|
|
91
139
|
// ─── Colors ──────────────────────────────────────────────────────────
|
|
92
140
|
|
|
@@ -94,7 +142,6 @@ const bold = '\x1b[1m';
|
|
|
94
142
|
const dim = '\x1b[2m';
|
|
95
143
|
const green = '\x1b[32m';
|
|
96
144
|
const red = '\x1b[31m';
|
|
97
|
-
const cyan = '\x1b[36m';
|
|
98
145
|
const reset = '\x1b[0m';
|
|
99
146
|
|
|
100
147
|
const interactive =
|
|
@@ -386,19 +433,36 @@ function dockerComposeShared(cmd: string): string {
|
|
|
386
433
|
}
|
|
387
434
|
}
|
|
388
435
|
|
|
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
436
|
function stopSharedInfra(): void {
|
|
398
437
|
dockerComposeShared('down --volumes --remove-orphans');
|
|
399
438
|
try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
|
|
400
439
|
}
|
|
401
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Detect cross-test pollution of the SHARED authoritative DNS at the test
|
|
443
|
+
* boundary (e2e-confidence #254). A test that leaves a protected sim record
|
|
444
|
+
* clobbered — canonically celilo.computer's apex off the website-sim, the bug
|
|
445
|
+
* that silently broke install-sh ~15 tests downstream — has violated the shared
|
|
446
|
+
* contract. Checked AFTER each test so the polluter is named HERE, at the cause,
|
|
447
|
+
* instead of a later test failing mysteriously. On detection we re-scrub
|
|
448
|
+
* (cp /seed -> /config, mirroring scrubDnsZones) so the damage doesn't cascade.
|
|
449
|
+
*
|
|
450
|
+
* Returns a description of the violation (and heals) if polluted, else null.
|
|
451
|
+
* A query failure (infra down / tool missing) is not treated as pollution.
|
|
452
|
+
*/
|
|
453
|
+
function detectSharedDnsPollution(): string | null {
|
|
454
|
+
const served = dockerComposeShared(
|
|
455
|
+
`exec -T namecheap-dns sh -c ${JSON.stringify('kdig @127.0.0.1 celilo.computer A +short')}`,
|
|
456
|
+
).trim();
|
|
457
|
+
if (!served) return null;
|
|
458
|
+
if (served.split(/\s+/).includes(SIMULATOR_IPS.WEBSITE)) return null;
|
|
459
|
+
// Polluted — heal so the next test starts from a clean baseline.
|
|
460
|
+
dockerComposeShared(
|
|
461
|
+
`exec -T namecheap-dns sh -c ${JSON.stringify('cp -f /seed/*.zone /config/ && knotc zone-reload')}`,
|
|
462
|
+
);
|
|
463
|
+
return `celilo.computer apex resolves to "${served}" (expected the website-sim ${SIMULATOR_IPS.WEBSITE})`;
|
|
464
|
+
}
|
|
465
|
+
|
|
402
466
|
// ─── Docker Check ────────────────────────────────────────────────────
|
|
403
467
|
|
|
404
468
|
function assertDockerAvailable(): void {
|
|
@@ -417,6 +481,24 @@ function assertDockerAvailable(): void {
|
|
|
417
481
|
async function main() {
|
|
418
482
|
assertDockerAvailable();
|
|
419
483
|
|
|
484
|
+
// Serialize against other cele2e runs sharing the global Docker infra. Fail
|
|
485
|
+
// fast naming the holder; pollers use `cele2e status`. Acquire BEFORE any
|
|
486
|
+
// docker mutation so a competing run can't wipe our shared infra mid-setup.
|
|
487
|
+
const lockLabel = patterns.length ? patterns.join(',') : moduleDirs.length ? 'modules' : 'all';
|
|
488
|
+
try {
|
|
489
|
+
acquireRunLock({ test: lockLabel, runId: process.env.CELE2E_RUN_ID ?? ambientRunId, allowKept: flagReuse });
|
|
490
|
+
// A --reuse run keeps using a kept stack that stays up afterwards — hold the
|
|
491
|
+
// lock in kept state so it isn't freed out from under the reused network.
|
|
492
|
+
if (flagReuse) markKept();
|
|
493
|
+
} catch (err) {
|
|
494
|
+
if (err instanceof E2eBusyError) {
|
|
495
|
+
console.error(`\n${red}${err.message}${reset}`);
|
|
496
|
+
console.error(`${dim}Poll with: cele2e status (machine-readable: cele2e status --json)${reset}\n`);
|
|
497
|
+
process.exit(3);
|
|
498
|
+
}
|
|
499
|
+
throw err;
|
|
500
|
+
}
|
|
501
|
+
|
|
420
502
|
let testFiles: string[] = [];
|
|
421
503
|
|
|
422
504
|
if (moduleDirs.length > 0) {
|
|
@@ -515,6 +597,17 @@ async function main() {
|
|
|
515
597
|
process.exit(1);
|
|
516
598
|
}
|
|
517
599
|
|
|
600
|
+
// e2e-confidence #258: optionally randomize order to surface order-dependent
|
|
601
|
+
// bugs (the install-sh DNS bleed only manifested in a specific order). Seeded
|
|
602
|
+
// + logged so a failing order replays exactly via --seed=<n>.
|
|
603
|
+
if (flagShuffle) {
|
|
604
|
+
const seed = (seedArg !== undefined && Number.isFinite(seedArg) ? seedArg : Date.now()) >>> 0;
|
|
605
|
+
testFiles = seededShuffle(testFiles, seed);
|
|
606
|
+
console.log(
|
|
607
|
+
`${dim}--shuffle: randomized test order (seed=${seed}) — replay this exact order with --seed=${seed}${reset}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
518
611
|
// --keep keeps a single test network alive after the run; only one network
|
|
519
612
|
// can live in `.e2e-persistent.json` at a time, so multiple tests would
|
|
520
613
|
// either fight for that slot or run with no functional --keep at all. Bail
|
|
@@ -557,9 +650,8 @@ async function main() {
|
|
|
557
650
|
mkdirSync(resultsDir, { recursive: true });
|
|
558
651
|
|
|
559
652
|
// 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.
|
|
653
|
+
// let callers (CI) inject a known id and subscribe to it before launching
|
|
654
|
+
// cele2e; otherwise generate one and print it so the operator can correlate.
|
|
563
655
|
const runId = process.env.CELE2E_RUN_ID || crypto.randomUUID();
|
|
564
656
|
if (!process.env.CELE2E_RUN_ID) {
|
|
565
657
|
console.log(`${dim}runId: ${runId}${reset}`);
|
|
@@ -574,7 +666,7 @@ async function main() {
|
|
|
574
666
|
|
|
575
667
|
// 'auto' resolves to 'render' when isTTY is true (operator at a
|
|
576
668
|
// terminal sees animated spinners) and 'protocol' when isTTY is
|
|
577
|
-
// false (
|
|
669
|
+
// false (a backgrounded run / any redirected stdout gets ASCII
|
|
578
670
|
// `[progress:*]` markers — grep-friendly, no braille noise).
|
|
579
671
|
// Prior to this change the mode was hardcoded 'render', so non-TTY
|
|
580
672
|
// callers got `⣾ phase started` / `✔ phase done` lines per phase
|
|
@@ -635,6 +727,17 @@ async function main() {
|
|
|
635
727
|
|
|
636
728
|
emitTestStarted({ runId, name, expectedDurationS: expected });
|
|
637
729
|
const result = await runTest(file, name, expected, logDir, display);
|
|
730
|
+
|
|
731
|
+
// e2e-confidence #254: name a test that leaves shared infra polluted, at the
|
|
732
|
+
// boundary — before a later test fails mysteriously downstream. detect…()
|
|
733
|
+
// also heals (re-scrubs), so a passing-but-polluting test is failed here AND
|
|
734
|
+
// the next test still starts clean.
|
|
735
|
+
const pollution = detectSharedDnsPollution();
|
|
736
|
+
if (pollution && result.status === 'pass') {
|
|
737
|
+
result.status = 'fail';
|
|
738
|
+
result.error = `cross-test pollution (#254): this test left shared DNS dirty — ${pollution}`;
|
|
739
|
+
}
|
|
740
|
+
|
|
638
741
|
emitTestCompleted({
|
|
639
742
|
runId,
|
|
640
743
|
name,
|
|
@@ -663,6 +766,9 @@ async function main() {
|
|
|
663
766
|
console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
|
|
664
767
|
console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
|
|
665
768
|
console.log(`${dim} Tear down: cele2e down${reset}`);
|
|
769
|
+
// The kept network outlives this process — hold the lock in a `kept`
|
|
770
|
+
// state so the next run refuses to clobber it (cleared by `cele2e down`).
|
|
771
|
+
markKept();
|
|
666
772
|
}
|
|
667
773
|
|
|
668
774
|
if (result.status === 'pass') {
|
|
@@ -745,6 +851,7 @@ async function main() {
|
|
|
745
851
|
resultsDir,
|
|
746
852
|
});
|
|
747
853
|
|
|
854
|
+
notify('cele2e', `${passed} passed, ${failed} failed — ${formatDuration(suiteDuration)}`);
|
|
748
855
|
process.exit(failed > 0 ? 1 : 0);
|
|
749
856
|
}
|
|
750
857
|
|
|
@@ -762,5 +869,6 @@ main().catch((err) => {
|
|
|
762
869
|
error: err instanceof Error ? err.message : String(err),
|
|
763
870
|
});
|
|
764
871
|
console.error(err);
|
|
872
|
+
notify('cele2e', 'run failed');
|
|
765
873
|
process.exit(1);
|
|
766
874
|
});
|