@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/bin/e2e-bake-management
CHANGED
|
@@ -33,10 +33,12 @@ 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';
|
|
39
40
|
import { readFileSync } from 'node:fs';
|
|
41
|
+
import { ZONE_GATEWAYS, greenwaveRouterIp } from '../src/types';
|
|
40
42
|
|
|
41
43
|
const PACKAGE_ROOT = join(import.meta.dir, '..');
|
|
42
44
|
const COMPOSE_FILE = join(PACKAGE_ROOT, 'docker-compose.test.yml');
|
|
@@ -209,8 +211,25 @@ function simStampFromManifest(): string {
|
|
|
209
211
|
* whether the CLI under test is the code in the working tree. See
|
|
210
212
|
* `src/source-fingerprint.ts` for why that has to be measured, not remembered.
|
|
211
213
|
*/
|
|
214
|
+
/**
|
|
215
|
+
* `celilo --version` prints "celilo <semver>", and `docker commit --change
|
|
216
|
+
* "LABEL name=value"` splits on whitespace, so stamping the raw output fails
|
|
217
|
+
* the commit with `Syntax error - can't find = in "2.2.0"`. Take the last
|
|
218
|
+
* field, so the label carries the version alone (celilo#1318).
|
|
219
|
+
*/
|
|
220
|
+
function labelSafeVersion(version: string): string {
|
|
221
|
+
return version.trim().split(/\s+/).pop() || 'unknown';
|
|
222
|
+
}
|
|
223
|
+
|
|
212
224
|
function sourceStamp(published: boolean, version: string): string {
|
|
213
|
-
if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${version
|
|
225
|
+
if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
|
|
226
|
+
// Consumer mode: restageSimulatorInputs is the only writer of
|
|
227
|
+
// pack-manifest.json and it is skipped without a checkout, so there is no
|
|
228
|
+
// tree fingerprint to read — and none to record, since the tree does not
|
|
229
|
+
// exist. Stamp the version the bake just installed and verified (celilo#1318).
|
|
230
|
+
if (!findMonorepoRoot(PACKAGE_ROOT)) {
|
|
231
|
+
return `${CONSUMER_FINGERPRINT_PREFIX}${labelSafeVersion(version)}`;
|
|
232
|
+
}
|
|
214
233
|
return simStampFromManifest();
|
|
215
234
|
}
|
|
216
235
|
|
|
@@ -229,9 +248,16 @@ function sourceStamp(published: boolean, version: string): string {
|
|
|
229
248
|
function restageSimulatorInputs(): void {
|
|
230
249
|
const repoRoot = findMonorepoRoot(PACKAGE_ROOT);
|
|
231
250
|
if (!repoRoot) {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
251
|
+
// Consumer mode (no checkout): there is no tree to repack, and nothing to
|
|
252
|
+
// correct. stageFromPublic already fetched THIS run's tarballs and site
|
|
253
|
+
// dist into the sim caches, and the sim images were built from them before
|
|
254
|
+
// the bake started. celilo#1299 is a monorepo-mode hazard — there the bake
|
|
255
|
+
// would otherwise reinstall the previous build's cached CLI — so the
|
|
256
|
+
// restage is redundant here, not impossible-but-required. Throwing broke
|
|
257
|
+
// npm-consumer-smoke, whose whole point is a bake with no monorepo source
|
|
258
|
+
// (celilo#1318).
|
|
259
|
+
console.log(' restage ................ skipped (consumer mode: inputs staged this run)');
|
|
260
|
+
return;
|
|
235
261
|
}
|
|
236
262
|
stageWebsiteDist(repoRoot, PACKAGE_ROOT);
|
|
237
263
|
packNpmRegistryTarballs(repoRoot, PACKAGE_ROOT);
|
|
@@ -331,8 +357,23 @@ async function bakeViaSim(): Promise<void> {
|
|
|
331
357
|
throw new Error('Could not resolve management container id');
|
|
332
358
|
}
|
|
333
359
|
const superseded = imageIdOnTag('celilo-e2e/management:latest');
|
|
360
|
+
// The committed container runs under the sim topology's compose env, and
|
|
361
|
+
// commit captures its Env. Since the default managementZone became
|
|
362
|
+
// secure-mgmt, that baked FW_MAIN_HOP=10.226.120.1 (and the control-plane
|
|
363
|
+
// default gateway) into :latest — every internal-topology suite then
|
|
364
|
+
// inherited a nexthop that is not on-link from the internal LAN and the
|
|
365
|
+
// mgmt box crash-looped in management-routes.sh (celilo#1351). Reset the
|
|
366
|
+
// per-topology routing vars to the internal-topology defaults at commit
|
|
367
|
+
// time, so the image is topology-neutral; every compose sets both anyway.
|
|
334
368
|
run(
|
|
335
|
-
|
|
369
|
+
[
|
|
370
|
+
'docker commit',
|
|
371
|
+
`--change ${JSON.stringify(`ENV DEFAULT_GATEWAY=${greenwaveRouterIp()}`)}`,
|
|
372
|
+
`--change ${JSON.stringify(`ENV FW_MAIN_HOP=${ZONE_GATEWAYS.internal}`)}`,
|
|
373
|
+
`--change ${JSON.stringify(`LABEL ${SOURCE_LABEL}=${sourceStamp(false, version)}`)}`,
|
|
374
|
+
containerId,
|
|
375
|
+
'celilo-e2e/management:latest',
|
|
376
|
+
].join(' '),
|
|
336
377
|
);
|
|
337
378
|
console.log(`✔ ${Math.round((Date.now() - t5) / 1000)}s`);
|
|
338
379
|
removeSupersededImage(superseded, 'celilo-e2e/management:latest');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.2",
|
|
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",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"test": "bun test --timeout 30000 ./tests/ ./src/ ./npm-registry-server/ ./simulators/",
|
|
22
22
|
"test:completion": "bun test tests/completion",
|
|
23
|
-
"test:integration": "bun test tests-integration/"
|
|
23
|
+
"test:integration": "bun test tests-integration/",
|
|
24
|
+
"test:weekly": "bun test --timeout 30000 ./weekly/"
|
|
24
25
|
},
|
|
25
26
|
"files": [
|
|
26
27
|
"src/",
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
"README.md"
|
|
38
39
|
],
|
|
39
40
|
"dependencies": {
|
|
40
|
-
"@celilo/capabilities": "^4.
|
|
41
|
+
"@celilo/capabilities": "^4.3.0",
|
|
41
42
|
"@celilo/cli-display": "^0.2.0",
|
|
42
43
|
"@celilo/event-bus": "^0.6.0",
|
|
43
44
|
"@celilo/terraform-fake": "^0.3.1",
|
package/src/block-timing.ts
CHANGED
|
@@ -35,10 +35,31 @@ export type BlockTiming = Record<string, Record<string, number>>;
|
|
|
35
35
|
/**
|
|
36
36
|
* A failed block below this did no work, so its duration measures nothing.
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* Measured, not guessed. Over the 58 recorded runs in `e2e/results/`, every
|
|
39
|
+
* failed block falls in one of two clumps with nothing in between:
|
|
40
|
+
*
|
|
41
|
+
* did no work (a `requireStage` throw) 0.00ms .. 43.6ms
|
|
42
|
+
* did real work over 2s, up to 140s
|
|
43
|
+
*
|
|
44
|
+
* The gap is narrower than those two clumps suggest, because the fastest real
|
|
45
|
+
* work seen anywhere is not 2s. `CASCADE_STDOUT` in the test file pins a real
|
|
46
|
+
* failure at 123.54ms, and celilo#1281 measured real blocks from 145ms. So the
|
|
47
|
+
* floor has to sit between roughly 44ms and 123ms, and where it sits decides
|
|
48
|
+
* which of two mistakes this file makes.
|
|
49
|
+
*
|
|
50
|
+
* At 50 the clearance over the worst observed skip is 1.15x. That is thin
|
|
51
|
+
* enough that one slow skip enters the duration record as a healthy fast
|
|
52
|
+
* block, which is the mistake that matters: it pollutes the baseline every
|
|
53
|
+
* later run is compared against. At 100 the clearance is 2.3x, and the cost is
|
|
54
|
+
* that a REAL failure under 100ms would be dropped. That is the cheaper
|
|
55
|
+
* mistake. This file exists to find blocks running OUT of budget, and one
|
|
56
|
+
* using 0.03% of its cap is not a candidate.
|
|
57
|
+
*
|
|
58
|
+
* Nothing observed is reclassified by the move. No failed block in the recorded
|
|
59
|
+
* runs sits between 43.6ms and 2s, and no fixture sits between 39.19ms and
|
|
60
|
+
* 123.54ms, so both keep their current verdict.
|
|
40
61
|
*/
|
|
41
|
-
const NOT_A_MEASUREMENT_MS =
|
|
62
|
+
const NOT_A_MEASUREMENT_MS = 100;
|
|
42
63
|
|
|
43
64
|
export function parseBlockDurations(lines: string[], junitXml?: string): Record<string, number> {
|
|
44
65
|
const failed = failedBlocks(lines);
|
package/src/cli/build.test.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
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
|
+
utimesSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
3
12
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
13
|
+
import { basename, join } from 'node:path';
|
|
5
14
|
import { gzipSync } from 'node:zlib';
|
|
6
15
|
import {
|
|
7
16
|
assertGzipValid,
|
|
17
|
+
bakeManagement,
|
|
18
|
+
packageNetapp,
|
|
19
|
+
reportBakeChildFailure,
|
|
8
20
|
stageNetappsFromRegistry,
|
|
9
21
|
verifyNetapp,
|
|
10
22
|
verifyStagedNetapps,
|
|
@@ -21,7 +33,7 @@ beforeEach(() => {
|
|
|
21
33
|
afterEach(() => {
|
|
22
34
|
globalThis.fetch = realFetch;
|
|
23
35
|
rmSync(dir, { recursive: true, force: true });
|
|
24
|
-
process.env.CELILO_REGISTRY_URL
|
|
36
|
+
delete process.env.CELILO_REGISTRY_URL;
|
|
25
37
|
});
|
|
26
38
|
|
|
27
39
|
test('fetches each module latest version to <name>.netapp via the download endpoint', async () => {
|
|
@@ -95,3 +107,130 @@ test('verifyStagedNetapps accepts a valid .netapp and refuses a truncated one',
|
|
|
95
107
|
expect(() => verifyNetapp(join(dir, 'technitium.netapp'))).toThrow(/technitium\.netapp/s);
|
|
96
108
|
expect(() => assertGzipValid(join(dir, 'technitium.netapp'))).toThrow(/unexpected end of file/s);
|
|
97
109
|
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Stub process.exit and console.error around a call expected to terminate.
|
|
113
|
+
* Returns the exit codes collected and everything written to stderr.
|
|
114
|
+
*/
|
|
115
|
+
function captureTerminalExit(run: () => void): { exitCodes: unknown[]; stderr: string } {
|
|
116
|
+
const realExit = process.exit;
|
|
117
|
+
const realError = console.error;
|
|
118
|
+
const exitCodes: unknown[] = [];
|
|
119
|
+
const stderrLines: string[] = [];
|
|
120
|
+
(process as unknown as { exit: (code?: number) => never }).exit = ((code?: number) => {
|
|
121
|
+
exitCodes.push(code);
|
|
122
|
+
throw new Error(`process.exit(${code})`);
|
|
123
|
+
}) as typeof process.exit;
|
|
124
|
+
console.error = (...args: unknown[]) => {
|
|
125
|
+
stderrLines.push(args.map(String).join(' '));
|
|
126
|
+
};
|
|
127
|
+
try {
|
|
128
|
+
try {
|
|
129
|
+
run();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// The stubbed exit throws to unwind; anything else is a real failure.
|
|
132
|
+
if (!String(err).startsWith('Error: process.exit(')) throw err;
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
process.exit = realExit;
|
|
136
|
+
console.error = realError;
|
|
137
|
+
}
|
|
138
|
+
return { exitCodes, stderr: stderrLines.join('\n') };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// celilo#1302: a lock-free live stack on the builder made the bake child's
|
|
142
|
+
// startup cleanup refuse (exit 3), and the parent reported "Bake step failed"
|
|
143
|
+
// with three install.sh causes for a step that never executed.
|
|
144
|
+
/**
|
|
145
|
+
* Stub console.log/console.error around `run`, returning what each captured.
|
|
146
|
+
*/
|
|
147
|
+
function captureConsole(run: () => void): { stdout: string; stderr: string } {
|
|
148
|
+
const realLog = console.log;
|
|
149
|
+
const realError = console.error;
|
|
150
|
+
const out: string[] = [];
|
|
151
|
+
const err: string[] = [];
|
|
152
|
+
console.log = (...args: unknown[]) => {
|
|
153
|
+
out.push(args.map(String).join(' '));
|
|
154
|
+
};
|
|
155
|
+
console.error = (...args: unknown[]) => {
|
|
156
|
+
err.push(args.map(String).join(' '));
|
|
157
|
+
};
|
|
158
|
+
try {
|
|
159
|
+
run();
|
|
160
|
+
} finally {
|
|
161
|
+
console.log = realLog;
|
|
162
|
+
console.error = realError;
|
|
163
|
+
}
|
|
164
|
+
return { stdout: out.join('\n'), stderr: err.join('\n') };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// celilo#1258: build-infra is mandatory after cele2e down, and it used to
|
|
168
|
+
// repackage all 37 modules from scratch every run (about 5 minutes to reach a
|
|
169
|
+
// 38 second test) because nothing reused a current .netapp.
|
|
170
|
+
test('a second build-infra with no source change skips repackaging', () => {
|
|
171
|
+
const moduleDir = mkdtempSync(join(tmpdir(), 'module-fixture-'));
|
|
172
|
+
const netappsDir = mkdtempSync(join(tmpdir(), 'netapps-fixture-'));
|
|
173
|
+
writeFileSync(join(moduleDir, 'main.sh'), 'echo hello');
|
|
174
|
+
// First run's output: staged after the source, so it is current.
|
|
175
|
+
const staged = join(netappsDir, `${basename(moduleDir)}.netapp`);
|
|
176
|
+
writeFileSync(staged, 'netapp-bytes');
|
|
177
|
+
|
|
178
|
+
const { stdout, stderr } = captureConsole(() => packageNetapp(moduleDir, netappsDir));
|
|
179
|
+
expect(stdout).toContain('current, skipped');
|
|
180
|
+
// The skip returns before any CLI lookup: no packaging attempt happened.
|
|
181
|
+
expect(stderr).not.toContain('celilo CLI not found');
|
|
182
|
+
|
|
183
|
+
// Recurrence gate: touching a shipped source file flips the answer, and the
|
|
184
|
+
// same call proceeds toward packaging. In a tmpdir there is no monorepo CLI,
|
|
185
|
+
// so getting past the skip surfaces as the CLI-not-found report; the point
|
|
186
|
+
// is that the skip did not fire.
|
|
187
|
+
const later = new Date(Date.now() + 60_000);
|
|
188
|
+
const touched = join(moduleDir, 'main.sh');
|
|
189
|
+
utimesSync(touched, later, later);
|
|
190
|
+
const { stdout: stdoutAfterTouch, stderr: stderrAfterTouch } = captureConsole(() =>
|
|
191
|
+
packageNetapp(moduleDir, netappsDir),
|
|
192
|
+
);
|
|
193
|
+
expect(stderrAfterTouch).toContain('celilo CLI not found');
|
|
194
|
+
expect(stdoutAfterTouch).not.toContain('current, skipped');
|
|
195
|
+
|
|
196
|
+
rmSync(moduleDir, { recursive: true, force: true });
|
|
197
|
+
rmSync(netappsDir, { recursive: true, force: true });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('a refusal exit (3) from the bake child reports the bake did not run, not a bake failure', () => {
|
|
201
|
+
const { exitCodes, stderr } = captureTerminalExit(() => reportBakeChildFailure(3, 0));
|
|
202
|
+
expect(exitCodes).toEqual([3]);
|
|
203
|
+
expect(stderr).toContain('did not run');
|
|
204
|
+
expect(stderr).toContain('cele2e down');
|
|
205
|
+
// The wrong hint must not appear: these causes are all false when the bake
|
|
206
|
+
// never started.
|
|
207
|
+
expect(stderr).not.toContain('Likely causes');
|
|
208
|
+
expect(stderr).not.toContain('install.sh regressed');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('a genuine bake failure (exit 1) keeps the bake-failure report and its likely causes', () => {
|
|
212
|
+
const { exitCodes, stderr } = captureTerminalExit(() => reportBakeChildFailure(1, 12));
|
|
213
|
+
expect(exitCodes).toEqual([1]);
|
|
214
|
+
expect(stderr).toContain('Bake step failed after 12s');
|
|
215
|
+
expect(stderr).toContain('Likely causes');
|
|
216
|
+
expect(stderr).not.toContain('did not run');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('bakeManagement classifies a real child exit 3 end to end and exits 3', () => {
|
|
220
|
+
// A fake bake script that behaves like the real one does when the startup
|
|
221
|
+
// cleanup refuses: print the refusal to stderr, exit 3.
|
|
222
|
+
const pkgDir = mkdtempSync(join(tmpdir(), 'bake-child-'));
|
|
223
|
+
mkdirSync(join(pkgDir, 'bin'), { recursive: true });
|
|
224
|
+
writeFileSync(
|
|
225
|
+
join(pkgDir, 'bin', 'e2e-bake-management'),
|
|
226
|
+
'console.error("refusing to clean up"); process.exit(3);\n',
|
|
227
|
+
);
|
|
228
|
+
try {
|
|
229
|
+
const { exitCodes, stderr } = captureTerminalExit(() => bakeManagement(pkgDir, false));
|
|
230
|
+
expect(exitCodes).toEqual([3]);
|
|
231
|
+
expect(stderr).toContain('did not run');
|
|
232
|
+
expect(stderr).not.toContain('Likely causes');
|
|
233
|
+
} finally {
|
|
234
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
package/src/cli/build.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { gunzipSync } from 'node:zlib';
|
|
|
35
35
|
import { stageAptRepo } from '../../scripts/stage-apt-repo';
|
|
36
36
|
import { stageLibsignal } from '../../scripts/stage-libsignal';
|
|
37
37
|
import { explainBuildFailure } from '../doctor';
|
|
38
|
+
import { isNetappCurrent } from '../netapp-staleness';
|
|
38
39
|
import { ensureRegistryServerBundle, ensureTerraformFakeBundle } from '../registry-bundle';
|
|
39
40
|
import { findMonorepoRoot } from '../repo-root';
|
|
40
41
|
import { packNpmRegistryTarballs, stageWebsiteDist } from '../stage-simulator-inputs';
|
|
@@ -433,7 +434,12 @@ export async function fetchSiteFile(
|
|
|
433
434
|
writeFileSync(join(destDir, name), Buffer.from(await res.arrayBuffer()));
|
|
434
435
|
}
|
|
435
436
|
|
|
436
|
-
|
|
437
|
+
/**
|
|
438
|
+
* Package one module directory into `netappsDir` as `<name>.netapp`, skipping
|
|
439
|
+
* the work entirely when the staged netapp is already current over the source
|
|
440
|
+
* (celilo#1258). Exported for its recurrence test.
|
|
441
|
+
*/
|
|
442
|
+
export function packageNetapp(moduleDir: string, netappsDir: string): void {
|
|
437
443
|
const absDir = resolve(moduleDir);
|
|
438
444
|
if (!existsSync(absDir)) {
|
|
439
445
|
console.error(` ${red}skip${reset} ${moduleDir} ${dim}(not found)${reset}`);
|
|
@@ -443,6 +449,15 @@ function packageNetapp(moduleDir: string, netappsDir: string): void {
|
|
|
443
449
|
const name = basename(absDir);
|
|
444
450
|
const out = join(netappsDir, `${name}.netapp`);
|
|
445
451
|
|
|
452
|
+
// Skip a module whose staged .netapp is newer than every source file
|
|
453
|
+
// (celilo#1258): repackaging it again would reproduce the same bytes, and
|
|
454
|
+
// doing that for all 37 modules cost about 5 minutes on every build-infra
|
|
455
|
+
// run. A source edit flips the newest mtime and the module repackages.
|
|
456
|
+
if (isNetappCurrent(out, absDir)) {
|
|
457
|
+
console.log(` ${String(name).padEnd(20)} ${dim}· current, skipped${reset}`);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
446
461
|
process.stdout.write(` ${String(name).padEnd(20)} `);
|
|
447
462
|
const start = Date.now();
|
|
448
463
|
|
|
@@ -684,7 +699,7 @@ function tagVanillaManagement(): void {
|
|
|
684
699
|
* build-infra so the operator can't accidentally proceed with a
|
|
685
700
|
* non-functional default management image.
|
|
686
701
|
*/
|
|
687
|
-
function bakeManagement(pkgDir: string, published: boolean): void {
|
|
702
|
+
export function bakeManagement(pkgDir: string, published: boolean): void {
|
|
688
703
|
const bakeScript = join(pkgDir, 'bin', 'e2e-bake-management');
|
|
689
704
|
if (!existsSync(bakeScript)) {
|
|
690
705
|
console.error(
|
|
@@ -705,16 +720,43 @@ function bakeManagement(pkgDir: string, published: boolean): void {
|
|
|
705
720
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
706
721
|
|
|
707
722
|
if (result.status !== 0) {
|
|
708
|
-
console.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
);
|
|
712
|
-
process.exit(1);
|
|
723
|
+
console.log(`${red}✗${reset} ${dim}${elapsed}s${reset}`);
|
|
724
|
+
// status is null when the child died to a signal — that is a genuine
|
|
725
|
+
// failure, not a refusal, so it falls through to the exit-1 report.
|
|
726
|
+
reportBakeChildFailure(result.status ?? 1, elapsed);
|
|
713
727
|
}
|
|
714
728
|
|
|
715
729
|
console.log(`\n${green}Baked in ${elapsed}s${reset}\n`);
|
|
716
730
|
}
|
|
717
731
|
|
|
732
|
+
/**
|
|
733
|
+
* Report a non-zero bake-child exit and terminate. Exit 3 is the live-stack
|
|
734
|
+
* refusal convention (celilo#1297 guard; the run runner keys on the same code
|
|
735
|
+
* in its `refused` field): the bake child's startup cleanup found a live
|
|
736
|
+
* celilo-e2e-* stack or a foreign run lock and refused before the bake did
|
|
737
|
+
* any work. Reporting that as a bake failure named three causes — install.sh
|
|
738
|
+
* rot, registry tarballs, website sim — for a step that never executed
|
|
739
|
+
* (celilo#1302: 9 of ~111 smoke runs, every refusal diagnosed as install.sh
|
|
740
|
+
* rot). A refusal is not a bake failure, so it gets its own message and exits
|
|
741
|
+
* 3 so callers can tell the two apart.
|
|
742
|
+
*/
|
|
743
|
+
export function reportBakeChildFailure(status: number, elapsed: number): never {
|
|
744
|
+
if (status === 3) {
|
|
745
|
+
console.error(
|
|
746
|
+
`\n${red}Bake step did not run — the startup cleanup refused: a live e2e stack is in the way.${reset}`,
|
|
747
|
+
);
|
|
748
|
+
console.error(
|
|
749
|
+
`${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}`,
|
|
750
|
+
);
|
|
751
|
+
process.exit(3);
|
|
752
|
+
}
|
|
753
|
+
console.error(`\n${red}Bake step failed after ${elapsed}s${reset}`);
|
|
754
|
+
console.error(
|
|
755
|
+
`${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}`,
|
|
756
|
+
);
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
|
|
718
760
|
/**
|
|
719
761
|
* Pre-seed heavy application images (authentik server, postgres, redis) into
|
|
720
762
|
* the app-zone preload cache. Without this the `docker-image-cache` is empty on
|
package/src/container-manager.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
referencedImages,
|
|
23
23
|
registryUploadsHostDir,
|
|
24
24
|
} from './docker-compose-generator';
|
|
25
|
-
import { type ModuleHost, parseModuleHost } from './module-host';
|
|
25
|
+
import { type ModuleHost, parseModuleHost, parseModuleWhere } from './module-host';
|
|
26
26
|
import { CONTAINER_PREFIX, GUEST_PROJECT_LABEL } from './proxmox-provisioner';
|
|
27
27
|
import { ensureSharedInfra } from './shared-infra';
|
|
28
28
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
@@ -1110,6 +1110,23 @@ function buildNetworkHandle(
|
|
|
1110
1110
|
return host;
|
|
1111
1111
|
},
|
|
1112
1112
|
|
|
1113
|
+
async targetIp(moduleId: string): Promise<string> {
|
|
1114
|
+
const where = dockerExec(
|
|
1115
|
+
projectName,
|
|
1116
|
+
composeDir,
|
|
1117
|
+
'management',
|
|
1118
|
+
`celilo module where ${moduleId} --json`,
|
|
1119
|
+
);
|
|
1120
|
+
const addresses = parseModuleWhere(where.stdout);
|
|
1121
|
+
if (addresses.length === 0) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
`Could not resolve an address for '${moduleId}'. Its deploy did not record one in the inventory\n` +
|
|
1124
|
+
`(or the CLI answered something unparsable). Raw:\n${where.stdout.slice(0, 400)}`,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
return addresses[0];
|
|
1128
|
+
},
|
|
1129
|
+
|
|
1113
1130
|
async execOnModuleHost(moduleId, cmd, timeoutMs = 60_000): Promise<ExecResult> {
|
|
1114
1131
|
const host = await this.moduleHost(moduleId);
|
|
1115
1132
|
return host.reach === 'plain'
|
|
@@ -1280,6 +1297,35 @@ function buildNetworkHandle(
|
|
|
1280
1297
|
await handle.celilo(`system config set network.${zone}.gateway ${ZONE_GATEWAYS[zone]}`);
|
|
1281
1298
|
}
|
|
1282
1299
|
|
|
1300
|
+
// When fw-main carries the secure-mgmt leg, the management box sits on the
|
|
1301
|
+
// control-plane network behind this firewall, and the firewall must TRUST
|
|
1302
|
+
// that subnet or default-DROP drops the SSH `machine add` and every later
|
|
1303
|
+
// hook/converge needs (celilo#1353: 19 suites died at machine add with a
|
|
1304
|
+
// misleading key-mismatch message; the live stack showed the packet
|
|
1305
|
+
// timing out in fw-main's FORWARD chain, policy DROP, trusted sources =
|
|
1306
|
+
// internal only).
|
|
1307
|
+
//
|
|
1308
|
+
// The firewall DERIVES control-plane trust from where the celilo-mgmt
|
|
1309
|
+
// module is deployed (apps/celilo/src/hooks/capability-loader.ts
|
|
1310
|
+
// loadControlPlaneSubnet), falling back to the internal subnet. Suites
|
|
1311
|
+
// that machine-add before any celilo-mgmt deploy have neither, so the
|
|
1312
|
+
// declared control-plane subnet is trusted by nothing. The operator in
|
|
1313
|
+
// this topology declares it explicitly — `firewall.trusted_subnets` is the
|
|
1314
|
+
// product surface for exactly that (composeTrustedSubnets origin:
|
|
1315
|
+
// operator-override) — and the harness models that operator rather than
|
|
1316
|
+
// widening the product's fallback, which the approved design
|
|
1317
|
+
// (openspec/changes/recognize-management-network, D2) deliberately kept
|
|
1318
|
+
// as "previous behaviour + report".
|
|
1319
|
+
//
|
|
1320
|
+
// Idempotent with the derived path: composeTrustedSubnets dedupes by
|
|
1321
|
+
// subnet, so a suite that later deploys celilo-mgmt on secure-mgmt renders
|
|
1322
|
+
// the same ruleset.
|
|
1323
|
+
if (fwMainHasSecureMgmtLeg(join(composeDir, COMPOSE_FILE))) {
|
|
1324
|
+
await handle.celilo(
|
|
1325
|
+
`system config set firewall.trusted_subnets ${ZONE_SUBNETS['secure-mgmt']}`,
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1283
1329
|
// fw-main is registered as an internal-zone machine; iptables deploys to it.
|
|
1284
1330
|
await handle.celilo(
|
|
1285
1331
|
`machine add ${firewallIp} --ssh-user root --ssh-key-file /root/.ssh/id_ed25519 --zone internal`,
|
|
@@ -625,8 +625,9 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
625
625
|
dmz: zoneNetworkDef('dmz'),
|
|
626
626
|
app: zoneNetworkDef('app'),
|
|
627
627
|
secure: zoneNetworkDef('secure'),
|
|
628
|
-
// Only when
|
|
629
|
-
//
|
|
628
|
+
// Only when the control plane is in use (celilo-mgr, a secure-mgmt
|
|
629
|
+
// machine, or the proxmox simulator). With the default managementZone of
|
|
630
|
+
// `secure-mgmt` this is now present in every generated compose.
|
|
630
631
|
...(needsSecureMgmt ? { 'secure-mgmt': zoneNetworkDef('secure-mgmt') } : {}),
|
|
631
632
|
'isp-external': networkDef('203.0.113.0/24', '203.0.113.250'),
|
|
632
633
|
// internet-external is owned by shared infra; real-internet is per-test
|
|
@@ -747,8 +748,27 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
747
748
|
// On its own control-plane network the only router in reach is fw-main's
|
|
748
749
|
// leg there — both for the default route and for the segmented zones.
|
|
749
750
|
DEFAULT_GATEWAY: zone === 'secure-mgmt' ? SECURE_MGMT_GATEWAY : managementDefaultGw,
|
|
750
|
-
|
|
751
|
+
// BOTH branches always set the hop, never let the image's baked value
|
|
752
|
+
// through: the bake `docker commit`s the management container, so a
|
|
753
|
+
// bake from the secure-mgmt default baked FW_MAIN_HOP=10.226.120.1
|
|
754
|
+
// into :latest, and every internal-topology suite then inherited a
|
|
755
|
+
// nexthop that is not on-link from the internal LAN. The mgmt box
|
|
756
|
+
// exited 2 at the segmented-zone routes in management-routes.sh and
|
|
757
|
+
// crash-looped (celilo#1351). Compose env overrides image env, so an
|
|
758
|
+
// always-present FW_MAIN_HOP immunizes every topology against any
|
|
759
|
+
// baked value, current and future.
|
|
760
|
+
FW_MAIN_HOP: zone === 'secure-mgmt' ? SECURE_MGMT_GATEWAY : ZONE_GATEWAYS.internal,
|
|
751
761
|
CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
|
|
762
|
+
// The hook jail is REQUIRED in the e2e, not `auto` (peba, 2026-09-08).
|
|
763
|
+
// ce-rez7 made an unset policy resolve to `off`, which silently turned
|
|
764
|
+
// hook-jail-trespass into a test of nothing: its stage 2 (no policy set)
|
|
765
|
+
// and its stage 3 control (explicit `off`) became the same experiment.
|
|
766
|
+
// celilo#1329. `required` beats `auto` here because a missing bubblewrap
|
|
767
|
+
// backend is then a hard failure instead of a silent drop to unjailed, so
|
|
768
|
+
// it cannot rot the same way twice. A per-command `CELILO_HOOK_JAIL=off`
|
|
769
|
+
// prefix still overrides this, which is how the deliberate unjailed
|
|
770
|
+
// controls in both jail suites keep working.
|
|
771
|
+
CELILO_HOOK_JAIL: 'required',
|
|
752
772
|
// `cele2e run --source-cli` sets this, and the image's shim reads it to
|
|
753
773
|
// run the mounted workspace instead of the CLI install.sh installed.
|
|
754
774
|
// Off by default: the installed CLI is the artifact under test, and
|
|
@@ -979,6 +999,16 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
|
|
|
979
999
|
environment: {
|
|
980
1000
|
DEFAULT_GATEWAY: managementDefaultGw,
|
|
981
1001
|
CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
|
|
1002
|
+
// The hook jail is REQUIRED in the e2e, not `auto` (peba, 2026-09-08).
|
|
1003
|
+
// ce-rez7 made an unset policy resolve to `off`, which silently turned
|
|
1004
|
+
// hook-jail-trespass into a test of nothing: its stage 2 (no policy set)
|
|
1005
|
+
// and its stage 3 control (explicit `off`) became the same experiment.
|
|
1006
|
+
// celilo#1329. `required` beats `auto` here because a missing bubblewrap
|
|
1007
|
+
// backend is then a hard failure instead of a silent drop to unjailed, so
|
|
1008
|
+
// it cannot rot the same way twice. A per-command `CELILO_HOOK_JAIL=off`
|
|
1009
|
+
// prefix still overrides this, which is how the deliberate unjailed
|
|
1010
|
+
// controls in both jail suites keep working.
|
|
1011
|
+
CELILO_HOOK_JAIL: 'required',
|
|
982
1012
|
},
|
|
983
1013
|
});
|
|
984
1014
|
|
package/src/extract-failure.ts
CHANGED
|
@@ -28,26 +28,48 @@ export interface StageTally {
|
|
|
28
28
|
* as "9 failed" in a 10-stage suite — nine counts of a defect that does not
|
|
29
29
|
* exist, and a summary that buries the one that does.
|
|
30
30
|
*
|
|
31
|
-
* The `Skipped:`
|
|
32
|
-
*
|
|
31
|
+
* The `error: Skipped:` line is the semantic signal, and bun prints it in BOTH
|
|
32
|
+
* orders relative to the `(fail)` marker: a thrown error's block (source
|
|
33
|
+
* excerpt, error line, stack) lands BEFORE its marker, while a top-level error
|
|
34
|
+
* print and bun's own timeout pointer land AFTER it. A one-directional window
|
|
35
|
+
* misattributes both ways — measured as ce-59f9: a window after a timeout
|
|
36
|
+
* marker catches the NEXT test's excerpt quoting the `Skipped:` template (and
|
|
37
|
+
* calls the timeout a skip), and a skip's reason pushed past the window calls
|
|
38
|
+
* the skip a failure. So each reason line is attributed to its NEAREST marker
|
|
39
|
+
* instead, ties to the earlier one (a thrown-error block sits closest to the
|
|
40
|
+
* marker that follows it), and a marker no reason line claims is a failure on
|
|
41
|
+
* its own merits.
|
|
33
42
|
*/
|
|
34
43
|
export function tallyStages(lines: string[]): StageTally {
|
|
35
44
|
const plain = lines.map(stripAnsi);
|
|
36
45
|
const isFailure = (l: string): boolean => /^\(fail\)\s+\S/.test(l);
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const isSkippedReason = (l: string): boolean => /^\s*error:\s*Skipped:\s/.test(l);
|
|
47
|
+
|
|
48
|
+
const markers: number[] = [];
|
|
49
|
+
for (let i = 0; i < plain.length; i++) {
|
|
50
|
+
if (isFailure(plain[i])) markers.push(i);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const skippedMarkers = new Set<number>();
|
|
54
|
+
const MAX_REASON_DISTANCE = 8;
|
|
39
55
|
for (let i = 0; i < plain.length; i++) {
|
|
40
|
-
if (!
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
if (!isSkippedReason(plain[i])) continue;
|
|
57
|
+
let nearest = -1;
|
|
58
|
+
let nearestDist = Number.POSITIVE_INFINITY;
|
|
59
|
+
for (const m of markers) {
|
|
60
|
+
const dist = Math.abs(m - i);
|
|
61
|
+
if (dist <= MAX_REASON_DISTANCE && dist < nearestDist) {
|
|
62
|
+
nearest = m;
|
|
63
|
+
nearestDist = dist;
|
|
64
|
+
}
|
|
49
65
|
}
|
|
50
|
-
if (
|
|
66
|
+
if (nearest >= 0) skippedMarkers.add(nearest);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let failed = 0;
|
|
70
|
+
let skipped = 0;
|
|
71
|
+
for (const m of markers) {
|
|
72
|
+
if (skippedMarkers.has(m)) skipped++;
|
|
51
73
|
else failed++;
|
|
52
74
|
}
|
|
53
75
|
return { failed, skipped };
|