@celilo/e2e 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/e2e-bake-management +46 -5
- package/package.json +4 -3
- package/src/block-timing.ts +24 -3
- package/src/cli/build.test.ts +142 -3
- package/src/cli/build.ts +49 -7
- package/src/container-manager.ts +47 -1
- package/src/docker-compose-generator.ts +33 -3
- package/src/extract-failure.ts +36 -14
- package/src/live-stack.test.ts +171 -4
- package/src/live-stack.ts +285 -17
- package/src/module-host.test.ts +55 -1
- package/src/module-host.ts +31 -0
- package/src/netapp-staleness.test.ts +139 -0
- package/src/netapp-staleness.ts +62 -0
- package/src/network-builder.ts +19 -3
- package/src/registry-bundle.ts +14 -2
- package/src/runner-keep.test.ts +72 -0
- package/src/runner.ts +19 -2
- package/src/shared-infra.ts +7 -2
- package/src/source-fingerprint.ts +11 -0
- package/src/types.ts +21 -7
- package/registry-server/src/auth.test.ts +0 -76
- package/registry-server/src/bootstrap-packaging.test.ts +0 -71
- package/registry-server/src/introspection.test.ts +0 -243
- package/registry-server/src/module-owner-store.test.ts +0 -85
- package/registry-server/src/rate-limit.test.ts +0 -62
- package/registry-server/src/scoped-token-store.test.ts +0 -93
- package/registry-server/src/server.test.ts +0 -991
- package/registry-server/src/storage.test.ts +0 -152
- package/registry-server/src/sweep.test.ts +0 -326
- package/registry-server/src/validation.test.ts +0 -86
package/src/live-stack.test.ts
CHANGED
|
@@ -3,7 +3,17 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type DockerReader,
|
|
8
|
+
LiveStackError,
|
|
9
|
+
type ProcessProbe,
|
|
10
|
+
SHARED_ORPHAN_MIN_AGE_MS,
|
|
11
|
+
UNIT_ONLY_ENV_ENTRY,
|
|
12
|
+
environMarksUnitOnly,
|
|
13
|
+
findLiveE2eStack,
|
|
14
|
+
isUnitOnlyProcess,
|
|
15
|
+
looksLikeE2eRunCommand,
|
|
16
|
+
} from './live-stack';
|
|
7
17
|
import type { LockStatus } from './run-lock';
|
|
8
18
|
import { startupCleanup } from './shared-infra';
|
|
9
19
|
|
|
@@ -67,11 +77,24 @@ describe('findLiveE2eStack', () => {
|
|
|
67
77
|
const { docker } = fakeDocker(
|
|
68
78
|
'celilo-e2e-shared_namecheap-dns\trunning\ncelilo-e2e-1788769175864_fw-main\texited\n',
|
|
69
79
|
);
|
|
70
|
-
|
|
80
|
+
// The default probe reads the REAL host process table. Under another
|
|
81
|
+
// session's live e2e run (celilo#1332: the bun 1.4.2 grind) the
|
|
82
|
+
// shared-only + live-runner branch fires instead of the unreadable-age
|
|
83
|
+
// branch pinned here, and the refusal names pid counts instead of the
|
|
84
|
+
// container. A test pinning a message shape injects its inputs: an empty
|
|
85
|
+
// probe makes the pinned shape deterministic.
|
|
86
|
+
const refusal = findLiveE2eStack(docker, noLock, () => []);
|
|
71
87
|
expect(refusal).not.toBeNull();
|
|
72
88
|
expect(refusal?.reason).toContain('celilo-e2e-shared_namecheap-dns');
|
|
73
89
|
expect(refusal?.reason).not.toContain('celilo-e2e-1788769175864_fw-main');
|
|
74
|
-
|
|
90
|
+
// The remedy must be a command that EXISTS on the host printing the
|
|
91
|
+
// message (celilo#1314): a bare `cele2e down` does not — forgejo job
|
|
92
|
+
// workspaces are ephemeral, so the binary only exists inside a checkout.
|
|
93
|
+
expect(refusal?.reason).toContain('docker rm -f');
|
|
94
|
+
expect(refusal?.reason).not.toContain('cele2e down');
|
|
95
|
+
// The refusal must be greppable as an environment problem, not a check
|
|
96
|
+
// failure (celilo#1314 direction 3).
|
|
97
|
+
expect(refusal?.reason).toContain('[infra-refusal]');
|
|
75
98
|
expect(refusal?.runningContainers).toEqual(['celilo-e2e-shared_namecheap-dns']);
|
|
76
99
|
});
|
|
77
100
|
|
|
@@ -131,6 +154,150 @@ describe('findLiveE2eStack', () => {
|
|
|
131
154
|
});
|
|
132
155
|
});
|
|
133
156
|
|
|
157
|
+
describe('unit-only exclusion (celilo#1320)', () => {
|
|
158
|
+
const environOf =
|
|
159
|
+
(byPid: Record<number, string | null>) =>
|
|
160
|
+
(pid: number): string | null =>
|
|
161
|
+
byPid[pid] ?? null;
|
|
162
|
+
|
|
163
|
+
test('an environ carrying the marker is unit-only', () => {
|
|
164
|
+
expect(
|
|
165
|
+
environMarksUnitOnly(['PATH=/usr/bin', UNIT_ONLY_ENV_ENTRY, 'HOME=/root'].join('\0')),
|
|
166
|
+
).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the marker must be a whole entry, not a substring', () => {
|
|
170
|
+
expect(environMarksUnitOnly(['PATH=/x', 'SOMETHING_CELILO_UNIT_ONLY=12'].join('\0'))).toBe(
|
|
171
|
+
false,
|
|
172
|
+
);
|
|
173
|
+
expect(environMarksUnitOnly('CELILO_UNIT_ONLY=12')).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('an unreadable environ is NOT unit-only (inconclusive never reaps)', () => {
|
|
177
|
+
expect(environMarksUnitOnly(null)).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('isUnitOnlyProcess reads the environ of the matched pid', () => {
|
|
181
|
+
const environ = environOf({
|
|
182
|
+
101: ['PATH=/x', UNIT_ONLY_ENV_ENTRY].join('\0'),
|
|
183
|
+
102: 'PATH=/x',
|
|
184
|
+
103: null,
|
|
185
|
+
});
|
|
186
|
+
expect(isUnitOnlyProcess(101, environ)).toBe(true);
|
|
187
|
+
expect(isUnitOnlyProcess(102, environ)).toBe(false);
|
|
188
|
+
expect(isUnitOnlyProcess(103, environ)).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('the marker matches what test:unit actually exports', () => {
|
|
192
|
+
const rootPackageJson = JSON.parse(
|
|
193
|
+
readFileSync(join(DIR, '../../../package.json'), 'utf-8'),
|
|
194
|
+
) as { scripts: Record<string, string> };
|
|
195
|
+
expect(rootPackageJson.scripts['test:unit']).toContain('CELILO_UNIT_ONLY=1');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('looksLikeE2eRunCommand', () => {
|
|
200
|
+
test('matches the cele2e CLI by any argv mention', () => {
|
|
201
|
+
expect(looksLikeE2eRunCommand('cele2e run smoke')).toBe(true);
|
|
202
|
+
expect(looksLikeE2eRunCommand('./node_modules/.bin/cele2e run smoke')).toBe(true);
|
|
203
|
+
expect(looksLikeE2eRunCommand('bun run packages/e2e/bin/cele2e.ts run --all')).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('matches a direct bun test of this package, which has no cele2e in argv', () => {
|
|
207
|
+
expect(looksLikeE2eRunCommand('bun test packages/e2e/tests/dns-replication.test.ts')).toBe(
|
|
208
|
+
true,
|
|
209
|
+
);
|
|
210
|
+
expect(looksLikeE2eRunCommand('bun test e2e/tests/smoke.test.ts')).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('does not match unrelated bun test runs, editors, or scripts', () => {
|
|
214
|
+
expect(looksLikeE2eRunCommand('bun test packages/mcp-server/src/tools.test.ts')).toBe(false);
|
|
215
|
+
expect(looksLikeE2eRunCommand('vi packages/e2e/src/live-stack.test.ts')).toBe(false);
|
|
216
|
+
expect(looksLikeE2eRunCommand('bun run packages/e2e/scripts/pack-celilo-packages.ts')).toBe(
|
|
217
|
+
false,
|
|
218
|
+
);
|
|
219
|
+
expect(looksLikeE2eRunCommand('bun build apps/celilo/src/index.ts')).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('shared-only orphan reap (celilo#1314)', () => {
|
|
224
|
+
// Docker's `{{.CreatedAt}}` format, e.g. "2026-09-07 18:49:14 +0000 UTC".
|
|
225
|
+
const dockerAge = (msAgo: number): string => {
|
|
226
|
+
const d = new Date(Date.now() - msAgo);
|
|
227
|
+
return `${d.toISOString().slice(0, 10)} ${d.toISOString().slice(11, 19)} +0000 UTC`;
|
|
228
|
+
};
|
|
229
|
+
const noProcesses: ProcessProbe = () => [];
|
|
230
|
+
|
|
231
|
+
test('REAPS the exact shape measured on the builder: shared-only stack, 11h old, no per-test project, no cele2e process', () => {
|
|
232
|
+
const ps = [
|
|
233
|
+
`celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`,
|
|
234
|
+
`celilo-e2e-shared_registry-1\trunning\t${dockerAge(11 * 3_600_000)}`,
|
|
235
|
+
].join('\n');
|
|
236
|
+
const { docker, commands } = fakeDocker(ps);
|
|
237
|
+
// No containers at all is NOT the test: the guard must see the live shared
|
|
238
|
+
// containers and STILL clear the way, because every piece of run evidence
|
|
239
|
+
// is absent.
|
|
240
|
+
expect(findLiveE2eStack(docker, noLock, noProcesses)).toBeNull();
|
|
241
|
+
startupCleanup('/tmp/e2e', docker, noLock, noProcesses);
|
|
242
|
+
// The reap is a real teardown, not a refusal: the shared compose down ran.
|
|
243
|
+
expect(commands.some((c) => c.includes(' down '))).toBe(true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('must NOT reap a shared stack with a live per-test project beside it (the celilo#1297 shape)', () => {
|
|
247
|
+
const ps = [
|
|
248
|
+
`celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`,
|
|
249
|
+
`celilo-e2e-1788769175864_fw-main\trunning\t${dockerAge(5 * 60_000)}`,
|
|
250
|
+
].join('\n');
|
|
251
|
+
const { docker, commands } = fakeDocker(ps);
|
|
252
|
+
const refusal = findLiveE2eStack(docker, noLock, noProcesses);
|
|
253
|
+
expect(refusal).not.toBeNull();
|
|
254
|
+
expect(refusal?.runningContainers).toContain('celilo-e2e-shared_namecheap-dns-1');
|
|
255
|
+
expect(refusal?.runningContainers).toContain('celilo-e2e-1788769175864_fw-main');
|
|
256
|
+
expect(() => startupCleanup('/tmp/e2e', docker, noLock, noProcesses)).toThrow(LiveStackError);
|
|
257
|
+
expect(commands.some((c) => c.includes(' down '))).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('must NOT reap while any other cele2e process is alive — a run may be between suites', () => {
|
|
261
|
+
const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`;
|
|
262
|
+
const { docker } = fakeDocker(ps);
|
|
263
|
+
const oneRunner: ProcessProbe = () => [424242];
|
|
264
|
+
const refusal = findLiveE2eStack(docker, noLock, oneRunner);
|
|
265
|
+
expect(refusal).not.toBeNull();
|
|
266
|
+
expect(refusal?.reason).toContain('424242');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
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`, () => {
|
|
270
|
+
const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(5 * 60_000)}`;
|
|
271
|
+
const { docker } = fakeDocker(ps);
|
|
272
|
+
const refusal = findLiveE2eStack(docker, noLock, noProcesses);
|
|
273
|
+
expect(refusal).not.toBeNull();
|
|
274
|
+
expect(refusal?.reason).toContain('5m');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('must NOT reap when the age cannot be read — inconclusive evidence refuses, it never reaps', () => {
|
|
278
|
+
// Two fields, no CreatedAt: the pre-#1314 fake shape, and anything docker
|
|
279
|
+
// might print that the parser does not recognize.
|
|
280
|
+
const { docker } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
|
|
281
|
+
const refusal = findLiveE2eStack(docker, noLock, noProcesses);
|
|
282
|
+
expect(refusal).not.toBeNull();
|
|
283
|
+
expect(refusal?.reason).toContain('age');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('a foreign fresh lock still refuses a shared-only orphan — the reap never overrides the lock', () => {
|
|
287
|
+
const ps = `celilo-e2e-shared_namecheap-dns-1\trunning\t${dockerAge(11 * 3_600_000)}`;
|
|
288
|
+
const { docker } = fakeDocker(ps);
|
|
289
|
+
const refusal = findLiveE2eStack(docker, foreignLock(), noProcesses);
|
|
290
|
+
expect(refusal).not.toBeNull();
|
|
291
|
+
expect(refusal?.reason).toContain('polecat/ce-9999');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('exited containers of the shared project alone still proceed (unchanged)', () => {
|
|
295
|
+
const ps = `celilo-e2e-shared_registry-1\texited\t${dockerAge(11 * 3_600_000)}`;
|
|
296
|
+
const { docker } = fakeDocker(ps);
|
|
297
|
+
expect(findLiveE2eStack(docker, noLock, noProcesses)).toBeNull();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
134
301
|
describe('startupCleanup', () => {
|
|
135
302
|
test('refuses behind a live stack and removes NOTHING (celilo#1297)', () => {
|
|
136
303
|
const { docker, commands } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
|
|
@@ -171,7 +338,7 @@ describe('wiring in shared-infra.ts', () => {
|
|
|
171
338
|
SHARED_INFRA_SRC.indexOf('export function startupCleanup'),
|
|
172
339
|
SHARED_INFRA_SRC.indexOf('nukeE2eResources(e2eDir, docker)'),
|
|
173
340
|
);
|
|
174
|
-
expect(cleanupBody).toContain('findLiveE2eStack(docker, lock)');
|
|
341
|
+
expect(cleanupBody).toContain('findLiveE2eStack(docker, lock, processes)');
|
|
175
342
|
});
|
|
176
343
|
|
|
177
344
|
test('the DNS-restart branch guards before tearing down the running stack', () => {
|
package/src/live-stack.ts
CHANGED
|
@@ -19,10 +19,43 @@
|
|
|
19
19
|
* Deliberately NOT guarded: the runner's end-of-run stopSharedInfra teardown.
|
|
20
20
|
* The owner tearing down its own live stack is the normal exit path, and the
|
|
21
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
|
+
* The unit-only exclusion (celilo#1320). ci/validate runs `bun run test:unit`
|
|
45
|
+
* on the same persistent builder as npm-consumer-smoke, and every `bun test`
|
|
46
|
+
* child it spawns matched the probe's bun-test arm while managing no Docker
|
|
47
|
+
* at all — the smoke job's teardown then declined to clear a finished run's
|
|
48
|
+
* shared stack, and the next run inside the orphan hour refused. A process
|
|
49
|
+
* whose environ carries CELILO_UNIT_ONLY=1 (the root script exports it; every
|
|
50
|
+
* child inherits) is a unit-test run and is not run evidence. An unreadable
|
|
51
|
+
* environ keeps the match: the exclusion follows the same bias as the
|
|
52
|
+
* evidence test itself, inconclusive never reaps.
|
|
22
53
|
*/
|
|
23
54
|
|
|
24
55
|
import type { ExecFileSyncOptions } from 'node:child_process';
|
|
25
56
|
import { execFileSync } from 'node:child_process';
|
|
57
|
+
import { readFileSync } from 'node:fs';
|
|
58
|
+
import { SHARED_PROJECT_NAME } from './docker-compose-generator';
|
|
26
59
|
import {
|
|
27
60
|
type LockHolder,
|
|
28
61
|
type LockStatus,
|
|
@@ -65,6 +98,193 @@ export class LiveStackError extends Error {
|
|
|
65
98
|
}
|
|
66
99
|
}
|
|
67
100
|
|
|
101
|
+
/**
|
|
102
|
+
* How old the youngest live shared-stack container must be before a
|
|
103
|
+
* shared-only stack with no other run evidence is treated as an orphan.
|
|
104
|
+
* One hour sits far above any window in which a run process could have died
|
|
105
|
+
* without its containers dying too, and far below the 11h the builder sat
|
|
106
|
+
* wedged (celilo#1314). A stack younger than this refuses even with no other
|
|
107
|
+
* evidence: the reap is the dangerous direction, so it waits for certainty.
|
|
108
|
+
*/
|
|
109
|
+
export const SHARED_ORPHAN_MIN_AGE_MS = 60 * 60_000;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Live pids of processes that may own an e2e run. Injectable so the reap's
|
|
113
|
+
* evidence test is unit-testable without a process table.
|
|
114
|
+
*/
|
|
115
|
+
export type ProcessProbe = () => number[];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The env entry `bun run test:unit` exports (root package.json). A process
|
|
119
|
+
* carrying it is a unit-test run: the e2e unit suites exercise Docker only
|
|
120
|
+
* through injected readers, so it never manages the shared stack.
|
|
121
|
+
*/
|
|
122
|
+
export const UNIT_ONLY_ENV_ENTRY = 'CELILO_UNIT_ONLY=1';
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Raw environment of one pid (NUL-separated entries), or null when it cannot
|
|
126
|
+
* be read — /proc is Linux-only, and a foreign-uid environ is unreadable even
|
|
127
|
+
* where /proc exists.
|
|
128
|
+
*/
|
|
129
|
+
export type EnvironReader = (pid: number) => string | null;
|
|
130
|
+
|
|
131
|
+
export const realEnvironReader: EnvironReader = (pid) => {
|
|
132
|
+
try {
|
|
133
|
+
return readFileSync(`/proc/${pid}/environ`, 'utf8');
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Does a raw environ block mark its process as unit-only? Pure so the
|
|
141
|
+
* probe's exclusion is testable without a filesystem. A null (unreadable)
|
|
142
|
+
* environ is NOT unit-only: an unreadable answer is a missing answer, and the
|
|
143
|
+
* probe biases to refusing — a false refusal costs the operator one docker
|
|
144
|
+
* command, a false miss reaps a live run.
|
|
145
|
+
*/
|
|
146
|
+
export function environMarksUnitOnly(environ: string | null): boolean {
|
|
147
|
+
if (environ === null) return false;
|
|
148
|
+
return environ.split('\0').includes(UNIT_ONLY_ENV_ENTRY);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The exclusion realProcessProbe applies to its matches; injectable for tests. */
|
|
152
|
+
export function isUnitOnlyProcess(
|
|
153
|
+
pid: number,
|
|
154
|
+
environ: EnvironReader = realEnvironReader,
|
|
155
|
+
): boolean {
|
|
156
|
+
return environMarksUnitOnly(environ(pid));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Does this process command line look like an e2e run? Pure so the probe's
|
|
161
|
+
* reach is testable. Matches the two ways a run actually exists:
|
|
162
|
+
* - the cele2e CLI and anything whose argv names it (`cele2e run|up|down|...`)
|
|
163
|
+
* - a direct `bun test` of this package's suites, which manages the shared
|
|
164
|
+
* stack through ensureSharedInfra but has no cele2e in argv
|
|
165
|
+
* The bun-test arm requires bun AND e2e AND test in the command, so an
|
|
166
|
+
* unrelated `bun test` elsewhere only false-matches when its path names e2e —
|
|
167
|
+
* and a false match refuses (cheap), where a false miss reaps a live run
|
|
168
|
+
* (expensive). An editor with an e2e test file open does not match: it does
|
|
169
|
+
* not start with a bun invocation.
|
|
170
|
+
*/
|
|
171
|
+
export function looksLikeE2eRunCommand(command: string): boolean {
|
|
172
|
+
if (command.includes('cele2e')) return true;
|
|
173
|
+
return (
|
|
174
|
+
/(^|[\\/])bun(\.exe)?\s/.test(command) && command.includes('e2e') && command.includes('test')
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** pids of this process's ancestors, self included, bounded at 64 levels. */
|
|
179
|
+
function familyOfSelf(): Set<number> {
|
|
180
|
+
const family = new Set<number>([process.pid]);
|
|
181
|
+
let pid: number | undefined = process.ppid;
|
|
182
|
+
for (let i = 0; pid !== undefined && pid > 1 && i < 64; i++) {
|
|
183
|
+
family.add(pid);
|
|
184
|
+
try {
|
|
185
|
+
const out = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
|
|
186
|
+
encoding: 'utf-8',
|
|
187
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
188
|
+
}).trim();
|
|
189
|
+
const ppid = Number.parseInt(out, 10);
|
|
190
|
+
pid = Number.isFinite(ppid) ? ppid : undefined;
|
|
191
|
+
} catch {
|
|
192
|
+
pid = undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return family;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The real probe: one `ps` listing, filtered by looksLikeE2eRunCommand, with
|
|
200
|
+
* this process and its ancestry removed — the cleanup runs INSIDE the run it
|
|
201
|
+
* would otherwise see as evidence, and the run's own launcher shell carries
|
|
202
|
+
* the same strings in its argv.
|
|
203
|
+
*/
|
|
204
|
+
export const realProcessProbe: ProcessProbe = (): number[] => {
|
|
205
|
+
let listing: string;
|
|
206
|
+
try {
|
|
207
|
+
listing = execFileSync('ps', ['-axo', 'pid=,command='], {
|
|
208
|
+
encoding: 'utf-8',
|
|
209
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
210
|
+
});
|
|
211
|
+
} catch {
|
|
212
|
+
// A process table that cannot be read is missing evidence. The caller
|
|
213
|
+
// treats any probe failure conservatively; here that means reporting no
|
|
214
|
+
// matches, which the shared-only path then backstops with the age
|
|
215
|
+
// threshold and the unreadable-age refusal.
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const family = familyOfSelf();
|
|
219
|
+
const pids: number[] = [];
|
|
220
|
+
for (const line of listing.split('\n')) {
|
|
221
|
+
const trimmed = line.trim();
|
|
222
|
+
if (!trimmed) continue;
|
|
223
|
+
const sep = trimmed.indexOf(' ');
|
|
224
|
+
if (sep <= 0) continue;
|
|
225
|
+
const pid = Number.parseInt(trimmed.slice(0, sep), 10);
|
|
226
|
+
if (!Number.isFinite(pid) || family.has(pid)) continue;
|
|
227
|
+
if (!looksLikeE2eRunCommand(trimmed.slice(sep + 1))) continue;
|
|
228
|
+
// celilo#1320: a unit-test run matches the command probe but manages no
|
|
229
|
+
// Docker. An unreadable environ keeps the match — inconclusive never
|
|
230
|
+
// reaps.
|
|
231
|
+
if (isUnitOnlyProcess(pid)) continue;
|
|
232
|
+
pids.push(pid);
|
|
233
|
+
}
|
|
234
|
+
return pids;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Every refusal goes through here so the printed reason is uniformly
|
|
239
|
+
* greppable as an infrastructure refusal (celilo#1314 direction 3) rather
|
|
240
|
+
* than reading as a check failure.
|
|
241
|
+
*/
|
|
242
|
+
function makeRefusal(
|
|
243
|
+
detail: string,
|
|
244
|
+
runningContainers: string[],
|
|
245
|
+
holder: LockHolder | null,
|
|
246
|
+
): LiveStackRefusal {
|
|
247
|
+
const reason = `[infra-refusal] refusing to clean up: ${detail}\n(this is an environment problem, not a test failure — the run exits 3)`;
|
|
248
|
+
return { reason, runningContainers, holder };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The remedy printed with a live-stack refusal. A raw docker removal, because
|
|
253
|
+
* `cele2e down` does not exist on the hosts that print this message: forgejo
|
|
254
|
+
* job workspaces are ephemeral, so the binary only exists inside a checkout
|
|
255
|
+
* (celilo#1314 direction 2). Removing the containers is what unblocks the
|
|
256
|
+
* guard; the next cleanup sweeps whatever name-prefix resources survive.
|
|
257
|
+
*/
|
|
258
|
+
export const CLEAR_STACK_COMMAND = 'docker rm -f $(docker ps -aq --filter name=celilo-e2e)';
|
|
259
|
+
|
|
260
|
+
/** Docker's `{{.CreatedAt}}` format, e.g. "2026-09-07 18:49:14 +0000 UTC". */
|
|
261
|
+
function parseDockerCreatedAt(raw: string | undefined): Date | null {
|
|
262
|
+
if (raw === undefined) return null;
|
|
263
|
+
const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(?:\.(\d+))? ([+-])(\d{2})(\d{2})/.exec(
|
|
264
|
+
raw.trim(),
|
|
265
|
+
);
|
|
266
|
+
if (!m) return null;
|
|
267
|
+
const [, date, time, frac, sign, offH, offM] = m;
|
|
268
|
+
const utcMs = Date.parse(`${date}T${time}Z`);
|
|
269
|
+
if (Number.isNaN(utcMs)) return null;
|
|
270
|
+
const offsetMs = (sign === '-' ? -1 : 1) * (Number(offH) * 60 + Number(offM)) * 60_000;
|
|
271
|
+
const fracMs = frac ? Number(frac.padEnd(3, '0').slice(0, 3)) : 0;
|
|
272
|
+
return new Date(utcMs - offsetMs + fracMs);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Containers of the shared compose project. Compose names them
|
|
277
|
+
* `<project>_<service>_<n>` (or with `-` separators on newer compose), so the
|
|
278
|
+
* character right after the project name is the tell. Everything else live —
|
|
279
|
+
* per-test projects AND sim-created guests, whose names carry no project —
|
|
280
|
+
* is run evidence.
|
|
281
|
+
*/
|
|
282
|
+
function isSharedStackContainer(name: string): boolean {
|
|
283
|
+
if (!name.startsWith(SHARED_PROJECT_NAME)) return false;
|
|
284
|
+
const sep = name[SHARED_PROJECT_NAME.length];
|
|
285
|
+
return sep === '_' || sep === '-';
|
|
286
|
+
}
|
|
287
|
+
|
|
68
288
|
/**
|
|
69
289
|
* Container states that mean the container cannot be holding a live stack.
|
|
70
290
|
* Everything else (running, paused, restarting) is live: paused containers
|
|
@@ -90,17 +310,18 @@ function isOwnHolder(h: LockHolder): boolean {
|
|
|
90
310
|
export function findLiveE2eStack(
|
|
91
311
|
docker: DockerReader = realDocker,
|
|
92
312
|
lock: () => LockStatus = lockStatus,
|
|
313
|
+
processes: ProcessProbe = realProcessProbe,
|
|
93
314
|
): LiveStackRefusal | null {
|
|
94
315
|
const status = lock();
|
|
95
316
|
if (!status.free && status.holder && !isOwnHolder(status.holder)) {
|
|
96
317
|
const h = status.holder;
|
|
97
318
|
const heartbeat =
|
|
98
319
|
h.state === 'running' ? `, heartbeat ${formatAge(heartbeatAgeMs(h))} old` : '';
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
320
|
+
return makeRefusal(
|
|
321
|
+
`the run lock is held by another session — ${formatBusy(h)}${heartbeat}`,
|
|
322
|
+
[],
|
|
323
|
+
h,
|
|
324
|
+
);
|
|
104
325
|
}
|
|
105
326
|
|
|
106
327
|
const out = docker([
|
|
@@ -109,21 +330,67 @@ export function findLiveE2eStack(
|
|
|
109
330
|
'--filter',
|
|
110
331
|
'name=celilo-e2e',
|
|
111
332
|
'--format',
|
|
112
|
-
'{{.Names}}\t{{.State}}',
|
|
333
|
+
'{{.Names}}\t{{.State}}\t{{.CreatedAt}}',
|
|
113
334
|
]);
|
|
114
|
-
const
|
|
335
|
+
const live = out
|
|
115
336
|
.split('\n')
|
|
116
337
|
.filter(Boolean)
|
|
117
|
-
.map((line) =>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
338
|
+
.map((line) => {
|
|
339
|
+
const [name, state, createdAt] = line.split('\t');
|
|
340
|
+
return { name: name ?? '', state: state ?? '', createdAt: parseDockerCreatedAt(createdAt) };
|
|
341
|
+
})
|
|
342
|
+
.filter((c) => c.name !== '' && !DEAD_STATES.has(c.state));
|
|
343
|
+
if (live.length === 0) return null;
|
|
344
|
+
|
|
345
|
+
// A per-test container (or a guest, which carries no project name at all)
|
|
346
|
+
// means a run owns this host. Refuse regardless of the shared stack — this
|
|
347
|
+
// is the #1297 protection, unchanged.
|
|
348
|
+
const runOwned = live.filter((c) => !isSharedStackContainer(c.name));
|
|
349
|
+
if (runOwned.length > 0) {
|
|
350
|
+
return makeRefusal(
|
|
351
|
+
`${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)`,
|
|
352
|
+
live.map((c) => c.name),
|
|
353
|
+
null,
|
|
354
|
+
);
|
|
126
355
|
}
|
|
356
|
+
|
|
357
|
+
// celilo#1314: ONLY shared-stack containers are live. Refusing here
|
|
358
|
+
// unconditionally is what wedged the builder — an orphaned shared stack is
|
|
359
|
+
// the one state the guard could never resolve. Apply the orphan evidence
|
|
360
|
+
// test instead. Any inconclusive answer refuses.
|
|
361
|
+
const runners = processes();
|
|
362
|
+
if (runners.length > 0) {
|
|
363
|
+
return makeRefusal(
|
|
364
|
+
`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}`,
|
|
365
|
+
live.map((c) => c.name),
|
|
366
|
+
null,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const youngest = Math.min(
|
|
371
|
+
...live.map((c) => {
|
|
372
|
+
if (c.createdAt === null) return Number.NaN;
|
|
373
|
+
return c.createdAt.getTime();
|
|
374
|
+
}),
|
|
375
|
+
);
|
|
376
|
+
if (!Number.isFinite(youngest)) {
|
|
377
|
+
return makeRefusal(
|
|
378
|
+
`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}`,
|
|
379
|
+
live.map((c) => c.name),
|
|
380
|
+
null,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const ageMs = Date.now() - youngest;
|
|
384
|
+
if (ageMs < SHARED_ORPHAN_MIN_AGE_MS) {
|
|
385
|
+
return makeRefusal(
|
|
386
|
+
`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}`,
|
|
387
|
+
live.map((c) => c.name),
|
|
388
|
+
null,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Every piece of run evidence is absent, and the stack is old enough that
|
|
393
|
+
// no live run can be hiding from the probe. Provably dead: reap it.
|
|
127
394
|
return null;
|
|
128
395
|
}
|
|
129
396
|
|
|
@@ -137,8 +404,9 @@ export function findLiveE2eStack(
|
|
|
137
404
|
export function refuseOnLiveStack(
|
|
138
405
|
docker: DockerReader = realDocker,
|
|
139
406
|
lock: () => LockStatus = lockStatus,
|
|
407
|
+
processes: ProcessProbe = realProcessProbe,
|
|
140
408
|
): void {
|
|
141
|
-
const refusal = findLiveE2eStack(docker, lock);
|
|
409
|
+
const refusal = findLiveE2eStack(docker, lock, processes);
|
|
142
410
|
if (!refusal) return;
|
|
143
411
|
console.error(`\n${refusal.reason}\n`);
|
|
144
412
|
process.exit(3);
|
package/src/module-host.test.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from 'bun:test';
|
|
11
|
-
import { parseModuleHost } from './module-host';
|
|
11
|
+
import { parseModuleHost, parseModuleWhere } from './module-host';
|
|
12
12
|
|
|
13
13
|
/** How the CLI really prints it — clack gutter and ANSI included. */
|
|
14
14
|
const withGutter = (line: string) => `\x1b[1mModule: caddy\x1b[0m\n│ Placement:\n│ ${line}\n`;
|
|
@@ -69,3 +69,57 @@ describe('parseModuleHost', () => {
|
|
|
69
69
|
expect(parseModuleHost(output)?.hostname).toBe('web');
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
describe('parseModuleWhere', () => {
|
|
74
|
+
test('the inventory payload yields the recorded address', () => {
|
|
75
|
+
const payload = JSON.stringify({
|
|
76
|
+
module: 'caddy-internal',
|
|
77
|
+
systems: [
|
|
78
|
+
{
|
|
79
|
+
name: 'main',
|
|
80
|
+
hostname: 'caddy-internal',
|
|
81
|
+
ipv4_address: '10.226.30.14',
|
|
82
|
+
zone: 'dmz',
|
|
83
|
+
vmid: 2201,
|
|
84
|
+
infra_type: 'container_service',
|
|
85
|
+
placement: 'caddy-internal (vmid 2201) → pve1 (zone dmz)',
|
|
86
|
+
reachability: "firewall-segmented (dmz) — reach via the firewall's natIp DNAT",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(parseModuleWhere(payload)).toEqual(['10.226.30.14']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('ANSI and whitespace around the payload do not break it', () => {
|
|
95
|
+
// The success path prints verbatim (celilo#698), but dev-mode shims have
|
|
96
|
+
// surprised us before, so the parse tolerates decoration.
|
|
97
|
+
const payload = `\x1b[1m${JSON.stringify({ systems: [{ ipv4_address: '10.226.30.15' }] })}\x1b[0m\n`;
|
|
98
|
+
|
|
99
|
+
expect(parseModuleWhere(payload)).toEqual(['10.226.30.15']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a module with several systems yields them all, caller picks', () => {
|
|
103
|
+
const payload = JSON.stringify({
|
|
104
|
+
systems: [
|
|
105
|
+
{ name: 'main', ipv4_address: '10.226.20.9' },
|
|
106
|
+
{ name: 'replica', ipv4_address: '10.226.20.10' },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(parseModuleWhere(payload)).toEqual(['10.226.20.9', '10.226.20.10']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('an API-only module yields an empty list, a real state', () => {
|
|
114
|
+
// namecheap has no host. Empty is not a parse failure — the caller decides
|
|
115
|
+
// whether "no recorded address" is expected for their module.
|
|
116
|
+
expect(parseModuleWhere(JSON.stringify({ systems: [] }))).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a non-JSON answer yields an empty list, not a throw', () => {
|
|
120
|
+
// Crash text on stdout is a CLI failure and the harness method attaches
|
|
121
|
+
// the raw output to its error; the parser's contract is "addresses found",
|
|
122
|
+
// not "reasons why not".
|
|
123
|
+
expect(parseModuleWhere('Error: module not found: caddy\n')).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/module-host.ts
CHANGED
|
@@ -70,3 +70,34 @@ export function parseModuleHost(statusOutput: string): ModuleHost | null {
|
|
|
70
70
|
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The address(es) a module's deploy recorded, from `celilo module where --json`.
|
|
76
|
+
*
|
|
77
|
+
* The old source was `grep target_ip` over the module's `generated/` tree
|
|
78
|
+
* (celilo#1334). D4 of control-plane-stops-building-modules makes that tree
|
|
79
|
+
* ephemeral — a successful deploy deletes it — so the inventory is where the
|
|
80
|
+
* address lives now: `module_systems` is written by the deploy itself and
|
|
81
|
+
* survives it. Every current module declares exactly one system, so callers
|
|
82
|
+
* take the first address; a module with none is an API-only module or an
|
|
83
|
+
* undeployed one, and that distinction belongs to the caller.
|
|
84
|
+
*/
|
|
85
|
+
export function parseModuleWhere(whereOutput: string): string[] {
|
|
86
|
+
// The success path prints the payload verbatim (celilo#698), but defensive
|
|
87
|
+
// ANSI stripping costs one line and dev-mode shims have surprised us before.
|
|
88
|
+
const stripped = whereOutput.replace(/\x1b\[[0-9;]*m/g, '').trim();
|
|
89
|
+
|
|
90
|
+
let parsed: { systems?: { ipv4_address?: string }[] };
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(stripped) as { systems?: { ipv4_address?: string }[] };
|
|
93
|
+
} catch {
|
|
94
|
+
// A non-JSON answer is a CLI failure (version drift, crash text) arriving
|
|
95
|
+
// on stdout. An empty list reads as "no systems", which is a real state a
|
|
96
|
+
// caller may legitimately handle — these are not the same problem.
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (parsed.systems ?? [])
|
|
101
|
+
.map((system) => system.ipv4_address ?? '')
|
|
102
|
+
.filter((address) => address !== '');
|
|
103
|
+
}
|