@celilo/e2e 0.19.2 → 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
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
- import { stringify } from 'yaml';
3
+ import { parse as parseYaml, stringify } from 'yaml';
4
4
  import { normalizeObservers, observerEnv, observerPlacement } from './observer';
5
5
  import { PROXMOX_SIM_IP, SIMULATOR_IPS } from './simulator-ips';
6
6
  import type { MachineSpec, NetworkConfig, TopologyPreset, Zone } from './types';
@@ -113,21 +113,35 @@ function zoneNetworkDef(zone: Zone) {
113
113
  }
114
114
 
115
115
  /**
116
- * Image tag prefix for pre-built e2e images. When images with these
117
- * tags exist locally, `docker compose build` skips rebuilding them.
118
- * The e2e-build script creates these tags; e2e-save/e2e-load persist
119
- * them across colima restarts.
116
+ * Image tag prefix for baked e2e images. `cele2e build-infra` creates these
117
+ * tags; e2e-save/e2e-load persist them across colima restarts. A compose
118
+ * referencing one of these tags never builds it — a missing tag is a loud
119
+ * failure naming the remedy (see referencedImages / container-manager).
120
120
  */
121
121
  const IMAGE_PREFIX = 'celilo-e2e';
122
122
 
123
- function imageTag(dockerfile: string): string {
123
+ /**
124
+ * The image tag `baseService` assigns a service built from `dockerfile`.
125
+ * Stable across runs (unlike compose's `<project>-<service>` default), which is
126
+ * what lets callers rebuild a sim image by tag — e.g. the bake re-building the
127
+ * registry sim after re-staging tarballs (celilo#1299).
128
+ */
129
+ export function imageTag(dockerfile: string): string {
124
130
  // docker/Dockerfile.management -> celilo-e2e/management
125
131
  const name = dockerfile.replace('docker/Dockerfile.', '');
126
132
  return `${IMAGE_PREFIX}/${name}`;
127
133
  }
128
134
 
129
135
  function baseService(opts: {
130
- build?: { context: string; dockerfile: string };
136
+ /**
137
+ * Which Dockerfile bakes this image: `docker/Dockerfile.firewall` ->
138
+ * `celilo-e2e/firewall`. The yaml carries `image:` ONLY. `cele2e
139
+ * build-infra` is the one builder (it owns the network phase); a suite
140
+ * run verifies the tag exists instead of building, so a missing image
141
+ * fails loudly rather than silently resolving FROM from docker.io.
142
+ * See tests/composes-reference-baked-images.test.ts.
143
+ */
144
+ dockerfile?: string;
131
145
  image?: string;
132
146
  networks: Record<string, { ipv4_address: string }>;
133
147
  cap_add?: string[];
@@ -148,11 +162,12 @@ function baseService(opts: {
148
162
  /** Host devices to expose, e.g. `/dev/net/tun` for a userspace WireGuard tunnel. */
149
163
  devices?: string[];
150
164
  }) {
151
- // Auto-assign an image tag from the Dockerfile name so Docker can
152
- // skip builds when pre-built images exist.
153
- const service: Record<string, unknown> = { ...opts, restart: 'unless-stopped' };
154
- if (opts.build && !opts.image) {
155
- service.image = imageTag(opts.build.dockerfile);
165
+ // The Dockerfile name drives the tag but must NOT reach the yaml: compose
166
+ // rejects unknown service keys. The emitted service carries `image:` only.
167
+ const { dockerfile, ...rest } = opts;
168
+ const service: Record<string, unknown> = { ...rest, restart: 'unless-stopped' };
169
+ if (dockerfile && !opts.image) {
170
+ service.image = imageTag(dockerfile);
156
171
  }
157
172
  return service;
158
173
  }
@@ -162,6 +177,30 @@ function baseService(opts: {
162
177
  // manager and per-test compose files (to reference external networks).
163
178
  // ---------------------------------------------------------------------------
164
179
 
180
+ /**
181
+ * The baked images a run needs — every service's `celilo-e2e/*` tag, paired
182
+ * with the tag `baseService` assigned it.
183
+ *
184
+ * Used to answer "is everything the compose references present?" before the
185
+ * run. The yaml carries `image:` only (never `build:`), so this is the full
186
+ * set: a missing tag is a loud failure naming `cele2e build-infra`, never a
187
+ * silent suite-time build resolving FROM from docker.io (tasks.md 5b.2).
188
+ *
189
+ * Pure and parsed back out of the emitted YAML rather than computed alongside
190
+ * it, so it reports what the compose file actually says. A service that gains
191
+ * a reference is covered without anyone remembering to add it here.
192
+ */
193
+ export function referencedImages(yaml: string): string[] {
194
+ const compose = parseYaml(yaml) as {
195
+ services?: Record<string, { image?: string }>;
196
+ };
197
+ const images = new Set<string>();
198
+ for (const service of Object.values(compose.services ?? {})) {
199
+ if (service.image) images.add(service.image);
200
+ }
201
+ return [...images].sort();
202
+ }
203
+
165
204
  export const SHARED_PROJECT_NAME = 'celilo-e2e-shared';
166
205
 
167
206
  /** Docker network names as created by the shared infrastructure project */
@@ -186,14 +225,38 @@ export const SHARED_NETWORKS = {
186
225
  const SECURE_MGMT_GATEWAY = ZONE_GATEWAYS['secure-mgmt'];
187
226
  const SECURE_MGMT_MANAGEMENT_IP = zoneIp('secure-mgmt', 100);
188
227
 
228
+ /**
229
+ * Zones deliberately LEFT OUT of the no-NAT class, each mapped to the reason it
230
+ * is excluded. The no-NAT set is computed as every declared zone (ZONE_SUBNETS)
231
+ * minus these, so a zone added to the Zone type joins the class by default and
232
+ * leaving one out is a deliberate edit to this map, never an omission. A test
233
+ * over this map is the recurrence gate for celilo#1263: the suite once
234
+ * reproduced that bug class by listing these zones by hand, and a hand-written
235
+ * list rots the moment a zone is added.
236
+ */
237
+ export const NO_NAT_EXCLUDED_ZONES: Partial<Record<Zone, string>> = {
238
+ internal:
239
+ 'A LAN device’s DNS query must arrive SNAT’d to a zone-gateway address so it matches the passthrough view and gets the natIp answer, not the protected one (ISS-0156).',
240
+ };
241
+
189
242
  /**
190
243
  * The zones fw-main routes between WITHOUT NAT, so the dmz-resident resolver
191
244
  * sees each client's real zone (source-based split-horizon, ISS-0156).
192
- * `internal` is deliberately absent: a LAN device's query must arrive SNAT'd to
193
- * a zone-gateway address so it matches the passthrough view and gets the natIp
194
- * answer, not the protected one.
245
+ *
246
+ * Computed, never listed: every declared zone minus
247
+ * {@link NO_NAT_EXCLUDED_ZONES}. THE COST OF EXCLUDING `internal`, recorded so
248
+ * the next reader can connect it (celilo#1263): traffic in the other direction —
249
+ * from a no-NAT zone to an internal host, e.g. the control plane on secure-mgmt
250
+ * reaching fw-isp at 10.226.1.1 — matches no RETURN row and falls through to the
251
+ * blanket MASQUERADE. Traffic to every other declared zone keeps its source;
252
+ * traffic to internal does not. Whether that asymmetry is tolerable is a
253
+ * control-plane question, not a fixture typo.
195
254
  */
196
- const PROTECTED_SUBNETS = (['dmz', 'app', 'secure', 'secure-mgmt'] as const)
255
+ export function noNatZones(): Zone[] {
256
+ return (Object.keys(ZONE_SUBNETS) as Zone[]).filter((zone) => !(zone in NO_NAT_EXCLUDED_ZONES));
257
+ }
258
+
259
+ const PROTECTED_SUBNETS = noNatZones()
197
260
  .map((zone) => ZONE_SUBNETS[zone])
198
261
  .join(' ');
199
262
 
@@ -239,7 +302,7 @@ function firewallNetworks(topology: TopologyPreset): Record<string, { ipv4_addre
239
302
  function defaultTopologyServices(config: NetworkConfig): Record<string, unknown> {
240
303
  return {
241
304
  'fw-main': baseService({
242
- build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
305
+ dockerfile: 'docker/Dockerfile.firewall',
243
306
  networks: firewallNetworks('default'),
244
307
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
245
308
  // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
@@ -251,7 +314,7 @@ function defaultTopologyServices(config: NetworkConfig): Record<string, unknown>
251
314
  environment: { PROTECTED_SUBNETS },
252
315
  }),
253
316
  'fw-isp': baseService({
254
- build: { context: '.', dockerfile: 'docker/Dockerfile.greenwave-sim' },
317
+ dockerfile: 'docker/Dockerfile.greenwave-sim',
255
318
  networks: {
256
319
  internal: { ipv4_address: greenwaveRouterIp() },
257
320
  'isp-external': { ipv4_address: '203.0.113.100' },
@@ -284,7 +347,7 @@ function defaultTopologyServices(config: NetworkConfig): Record<string, unknown>
284
347
  function directInternetTopologyServices(): Record<string, unknown> {
285
348
  return {
286
349
  'fw-main': baseService({
287
- build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
350
+ dockerfile: 'docker/Dockerfile.firewall',
288
351
  networks: firewallNetworks('direct-internet'),
289
352
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
290
353
  // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
@@ -332,7 +395,7 @@ export function generateSharedInfraYaml(): string {
332
395
  const services: Record<string, unknown> = {};
333
396
 
334
397
  services['root-dns'] = baseService({
335
- build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
398
+ dockerfile: 'docker/Dockerfile.authoritative-dns',
336
399
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.ROOT_DNS } },
337
400
  cap_add: ['NET_ADMIN'],
338
401
  volumes: [
@@ -342,7 +405,7 @@ export function generateSharedInfraYaml(): string {
342
405
  });
343
406
 
344
407
  services['tld-dns'] = baseService({
345
- build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
408
+ dockerfile: 'docker/Dockerfile.authoritative-dns',
346
409
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.TLD_DNS } },
347
410
  cap_add: ['NET_ADMIN'],
348
411
  volumes: [
@@ -354,7 +417,7 @@ export function generateSharedInfraYaml(): string {
354
417
  });
355
418
 
356
419
  services['namecheap-dns'] = baseService({
357
- build: { context: '.', dockerfile: 'docker/Dockerfile.namecheap-dns' },
420
+ dockerfile: 'docker/Dockerfile.namecheap-dns',
358
421
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.NAMECHEAP_DNS } },
359
422
  cap_add: ['NET_ADMIN'],
360
423
  // Per-domain DDNS passwords for the cross-domain test. Fallback
@@ -384,7 +447,7 @@ export function generateSharedInfraYaml(): string {
384
447
  // cycle. See CLAUDE.md history for details.
385
448
 
386
449
  services.letsencrypt = baseService({
387
- build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
450
+ dockerfile: 'docker/Dockerfile.pebble',
388
451
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PEBBLE } },
389
452
  cap_add: ['NET_ADMIN'],
390
453
  command: '-config /config/pebble-config.json -dnsserver 203.0.113.1:53',
@@ -400,7 +463,7 @@ export function generateSharedInfraYaml(): string {
400
463
  // the package (bundled at build time from packages/registry-server,
401
464
  // shipped in the npm tarball). See packages/e2e/src/registry-bundle.ts.
402
465
  services.registry = baseService({
403
- build: { context: '.', dockerfile: 'docker/Dockerfile.registry' },
466
+ dockerfile: 'docker/Dockerfile.registry',
404
467
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.REGISTRY } },
405
468
  cap_add: ['NET_ADMIN'],
406
469
  volumes: getRegistryVolumes(),
@@ -422,7 +485,7 @@ export function generateSharedInfraYaml(): string {
422
485
  // internet-external with the authoritative servers, so it needs no route
423
486
  // through fw-ext to reach them.
424
487
  services['public-resolver'] = baseService({
425
- build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
488
+ dockerfile: 'docker/Dockerfile.resolver',
426
489
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PUBLIC_RESOLVER } },
427
490
  cap_add: ['NET_ADMIN'],
428
491
  command: '/public-resolver-startup.sh',
@@ -434,7 +497,7 @@ export function generateSharedInfraYaml(): string {
434
497
  // check's EXPECTATION comes from; comparing public DNS against the
435
498
  // registrar's own response would be self-agreement.
436
499
  services['ip-echo'] = baseService({
437
- build: { context: '.', dockerfile: 'docker/Dockerfile.ip-echo' },
500
+ dockerfile: 'docker/Dockerfile.ip-echo',
438
501
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.IP_ECHO } },
439
502
  cap_add: ['NET_ADMIN'],
440
503
  });
@@ -445,7 +508,7 @@ export function generateSharedInfraYaml(): string {
445
508
  // at 203.0.113.1 as DNS so it can recursively resolve the simulated public
446
509
  // zones (e.g. iamtheinternet.org).
447
510
  services.isitup = baseService({
448
- build: { context: '.', dockerfile: 'docker/Dockerfile.isitup' },
511
+ dockerfile: 'docker/Dockerfile.isitup',
449
512
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.ISITUP } },
450
513
  cap_add: ['NET_ADMIN'],
451
514
  dns: ['203.0.113.1'],
@@ -457,7 +520,7 @@ export function generateSharedInfraYaml(): string {
457
520
  // without reaching the real internet. Static content is staged into the
458
521
  // image at build time from modules/celilo-website/site/dist/.
459
522
  services['celilo-website'] = baseService({
460
- build: { context: '.', dockerfile: 'docker/Dockerfile.celilo-website-sim' },
523
+ dockerfile: 'docker/Dockerfile.celilo-website-sim',
461
524
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.WEBSITE } },
462
525
  cap_add: ['NET_ADMIN'],
463
526
  dns: ['203.0.113.1'],
@@ -474,13 +537,13 @@ export function generateSharedInfraYaml(): string {
474
537
  // DNS resolves tangohost.com straight to this address. It runs its own Apache
475
538
  // and its own pre-installed TLS — celilo governs none of it.
476
539
  services['cpanel-host'] = baseService({
477
- build: { context: '.', dockerfile: 'docker/Dockerfile.cpanel-host-sim' },
540
+ dockerfile: 'docker/Dockerfile.cpanel-host-sim',
478
541
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.CPANEL_HOST } },
479
542
  cap_add: ['NET_ADMIN'],
480
543
  });
481
544
 
482
545
  services['npm-registry'] = baseService({
483
- build: { context: '.', dockerfile: 'docker/Dockerfile.npm-registry-sim' },
546
+ dockerfile: 'docker/Dockerfile.npm-registry-sim',
484
547
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.NPM_REGISTRY } },
485
548
  cap_add: ['NET_ADMIN'],
486
549
  });
@@ -492,7 +555,7 @@ export function generateSharedInfraYaml(): string {
492
555
  // inside the image (Dockerfile.apt-repo-sim). Drives the bootstrap-apt
493
556
  // test's `apt install celilo-bootstrap`.
494
557
  services['apt-repo'] = baseService({
495
- build: { context: '.', dockerfile: 'docker/Dockerfile.apt-repo-sim' },
558
+ dockerfile: 'docker/Dockerfile.apt-repo-sim',
496
559
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.APT_REPO } },
497
560
  cap_add: ['NET_ADMIN'],
498
561
  });
@@ -505,7 +568,7 @@ export function generateSharedInfraYaml(): string {
505
568
  // Drives the migration restore e2e (push via `backup --storage`, pull via
506
569
  // `backup pull`).
507
570
  services.minio = baseService({
508
- build: { context: '.', dockerfile: 'docker/Dockerfile.minio' },
571
+ dockerfile: 'docker/Dockerfile.minio',
509
572
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.MINIO } },
510
573
  cap_add: ['NET_ADMIN'],
511
574
  });
@@ -533,7 +596,7 @@ export function generateSharedInfraYaml(): string {
533
596
  */
534
597
  function proxmoxSimService() {
535
598
  return baseService({
536
- build: { context: '.', dockerfile: 'docker/Dockerfile.proxmox-sim' },
599
+ dockerfile: 'docker/Dockerfile.proxmox-sim',
537
600
  networks: { 'secure-mgmt': { ipv4_address: PROXMOX_SIM_IP } },
538
601
  volumes: ['/var/run/docker.sock:/var/run/docker.sock'],
539
602
  // Compose substitutes this at up-time from `-p`, so the generator does not
@@ -592,14 +655,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
592
655
 
593
656
  // Pick the management image based on variant. Both branches use
594
657
  // `image:` (not `build:`) so docker compose uses the existing tag —
595
- // critical, because `cele2e build-infra` runs install.sh and
596
- // `docker commit`s the result onto :latest (Phase 2 bake step). If
597
- // we used `build: { Dockerfile.management }` here, every test's
598
- // streamingBuild would rebuild from the Dockerfile and OVERWRITE
599
- // the bake commit, destroying the post-install.sh image. Confirmed
600
- // bitten on 2026-05-06: caddy-custom-hostname test failed with
601
- // `celilo: command not found` because the compose rebuild had
602
- // wiped install.sh's output.
658
+ // critical for two reasons. First, `cele2e build-infra` runs
659
+ // install.sh and `docker commit`s the result onto :latest (Phase 2
660
+ // bake step). If compose rebuilt from Dockerfile.management it would
661
+ // OVERWRITE the bake commit, destroying the post-install.sh image.
662
+ // Confirmed bitten on 2026-05-06: caddy-custom-hostname test failed
663
+ // with `celilo: command not found` because the compose rebuild had
664
+ // wiped install.sh's output. Second, no compose anywhere may carry a
665
+ // `build:` section: a suite-time build resolves FROM from docker.io
666
+ // and fetches over the network (tasks.md 5b.2). Every generated
667
+ // compose is image-references-only; the gate is
668
+ // tests/composes-reference-baked-images.test.ts.
603
669
  //
604
670
  // :vanilla — bun + unzip + bunfig, no celilo. Used by the
605
671
  // install-sh regression test.
@@ -683,6 +749,14 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
683
749
  DEFAULT_GATEWAY: zone === 'secure-mgmt' ? SECURE_MGMT_GATEWAY : managementDefaultGw,
684
750
  ...(zone === 'secure-mgmt' ? { FW_MAIN_HOP: SECURE_MGMT_GATEWAY } : {}),
685
751
  CELILO_REGISTRY_URL: 'http://e2e-registry.lab',
752
+ // `cele2e run --source-cli` sets this, and the image's shim reads it to
753
+ // run the mounted workspace instead of the CLI install.sh installed.
754
+ // Off by default: the installed CLI is the artifact under test, and
755
+ // running the source over the bind mount roughly doubles a celilo
756
+ // command's start-up (see the shim's comment in bin/e2e-bake-management).
757
+ ...(process.env.CELILO_E2E_SOURCE_CLI
758
+ ? { CELILO_E2E_SOURCE_CLI: process.env.CELILO_E2E_SOURCE_CLI }
759
+ : {}),
686
760
  },
687
761
  });
688
762
 
@@ -707,7 +781,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
707
781
  config.dmzMachines[0]?.ip ??
708
782
  zoneIp('dmz', 10);
709
783
  services['dns-int'] = baseService({
710
- build: { context: '.', dockerfile: 'docker/Dockerfile.internal-resolver' },
784
+ dockerfile: 'docker/Dockerfile.internal-resolver',
711
785
  networks: { internal: { ipv4_address: internalResolverIp() } },
712
786
  cap_add: ['NET_ADMIN'],
713
787
  environment: {
@@ -734,7 +808,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
734
808
 
735
809
  // --- Routing infrastructure (bridges per-test to shared infra) ---
736
810
  services['fw-ext'] = baseService({
737
- build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
811
+ dockerfile: 'docker/Dockerfile.router',
738
812
  networks: {
739
813
  'isp-external': { ipv4_address: '203.0.113.101' },
740
814
  'internet-external': { ipv4_address: '100.64.0.1' },
@@ -745,7 +819,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
745
819
  });
746
820
 
747
821
  services['comcast-resolver'] = baseService({
748
- build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
822
+ dockerfile: 'docker/Dockerfile.resolver',
749
823
  networks: {
750
824
  'isp-external': { ipv4_address: '203.0.113.1' },
751
825
  'real-internet': { ipv4_address: '172.30.0.4' },
@@ -780,7 +854,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
780
854
  }
781
855
 
782
856
  services[machine.name] = baseService({
783
- build: { context: '.', dockerfile },
857
+ dockerfile,
784
858
  networks: { [zoneNetwork]: { ipv4_address: machine.ip } },
785
859
  privileged: true,
786
860
  volumes: machineVolumes,
@@ -792,7 +866,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
792
866
  // --- Optional DHCP client ---
793
867
  if (config.dhcpClient) {
794
868
  services['dhcp-client'] = baseService({
795
- build: { context: '.', dockerfile: 'docker/Dockerfile.dhcp-client' },
869
+ dockerfile: 'docker/Dockerfile.dhcp-client',
796
870
  networks: { internal: { ipv4_address: zoneIp('internal', 210) } },
797
871
  cap_add: ['NET_ADMIN'],
798
872
  });
@@ -804,7 +878,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
804
878
  // on internet-external. Unlinked: the JSON-RPC surface needs no account.
805
879
  if (config.signalCli) {
806
880
  services['signal-cli'] = baseService({
807
- build: { context: '.', dockerfile: 'docker/Dockerfile.signal-cli' },
881
+ dockerfile: 'docker/Dockerfile.signal-cli',
808
882
  networks: { internal: { ipv4_address: zoneIp('internal', 90) } },
809
883
  cap_add: ['NET_ADMIN'],
810
884
  });
@@ -815,7 +889,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
815
889
  // module downloads FROM the internet at deploy time.
816
890
  if (config.signalRelease) {
817
891
  services['signal-release'] = baseService({
818
- build: { context: '.', dockerfile: 'docker/Dockerfile.signal-release' },
892
+ dockerfile: 'docker/Dockerfile.signal-release',
819
893
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.SIGNAL_RELEASE } },
820
894
  cap_add: ['NET_ADMIN'],
821
895
  });
@@ -826,7 +900,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
826
900
  // other without touching the module's configured endpoint.
827
901
  if (config.signalSim) {
828
902
  services['signal-sim'] = baseService({
829
- build: { context: '.', dockerfile: 'docker/Dockerfile.signal-sim' },
903
+ dockerfile: 'docker/Dockerfile.signal-sim',
830
904
  networks: { internal: { ipv4_address: zoneIp('internal', 91) } },
831
905
  cap_add: ['NET_ADMIN'],
832
906
  environment: {
@@ -848,7 +922,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
848
922
  for (const spec of normalizeObservers(config.observers ?? [])) {
849
923
  const p = observerPlacement(spec.vantage);
850
924
  services[p.service] = baseService({
851
- build: { context: '.', dockerfile: 'docker/Dockerfile.observer' },
925
+ dockerfile: 'docker/Dockerfile.observer',
852
926
  networks: { [p.network]: { ipv4_address: p.ip } },
853
927
  cap_add: ['NET_ADMIN'],
854
928
  // A vantage may dial a VPN and probe from INSIDE the tunnel — the only way
@@ -894,7 +968,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
894
968
  config.topology === 'direct-internet' ? ZONE_GATEWAYS.internal : greenwaveRouterIp();
895
969
 
896
970
  services.management = baseService({
897
- build: { context: '.', dockerfile: 'docker/Dockerfile.management' },
971
+ dockerfile: 'docker/Dockerfile.management',
898
972
  networks: { internal: { ipv4_address: zoneIp('internal', 100) } },
899
973
  cap_add: ['NET_ADMIN'],
900
974
  volumes: [
@@ -916,7 +990,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
916
990
  config.dmzMachines[0]?.ip ??
917
991
  zoneIp('dmz', 10);
918
992
  services['dns-int'] = baseService({
919
- build: { context: '.', dockerfile: 'docker/Dockerfile.internal-resolver' },
993
+ dockerfile: 'docker/Dockerfile.internal-resolver',
920
994
  networks: { internal: { ipv4_address: internalResolverIp() } },
921
995
  cap_add: ['NET_ADMIN'],
922
996
  environment: {
@@ -927,7 +1001,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
927
1001
  });
928
1002
 
929
1003
  services['fw-ext'] = baseService({
930
- build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
1004
+ dockerfile: 'docker/Dockerfile.router',
931
1005
  networks: {
932
1006
  'isp-external': { ipv4_address: '203.0.113.101' },
933
1007
  'internet-external': { ipv4_address: '100.64.0.1' },
@@ -938,7 +1012,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
938
1012
  });
939
1013
 
940
1014
  services['comcast-resolver'] = baseService({
941
- build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
1015
+ dockerfile: 'docker/Dockerfile.resolver',
942
1016
  networks: {
943
1017
  'isp-external': { ipv4_address: '203.0.113.1' },
944
1018
  'real-internet': { ipv4_address: '172.30.0.4' },
@@ -947,7 +1021,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
947
1021
  });
948
1022
 
949
1023
  services['root-dns'] = baseService({
950
- build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
1024
+ dockerfile: 'docker/Dockerfile.authoritative-dns',
951
1025
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.ROOT_DNS } },
952
1026
  cap_add: ['NET_ADMIN'],
953
1027
  volumes: [
@@ -957,7 +1031,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
957
1031
  });
958
1032
 
959
1033
  services['tld-dns'] = baseService({
960
- build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
1034
+ dockerfile: 'docker/Dockerfile.authoritative-dns',
961
1035
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.TLD_DNS } },
962
1036
  cap_add: ['NET_ADMIN'],
963
1037
  volumes: [
@@ -969,7 +1043,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
969
1043
  });
970
1044
 
971
1045
  services['namecheap-dns'] = baseService({
972
- build: { context: '.', dockerfile: 'docker/Dockerfile.namecheap-dns' },
1046
+ dockerfile: 'docker/Dockerfile.namecheap-dns',
973
1047
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.NAMECHEAP_DNS } },
974
1048
  cap_add: ['NET_ADMIN'],
975
1049
  // Per-domain DDNS passwords for the cross-domain test. Fallback
@@ -999,7 +1073,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
999
1073
  // cycle. See CLAUDE.md history for details.
1000
1074
 
1001
1075
  services.letsencrypt = baseService({
1002
- build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
1076
+ dockerfile: 'docker/Dockerfile.pebble',
1003
1077
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PEBBLE } },
1004
1078
  cap_add: ['NET_ADMIN'],
1005
1079
  command: '-config /config/pebble-config.json -dnsserver 203.0.113.1:53',
@@ -1015,7 +1089,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
1015
1089
  // the package (bundled at build time from packages/registry-server,
1016
1090
  // shipped in the npm tarball). See packages/e2e/src/registry-bundle.ts.
1017
1091
  services.registry = baseService({
1018
- build: { context: '.', dockerfile: 'docker/Dockerfile.registry' },
1092
+ dockerfile: 'docker/Dockerfile.registry',
1019
1093
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.REGISTRY } },
1020
1094
  cap_add: ['NET_ADMIN'],
1021
1095
  volumes: getRegistryVolumes(),
@@ -1045,7 +1119,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
1045
1119
  }
1046
1120
 
1047
1121
  services[machine.name] = baseService({
1048
- build: { context: '.', dockerfile },
1122
+ dockerfile,
1049
1123
  networks: { [zoneNetwork]: { ipv4_address: machine.ip } },
1050
1124
  privileged: true,
1051
1125
  volumes: machineVolumes,
@@ -1056,7 +1130,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
1056
1130
 
1057
1131
  if (config.dhcpClient) {
1058
1132
  services['dhcp-client'] = baseService({
1059
- build: { context: '.', dockerfile: 'docker/Dockerfile.dhcp-client' },
1133
+ dockerfile: 'docker/Dockerfile.dhcp-client',
1060
1134
  networks: { internal: { ipv4_address: zoneIp('internal', 210) } },
1061
1135
  cap_add: ['NET_ADMIN'],
1062
1136
  });