@celilo/e2e 0.19.3 → 0.20.0

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 +171 -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 +54 -4
  14. package/src/cli/build.ts +204 -88
  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 +184 -0
  35. package/src/live-stack.ts +145 -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 +83 -32
  45. package/src/socks-proxy.ts +2 -0
  46. package/src/source-fingerprint.test.ts +213 -0
  47. package/src/source-fingerprint.ts +201 -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,17 @@ 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
+ PUBLISHED_FINGERPRINT_PREFIX,
37
+ SOURCE_LABEL,
38
+ } from '../src/source-fingerprint';
39
+ import { readFileSync } from 'node:fs';
29
40
 
30
41
  const PACKAGE_ROOT = join(import.meta.dir, '..');
31
42
  const COMPOSE_FILE = join(PACKAGE_ROOT, 'docker-compose.test.yml');
@@ -42,13 +53,40 @@ interface ExecResult {
42
53
  type Exec = (cmd: string, timeoutMs?: number) => Promise<ExecResult>;
43
54
 
44
55
  /**
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.
56
+ * Write the CLI shim to /usr/local/bin/celilo and verify the CLI works through
57
+ * it. Shared by both bake modes.
47
58
  *
48
59
  * 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:
60
+ * finds it) runs the INSTALLED CLI at /root/.bun/bin/celilo — the artifact
61
+ * install.sh just produced, which is the thing the rig exists to test. It runs
62
+ * the mounted /celilo workspace instead only when `CELILO_E2E_SOURCE_CLI` is
63
+ * set, which `cele2e run --source-cli` does.
64
+ *
65
+ * That default used to be the other way round, and both halves of the swap are
66
+ * worth stating.
67
+ *
68
+ * It cost real time per invocation. `bun run <source>` resolves 1522 modules
69
+ * across the bind mount rather than starting an installed binary. Measured
70
+ * inside this image on 2026-09-05, same command and same container, on a VM
71
+ * sized per the host policy: `celilo --version` took 0.14s installed and 0.31s
72
+ * from the mounted source over virtiofs, or 0.16s against 0.56s over sshfs. A
73
+ * test issues 25 to 40 celilo commands.
74
+ *
75
+ * (An earlier measurement put that gap at 2.3s. Most of that was a separate
76
+ * problem — the VM had been given 24 of the host's 32 GiB and the host was
77
+ * swapping 21.9 GiB. Fixing the memory removed about 1.9s of it. Worth stating,
78
+ * because a number measured with two variables moving blamed the wrong one.)
79
+ *
80
+ * And it made the verify below dishonest. This function exists because of
81
+ * ISS-0124 — a broken shim fallback shipping green — and its comment says the
82
+ * verify runs "with NO /celilo mount, i.e. exactly the shipped path". That was
83
+ * true of the published bake, which runs in a standalone container, and false
84
+ * of this one: the sim bake's container HAS the workspace mounted, so the old
85
+ * shim sent the verify down the source branch and the installed CLI was never
86
+ * executed at all. The check could not reach its subject. Now both modes verify
87
+ * the installed binary.
88
+ *
89
+ * Two things must hold, both bitten in ISS-0124:
52
90
  *
53
91
  * 1. `bun add -g` links the celilo binary at /usr/local/bin/celilo as a
54
92
  * SYMLINK into the package (bun's global bin dir in this image is
@@ -65,12 +103,14 @@ type Exec = (cmd: string, timeoutMs?: number) => Promise<ExecResult>;
65
103
  * shipped path — so a dangling/looping fallback fails the bake instead of
66
104
  * shipping green.
67
105
  */
68
- async function installShimAndVerify(exec: Exec, label: string): Promise<void> {
106
+ async function installShimAndVerify(exec: Exec, label: string): Promise<string> {
69
107
  process.stdout.write(' source-mount shim ...... ');
70
108
  const t0 = Date.now();
71
109
  const shim = [
72
110
  '#!/bin/bash',
73
- 'if [ -d /celilo/apps/celilo ]; then',
111
+ '# Opt-in source loop: `cele2e run --source-cli` sets this in the container',
112
+ '# environment. Unset (the default) runs the CLI install.sh actually installed.',
113
+ 'if [ -n "$CELILO_E2E_SOURCE_CLI" ] && [ -d /celilo/apps/celilo ]; then',
74
114
  ' exec bun run /celilo/apps/celilo/src/cli/index.ts "$@"',
75
115
  'fi',
76
116
  'exec /root/.bun/bin/celilo "$@"',
@@ -101,6 +141,113 @@ async function installShimAndVerify(exec: Exec, label: string): Promise<void> {
101
141
  );
102
142
  }
103
143
  console.log(`✔ ${Math.round((Date.now() - t1) / 1000)}s (${verify.stdout.trim()})`);
144
+ return verify.stdout.trim();
145
+ }
146
+
147
+ /**
148
+ * The image id currently on a tag, or '' when the tag is unused.
149
+ *
150
+ * Captured before a commit so the superseded image can be removed after it.
151
+ * Every bake used to orphan a 1.5 GB untagged image and nothing ever collected
152
+ * them: measured 2026-09-05, 3178 dangling images holding 25.8 GB, 1478 of them
153
+ * from a single 35-hour window. That pile is what drives an operator to reach
154
+ * for `docker image prune -a`, which deletes the TAGGED base images the next
155
+ * build needs and costs a full 27-image rebuild.
156
+ */
157
+ function imageIdOnTag(tag: string): string {
158
+ try {
159
+ return run(`docker image inspect --format '{{.Id}}' ${tag}`).trim();
160
+ } catch {
161
+ return '';
162
+ }
163
+ }
164
+
165
+ /** Remove an image the bake just superseded. Best-effort: a tag or container still using it wins. */
166
+ function removeSupersededImage(previousId: string, tag: string): void {
167
+ if (!previousId || previousId === imageIdOnTag(tag)) return;
168
+ try {
169
+ run(`docker image rm ${previousId}`);
170
+ console.log(` superseded image ....... ✔ removed ${previousId.slice(7, 19)}`);
171
+ } catch {
172
+ // Still referenced (a running container, another tag). Leaving it is correct.
173
+ }
174
+ }
175
+
176
+ /**
177
+ * The sim tarballs the default bake installs, staged fresh by
178
+ * restageSimulatorInputs, carry a manifest recording the tree fingerprint they
179
+ * were packed from. The stamp IS that value: the label then describes the
180
+ * tarballs install.sh actually installs, not the tree the bake happened to run
181
+ * in. Stamping `computeSourceFingerprint(tree)` here is what produced a green
182
+ * doctor over a CLI without the tree's changes (celilo#1299): the tree moved
183
+ * between the last `cele2e build` and the bake, and the fingerprint followed
184
+ * the tree, not the stale cache the bake reinstalled.
185
+ */
186
+ function simStampFromManifest(): string {
187
+ const manifestPath = join(PACKAGE_ROOT, '.npm-registry-cache', 'pack-manifest.json');
188
+ let manifest: { sourceFingerprint?: string | null };
189
+ try {
190
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as {
191
+ sourceFingerprint?: string | null;
192
+ };
193
+ } catch {
194
+ throw new Error(
195
+ `No readable pack-manifest.json at ${manifestPath} — the sim tarballs were never staged. ` +
196
+ 'Run the bake from a monorepo checkout; restageSimulatorInputs writes the manifest.',
197
+ );
198
+ }
199
+ if (!manifest.sourceFingerprint) {
200
+ throw new Error(
201
+ 'pack-manifest.json carries no sourceFingerprint — the tarballs this bake installs cannot be attributed to a tree.',
202
+ );
203
+ }
204
+ return manifest.sourceFingerprint;
205
+ }
206
+
207
+ /**
208
+ * Stamp the source this image was baked from, so `cele2e doctor` can say
209
+ * whether the CLI under test is the code in the working tree. See
210
+ * `src/source-fingerprint.ts` for why that has to be measured, not remembered.
211
+ */
212
+ function sourceStamp(published: boolean, version: string): string {
213
+ if (published) return `${PUBLISHED_FINGERPRINT_PREFIX}${version || 'unknown'}`;
214
+ return simStampFromManifest();
215
+ }
216
+
217
+ /**
218
+ * Re-stage the simulator inputs the default bake's install path depends on,
219
+ * and rebuild the two sim images that COPY them at docker-build time.
220
+ *
221
+ * Without this, the bake reinstalls whatever tarballs the last `cele2e build`
222
+ * left in `.npm-registry-cache/` — measured 2026-09-07: a bake at 00:17 UTC
223
+ * shipped @celilo/cli 2.1.0 from a cache packed at 15:18 the day before,
224
+ * silently dropping a CLI fix that had landed at 19:48 (celilo#1299). The
225
+ * rebuilds are not optional either: the per-test stack only builds MISSING
226
+ * images, so fresh tarballs on disk never reach the running sim otherwise.
227
+ * Both docker builds are cache-warm no-ops when nothing staged has changed.
228
+ */
229
+ function restageSimulatorInputs(): void {
230
+ const repoRoot = findMonorepoRoot(PACKAGE_ROOT);
231
+ 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
+ );
235
+ }
236
+ stageWebsiteDist(repoRoot, PACKAGE_ROOT);
237
+ packNpmRegistryTarballs(repoRoot, PACKAGE_ROOT);
238
+ for (const dockerfile of [
239
+ 'docker/Dockerfile.celilo-website-sim',
240
+ 'docker/Dockerfile.npm-registry-sim',
241
+ ]) {
242
+ const tag = imageTag(dockerfile);
243
+ process.stdout.write(` rebuild ${tag.padEnd(22)} `);
244
+ const t0 = Date.now();
245
+ execSync(
246
+ `docker build -q -f ${join(PACKAGE_ROOT, dockerfile)} -t ${tag} ${PACKAGE_ROOT}`,
247
+ { stdio: 'pipe' },
248
+ );
249
+ console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);
250
+ }
104
251
  }
105
252
 
106
253
  /**
@@ -116,6 +263,11 @@ async function bakeViaSim(): Promise<void> {
116
263
  await ensureSharedInfra();
117
264
  console.log(`✔ ${Math.round((Date.now() - t0) / 1000)}s`);
118
265
 
266
+ // Fresh tarballs and site dist, and sim images that actually carry them,
267
+ // BEFORE install.sh runs — otherwise this bake reinstalls the last build's
268
+ // CLI (celilo#1299).
269
+ restageSimulatorInputs();
270
+
119
271
  process.stdout.write(' per-test stack ......... ');
120
272
  const t1 = Date.now();
121
273
  const net = await network().topology('default').managementVariant('vanilla').start();
@@ -166,8 +318,9 @@ async function bakeViaSim(): Promise<void> {
166
318
  }
167
319
  console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);
168
320
 
169
- // Shim + verify (verify is post-shim, no mount — the shipped path).
170
- await installShimAndVerify(exec, 'sim');
321
+ // Shim + verify. Post-shim and, since the shim now prefers the installed
322
+ // CLI, genuinely against the installed binary rather than the mount.
323
+ const version = await installShimAndVerify(exec, 'sim');
171
324
 
172
325
  process.stdout.write(' docker commit .......... ');
173
326
  const t5 = Date.now();
@@ -177,8 +330,12 @@ async function bakeViaSim(): Promise<void> {
177
330
  if (!containerId) {
178
331
  throw new Error('Could not resolve management container id');
179
332
  }
180
- run(`docker commit ${containerId} celilo-e2e/management:latest`);
333
+ const superseded = imageIdOnTag('celilo-e2e/management:latest');
334
+ run(
335
+ `docker commit --change ${JSON.stringify(`LABEL ${SOURCE_LABEL}=${sourceStamp(false, version)}`)} ${containerId} celilo-e2e/management:latest`,
336
+ );
181
337
  console.log(`✔ ${Math.round((Date.now() - t5) / 1000)}s`);
338
+ removeSupersededImage(superseded, 'celilo-e2e/management:latest');
182
339
  } finally {
183
340
  process.stdout.write(' tearing down ........... ');
184
341
  const tDown = Date.now();
@@ -265,7 +422,7 @@ async function bakeViaPublished(): Promise<void> {
265
422
  console.log(`✔ ${Math.round((Date.now() - t2) / 1000)}s`);
266
423
 
267
424
  // Shim + verify (verify is post-shim, no mount — the shipped path).
268
- await installShimAndVerify(dexec, 'published');
425
+ const version = await installShimAndVerify(dexec, 'published');
269
426
 
270
427
  // Restore the sim @celilo scope override so the committed image is
271
428
  // drop-in compatible with the simulated topology it runs tests in.
@@ -280,10 +437,12 @@ async function bakeViaPublished(): Promise<void> {
280
437
  // real entrypoint (Dockerfile.management: CMD ["/startup.sh"], no
281
438
  // ENTRYPOINT). Without this the baked image never runs /startup.sh ->
282
439
  // ssh-keys volume stays empty -> fw-main crashloops (ce-um6).
440
+ const superseded = imageIdOnTag('celilo-e2e/management:latest');
283
441
  run(
284
- `docker commit --change 'CMD ["/startup.sh"]' ${name} celilo-e2e/management:latest`,
442
+ `docker commit --change 'CMD ["/startup.sh"]' --change ${JSON.stringify(`LABEL ${SOURCE_LABEL}=${sourceStamp(true, version)}`)} ${name} celilo-e2e/management:latest`,
285
443
  );
286
444
  console.log(`✔ ${Math.round((Date.now() - t3) / 1000)}s`);
445
+ removeSupersededImage(superseded, 'celilo-e2e/management:latest');
287
446
  } finally {
288
447
  process.stdout.write(' removing container ..... ');
289
448
  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.0",
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) {