@celilo/e2e 0.19.3 → 0.20.1
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 +30 -13
- package/bin/e2e-bake-management +196 -12
- package/bin/e2e-infra +0 -1
- package/bin/e2e-up +14 -3
- package/docker/Dockerfile.observer +12 -1
- package/docker/Dockerfile.target-machine +22 -1
- package/npm-registry-server/package.json +1 -1
- package/package.json +3 -3
- package/registry-server/package.json +1 -1
- package/scripts/pack-celilo-packages.ts +15 -0
- package/src/block-timing.test.ts +559 -0
- package/src/block-timing.ts +366 -0
- package/src/cli/build.test.ts +135 -4
- package/src/cli/build.ts +237 -94
- package/src/cli/command-registry.ts +21 -0
- package/src/cli/command-tree-parser.ts +11 -2
- package/src/cli/completion.ts +9 -0
- package/src/cli/host.ts +252 -0
- package/src/cli/index.ts +78 -51
- package/src/cli/module-discovery.ts +108 -13
- package/src/cli/scaffold.ts +18 -26
- package/src/container-manager.cleanup.test.ts +284 -0
- package/src/container-manager.runner.test.ts +351 -0
- package/src/container-manager.test.ts +84 -0
- package/src/container-manager.ts +721 -185
- package/src/docker-compose-generator.ts +135 -61
- package/src/doctor.test.ts +259 -4
- package/src/doctor.ts +276 -3
- package/src/exit-cleanup.test.ts +83 -1
- package/src/fleet-nameserver-gate.test.ts +45 -0
- package/src/host-vm.test.ts +156 -0
- package/src/host-vm.ts +230 -0
- package/src/index.ts +11 -0
- package/src/live-stack.test.ts +300 -0
- package/src/live-stack.ts +355 -0
- package/src/no-unjustified-sleep.test.ts +90 -0
- package/src/proxmox-provisioner.test.ts +18 -2
- package/src/proxmox-provisioner.ts +22 -0
- package/src/public-sim-routes.test.ts +9 -2
- package/src/repo-root.ts +33 -0
- package/src/run-args.test.ts +76 -0
- package/src/run-args.ts +89 -0
- package/src/runner.ts +213 -8
- package/src/shared-infra.ts +88 -32
- package/src/socks-proxy.ts +2 -0
- package/src/source-fingerprint.test.ts +213 -0
- package/src/source-fingerprint.ts +212 -0
- package/src/stage-simulator-inputs.ts +93 -0
- package/src/stages.ts +133 -0
- package/src/wait-for-run.ts +1 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
type DockerReader,
|
|
8
|
+
LiveStackError,
|
|
9
|
+
type ProcessProbe,
|
|
10
|
+
SHARED_ORPHAN_MIN_AGE_MS,
|
|
11
|
+
findLiveE2eStack,
|
|
12
|
+
looksLikeE2eRunCommand,
|
|
13
|
+
} from './live-stack';
|
|
14
|
+
import type { LockStatus } from './run-lock';
|
|
15
|
+
import { startupCleanup } from './shared-infra';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The live-stack guard for the startup cleanup (celilo#1297, ce-h04y).
|
|
19
|
+
*
|
|
20
|
+
* The #1297 incident: a second cele2e invocation got past the host-global run
|
|
21
|
+
* lock and nukeE2eResources force-removed another run's mid-flight stack. The
|
|
22
|
+
* guard checks Docker and the lock file at the removal site. These tests prove
|
|
23
|
+
* the wiring through the injected runner: a live stack refuses with NOTHING
|
|
24
|
+
* removed, a dead one sweeps. Delete the guard call and the first test goes
|
|
25
|
+
* red — the removal commands run behind what should have been a refusal.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const DIR = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const SHARED_INFRA_SRC = readFileSync(join(DIR, 'shared-infra.ts'), 'utf-8');
|
|
30
|
+
|
|
31
|
+
/** Recording fake: answers `docker ps` from `psOutput`, records every command. */
|
|
32
|
+
function fakeDocker(psOutput: string): { docker: DockerReader; commands: string[] } {
|
|
33
|
+
const commands: string[] = [];
|
|
34
|
+
return {
|
|
35
|
+
commands,
|
|
36
|
+
docker: (args) => {
|
|
37
|
+
commands.push(args.join(' '));
|
|
38
|
+
if (args[0] === 'ps') return psOutput;
|
|
39
|
+
return '';
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const noLock: () => LockStatus = () => ({
|
|
45
|
+
free: true,
|
|
46
|
+
holder: null,
|
|
47
|
+
heartbeatAgeMs: null,
|
|
48
|
+
suspect: false,
|
|
49
|
+
ownKept: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function foreignLock(overrides: Partial<LockStatus> = {}): () => LockStatus {
|
|
53
|
+
return () => ({
|
|
54
|
+
free: false,
|
|
55
|
+
holder: {
|
|
56
|
+
pid: 1,
|
|
57
|
+
hostname: hostname(),
|
|
58
|
+
session: 'polecat/ce-9999 (some other worktree)',
|
|
59
|
+
test: 'crew-alerting',
|
|
60
|
+
runId: 'run-abc',
|
|
61
|
+
startedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
|
62
|
+
beatAt: Date.now() - 45_000,
|
|
63
|
+
state: 'running',
|
|
64
|
+
},
|
|
65
|
+
heartbeatAgeMs: 45_000,
|
|
66
|
+
suspect: false,
|
|
67
|
+
ownKept: false,
|
|
68
|
+
...overrides,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('findLiveE2eStack', () => {
|
|
73
|
+
test('refuses when a celilo-e2e-* container is running, naming it', () => {
|
|
74
|
+
const { docker } = fakeDocker(
|
|
75
|
+
'celilo-e2e-shared_namecheap-dns\trunning\ncelilo-e2e-1788769175864_fw-main\texited\n',
|
|
76
|
+
);
|
|
77
|
+
const refusal = findLiveE2eStack(docker, noLock);
|
|
78
|
+
expect(refusal).not.toBeNull();
|
|
79
|
+
expect(refusal?.reason).toContain('celilo-e2e-shared_namecheap-dns');
|
|
80
|
+
expect(refusal?.reason).not.toContain('celilo-e2e-1788769175864_fw-main');
|
|
81
|
+
// The remedy must be a command that EXISTS on the host printing the
|
|
82
|
+
// message (celilo#1314): a bare `cele2e down` does not — forgejo job
|
|
83
|
+
// workspaces are ephemeral, so the binary only exists inside a checkout.
|
|
84
|
+
expect(refusal?.reason).toContain('docker rm -f');
|
|
85
|
+
expect(refusal?.reason).not.toContain('cele2e down');
|
|
86
|
+
// The refusal must be greppable as an environment problem, not a check
|
|
87
|
+
// failure (celilo#1314 direction 3).
|
|
88
|
+
expect(refusal?.reason).toContain('[infra-refusal]');
|
|
89
|
+
expect(refusal?.runningContainers).toEqual(['celilo-e2e-shared_namecheap-dns']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('proceeds when every container is exited', () => {
|
|
93
|
+
const { docker } = fakeDocker(
|
|
94
|
+
'celilo-e2e-1788769175864_fw-main\texited\ncelilo-e2e-shared_registry\tdead\n',
|
|
95
|
+
);
|
|
96
|
+
expect(findLiveE2eStack(docker, noLock)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('treats paused and restarting containers as live — they hold real state', () => {
|
|
100
|
+
for (const state of ['paused', 'restarting', 'running']) {
|
|
101
|
+
const { docker } = fakeDocker(`celilo-e2e-shared_registry\t${state}\n`);
|
|
102
|
+
const refusal = findLiveE2eStack(docker, noLock);
|
|
103
|
+
expect(refusal?.runningContainers).toEqual(['celilo-e2e-shared_registry']);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('refuses on a foreign lock with a fresh heartbeat, naming session, test and heartbeat age', () => {
|
|
108
|
+
const { docker } = fakeDocker('');
|
|
109
|
+
const refusal = findLiveE2eStack(docker, foreignLock());
|
|
110
|
+
expect(refusal).not.toBeNull();
|
|
111
|
+
expect(refusal?.reason).toContain('polecat/ce-9999');
|
|
112
|
+
expect(refusal?.reason).toContain('crew-alerting');
|
|
113
|
+
expect(refusal?.reason).toContain('heartbeat 45s old');
|
|
114
|
+
expect(refusal?.holder?.runId).toBe('run-abc');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('exempts our own lock — the caller holds it for its whole run', () => {
|
|
118
|
+
const { docker } = fakeDocker('');
|
|
119
|
+
const own: () => LockStatus = () => ({
|
|
120
|
+
free: false,
|
|
121
|
+
holder: {
|
|
122
|
+
pid: process.pid,
|
|
123
|
+
hostname: hostname(),
|
|
124
|
+
session: 'whatever (this worktree)',
|
|
125
|
+
test: 'caddy-internal-private',
|
|
126
|
+
runId: 'run-own',
|
|
127
|
+
startedAt: new Date().toISOString(),
|
|
128
|
+
beatAt: Date.now(),
|
|
129
|
+
state: 'running',
|
|
130
|
+
},
|
|
131
|
+
heartbeatAgeMs: 0,
|
|
132
|
+
suspect: false,
|
|
133
|
+
ownKept: false,
|
|
134
|
+
});
|
|
135
|
+
expect(findLiveE2eStack(docker, own)).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('the container check has no own-lock exemption — a stolen lock says "ours" while another run owns the containers', () => {
|
|
139
|
+
// The #1297 suspected path: the second invocation ends up holding the lock
|
|
140
|
+
// while the first run's containers are still up. Only Docker can tell that
|
|
141
|
+
// truth, so running containers refuse even when the lock is ours.
|
|
142
|
+
const { docker } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
|
|
143
|
+
const refusal = findLiveE2eStack(docker, foreignLock({ holder: null }));
|
|
144
|
+
expect(refusal?.runningContainers.length).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('looksLikeE2eRunCommand', () => {
|
|
149
|
+
test('matches the cele2e CLI by any argv mention', () => {
|
|
150
|
+
expect(looksLikeE2eRunCommand('cele2e run smoke')).toBe(true);
|
|
151
|
+
expect(looksLikeE2eRunCommand('./node_modules/.bin/cele2e run smoke')).toBe(true);
|
|
152
|
+
expect(looksLikeE2eRunCommand('bun run packages/e2e/bin/cele2e.ts run --all')).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('matches a direct bun test of this package, which has no cele2e in argv', () => {
|
|
156
|
+
expect(looksLikeE2eRunCommand('bun test packages/e2e/tests/dns-replication.test.ts')).toBe(
|
|
157
|
+
true,
|
|
158
|
+
);
|
|
159
|
+
expect(looksLikeE2eRunCommand('bun test e2e/tests/smoke.test.ts')).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('does not match unrelated bun test runs, editors, or scripts', () => {
|
|
163
|
+
expect(looksLikeE2eRunCommand('bun test packages/mcp-server/src/tools.test.ts')).toBe(false);
|
|
164
|
+
expect(looksLikeE2eRunCommand('vi packages/e2e/src/live-stack.test.ts')).toBe(false);
|
|
165
|
+
expect(looksLikeE2eRunCommand('bun run packages/e2e/scripts/pack-celilo-packages.ts')).toBe(
|
|
166
|
+
false,
|
|
167
|
+
);
|
|
168
|
+
expect(looksLikeE2eRunCommand('bun build apps/celilo/src/index.ts')).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('shared-only orphan reap (celilo#1314)', () => {
|
|
173
|
+
// Docker's `{{.CreatedAt}}` format, e.g. "2026-09-07 18:49:14 +0000 UTC".
|
|
174
|
+
const dockerAge = (msAgo: number): string => {
|
|
175
|
+
const d = new Date(Date.now() - msAgo);
|
|
176
|
+
return `${d.toISOString().slice(0, 10)} ${d.toISOString().slice(11, 19)} +0000 UTC`;
|
|
177
|
+
};
|
|
178
|
+
const noProcesses: ProcessProbe = () => [];
|
|
179
|
+
|
|
180
|
+
test('REAPS the exact shape measured on the builder: shared-only stack, 11h old, no per-test project, no cele2e process', () => {
|
|
181
|
+
const ps = [
|
|
182
|
+
`celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`,
|
|
183
|
+
`celilo-e2e-shared_registry-1\trunning\t${dockerAge(11 * 3_600_000)}`,
|
|
184
|
+
].join('\n');
|
|
185
|
+
const { docker, commands } = fakeDocker(ps);
|
|
186
|
+
// No containers at all is NOT the test: the guard must see the live shared
|
|
187
|
+
// containers and STILL clear the way, because every piece of run evidence
|
|
188
|
+
// is absent.
|
|
189
|
+
expect(findLiveE2eStack(docker, noLock, noProcesses)).toBeNull();
|
|
190
|
+
startupCleanup('/tmp/e2e', docker, noLock, noProcesses);
|
|
191
|
+
// The reap is a real teardown, not a refusal: the shared compose down ran.
|
|
192
|
+
expect(commands.some((c) => c.includes(' down '))).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('must NOT reap a shared stack with a live per-test project beside it (the celilo#1297 shape)', () => {
|
|
196
|
+
const ps = [
|
|
197
|
+
`celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`,
|
|
198
|
+
`celilo-e2e-1788769175864_fw-main\trunning\t${dockerAge(5 * 60_000)}`,
|
|
199
|
+
].join('\n');
|
|
200
|
+
const { docker, commands } = fakeDocker(ps);
|
|
201
|
+
const refusal = findLiveE2eStack(docker, noLock, noProcesses);
|
|
202
|
+
expect(refusal).not.toBeNull();
|
|
203
|
+
expect(refusal?.runningContainers).toContain('celilo-e2e-shared_namecheap-dns-1');
|
|
204
|
+
expect(refusal?.runningContainers).toContain('celilo-e2e-1788769175864_fw-main');
|
|
205
|
+
expect(() => startupCleanup('/tmp/e2e', docker, noLock, noProcesses)).toThrow(LiveStackError);
|
|
206
|
+
expect(commands.some((c) => c.includes(' down '))).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('must NOT reap while any other cele2e process is alive — a run may be between suites', () => {
|
|
210
|
+
const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`;
|
|
211
|
+
const { docker } = fakeDocker(ps);
|
|
212
|
+
const oneRunner: ProcessProbe = () => [424242];
|
|
213
|
+
const refusal = findLiveE2eStack(docker, noLock, oneRunner);
|
|
214
|
+
expect(refusal).not.toBeNull();
|
|
215
|
+
expect(refusal?.reason).toContain('424242');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test(`must NOT reap a stack younger than ${SHARED_ORPHAN_MIN_AGE_MS / 60_000}m — the evidence is not old enough to be conclusive`, () => {
|
|
219
|
+
const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(5 * 60_000)}`;
|
|
220
|
+
const { docker } = fakeDocker(ps);
|
|
221
|
+
const refusal = findLiveE2eStack(docker, noLock, noProcesses);
|
|
222
|
+
expect(refusal).not.toBeNull();
|
|
223
|
+
expect(refusal?.reason).toContain('5m');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('must NOT reap when the age cannot be read — inconclusive evidence refuses, it never reaps', () => {
|
|
227
|
+
// Two fields, no CreatedAt: the pre-#1314 fake shape, and anything docker
|
|
228
|
+
// might print that the parser does not recognize.
|
|
229
|
+
const { docker } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
|
|
230
|
+
const refusal = findLiveE2eStack(docker, noLock, noProcesses);
|
|
231
|
+
expect(refusal).not.toBeNull();
|
|
232
|
+
expect(refusal?.reason).toContain('age');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a foreign fresh lock still refuses a shared-only orphan — the reap never overrides the lock', () => {
|
|
236
|
+
const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`;
|
|
237
|
+
const { docker } = fakeDocker(ps);
|
|
238
|
+
const refusal = findLiveE2eStack(docker, foreignLock(), noProcesses);
|
|
239
|
+
expect(refusal).not.toBeNull();
|
|
240
|
+
expect(refusal?.reason).toContain('polecat/ce-9999');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('exited containers of the shared project alone still proceed (unchanged)', () => {
|
|
244
|
+
const ps = `celilo-e2e-shared_registry-1\texited\t${dockerAge(11 * 3_600_000)}`;
|
|
245
|
+
const { docker } = fakeDocker(ps);
|
|
246
|
+
expect(findLiveE2eStack(docker, noLock, noProcesses)).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('startupCleanup', () => {
|
|
251
|
+
test('refuses behind a live stack and removes NOTHING (celilo#1297)', () => {
|
|
252
|
+
const { docker, commands } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
|
|
253
|
+
expect(() => startupCleanup('/tmp/e2e', docker, noLock)).toThrow(LiveStackError);
|
|
254
|
+
// Only the ps read happened. No compose down, no rm, no prune.
|
|
255
|
+
expect(commands).toEqual([expect.stringContaining('ps -a --filter name=celilo-e2e')]);
|
|
256
|
+
expect(commands.some((c) => c.includes(' down '))).toBe(false);
|
|
257
|
+
expect(commands.some((c) => c.includes('rm -f'))).toBe(false);
|
|
258
|
+
expect(commands.some((c) => c.includes('prune'))).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('sweeps when the environment is provably dead, compose down before the force sweep', () => {
|
|
262
|
+
const { docker, commands } = fakeDocker('celilo-e2e-1788769175864_fw-main\texited\n');
|
|
263
|
+
startupCleanup('/tmp/e2e', docker, noLock);
|
|
264
|
+
const down = commands.findIndex((c) => c.includes('compose -f docker-compose.shared.yml'));
|
|
265
|
+
const rm = commands.findIndex((c) => c.includes('rm -f'));
|
|
266
|
+
const netPrune = commands.findIndex((c) => c.includes('network prune'));
|
|
267
|
+
const volPrune = commands.findIndex((c) => c.includes('volume prune'));
|
|
268
|
+
expect(down).toBeGreaterThanOrEqual(0);
|
|
269
|
+
expect(rm).toBeGreaterThan(down);
|
|
270
|
+
expect(netPrune).toBeGreaterThan(rm);
|
|
271
|
+
expect(volPrune).toBeGreaterThan(netPrune);
|
|
272
|
+
// The sweep keys off the celilo-e2e prefix; it must never name the shared
|
|
273
|
+
// project in a force removal (the graceful compose down above owns that).
|
|
274
|
+
for (const command of commands.filter((c) => c.includes('rm -f'))) {
|
|
275
|
+
expect(command).not.toContain('celilo-e2e-shared');
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// The guard is only real if it is WIRED — a perfect guard nobody calls is the
|
|
281
|
+
// same failure as no guard. These source assertions pin the two removal sites
|
|
282
|
+
// in ensureSharedInfra; the behavioral tests above pin the guard itself.
|
|
283
|
+
describe('wiring in shared-infra.ts', () => {
|
|
284
|
+
test('the start-of-run nuke runs through startupCleanup, which guards first', () => {
|
|
285
|
+
expect(SHARED_INFRA_SRC).toContain('startupCleanup(e2eDir)');
|
|
286
|
+
const cleanupBody = SHARED_INFRA_SRC.slice(
|
|
287
|
+
SHARED_INFRA_SRC.indexOf('export function startupCleanup'),
|
|
288
|
+
SHARED_INFRA_SRC.indexOf('nukeE2eResources(e2eDir, docker)'),
|
|
289
|
+
);
|
|
290
|
+
expect(cleanupBody).toContain('findLiveE2eStack(docker, lock, processes)');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('the DNS-restart branch guards before tearing down the running stack', () => {
|
|
294
|
+
const branchBody = SHARED_INFRA_SRC.slice(
|
|
295
|
+
SHARED_INFRA_SRC.indexOf('DNS check failed'),
|
|
296
|
+
SHARED_INFRA_SRC.indexOf('await stopSharedInfra()'),
|
|
297
|
+
);
|
|
298
|
+
expect(branchBody).toContain('refuseOnLiveStack()');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-stack guard for the e2e startup cleanup (celilo#1297, ce-h04y).
|
|
3
|
+
*
|
|
4
|
+
* nukeE2eResources tears down EVERY celilo-e2e-* container by name prefix, on
|
|
5
|
+
* the assumption that holding the run-lock proves no other session's stack is
|
|
6
|
+
* live. The #1297 incident broke that assumption: a second invocation got past
|
|
7
|
+
* the lock anyway and force-removed another run's mid-flight stack, nine
|
|
8
|
+
* containers, the suite dead at 12.95s with exit 137. Which lock path let it
|
|
9
|
+
* through is not reconstructable, and does not need to be — the guard defends
|
|
10
|
+
* every path by checking ground truth (Docker and the lock file) at the
|
|
11
|
+
* removal site instead of trusting the lock's own verdict.
|
|
12
|
+
*
|
|
13
|
+
* Decided (peba, 2026-09-07, option a): before the startup cleanup removes
|
|
14
|
+
* anything, it refuses when any celilo-e2e-* container is still live or the
|
|
15
|
+
* run-lock heartbeat is fresh and foreign. It removes only what is provably
|
|
16
|
+
* dead. There is no --force: an operator who wants a live stack gone uses
|
|
17
|
+
* `cele2e down`.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately NOT guarded: the runner's end-of-run stopSharedInfra teardown.
|
|
20
|
+
* The owner tearing down its own live stack is the normal exit path, and the
|
|
21
|
+
* guard's container check would refuse it by definition.
|
|
22
|
+
*
|
|
23
|
+
* The shared-stack orphan reap (celilo#1314). The #1297 guard is keyed on
|
|
24
|
+
* "any live celilo-e2e-* container", and the shared stack is designed never to
|
|
25
|
+
* be touched by name-protecting cleanup — so one abnormal exit wedges the
|
|
26
|
+
* host permanently: the orphaned shared stack refuses every later cleanup, it
|
|
27
|
+
* is the one thing the guard structurally cannot resolve. Measured on the
|
|
28
|
+
* builder 2026-09-08: 13 celilo-e2e-shared-* containers, 11h old, no per-test
|
|
29
|
+
* project, no cele2e process, and every npm-consumer-smoke run red on a check
|
|
30
|
+
* that never executed. So a stack whose ONLY live containers belong to the
|
|
31
|
+
* shared project gets an evidence test instead of a blanket refusal: no
|
|
32
|
+
* foreign lock, no live run process, and an age past a threshold means the
|
|
33
|
+
* stack is garbage and cleanup may act. Any single piece of run evidence
|
|
34
|
+
* present, or any age that cannot be read, refuses — inconclusive evidence
|
|
35
|
+
* never reaps, because a false reap kills a real run while a false refusal
|
|
36
|
+
* costs the operator one docker command.
|
|
37
|
+
*
|
|
38
|
+
* The refusal itself names a command that exists on the host printing it (a
|
|
39
|
+
* bare `cele2e down` does not: forgejo job workspaces are ephemeral, so the
|
|
40
|
+
* binary only exists inside a checkout), and every refusal reason carries the
|
|
41
|
+
* `[infra-refusal]` marker with its exit code, so a log search distinguishes
|
|
42
|
+
* an environment problem from a check failure (celilo#1314 directions 2+3).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { ExecFileSyncOptions } from 'node:child_process';
|
|
46
|
+
import { execFileSync } from 'node:child_process';
|
|
47
|
+
import { SHARED_PROJECT_NAME } from './docker-compose-generator';
|
|
48
|
+
import {
|
|
49
|
+
type LockHolder,
|
|
50
|
+
type LockStatus,
|
|
51
|
+
formatAge,
|
|
52
|
+
formatBusy,
|
|
53
|
+
heartbeatAgeMs,
|
|
54
|
+
isSameSession,
|
|
55
|
+
lockStatus,
|
|
56
|
+
} from './run-lock';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Docker access, injected so the guard and the sweep it protects are
|
|
60
|
+
* unit-testable without a daemon (the seam lane from ce-h4no). Unlike
|
|
61
|
+
* proxmox-provisioner's DockerRunner this carries per-call timeouts and cwd,
|
|
62
|
+
* which the compose sweep needs.
|
|
63
|
+
*/
|
|
64
|
+
export type DockerReader = (args: string[], opts?: { timeoutMs?: number; cwd?: string }) => string;
|
|
65
|
+
|
|
66
|
+
export const realDocker: DockerReader = (args, opts) =>
|
|
67
|
+
execFileSync('docker', args, {
|
|
68
|
+
encoding: 'utf-8',
|
|
69
|
+
timeout: opts?.timeoutMs ?? 60_000,
|
|
70
|
+
cwd: opts?.cwd,
|
|
71
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
} satisfies ExecFileSyncOptions).trim();
|
|
73
|
+
|
|
74
|
+
/** What the guard found, and the human-readable refusal naming it. */
|
|
75
|
+
export interface LiveStackRefusal {
|
|
76
|
+
reason: string;
|
|
77
|
+
/** Container names still live, when the refusal is about containers. */
|
|
78
|
+
runningContainers: string[];
|
|
79
|
+
/** The foreign lock holder, when the refusal is about the lock. */
|
|
80
|
+
holder: LockHolder | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class LiveStackError extends Error {
|
|
84
|
+
constructor(readonly refusal: LiveStackRefusal) {
|
|
85
|
+
super(refusal.reason);
|
|
86
|
+
this.name = 'LiveStackError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How old the youngest live shared-stack container must be before a
|
|
92
|
+
* shared-only stack with no other run evidence is treated as an orphan.
|
|
93
|
+
* One hour sits far above any window in which a run process could have died
|
|
94
|
+
* without its containers dying too, and far below the 11h the builder sat
|
|
95
|
+
* wedged (celilo#1314). A stack younger than this refuses even with no other
|
|
96
|
+
* evidence: the reap is the dangerous direction, so it waits for certainty.
|
|
97
|
+
*/
|
|
98
|
+
export const SHARED_ORPHAN_MIN_AGE_MS = 60 * 60_000;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Live pids of processes that may own an e2e run. Injectable so the reap's
|
|
102
|
+
* evidence test is unit-testable without a process table.
|
|
103
|
+
*/
|
|
104
|
+
export type ProcessProbe = () => number[];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Does this process command line look like an e2e run? Pure so the probe's
|
|
108
|
+
* reach is testable. Matches the two ways a run actually exists:
|
|
109
|
+
* - the cele2e CLI and anything whose argv names it (`cele2e run|up|down|...`)
|
|
110
|
+
* - a direct `bun test` of this package's suites, which manages the shared
|
|
111
|
+
* stack through ensureSharedInfra but has no cele2e in argv
|
|
112
|
+
* The bun-test arm requires bun AND e2e AND test in the command, so an
|
|
113
|
+
* unrelated `bun test` elsewhere only false-matches when its path names e2e —
|
|
114
|
+
* and a false match refuses (cheap), where a false miss reaps a live run
|
|
115
|
+
* (expensive). An editor with an e2e test file open does not match: it does
|
|
116
|
+
* not start with a bun invocation.
|
|
117
|
+
*/
|
|
118
|
+
export function looksLikeE2eRunCommand(command: string): boolean {
|
|
119
|
+
if (command.includes('cele2e')) return true;
|
|
120
|
+
return (
|
|
121
|
+
/(^|[\\/])bun(\.exe)?\s/.test(command) && command.includes('e2e') && command.includes('test')
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** pids of this process's ancestors, self included, bounded at 64 levels. */
|
|
126
|
+
function familyOfSelf(): Set<number> {
|
|
127
|
+
const family = new Set<number>([process.pid]);
|
|
128
|
+
let pid: number | undefined = process.ppid;
|
|
129
|
+
for (let i = 0; pid !== undefined && pid > 1 && i < 64; i++) {
|
|
130
|
+
family.add(pid);
|
|
131
|
+
try {
|
|
132
|
+
const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
|
|
133
|
+
encoding: 'utf-8',
|
|
134
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
135
|
+
}).trim();
|
|
136
|
+
const ppid = Number.parseInt(out, 10);
|
|
137
|
+
pid = Number.isFinite(ppid) ? ppid : undefined;
|
|
138
|
+
} catch {
|
|
139
|
+
pid = undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return family;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The real probe: one `ps` listing, filtered by looksLikeE2eRunCommand, with
|
|
147
|
+
* this process and its ancestry removed — the cleanup runs INSIDE the run it
|
|
148
|
+
* would otherwise see as evidence, and the run's own launcher shell carries
|
|
149
|
+
* the same strings in its argv.
|
|
150
|
+
*/
|
|
151
|
+
export const realProcessProbe: ProcessProbe = (): number[] => {
|
|
152
|
+
let listing: string;
|
|
153
|
+
try {
|
|
154
|
+
listing = execFileSync('ps', ['-axo', 'pid=,command='], {
|
|
155
|
+
encoding: 'utf-8',
|
|
156
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
// A process table that cannot be read is missing evidence. The caller
|
|
160
|
+
// treats any probe failure conservatively; here that means reporting no
|
|
161
|
+
// matches, which the shared-only path then backstops with the age
|
|
162
|
+
// threshold and the unreadable-age refusal.
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const family = familyOfSelf();
|
|
166
|
+
const pids: number[] = [];
|
|
167
|
+
for (const line of listing.split('\n')) {
|
|
168
|
+
const trimmed = line.trim();
|
|
169
|
+
if (!trimmed) continue;
|
|
170
|
+
const sep = trimmed.indexOf(' ');
|
|
171
|
+
if (sep <= 0) continue;
|
|
172
|
+
const pid = Number.parseInt(trimmed.slice(0, sep), 10);
|
|
173
|
+
if (!Number.isFinite(pid) || family.has(pid)) continue;
|
|
174
|
+
if (looksLikeE2eRunCommand(trimmed.slice(sep + 1))) pids.push(pid);
|
|
175
|
+
}
|
|
176
|
+
return pids;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Every refusal goes through here so the printed reason is uniformly
|
|
181
|
+
* greppable as an infrastructure refusal (celilo#1314 direction 3) rather
|
|
182
|
+
* than reading as a check failure.
|
|
183
|
+
*/
|
|
184
|
+
function makeRefusal(
|
|
185
|
+
detail: string,
|
|
186
|
+
runningContainers: string[],
|
|
187
|
+
holder: LockHolder | null,
|
|
188
|
+
): LiveStackRefusal {
|
|
189
|
+
const reason = `[infra-refusal] refusing to clean up: ${detail}\n(this is an environment problem, not a test failure — the run exits 3)`;
|
|
190
|
+
return { reason, runningContainers, holder };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The remedy printed with a live-stack refusal. A raw docker removal, because
|
|
195
|
+
* `cele2e down` does not exist on the hosts that print this message: forgejo
|
|
196
|
+
* job workspaces are ephemeral, so the binary only exists inside a checkout
|
|
197
|
+
* (celilo#1314 direction 2). Removing the containers is what unblocks the
|
|
198
|
+
* guard; the next cleanup sweeps whatever name-prefix resources survive.
|
|
199
|
+
*/
|
|
200
|
+
export const CLEAR_STACK_COMMAND = 'docker rm -f $(docker ps -aq --filter name=celilo-e2e)';
|
|
201
|
+
|
|
202
|
+
/** Docker's `{{.CreatedAt}}` format, e.g. "2026-09-07 18:49:14 +0000 UTC". */
|
|
203
|
+
function parseDockerCreatedAt(raw: string | undefined): Date | null {
|
|
204
|
+
if (raw === undefined) return null;
|
|
205
|
+
const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(?:\.(\d+))? ([+-])(\d{2})(\d{2})/.exec(
|
|
206
|
+
raw.trim(),
|
|
207
|
+
);
|
|
208
|
+
if (!m) return null;
|
|
209
|
+
const [, date, time, frac, sign, offH, offM] = m;
|
|
210
|
+
const utcMs = Date.parse(`${date}T${time}Z`);
|
|
211
|
+
if (Number.isNaN(utcMs)) return null;
|
|
212
|
+
const offsetMs = (sign === '-' ? -1 : 1) * (Number(offH) * 60 + Number(offM)) * 60_000;
|
|
213
|
+
const fracMs = frac ? Number(frac.padEnd(3, '0').slice(0, 3)) : 0;
|
|
214
|
+
return new Date(utcMs - offsetMs + fracMs);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Containers of the shared compose project. Compose names them
|
|
219
|
+
* `<project>_<service>_<n>` (or with `-` separators on newer compose), so the
|
|
220
|
+
* character right after the project name is the tell. Everything else live —
|
|
221
|
+
* per-test projects AND sim-created guests, whose names carry no project —
|
|
222
|
+
* is run evidence.
|
|
223
|
+
*/
|
|
224
|
+
function isSharedStackContainer(name: string): boolean {
|
|
225
|
+
if (!name.startsWith(SHARED_PROJECT_NAME)) return false;
|
|
226
|
+
const sep = name[SHARED_PROJECT_NAME.length];
|
|
227
|
+
return sep === '_' || sep === '-';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Container states that mean the container cannot be holding a live stack.
|
|
232
|
+
* Everything else (running, paused, restarting) is live: paused containers
|
|
233
|
+
* keep their memory, restarting ones own their networks, and `docker rm -f`
|
|
234
|
+
* on either kills real work.
|
|
235
|
+
*/
|
|
236
|
+
const DEAD_STATES = new Set(['exited', 'created', 'dead']);
|
|
237
|
+
|
|
238
|
+
function isOwnHolder(h: LockHolder): boolean {
|
|
239
|
+
return h.pid === process.pid || isSameSession(h);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Inspect the machine for a live e2e stack. Returns a refusal when the caller
|
|
244
|
+
* must not remove anything, null when the environment is provably dead.
|
|
245
|
+
*
|
|
246
|
+
* The lock check exempts our own process (the caller holds the lock for its
|
|
247
|
+
* whole run; without the exemption every run would refuse its own cleanup).
|
|
248
|
+
* The container check has NO exemption: a stolen lock (the #1297 suspected
|
|
249
|
+
* path) makes the lock file say "ours" while another run's containers are
|
|
250
|
+
* still up, so only Docker itself can tell that truth.
|
|
251
|
+
*/
|
|
252
|
+
export function findLiveE2eStack(
|
|
253
|
+
docker: DockerReader = realDocker,
|
|
254
|
+
lock: () => LockStatus = lockStatus,
|
|
255
|
+
processes: ProcessProbe = realProcessProbe,
|
|
256
|
+
): LiveStackRefusal | null {
|
|
257
|
+
const status = lock();
|
|
258
|
+
if (!status.free && status.holder && !isOwnHolder(status.holder)) {
|
|
259
|
+
const h = status.holder;
|
|
260
|
+
const heartbeat =
|
|
261
|
+
h.state === 'running' ? `, heartbeat ${formatAge(heartbeatAgeMs(h))} old` : '';
|
|
262
|
+
return makeRefusal(
|
|
263
|
+
`the run lock is held by another session — ${formatBusy(h)}${heartbeat}`,
|
|
264
|
+
[],
|
|
265
|
+
h,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const out = docker([
|
|
270
|
+
'ps',
|
|
271
|
+
'-a',
|
|
272
|
+
'--filter',
|
|
273
|
+
'name=celilo-e2e',
|
|
274
|
+
'--format',
|
|
275
|
+
'{{.Names}}\t{{.State}}\t{{.CreatedAt}}',
|
|
276
|
+
]);
|
|
277
|
+
const live = out
|
|
278
|
+
.split('\n')
|
|
279
|
+
.filter(Boolean)
|
|
280
|
+
.map((line) => {
|
|
281
|
+
const [name, state, createdAt] = line.split('\t');
|
|
282
|
+
return { name: name ?? '', state: state ?? '', createdAt: parseDockerCreatedAt(createdAt) };
|
|
283
|
+
})
|
|
284
|
+
.filter((c) => c.name !== '' && !DEAD_STATES.has(c.state));
|
|
285
|
+
if (live.length === 0) return null;
|
|
286
|
+
|
|
287
|
+
// A per-test container (or a guest, which carries no project name at all)
|
|
288
|
+
// means a run owns this host. Refuse regardless of the shared stack — this
|
|
289
|
+
// is the #1297 protection, unchanged.
|
|
290
|
+
const runOwned = live.filter((c) => !isSharedStackContainer(c.name));
|
|
291
|
+
if (runOwned.length > 0) {
|
|
292
|
+
return makeRefusal(
|
|
293
|
+
`${live.length} live celilo-e2e-* container(s):\n${live.map((c) => ` - ${c.name}`).join('\n')}\nA live stack owns these. If the run is gone and the stack is truly abandoned, clear it with:\n ${CLEAR_STACK_COMMAND}\n(there may be no cele2e binary outside a checkout — celilo#1314)`,
|
|
294
|
+
live.map((c) => c.name),
|
|
295
|
+
null,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// celilo#1314: ONLY shared-stack containers are live. Refusing here
|
|
300
|
+
// unconditionally is what wedged the builder — an orphaned shared stack is
|
|
301
|
+
// the one state the guard could never resolve. Apply the orphan evidence
|
|
302
|
+
// test instead. Any inconclusive answer refuses.
|
|
303
|
+
const runners = processes();
|
|
304
|
+
if (runners.length > 0) {
|
|
305
|
+
return makeRefusal(
|
|
306
|
+
`a shared-only stack is live (${live.length} celilo-e2e-shared-* container(s)), but a run process is still alive (pid ${runners.join(', ')}) — it may be between suites and about to use the stack. If it is truly abandoned, clear it with:\n ${CLEAR_STACK_COMMAND}`,
|
|
307
|
+
live.map((c) => c.name),
|
|
308
|
+
null,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const youngest = Math.min(
|
|
313
|
+
...live.map((c) => {
|
|
314
|
+
if (c.createdAt === null) return Number.NaN;
|
|
315
|
+
return c.createdAt.getTime();
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
if (!Number.isFinite(youngest)) {
|
|
319
|
+
return makeRefusal(
|
|
320
|
+
`a shared-only stack is live (${live.map((c) => c.name).join(', ')}), but its container age cannot be read from docker — the orphan evidence is inconclusive. If it is truly abandoned, clear it with:\n ${CLEAR_STACK_COMMAND}`,
|
|
321
|
+
live.map((c) => c.name),
|
|
322
|
+
null,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const ageMs = Date.now() - youngest;
|
|
326
|
+
if (ageMs < SHARED_ORPHAN_MIN_AGE_MS) {
|
|
327
|
+
return makeRefusal(
|
|
328
|
+
`a shared-only stack is live (${live.length} celilo-e2e-shared-* container(s)), but its youngest container is only ${formatAge(ageMs)} old — under the ${SHARED_ORPHAN_MIN_AGE_MS / 60_000}m orphan threshold, so it may belong to a run the probe cannot see. If it is truly abandoned, clear it with:\n ${CLEAR_STACK_COMMAND}`,
|
|
329
|
+
live.map((c) => c.name),
|
|
330
|
+
null,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Every piece of run evidence is absent, and the stack is old enough that
|
|
335
|
+
// no live run can be hiding from the probe. Provably dead: reap it.
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The startup-cleanup boundary: refuse with exit 3 when a live stack is in
|
|
341
|
+
* the way, return when the caller may remove. Exiting here (rather than
|
|
342
|
+
* throwing) is what makes the refusal survive every caller shape: the run
|
|
343
|
+
* path's bun test child, `cele2e up`, and the build paths all surface a
|
|
344
|
+
* process exit code without each one needing its own handling.
|
|
345
|
+
*/
|
|
346
|
+
export function refuseOnLiveStack(
|
|
347
|
+
docker: DockerReader = realDocker,
|
|
348
|
+
lock: () => LockStatus = lockStatus,
|
|
349
|
+
processes: ProcessProbe = realProcessProbe,
|
|
350
|
+
): void {
|
|
351
|
+
const refusal = findLiveE2eStack(docker, lock, processes);
|
|
352
|
+
if (!refusal) return;
|
|
353
|
+
console.error(`\n${refusal.reason}\n`);
|
|
354
|
+
process.exit(3);
|
|
355
|
+
}
|