@celilo/e2e 0.19.3 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +30 -13
  2. package/bin/e2e-bake-management +196 -12
  3. package/bin/e2e-infra +0 -1
  4. package/bin/e2e-up +14 -3
  5. package/docker/Dockerfile.observer +12 -1
  6. package/docker/Dockerfile.target-machine +22 -1
  7. package/npm-registry-server/package.json +1 -1
  8. package/package.json +3 -3
  9. package/registry-server/package.json +1 -1
  10. package/scripts/pack-celilo-packages.ts +15 -0
  11. package/src/block-timing.test.ts +559 -0
  12. package/src/block-timing.ts +366 -0
  13. package/src/cli/build.test.ts +135 -4
  14. package/src/cli/build.ts +237 -94
  15. package/src/cli/command-registry.ts +21 -0
  16. package/src/cli/command-tree-parser.ts +11 -2
  17. package/src/cli/completion.ts +9 -0
  18. package/src/cli/host.ts +252 -0
  19. package/src/cli/index.ts +78 -51
  20. package/src/cli/module-discovery.ts +108 -13
  21. package/src/cli/scaffold.ts +18 -26
  22. package/src/container-manager.cleanup.test.ts +284 -0
  23. package/src/container-manager.runner.test.ts +351 -0
  24. package/src/container-manager.test.ts +84 -0
  25. package/src/container-manager.ts +721 -185
  26. package/src/docker-compose-generator.ts +135 -61
  27. package/src/doctor.test.ts +259 -4
  28. package/src/doctor.ts +276 -3
  29. package/src/exit-cleanup.test.ts +83 -1
  30. package/src/fleet-nameserver-gate.test.ts +45 -0
  31. package/src/host-vm.test.ts +156 -0
  32. package/src/host-vm.ts +230 -0
  33. package/src/index.ts +11 -0
  34. package/src/live-stack.test.ts +300 -0
  35. package/src/live-stack.ts +355 -0
  36. package/src/no-unjustified-sleep.test.ts +90 -0
  37. package/src/proxmox-provisioner.test.ts +18 -2
  38. package/src/proxmox-provisioner.ts +22 -0
  39. package/src/public-sim-routes.test.ts +9 -2
  40. package/src/repo-root.ts +33 -0
  41. package/src/run-args.test.ts +76 -0
  42. package/src/run-args.ts +89 -0
  43. package/src/runner.ts +213 -8
  44. package/src/shared-infra.ts +88 -32
  45. package/src/socks-proxy.ts +2 -0
  46. package/src/source-fingerprint.test.ts +213 -0
  47. package/src/source-fingerprint.ts +212 -0
  48. package/src/stage-simulator-inputs.ts +93 -0
  49. package/src/stages.ts +133 -0
  50. package/src/wait-for-run.ts +1 -0
package/README.md CHANGED
@@ -281,10 +281,27 @@ describe('my deployment test', () => {
281
281
  | `exec(container, cmd, timeout?)` | Execute a command in any container |
282
282
  | `dig(name)` | Resolve a DNS name from management |
283
283
  | `waitFor(check, timeout, label)` | Poll until a condition is true |
284
+ | `debug(container?)` | Pause the test and shell into a container (default `management`); exit resumes ([details](#mid-test-pause)) |
284
285
  | `socksProxy(opts?)` | Spawn a SOCKS5 proxy attached to the test network ([details](#browser-driven-tests)) |
285
286
  | `browser(opts)` | Launch a host-side browser routed into the network ([details](#browser-driven-tests)) |
286
287
  | `stop()` | Tear down the entire network |
287
288
 
289
+ ### Mid-test pause
290
+
291
+ `net.debug()` freezes a running suite at the line that calls it and hands you a
292
+ bash shell inside the named container (default `management`). Type `exit` and
293
+ the test resumes. In a non-interactive run it instead prints
294
+ `Resume: touch <signalFile>` and waits, so you can probe the stack from another
295
+ terminal and resume by touching the file.
296
+
297
+ This shows live state at the exact failing moment, which `--keep` cannot: a
298
+ kept stack is only available after the suite finished, once later stages have
299
+ already mutated it.
300
+
301
+ The pause holds the machine-global run lock for up to 24 hours, so a forgotten
302
+ debug session blocks every other session's run. Resume promptly. See
303
+ `docs/RUNNING_CELE2E_TESTS.md` for the full write-up.
304
+
288
305
  ### Browser-driven tests
289
306
 
290
307
  `net.browser()` returns a Playwright `Browser` whose traffic enters the simulated network through a containerized SOCKS5 proxy, so a host-side browser sees the deployed app exactly as a real user would (DNS, TLS, ingress firewall, all in the loop).
@@ -347,20 +364,18 @@ const net = await network()
347
364
 
348
365
  ### Running Tests
349
366
 
350
- ```bash
351
- cd e2e
352
-
353
- # Run all E2E tests
354
- bun run vitest run
355
-
356
- # Run a specific test
357
- bun run vitest run tests/caddy-deploy.test.ts
367
+ The repo's own tests live in `e2e/tests/` at the repo root. They resolve
368
+ `@celilo/e2e` through the root workspace install, so there is nothing to
369
+ install in `e2e/` itself.
358
370
 
359
- # Run with verbose output
360
- bun run vitest run --reporter=verbose
371
+ ```bash
372
+ # From the repo root, after `bun run setup`:
373
+ cele2e run <test-name> # Docker-backed suite, e.g. `cele2e run machine-add`
374
+ cele2e run --all # every module suite + the top-level e2e/tests/
361
375
  ```
362
376
 
363
- Tests run sequentially (fixed CIDRs prevent parallelism). The container manager automatically cleans up leftover networks from previous failed runs.
377
+ See `docs/RUNNING_CELE2E_TESTS.md` for the full operator guide (run lock,
378
+ build-infra triggers, `--keep` debugging).
364
379
 
365
380
  ## Key Design Decisions
366
381
 
@@ -368,9 +383,11 @@ Tests run sequentially (fixed CIDRs prevent parallelism). The container manager
368
383
 
369
384
  **No test parallelism:** Fixed network CIDRs mean only one test network can run at a time. Tests run sequentially via Vitest's `singleFork` config.
370
385
 
371
- **Bind-mounted source:** The celilo source tree is bind-mounted at `/celilo` on the management container. Code changes take effect immediately without rebuilding images.
386
+ **Bind-mounted source:** The celilo source tree is bind-mounted at `/celilo` on the management container, which is where module sources are read from (`celilo module import /celilo/modules/<id>`).
387
+
388
+ The CLI itself is NOT read from there. Tests run the `celilo` baked into the management image by `cele2e build-infra` — the artifact install.sh produces — so **a change under `apps/celilo/` or `packages/` needs a `build-infra` before it reaches a test.** `cele2e doctor` compares a fingerprint stamped on the image against the working tree and warns when they differ, and `cele2e run --source-cli` runs the mounted source instead for iteration. Running the source roughly doubles each celilo command's start-up (measured `celilo --version`: 0.14s installed against 0.31s from the mount on virtiofs, 0.16s against 0.56s on sshfs).
372
389
 
373
- **Docker image caching:** Images are built once and cached. Only config/simulator changes require rebuilds. The management container uses the bind mount, so celilo code changes are free.
390
+ **Docker image caching:** Images are built once and cached. Only config/simulator changes require rebuilds. A run skips `docker compose build` entirely when every image it needs already exists — that check costs one `docker images` listing, against about 0.7s per service for a build that rebuilds nothing.
374
391
 
375
392
  **ACME via DNS interception:** Instead of overriding Caddy's ACME URL, `acme-v02.api.letsencrypt.org` resolves to Pebble (100.64.0.100) in the simulated DNS. The caddy module's `acme_ca` variable allows pointing to Pebble's `/dir` endpoint (vs Let's Encrypt's `/directory`).
376
393
 
@@ -26,6 +26,18 @@ import { execSync } from 'node:child_process';
26
26
  import { join } from 'node:path';
27
27
  import { ensureSharedInfra } from '../src/shared-infra';
28
28
  import { network } from '../src/network-builder';
29
+ import { imageTag } from '../src/docker-compose-generator';
30
+ import { findMonorepoRoot } from '../src/repo-root';
31
+ import {
32
+ packNpmRegistryTarballs,
33
+ stageWebsiteDist,
34
+ } from '../src/stage-simulator-inputs';
35
+ import {
36
+ CONSUMER_FINGERPRINT_PREFIX,
37
+ PUBLISHED_FINGERPRINT_PREFIX,
38
+ SOURCE_LABEL,
39
+ } from '../src/source-fingerprint';
40
+ import { readFileSync } from 'node:fs';
29
41
 
30
42
  const PACKAGE_ROOT = join(import.meta.dir, '..');
31
43
  const COMPOSE_FILE = join(PACKAGE_ROOT, 'docker-compose.test.yml');
@@ -42,13 +54,40 @@ interface ExecResult {
42
54
  type Exec = (cmd: string, timeoutMs?: number) => Promise<ExecResult>;
43
55
 
44
56
  /**
45
- * Write the source-mount dev-loop shim to /usr/local/bin/celilo and verify
46
- * the CLI works through it. Shared by both bake modes.
57
+ * Write the CLI shim to /usr/local/bin/celilo and verify the CLI works through
58
+ * it. Shared by both bake modes.
47
59
  *
48
60
  * The shim (at /usr/local/bin/celilo so plain `docker exec bash -c celilo`
49
- * finds it) prefers a mounted /celilo workspace, else falls through to the
50
- * real installed CLI at /root/.bun/bin/celilo. Two things must hold, both
51
- * bitten in ISS-0124:
61
+ * finds it) runs the INSTALLED CLI at /root/.bun/bin/celilo — the artifact
62
+ * install.sh just produced, which is the thing the rig exists to test. It runs
63
+ * the mounted /celilo workspace instead only when `CELILO_E2E_SOURCE_CLI` is
64
+ * set, which `cele2e run --source-cli` does.
65
+ *
66
+ * That default used to be the other way round, and both halves of the swap are
67
+ * worth stating.
68
+ *
69
+ * It cost real time per invocation. `bun run <source>` resolves 1522 modules
70
+ * across the bind mount rather than starting an installed binary. Measured
71
+ * inside this image on 2026-09-05, same command and same container, on a VM
72
+ * sized per the host policy: `celilo --version` took 0.14s installed and 0.31s
73
+ * from the mounted source over virtiofs, or 0.16s against 0.56s over sshfs. A
74
+ * test issues 25 to 40 celilo commands.
75
+ *
76
+ * (An earlier measurement put that gap at 2.3s. Most of that was a separate
77
+ * problem — the VM had been given 24 of the host's 32 GiB and the host was
78
+ * swapping 21.9 GiB. Fixing the memory removed about 1.9s of it. Worth stating,
79
+ * because a number measured with two variables moving blamed the wrong one.)
80
+ *
81
+ * And it made the verify below dishonest. This function exists because of
82
+ * ISS-0124 — a broken shim fallback shipping green — and its comment says the
83
+ * verify runs "with NO /celilo mount, i.e. exactly the shipped path". That was
84
+ * true of the published bake, which runs in a standalone container, and false
85
+ * of this one: the sim bake's container HAS the workspace mounted, so the old
86
+ * shim sent the verify down the source branch and the installed CLI was never
87
+ * executed at all. The check could not reach its subject. Now both modes verify
88
+ * the installed binary.
89
+ *
90
+ * Two things must hold, both bitten in ISS-0124:
52
91
  *
53
92
  * 1. `bun add -g` links the celilo binary at /usr/local/bin/celilo as a
54
93
  * SYMLINK into the package (bun's global bin dir in this image is
@@ -65,12 +104,14 @@ type Exec = (cmd: string, timeoutMs?: number) => Promise<ExecResult>;
65
104
  * shipped path — so a dangling/looping fallback fails the bake instead of
66
105
  * shipping green.
67
106
  */
68
- async function installShimAndVerify(exec: Exec, label: string): Promise<void> {
107
+ async function installShimAndVerify(exec: Exec, label: string): Promise<string> {
69
108
  process.stdout.write(' source-mount shim ...... ');
70
109
  const t0 = Date.now();
71
110
  const shim = [
72
111
  '#!/bin/bash',
73
- 'if [ -d /celilo/apps/celilo ]; then',
112
+ '# Opt-in source loop: `cele2e run --source-cli` sets this in the container',
113
+ '# environment. Unset (the default) runs the CLI install.sh actually installed.',
114
+ 'if [ -n "$CELILO_E2E_SOURCE_CLI" ] && [ -d /celilo/apps/celilo ]; then',
74
115
  ' exec bun run /celilo/apps/celilo/src/cli/index.ts "$@"',
75
116
  'fi',
76
117
  'exec /root/.bun/bin/celilo "$@"',
@@ -101,6 +142,137 @@ async function installShimAndVerify(exec: Exec, label: string): Promise<void> {
101
142
  );
102
143
  }
103
144
  console.log(`✔ ${Math.round((Date.now() - t1) / 1000)}s (${verify.stdout.trim()})`);
145
+ return verify.stdout.trim();
146
+ }
147
+
148
+ /**
149
+ * The image id currently on a tag, or '' when the tag is unused.
150
+ *
151
+ * Captured before a commit so the superseded image can be removed after it.
152
+ * Every bake used to orphan a 1.5 GB untagged image and nothing ever collected
153
+ * them: measured 2026-09-05, 3178 dangling images holding 25.8 GB, 1478 of them
154
+ * from a single 35-hour window. That pile is what drives an operator to reach
155
+ * for `docker image prune -a`, which deletes the TAGGED base images the next
156
+ * build needs and costs a full 27-image rebuild.
157
+ */
158
+ function imageIdOnTag(tag: string): string {
159
+ try {
160
+ return run(`docker image inspect --format '{{.Id}}' ${tag}`).trim();
161
+ } catch {
162
+ return '';
163
+ }
164
+ }
165
+
166
+ /** Remove an image the bake just superseded. Best-effort: a tag or container still using it wins. */
167
+ function removeSupersededImage(previousId: string, tag: string): void {
168
+ if (!previousId || previousId === imageIdOnTag(tag)) return;
169
+ try {
170
+ run(`docker image rm ${previousId}`);
171
+ console.log(` superseded image ....... ✔ removed ${previousId.slice(7, 19)}`);
172
+ } catch {
173
+ // Still referenced (a running container, another tag). Leaving it is correct.
174
+ }
175
+ }
176
+
177
+ /**
178
+ * The sim tarballs the default bake installs, staged fresh by
179
+ * restageSimulatorInputs, carry a manifest recording the tree fingerprint they
180
+ * were packed from. The stamp IS that value: the label then describes the
181
+ * tarballs install.sh actually installs, not the tree the bake happened to run
182
+ * in. Stamping `computeSourceFingerprint(tree)` here is what produced a green
183
+ * doctor over a CLI without the tree's changes (celilo#1299): the tree moved
184
+ * between the last `cele2e build` and the bake, and the fingerprint followed
185
+ * the tree, not the stale cache the bake reinstalled.
186
+ */
187
+ function simStampFromManifest(): string {
188
+ const manifestPath = join(PACKAGE_ROOT, '.npm-registry-cache', 'pack-manifest.json');
189
+ let manifest: { sourceFingerprint?: string | null };
190
+ try {
191
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as {
192
+ sourceFingerprint?: string | null;
193
+ };
194
+ } catch {
195
+ throw new Error(
196
+ `No readable pack-manifest.json at ${manifestPath} — the sim tarballs were never staged. ` +
197
+ 'Run the bake from a monorepo checkout; restageSimulatorInputs writes the manifest.',
198
+ );
199
+ }
200
+ if (!manifest.sourceFingerprint) {
201
+ throw new Error(
202
+ 'pack-manifest.json carries no sourceFingerprint — the tarballs this bake installs cannot be attributed to a tree.',
203
+ );
204
+ }
205
+ return manifest.sourceFingerprint;
206
+ }
207
+
208
+ /**
209
+ * Stamp the source this image was baked from, so `cele2e doctor` can say
210
+ * whether the CLI under test is the code in the working tree. See
211
+ * `src/source-fingerprint.ts` for why that has to be measured, not remembered.
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
+
223
+ function sourceStamp(published: boolean, version: string): string {
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
+ }
232
+ return simStampFromManifest();
233
+ }
234
+
235
+ /**
236
+ * Re-stage the simulator inputs the default bake's install path depends on,
237
+ * and rebuild the two sim images that COPY them at docker-build time.
238
+ *
239
+ * Without this, the bake reinstalls whatever tarballs the last `cele2e build`
240
+ * left in `.npm-registry-cache/` — measured 2026-09-07: a bake at 00:17 UTC
241
+ * shipped @celilo/cli 2.1.0 from a cache packed at 15:18 the day before,
242
+ * silently dropping a CLI fix that had landed at 19:48 (celilo#1299). The
243
+ * rebuilds are not optional either: the per-test stack only builds MISSING
244
+ * images, so fresh tarballs on disk never reach the running sim otherwise.
245
+ * Both docker builds are cache-warm no-ops when nothing staged has changed.
246
+ */
247
+ function restageSimulatorInputs(): void {
248
+ const repoRoot = findMonorepoRoot(PACKAGE_ROOT);
249
+ if (!repoRoot) {
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;
260
+ }
261
+ stageWebsiteDist(repoRoot, PACKAGE_ROOT);
262
+ packNpmRegistryTarballs(repoRoot, PACKAGE_ROOT);
263
+ for (const dockerfile of [
264
+ 'docker/Dockerfile.celilo-website-sim',
265
+ 'docker/Dockerfile.npm-registry-sim',
266
+ ]) {
267
+ const tag = imageTag(dockerfile);
268
+ process.stdout.write(` rebuild ${tag.padEnd(22)} `);
269
+ const t0 = Date.now();
270
+ execSync(
271
+ `docker build -q -f ${join(PACKAGE_ROOT, dockerfile)} -t ${tag} ${PACKAGE_ROOT}`,
272
+ { stdio: 'pipe' },
273
+ );
274
+ console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);
275
+ }
104
276
  }
105
277
 
106
278
  /**
@@ -116,6 +288,11 @@ async function bakeViaSim(): Promise<void> {
116
288
  await ensureSharedInfra();
117
289
  console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);
118
290
 
291
+ // Fresh tarballs and site dist, and sim images that actually carry them,
292
+ // BEFORE install.sh runs — otherwise this bake reinstalls the last build's
293
+ // CLI (celilo#1299).
294
+ restageSimulatorInputs();
295
+
119
296
  process.stdout.write(' per-test stack ......... ');
120
297
  const t1 = Date.now();
121
298
  const net = await network().topology('default').managementVariant('vanilla').start();
@@ -166,8 +343,9 @@ async function bakeViaSim(): Promise<void> {
166
343
  }
167
344
  console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);
168
345
 
169
- // Shim + verify (verify is post-shim, no mount — the shipped path).
170
- await installShimAndVerify(exec, 'sim');
346
+ // Shim + verify. Post-shim and, since the shim now prefers the installed
347
+ // CLI, genuinely against the installed binary rather than the mount.
348
+ const version = await installShimAndVerify(exec, 'sim');
171
349
 
172
350
  process.stdout.write(' docker commit .......... ');
173
351
  const t5 = Date.now();
@@ -177,8 +355,12 @@ async function bakeViaSim(): Promise<void> {
177
355
  if (!containerId) {
178
356
  throw new Error('Could not resolve management container id');
179
357
  }
180
- run(`docker commit ${containerId} celilo-e2e/management:latest`);
358
+ const superseded = imageIdOnTag('celilo-e2e/management:latest');
359
+ run(
360
+ `docker commit --change ${JSON.stringify(`LABEL ${SOURCE_LABEL}=${sourceStamp(false, version)}`)} ${containerId} celilo-e2e/management:latest`,
361
+ );
181
362
  console.log(`✔ ${Math.round((Date.now() - t5) / 1000)}s`);
363
+ removeSupersededImage(superseded, 'celilo-e2e/management:latest');
182
364
  } finally {
183
365
  process.stdout.write(' tearing down ........... ');
184
366
  const tDown = Date.now();
@@ -265,7 +447,7 @@ async function bakeViaPublished(): Promise<void> {
265
447
  console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);
266
448
 
267
449
  // Shim + verify (verify is post-shim, no mount — the shipped path).
268
- await installShimAndVerify(dexec, 'published');
450
+ const version = await installShimAndVerify(dexec, 'published');
269
451
 
270
452
  // Restore the sim @celilo scope override so the committed image is
271
453
  // drop-in compatible with the simulated topology it runs tests in.
@@ -280,10 +462,12 @@ async function bakeViaPublished(): Promise<void> {
280
462
  // real entrypoint (Dockerfile.management: CMD ["/startup.sh"], no
281
463
  // ENTRYPOINT). Without this the baked image never runs /startup.sh ->
282
464
  // ssh-keys volume stays empty -> fw-main crashloops (ce-um6).
465
+ const superseded = imageIdOnTag('celilo-e2e/management:latest');
283
466
  run(
284
- `docker commit --change 'CMD ["/startup.sh"]' ${name} celilo-e2e/management:latest`,
467
+ `docker commit --change 'CMD ["/startup.sh"]' --change ${JSON.stringify(`LABEL ${SOURCE_LABEL}=${sourceStamp(true, version)}`)} ${name} celilo-e2e/management:latest`,
285
468
  );
286
469
  console.log(`✔ ${Math.round((Date.now() - t3) / 1000)}s`);
470
+ removeSupersededImage(superseded, 'celilo-e2e/management:latest');
287
471
  } finally {
288
472
  process.stdout.write(' removing container ..... ');
289
473
  const t4 = Date.now();
package/bin/e2e-infra CHANGED
@@ -34,7 +34,6 @@ case "${1:-status}" in
34
34
  console.log(generateSharedInfraYaml());
35
35
  " > "$SHARED_COMPOSE"
36
36
 
37
- docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" build
38
37
  docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" up -d
39
38
 
40
39
  echo -e "${DIM}Waiting for DNS convergence...${NC}"
package/bin/e2e-up CHANGED
@@ -96,9 +96,20 @@ if (customJson) {
96
96
  console.log(generateComposeYaml(config));
97
97
  " > docker-compose.yml
98
98
 
99
- # Build and start
100
- echo "Building images (this may take a while on first run)..."
101
- docker compose -p "$PROJECT_NAME" build
99
+ # Verify baked images. The compose carries `image:` only, so nothing here
100
+ # builds — a missing tag would surface as a confusing "pull access denied"
101
+ # from `up`. Name the remedy instead.
102
+ echo "Verifying baked images (suite time never builds)..."
103
+ MISSING=()
104
+ while read -r tag; do
105
+ [ -n "$tag" ] || continue
106
+ docker image inspect "$tag" >/dev/null 2>&1 || MISSING+=("$tag")
107
+ done < <(awk '/image: celilo-e2e\//{print $3}' docker-compose.yml | sort -u)
108
+ if [ ${#MISSING[@]} -gt 0 ]; then
109
+ echo "ERROR: ${#MISSING[@]} baked image(s) missing: ${MISSING[*]}"
110
+ echo "Remedy: cele2e build-infra"
111
+ exit 1
112
+ fi
102
113
 
103
114
  echo "Starting containers..."
104
115
  docker compose -p "$PROJECT_NAME" up -d
@@ -10,13 +10,24 @@ FROM ubuntu:22.04
10
10
 
11
11
  ENV DEBIAN_FRONTEND=noninteractive
12
12
 
13
- RUN apt-get update && apt-get install -y \
13
+ # `--no-install-recommends` is load-bearing: `wireguard-tools` Recommends
14
+ # `wireguard-modules | wireguard-dkms`, which apt satisfies with a realtime
15
+ # KERNEL plus `linux-firmware` (1.1 GB). Measured 2026-09-05: 1.83 GB of this
16
+ # image's 1.9 GB was that, for a probe toolbox that runs no kernel at all.
17
+ # See the same note in Dockerfile.target-machine.
18
+ #
19
+ # `iptables` is named because it used to arrive via `wireguard-tools`' other
20
+ # Recommends. This image dials a real tunnel (`wg-quick up wg0`), and wg-quick
21
+ # reaches for `iptables-save` behind a `type -p` guard — so losing it would not
22
+ # fail, it would silently skip part of teardown.
23
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
24
  dnsutils \
15
25
  curl \
16
26
  openssl \
17
27
  netcat-openbsd \
18
28
  iputils-ping \
19
29
  iproute2 \
30
+ iptables \
20
31
  ca-certificates \
21
32
  wireguard-tools \
22
33
  wireguard-go \
@@ -69,10 +69,31 @@ RUN systemctl enable ssh target-setup
69
69
  # --- Caddy pre-installation ---
70
70
  # Install Caddy (same steps as the Ansible role, but at image build time so
71
71
  # the role finds it already installed and skips the slow cloudsmith.io downloads)
72
- RUN apt-get update && apt-get install -y \
72
+ #
73
+ # `--no-install-recommends` is load-bearing here, not tidiness. `wireguard-tools`
74
+ # Recommends `wireguard-modules | wireguard-dkms`, and apt satisfies that with
75
+ # a KERNEL: `linux-modules-extra-*-realtime`, which drags in the realtime kernel
76
+ # image, its modules and `linux-firmware`. Measured 2026-09-05: that one line
77
+ # added 1.76 GB to this image (2.08 GB total), of which linux-firmware alone was
78
+ # 1.1 GB. A container cannot load a kernel module, and wg-quick already falls
79
+ # back to the userspace `wireguard-go` below, so none of it was ever used.
80
+ # The price of `--no-install-recommends` is that anything this image USED to get
81
+ # for free has to be named. Two things did, and both are load-bearing:
82
+ #
83
+ # gnupg — the very next step pipes the caddy signing key through `gpg
84
+ # --dearmor`. Without it the build dies with `gpg: not found`,
85
+ # which is how this was found: by building it.
86
+ # iptables — arrived via `wireguard-tools`' other Recommends
87
+ # (`nftables | iptables`). The wireguard module's health check
88
+ # runs `iptables-save` on the host it lands on, and wg-quick
89
+ # consults `iptables-save` on teardown behind a `type -p` guard,
90
+ # so its absence would not error — it would quietly do less.
91
+ RUN apt-get update && apt-get install -y --no-install-recommends \
73
92
  debian-keyring \
74
93
  debian-archive-keyring \
75
94
  apt-transport-https \
95
+ gnupg \
96
+ iptables \
76
97
  wireguard-tools \
77
98
  wireguard-go \
78
99
  && rm -rf /var/lib/apt/lists/*
@@ -9,7 +9,7 @@
9
9
  "test": "bun test"
10
10
  },
11
11
  "devDependencies": {
12
- "bun-types": "latest",
12
+ "bun-types": "1.3.13",
13
13
  "typescript": "^5"
14
14
  }
15
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.19.3",
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",
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "@celilo/capabilities": "^4.1.0",
40
+ "@celilo/capabilities": "^4.2.0",
41
41
  "@celilo/cli-display": "^0.2.0",
42
42
  "@celilo/event-bus": "^0.6.0",
43
43
  "@celilo/terraform-fake": "^0.3.1",
@@ -45,7 +45,7 @@
45
45
  "zod": "^3.24.1"
46
46
  },
47
47
  "devDependencies": {
48
- "@types/bun": "latest"
48
+ "@types/bun": "1.4.0"
49
49
  },
50
50
  "keywords": [
51
51
  "celilo",
@@ -11,7 +11,7 @@
11
11
  "test": "bun test --timeout 30000"
12
12
  },
13
13
  "devDependencies": {
14
- "bun-types": "latest",
14
+ "bun-types": "1.4.0",
15
15
  "typescript": "^5"
16
16
  }
17
17
  }
@@ -38,6 +38,7 @@ import {
38
38
  workspaceVersionMap,
39
39
  } from '../../../scripts/workspace-graph';
40
40
  import { ensureRegistryServerBundle } from '../src/registry-bundle';
41
+ import { computeSourceFingerprint } from '../src/source-fingerprint';
41
42
 
42
43
  /**
43
44
  * Resolve the celilo monorepo root by walking up from `startDir` (the caller's
@@ -242,6 +243,20 @@ function main(): void {
242
243
  }
243
244
 
244
245
  console.log(`✓ Packed ${results.length} package(s) to ${destination}`);
246
+
247
+ // Record the tree fingerprint these tarballs were packed FROM, next to the
248
+ // tarballs. The management-image bake stamps THIS value, not a fingerprint
249
+ // recomputed from the tree at bake time: the tarballs are what install.sh
250
+ // actually installs, so the stamp must describe them (celilo#1299 — a bake
251
+ // that reinstalls a stale cache used to stamp a fingerprint of the newer
252
+ // tree, claiming CLI content the image did not carry). The npm-registry
253
+ // server only serves *.tgz, so a JSON file in the same directory is inert.
254
+ const manifest = {
255
+ packedAt: new Date().toISOString(),
256
+ sourceFingerprint: computeSourceFingerprint(repoRoot()),
257
+ packages: results,
258
+ };
259
+ writeFileSync(join(destination, 'pack-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
245
260
  }
246
261
 
247
262
  if (import.meta.main) {