@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.
@@ -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 || 'unknown'}`;
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
- throw new Error(
233
- 'The default bake installs the monorepo DEV cli via the sim registry — it needs a celilo checkout. For a published-CLI bake use --published.',
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
- `docker commit --change ${JSON.stringify(`LABEL ${SOURCE_LABEL}=${sourceStamp(false, version)}`)} ${containerId} celilo-e2e/management:latest`,
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.0",
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.2.0",
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",
@@ -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
- * Nothing that stands up a container, deploys a module or waits on DNS returns
39
- * in 50ms. A `requireStage` skip returns in a fraction of one.
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 = 50;
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);
@@ -1,10 +1,22 @@
1
1
  import { afterEach, beforeEach, expect, test } from 'bun:test';
2
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
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 = undefined;
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
- function packageNetapp(moduleDir: string, netappsDir: string): void {
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.error(`\n${red}Bake step failed after ${elapsed}s${reset}`);
709
- console.error(
710
- `${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}`,
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
@@ -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 celilo-mgr lives off the internal LAN — keeps the default
629
- // topology's generated compose byte-identical for every existing test.
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
- ...(zone === 'secure-mgmt' ? { FW_MAIN_HOP: SECURE_MGMT_GATEWAY } : {}),
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
 
@@ -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:` marker is the semantic signal, so that is what we key on; the
32
- * sub-millisecond durations those stages show are a symptom, not the contract.
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
- let failed = 0;
38
- let skipped = 0;
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 (!isFailure(plain[i])) continue;
41
- // bun prints the reason on the following lines, indented under the failure.
42
- // The window MUST stop at the next failure: cascade-skips come in runs, and
43
- // a window that reads past the boundary attributes the next stage's
44
- // "Skipped:" to this one — which misreads the single real defect at the top
45
- // of a cascade as just another skip, losing the only line worth reading.
46
- const reason: string[] = [];
47
- for (let j = i + 1; j < plain.length && j <= i + 4 && !isFailure(plain[j]); j++) {
48
- reason.push(plain[j]);
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 (/(?:^|\W)Skipped:\s/.test(reason.join('\n'))) skipped++;
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 };