@celilo/e2e 0.20.0 → 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/bin/e2e-bake-management +29 -4
- package/package.json +1 -1
- package/src/cli/build.test.ts +82 -1
- package/src/cli/build.ts +33 -6
- package/src/live-stack.test.ts +119 -3
- package/src/live-stack.ts +227 -17
- package/src/shared-infra.ts +7 -2
- package/src/source-fingerprint.ts +11 -0
package/bin/e2e-bake-management
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
stageWebsiteDist,
|
|
34
34
|
} from '../src/stage-simulator-inputs';
|
|
35
35
|
import {
|
|
36
|
+
CONSUMER_FINGERPRINT_PREFIX,
|
|
36
37
|
PUBLISHED_FINGERPRINT_PREFIX,
|
|
37
38
|
SOURCE_LABEL,
|
|
38
39
|
} from '../src/source-fingerprint';
|
|
@@ -209,8 +210,25 @@ function simStampFromManifest(): string {
|
|
|
209
210
|
* whether the CLI under test is the code in the working tree. See
|
|
210
211
|
* `src/source-fingerprint.ts` for why that has to be measured, not remembered.
|
|
211
212
|
*/
|
|
213
|
+
/**
|
|
214
|
+
* `celilo --version` prints "celilo <semver>", and `docker commit --change
|
|
215
|
+
* "LABEL name=value"` splits on whitespace, so stamping the raw output fails
|
|
216
|
+
* the commit with `Syntax error - can't find = in "2.2.0"`. Take the last
|
|
217
|
+
* field, so the label carries the version alone (celilo#1318).
|
|
218
|
+
*/
|
|
219
|
+
function labelSafeVersion(version: string): string {
|
|
220
|
+
return version.trim().split(/\s+/).pop() || 'unknown';
|
|
221
|
+
}
|
|
222
|
+
|
|
212
223
|
function sourceStamp(published: boolean, version: string): string {
|
|
213
|
-
if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${version
|
|
224
|
+
if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
|
|
225
|
+
// Consumer mode: restageSimulatorInputs is the only writer of
|
|
226
|
+
// pack-manifest.json and it is skipped without a checkout, so there is no
|
|
227
|
+
// tree fingerprint to read — and none to record, since the tree does not
|
|
228
|
+
// exist. Stamp the version the bake just installed and verified (celilo#1318).
|
|
229
|
+
if (!findMonorepoRoot(PACKAGE_ROOT)) {
|
|
230
|
+
return `${CONSUMER_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
|
|
231
|
+
}
|
|
214
232
|
return simStampFromManifest();
|
|
215
233
|
}
|
|
216
234
|
|
|
@@ -229,9 +247,16 @@ function sourceStamp(published: boolean, version: string): string {
|
|
|
229
247
|
function restageSimulatorInputs(): void {
|
|
230
248
|
const repoRoot = findMonorepoRoot(PACKAGE_ROOT);
|
|
231
249
|
if (!repoRoot) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
// Consumer mode (no checkout): there is no tree to repack, and nothing to
|
|
251
|
+
// correct. stageFromPublic already fetched THIS run's tarballs and site
|
|
252
|
+
// dist into the sim caches, and the sim images were built from them before
|
|
253
|
+
// the bake started. celilo#1299 is a monorepo-mode hazard — there the bake
|
|
254
|
+
// would otherwise reinstall the previous build's cached CLI — so the
|
|
255
|
+
// restage is redundant here, not impossible-but-required. Throwing broke
|
|
256
|
+
// npm-consumer-smoke, whose whole point is a bake with no monorepo source
|
|
257
|
+
// (celilo#1318).
|
|
258
|
+
console.log(' restage ................ skipped (consumer mode: inputs staged this run)');
|
|
259
|
+
return;
|
|
235
260
|
}
|
|
236
261
|
stageWebsiteDist(repoRoot, PACKAGE_ROOT);
|
|
237
262
|
packNpmRegistryTarballs(repoRoot, PACKAGE_ROOT);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/cli/build.test.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
3
11
|
import { tmpdir } from 'node:os';
|
|
4
12
|
import { join } from 'node:path';
|
|
5
13
|
import { gzipSync } from 'node:zlib';
|
|
6
14
|
import {
|
|
7
15
|
assertGzipValid,
|
|
16
|
+
bakeManagement,
|
|
17
|
+
reportBakeChildFailure,
|
|
8
18
|
stageNetappsFromRegistry,
|
|
9
19
|
verifyNetapp,
|
|
10
20
|
verifyStagedNetapps,
|
|
@@ -95,3 +105,74 @@ test('verifyStagedNetapps accepts a valid .netapp and refuses a truncated one',
|
|
|
95
105
|
expect(() => verifyNetapp(join(dir, 'technitium.netapp'))).toThrow(/technitium\.netapp/s);
|
|
96
106
|
expect(() => assertGzipValid(join(dir, 'technitium.netapp'))).toThrow(/unexpected end of file/s);
|
|
97
107
|
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Stub process.exit and console.error around a call expected to terminate.
|
|
111
|
+
* Returns the exit codes collected and everything written to stderr.
|
|
112
|
+
*/
|
|
113
|
+
function captureTerminalExit(run: () => void): { exitCodes: unknown[]; stderr: string } {
|
|
114
|
+
const realExit = process.exit;
|
|
115
|
+
const realError = console.error;
|
|
116
|
+
const exitCodes: unknown[] = [];
|
|
117
|
+
const stderrLines: string[] = [];
|
|
118
|
+
(process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => {
|
|
119
|
+
exitCodes.push(code);
|
|
120
|
+
throw new Error(`process.exit(${code})`);
|
|
121
|
+
}) as typeof process.exit;
|
|
122
|
+
console.error = (...args: unknown[]) => {
|
|
123
|
+
stderrLines.push(args.map(String).join(' '));
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
try {
|
|
127
|
+
run();
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// The stubbed exit throws to unwind; anything else is a real failure.
|
|
130
|
+
if (!String(err).startsWith('Error: process.exit(')) throw err;
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
process.exit = realExit;
|
|
134
|
+
console.error = realError;
|
|
135
|
+
}
|
|
136
|
+
return { exitCodes, stderr: stderrLines.join('\n') };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// celilo#1302: a lock-free live stack on the builder made the bake child's
|
|
140
|
+
// startup cleanup refuse (exit 3), and the parent reported "Bake step failed"
|
|
141
|
+
// with three install.sh causes for a step that never executed.
|
|
142
|
+
test('a refusal exit (3) from the bake child reports the bake did not run, not a bake failure', () => {
|
|
143
|
+
const { exitCodes, stderr } = captureTerminalExit(() => reportBakeChildFailure(3, 0));
|
|
144
|
+
expect(exitCodes).toEqual([3]);
|
|
145
|
+
expect(stderr).toContain('did not run');
|
|
146
|
+
expect(stderr).toContain('cele2e down');
|
|
147
|
+
// The wrong hint must not appear: these causes are all false when the bake
|
|
148
|
+
// never started.
|
|
149
|
+
expect(stderr).not.toContain('Likely causes');
|
|
150
|
+
expect(stderr).not.toContain('install.sh regressed');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a genuine bake failure (exit 1) keeps the bake-failure report and its likely causes', () => {
|
|
154
|
+
const { exitCodes, stderr } = captureTerminalExit(() => reportBakeChildFailure(1, 12));
|
|
155
|
+
expect(exitCodes).toEqual([1]);
|
|
156
|
+
expect(stderr).toContain('Bake step failed after 12s');
|
|
157
|
+
expect(stderr).toContain('Likely causes');
|
|
158
|
+
expect(stderr).not.toContain('did not run');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('bakeManagement classifies a real child exit 3 end to end and exits 3', () => {
|
|
162
|
+
// A fake bake script that behaves like the real one does when the startup
|
|
163
|
+
// cleanup refuses: print the refusal to stderr, exit 3.
|
|
164
|
+
const pkgDir = mkdtempSync(join(tmpdir(), 'bake-child-'));
|
|
165
|
+
mkdirSync(join(pkgDir, 'bin'), { recursive: true });
|
|
166
|
+
writeFileSync(
|
|
167
|
+
join(pkgDir, 'bin', 'e2e-bake-management'),
|
|
168
|
+
'console.error("refusing to clean up"); process.exit(3);\n',
|
|
169
|
+
);
|
|
170
|
+
try {
|
|
171
|
+
const { exitCodes, stderr } = captureTerminalExit(() => bakeManagement(pkgDir, false));
|
|
172
|
+
expect(exitCodes).toEqual([3]);
|
|
173
|
+
expect(stderr).toContain('did not run');
|
|
174
|
+
expect(stderr).not.toContain('Likely causes');
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
package/src/cli/build.ts
CHANGED
|
@@ -684,7 +684,7 @@ function tagVanillaManagement(): void {
|
|
|
684
684
|
* build-infra so the operator can't accidentally proceed with a
|
|
685
685
|
* non-functional default management image.
|
|
686
686
|
*/
|
|
687
|
-
function bakeManagement(pkgDir: string, published: boolean): void {
|
|
687
|
+
export function bakeManagement(pkgDir: string, published: boolean): void {
|
|
688
688
|
const bakeScript = join(pkgDir, 'bin', 'e2e-bake-management');
|
|
689
689
|
if (!existsSync(bakeScript)) {
|
|
690
690
|
console.error(
|
|
@@ -705,16 +705,43 @@ function bakeManagement(pkgDir: string, published: boolean): void {
|
|
|
705
705
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
706
706
|
|
|
707
707
|
if (result.status !== 0) {
|
|
708
|
-
console.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
);
|
|
712
|
-
process.exit(1);
|
|
708
|
+
console.log(`${red}✗${reset} ${dim}${elapsed}s${reset}`);
|
|
709
|
+
// status is null when the child died to a signal — that is a genuine
|
|
710
|
+
// failure, not a refusal, so it falls through to the exit-1 report.
|
|
711
|
+
reportBakeChildFailure(result.status ?? 1, elapsed);
|
|
713
712
|
}
|
|
714
713
|
|
|
715
714
|
console.log(`\n${green}Baked in ${elapsed}s${reset}\n`);
|
|
716
715
|
}
|
|
717
716
|
|
|
717
|
+
/**
|
|
718
|
+
* Report a non-zero bake-child exit and terminate. Exit 3 is the live-stack
|
|
719
|
+
* refusal convention (celilo#1297 guard; the run runner keys on the same code
|
|
720
|
+
* in its `refused` field): the bake child's startup cleanup found a live
|
|
721
|
+
* celilo-e2e-* stack or a foreign run lock and refused before the bake did
|
|
722
|
+
* any work. Reporting that as a bake failure named three causes — install.sh
|
|
723
|
+
* rot, registry tarballs, website sim — for a step that never executed
|
|
724
|
+
* (celilo#1302: 9 of ~111 smoke runs, every refusal diagnosed as install.sh
|
|
725
|
+
* rot). A refusal is not a bake failure, so it gets its own message and exits
|
|
726
|
+
* 3 so callers can tell the two apart.
|
|
727
|
+
*/
|
|
728
|
+
export function reportBakeChildFailure(status: number, elapsed: number): never {
|
|
729
|
+
if (status === 3) {
|
|
730
|
+
console.error(
|
|
731
|
+
`\n${red}Bake step did not run — the startup cleanup refused: a live e2e stack is in the way.${reset}`,
|
|
732
|
+
);
|
|
733
|
+
console.error(
|
|
734
|
+
`${dim}A refusal is not a bake failure; install.sh was never exercised. Clear the stack with \`cele2e down\` (or free the foreign run lock) and re-run \`cele2e build-infra\`.${reset}`,
|
|
735
|
+
);
|
|
736
|
+
process.exit(3);
|
|
737
|
+
}
|
|
738
|
+
console.error(`\n${red}Bake step failed after ${elapsed}s${reset}`);
|
|
739
|
+
console.error(
|
|
740
|
+
`${dim}Likely causes: install.sh regressed, npm-registry-sim doesn't have the expected @celilo/* tarballs, or celilo-website-sim isn't serving install.sh. Run \`cele2e run install-sh\` for a focused reproducer with cleaner output.${reset}`,
|
|
741
|
+
);
|
|
742
|
+
process.exit(1);
|
|
743
|
+
}
|
|
744
|
+
|
|
718
745
|
/**
|
|
719
746
|
* Pre-seed heavy application images (authentik server, postgres, redis) into
|
|
720
747
|
* the app-zone preload cache. Without this the `docker-image-cache` is empty on
|
package/src/live-stack.test.ts
CHANGED
|
@@ -3,7 +3,14 @@ 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
|
+
findLiveE2eStack,
|
|
12
|
+
looksLikeE2eRunCommand,
|
|
13
|
+
} from './live-stack';
|
|
7
14
|
import type { LockStatus } from './run-lock';
|
|
8
15
|
import { startupCleanup } from './shared-infra';
|
|
9
16
|
|
|
@@ -71,7 +78,14 @@ describe('findLiveE2eStack', () => {
|
|
|
71
78
|
expect(refusal).not.toBeNull();
|
|
72
79
|
expect(refusal?.reason).toContain('celilo-e2e-shared_namecheap-dns');
|
|
73
80
|
expect(refusal?.reason).not.toContain('celilo-e2e-1788769175864_fw-main');
|
|
74
|
-
|
|
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]');
|
|
75
89
|
expect(refusal?.runningContainers).toEqual(['celilo-e2e-shared_namecheap-dns']);
|
|
76
90
|
});
|
|
77
91
|
|
|
@@ -131,6 +145,108 @@ describe('findLiveE2eStack', () => {
|
|
|
131
145
|
});
|
|
132
146
|
});
|
|
133
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
|
+
|
|
134
250
|
describe('startupCleanup', () => {
|
|
135
251
|
test('refuses behind a live stack and removes NOTHING (celilo#1297)', () => {
|
|
136
252
|
const { docker, commands } = fakeDocker('celilo-e2e-shared_namecheap-dns\trunning\n');
|
|
@@ -171,7 +287,7 @@ describe('wiring in shared-infra.ts', () => {
|
|
|
171
287
|
SHARED_INFRA_SRC.indexOf('export function startupCleanup'),
|
|
172
288
|
SHARED_INFRA_SRC.indexOf('nukeE2eResources(e2eDir, docker)'),
|
|
173
289
|
);
|
|
174
|
-
expect(cleanupBody).toContain('findLiveE2eStack(docker, lock)');
|
|
290
|
+
expect(cleanupBody).toContain('findLiveE2eStack(docker, lock, processes)');
|
|
175
291
|
});
|
|
176
292
|
|
|
177
293
|
test('the DNS-restart branch guards before tearing down the running stack', () => {
|
package/src/live-stack.ts
CHANGED
|
@@ -19,10 +19,32 @@
|
|
|
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).
|
|
22
43
|
*/
|
|
23
44
|
|
|
24
45
|
import type { ExecFileSyncOptions } from 'node:child_process';
|
|
25
46
|
import { execFileSync } from 'node:child_process';
|
|
47
|
+
import { SHARED_PROJECT_NAME } from './docker-compose-generator';
|
|
26
48
|
import {
|
|
27
49
|
type LockHolder,
|
|
28
50
|
type LockStatus,
|
|
@@ -65,6 +87,146 @@ export class LiveStackError extends Error {
|
|
|
65
87
|
}
|
|
66
88
|
}
|
|
67
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
|
+
|
|
68
230
|
/**
|
|
69
231
|
* Container states that mean the container cannot be holding a live stack.
|
|
70
232
|
* Everything else (running, paused, restarting) is live: paused containers
|
|
@@ -90,17 +252,18 @@ function isOwnHolder(h: LockHolder): boolean {
|
|
|
90
252
|
export function findLiveE2eStack(
|
|
91
253
|
docker: DockerReader = realDocker,
|
|
92
254
|
lock: () => LockStatus = lockStatus,
|
|
255
|
+
processes: ProcessProbe = realProcessProbe,
|
|
93
256
|
): LiveStackRefusal | null {
|
|
94
257
|
const status = lock();
|
|
95
258
|
if (!status.free && status.holder && !isOwnHolder(status.holder)) {
|
|
96
259
|
const h = status.holder;
|
|
97
260
|
const heartbeat =
|
|
98
261
|
h.state === 'running' ? `, heartbeat ${formatAge(heartbeatAgeMs(h))} old` : '';
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
262
|
+
return makeRefusal(
|
|
263
|
+
`the run lock is held by another session — ${formatBusy(h)}${heartbeat}`,
|
|
264
|
+
[],
|
|
265
|
+
h,
|
|
266
|
+
);
|
|
104
267
|
}
|
|
105
268
|
|
|
106
269
|
const out = docker([
|
|
@@ -109,21 +272,67 @@ export function findLiveE2eStack(
|
|
|
109
272
|
'--filter',
|
|
110
273
|
'name=celilo-e2e',
|
|
111
274
|
'--format',
|
|
112
|
-
'{{.Names}}\t{{.State}}',
|
|
275
|
+
'{{.Names}}\t{{.State}}\t{{.CreatedAt}}',
|
|
113
276
|
]);
|
|
114
|
-
const
|
|
277
|
+
const live = out
|
|
115
278
|
.split('\n')
|
|
116
279
|
.filter(Boolean)
|
|
117
|
-
.map((line) =>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
);
|
|
126
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.
|
|
127
336
|
return null;
|
|
128
337
|
}
|
|
129
338
|
|
|
@@ -137,8 +346,9 @@ export function findLiveE2eStack(
|
|
|
137
346
|
export function refuseOnLiveStack(
|
|
138
347
|
docker: DockerReader = realDocker,
|
|
139
348
|
lock: () => LockStatus = lockStatus,
|
|
349
|
+
processes: ProcessProbe = realProcessProbe,
|
|
140
350
|
): void {
|
|
141
|
-
const refusal = findLiveE2eStack(docker, lock);
|
|
351
|
+
const refusal = findLiveE2eStack(docker, lock, processes);
|
|
142
352
|
if (!refusal) return;
|
|
143
353
|
console.error(`\n${refusal.reason}\n`);
|
|
144
354
|
process.exit(3);
|
package/src/shared-infra.ts
CHANGED
|
@@ -22,8 +22,10 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
type DockerReader,
|
|
24
24
|
LiveStackError,
|
|
25
|
+
type ProcessProbe,
|
|
25
26
|
findLiveE2eStack,
|
|
26
27
|
realDocker,
|
|
28
|
+
realProcessProbe,
|
|
27
29
|
refuseOnLiveStack,
|
|
28
30
|
} from './live-stack';
|
|
29
31
|
import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from './registry-bundle';
|
|
@@ -101,8 +103,9 @@ export function startupCleanup(
|
|
|
101
103
|
e2eDir: string,
|
|
102
104
|
docker: DockerReader = realDocker,
|
|
103
105
|
lock: () => LockStatus = lockStatus,
|
|
106
|
+
processes: ProcessProbe = realProcessProbe,
|
|
104
107
|
): void {
|
|
105
|
-
const refusal = findLiveE2eStack(docker, lock);
|
|
108
|
+
const refusal = findLiveE2eStack(docker, lock, processes);
|
|
106
109
|
if (refusal) throw new LiveStackError(refusal);
|
|
107
110
|
console.log(
|
|
108
111
|
'[progress:start] cleaning up stale shared infrastructure | shared infra cleanup complete',
|
|
@@ -173,7 +176,9 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
173
176
|
// DNS check failed — restart shared infra. The restart tears down the
|
|
174
177
|
// running stack, so the live-stack guard fires first (celilo#1297): a
|
|
175
178
|
// stack that is up is never force-removed, even when its DNS is wedged.
|
|
176
|
-
// The operator clears it with
|
|
179
|
+
// The operator clears it with the docker removal named in the refusal
|
|
180
|
+
// (a bare `cele2e down` does not exist on ephemeral CI hosts —
|
|
181
|
+
// celilo#1314) and re-runs. This retires
|
|
177
182
|
// the automatic DNS-restart self-heal by decision (peba, 2026-09-07):
|
|
178
183
|
// fail loudly naming the holder beats silently clobbering a stack that
|
|
179
184
|
// might be another session's — the #1297 incident rode exactly this
|
|
@@ -50,6 +50,17 @@ export const SOURCE_LABEL = 'computer.celilo.e2e.source';
|
|
|
50
50
|
*/
|
|
51
51
|
export const PUBLISHED_FINGERPRINT_PREFIX = 'published:';
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Value stamped by a CONSUMER-mode bake: `cele2e build-infra` run from an
|
|
55
|
+
* installed `@celilo/e2e` with no monorepo checkout. The CLI comes from the sim
|
|
56
|
+
* registry's freshly staged tarballs, so it is neither real npm nor this tree,
|
|
57
|
+
* and no tree fingerprint exists to record. The version is the honest
|
|
58
|
+
* attribution. Deliberately NOT the published prefix: that one tells the
|
|
59
|
+
* freshness check there is nothing to be stale against, which would suppress a
|
|
60
|
+
* real warning if such an image were later inspected from a checkout.
|
|
61
|
+
*/
|
|
62
|
+
export const CONSUMER_FINGERPRINT_PREFIX = 'consumer:';
|
|
63
|
+
|
|
53
64
|
/**
|
|
54
65
|
* Repo-relative paths whose content reaches the baked CLI.
|
|
55
66
|
*
|