@celilo/e2e 0.11.3 → 0.13.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 (51) hide show
  1. package/README.md +5 -5
  2. package/bin/e2e-status +1 -1
  3. package/bin/e2e-up +2 -2
  4. package/config/dns/tangohost.com.zone +1 -1
  5. package/config/dns/templates/example.net.zone +3 -3
  6. package/config/dns/templates/iamtheinternet.org.zone +2 -2
  7. package/config/resolver/unbound.conf +4 -0
  8. package/config/routing/fw-ext-routes.sh +8 -8
  9. package/config/routing/fw-isp-routes.sh +2 -2
  10. package/config/routing/fw-main-routes.sh +5 -5
  11. package/config/routing/management-routes.sh +1 -1
  12. package/config/routing/observer-setup.sh +1 -1
  13. package/config/routing/public-resolver-routes.sh +33 -0
  14. package/config/routing/public-sim-entrypoint.sh +1 -1
  15. package/config/routing/resolver-internal-routes.sh +7 -2
  16. package/config/routing/resolver-routes.sh +1 -1
  17. package/config/routing/target-routes.sh +1 -1
  18. package/config/routing/target-setup.sh +1 -1
  19. package/config/socks/startup.sh +2 -2
  20. package/docker/Dockerfile.ip-echo +20 -0
  21. package/docker/Dockerfile.resolver +5 -1
  22. package/package.json +5 -4
  23. package/simulators/greenwave/server.ts +35 -0
  24. package/simulators/greenwave/state.ts +79 -7
  25. package/simulators/ip-echo/server.ts +76 -0
  26. package/src/address-plan.test.ts +4 -4
  27. package/src/cli/build.ts +30 -0
  28. package/src/cli/command-registry.ts +19 -0
  29. package/src/cli/completion.ts +16 -1
  30. package/src/cli/index.ts +85 -4
  31. package/src/container-manager.ts +75 -12
  32. package/src/docker-compose-generator.ts +97 -29
  33. package/src/doctor.test.ts +279 -0
  34. package/src/doctor.ts +421 -0
  35. package/src/extract-failure.ts +41 -0
  36. package/src/index.ts +12 -0
  37. package/src/last-run.test.ts +62 -0
  38. package/src/last-run.ts +54 -0
  39. package/src/network-builder.ts +11 -0
  40. package/src/observer.test.ts +2 -2
  41. package/src/observer.ts +1 -1
  42. package/src/router-swap.ts +121 -0
  43. package/src/run-lock.test.ts +73 -2
  44. package/src/run-lock.ts +110 -14
  45. package/src/runner.ts +99 -5
  46. package/src/simulator-ips.ts +21 -0
  47. package/src/socks-proxy.ts +2 -2
  48. package/src/types.ts +34 -4
  49. package/src/vantage.test.ts +1 -1
  50. package/src/zone-classifier.test.ts +11 -16
  51. package/src/zone-classifier.ts +16 -31
@@ -7,6 +7,7 @@ import type { MachineSpec, NetworkConfig, TopologyPreset, Zone } from './types';
7
7
  import {
8
8
  ZONE_GATEWAYS,
9
9
  ZONE_SUBNETS,
10
+ externalWanIp,
10
11
  greenwaveRouterIp,
11
12
  internalResolverIp,
12
13
  zoneIp,
@@ -146,16 +147,50 @@ const PROTECTED_SUBNETS = (['dmz', 'app', 'secure', 'secure-mgmt'] as const)
146
147
  .map((zone) => ZONE_SUBNETS[zone])
147
148
  .join(' ');
148
149
 
149
- function defaultTopologyServices(): Record<string, unknown> {
150
+ /**
151
+ * The zone legs fw-main actually holds, per topology — the SINGLE source of truth
152
+ * for "which networks is the customer firewall wired to".
153
+ *
154
+ * This exists because `deployFirewall`'s `opts.zones` was doing two jobs at once:
155
+ * saying which zones get `provided_networks` written to system config, AND
156
+ * standing in as a claim about the box's hardware. It was only ever the first.
157
+ * The compose file below is what decides the second, and roughly twenty call
158
+ * sites passed `['dmz']` while the generator wired four legs regardless — so
159
+ * every one of them under-declared, silently.
160
+ *
161
+ * Both the compose services and `deployFirewall` read this, so a test cannot
162
+ * under-declare by omission and a new leg cannot be added in one place only.
163
+ * `packages/e2e/tests/firewall-zone-legs.test.ts` holds them to each other.
164
+ */
165
+ export type FirewallLeg = Zone | 'external';
166
+
167
+ export function firewallZoneLegs(topology: TopologyPreset): FirewallLeg[] {
168
+ const segmented: FirewallLeg[] = ['internal', 'dmz', 'app', 'secure'];
169
+ return topology === 'direct-internet' ? [...segmented, 'external'] : segmented;
170
+ }
171
+
172
+ /**
173
+ * The compose `networks:` block for fw-main, derived from {@link firewallZoneLegs}
174
+ * so the wiring and the declaration cannot drift. `external` is the public leg and
175
+ * lives on the `isp-external` docker network rather than a zone-named one.
176
+ */
177
+ function firewallNetworks(topology: TopologyPreset): Record<string, { ipv4_address: string }> {
178
+ const nets: Record<string, { ipv4_address: string }> = {};
179
+ for (const zone of firewallZoneLegs(topology)) {
180
+ if (zone === 'external') {
181
+ nets['isp-external'] = { ipv4_address: externalWanIp() };
182
+ } else {
183
+ nets[zone] = { ipv4_address: ZONE_GATEWAYS[zone] };
184
+ }
185
+ }
186
+ return nets;
187
+ }
188
+
189
+ function defaultTopologyServices(config: NetworkConfig): Record<string, unknown> {
150
190
  return {
151
191
  'fw-main': baseService({
152
192
  build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
153
- networks: {
154
- internal: { ipv4_address: ZONE_GATEWAYS.internal },
155
- dmz: { ipv4_address: ZONE_GATEWAYS.dmz },
156
- app: { ipv4_address: ZONE_GATEWAYS.app },
157
- secure: { ipv4_address: ZONE_GATEWAYS.secure },
158
- },
193
+ networks: firewallNetworks('default'),
159
194
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
160
195
  // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
161
196
  // module owns). wireguard-go needs the tun device; the kernel module is
@@ -169,7 +204,7 @@ function defaultTopologyServices(): Record<string, unknown> {
169
204
  build: { context: '.', dockerfile: 'docker/Dockerfile.greenwave-sim' },
170
205
  networks: {
171
206
  internal: { ipv4_address: greenwaveRouterIp() },
172
- 'isp-external': { ipv4_address: '100.100.0.100' },
207
+ 'isp-external': { ipv4_address: '203.0.113.100' },
173
208
  },
174
209
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
175
210
  // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
@@ -178,6 +213,9 @@ function defaultTopologyServices(): Record<string, unknown> {
178
213
  devices: ['/dev/net/tun'],
179
214
  sysctls: ROUTER_SYSCTLS,
180
215
  volumes: ['ssh-keys:/ssh-keys:ro'],
216
+ // Which real ISP router this sim stands in for. The protocol is identical
217
+ // across the two; only the TR-181 vendor extension prefix differs.
218
+ environment: { ROUTER_VENDOR_PREFIX: config.routerVendorPrefix ?? 'X_GWS_' },
181
219
  }),
182
220
  };
183
221
  }
@@ -191,13 +229,7 @@ function directInternetTopologyServices(): Record<string, unknown> {
191
229
  return {
192
230
  'fw-main': baseService({
193
231
  build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
194
- networks: {
195
- internal: { ipv4_address: ZONE_GATEWAYS.internal },
196
- dmz: { ipv4_address: ZONE_GATEWAYS.dmz },
197
- app: { ipv4_address: ZONE_GATEWAYS.app },
198
- secure: { ipv4_address: ZONE_GATEWAYS.secure },
199
- 'isp-external': { ipv4_address: '100.100.0.100' },
200
- },
232
+ networks: firewallNetworks('direct-internet'),
201
233
  cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
202
234
  // A firewall may also terminate a VPN (the admin tunnel the `wireguard`
203
235
  // module owns). wireguard-go needs the tun device; the kernel module is
@@ -211,7 +243,10 @@ function directInternetTopologyServices(): Record<string, unknown> {
211
243
  };
212
244
  }
213
245
 
214
- const TOPOLOGY_SERVICES: Record<TopologyPreset, () => Record<string, unknown>> = {
246
+ const TOPOLOGY_SERVICES: Record<
247
+ TopologyPreset,
248
+ (config: NetworkConfig) => Record<string, unknown>
249
+ > = {
215
250
  default: defaultTopologyServices,
216
251
  'direct-internet': directInternetTopologyServices,
217
252
  };
@@ -296,7 +331,7 @@ export function generateSharedInfraYaml(): string {
296
331
  build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
297
332
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PEBBLE } },
298
333
  cap_add: ['NET_ADMIN'],
299
- command: '-config /config/pebble-config.json -dnsserver 100.100.0.1:53',
334
+ command: '-config /config/pebble-config.json -dnsserver 203.0.113.1:53',
300
335
  volumes: [
301
336
  './config/pebble/pebble-config.json:/config/pebble-config.json:ro',
302
337
  './config/pebble/pebble-tls.crt:/config/pebble-tls.crt:ro',
@@ -315,16 +350,49 @@ export function generateSharedInfraYaml(): string {
315
350
  volumes: getRegistryVolumes(),
316
351
  });
317
352
 
353
+ // OFF-FLEET recursive resolver — the rig's 1.1.1.1, and the vantage point
354
+ // celilo's `public_dns` check resolves from.
355
+ //
356
+ // A peer of comcast-resolver, not a replacement: the fleet is configured to
357
+ // use THAT one (`system init` writes dns.primary=203.0.113.1), and celilo
358
+ // refuses to accept a resolver it already asks as evidence about the public
359
+ // internet — a check that used the fleet's own resolver would pass whatever
360
+ // the internet sees, which is the celilo#626 blindness one layer up. So the
361
+ // simulation needs two independent public resolvers, exactly as the real
362
+ // world has your ISP's and Cloudflare's.
363
+ //
364
+ // Same image as comcast-resolver (identical unbound.conf → identical
365
+ // authoritative answers) with a different startup: this one sits ON
366
+ // internet-external with the authoritative servers, so it needs no route
367
+ // through fw-ext to reach them.
368
+ services['public-resolver'] = baseService({
369
+ build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
370
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PUBLIC_RESOLVER } },
371
+ cap_add: ['NET_ADMIN'],
372
+ command: '/public-resolver-startup.sh',
373
+ });
374
+
375
+ // IP echo service — mimics api.ipify.org. Reports the source address a
376
+ // request appears to come from, which for the customer fleet is the
377
+ // firewall's external address after SNAT. This is where the `public_dns`
378
+ // check's EXPECTATION comes from; comparing public DNS against the
379
+ // registrar's own response would be self-agreement.
380
+ services['ip-echo'] = baseService({
381
+ build: { context: '.', dockerfile: 'docker/Dockerfile.ip-echo' },
382
+ networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.IP_ECHO } },
383
+ cap_add: ['NET_ADMIN'],
384
+ });
385
+
318
386
  // External website prober simulator — mimics isitup.org's /api.json endpoint.
319
387
  // Sits on internet-external so probe requests exercise the real public-facing
320
388
  // path (target domain → DNS → ISP → firewall → Caddy). Uses comcast-resolver
321
- // at 100.100.0.1 as DNS so it can recursively resolve the simulated public
389
+ // at 203.0.113.1 as DNS so it can recursively resolve the simulated public
322
390
  // zones (e.g. iamtheinternet.org).
323
391
  services.isitup = baseService({
324
392
  build: { context: '.', dockerfile: 'docker/Dockerfile.isitup' },
325
393
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.ISITUP } },
326
394
  cap_add: ['NET_ADMIN'],
327
- dns: ['100.100.0.1'],
395
+ dns: ['203.0.113.1'],
328
396
  });
329
397
 
330
398
  // celilo.computer static site simulator — serves install.sh and the docs
@@ -336,7 +404,7 @@ export function generateSharedInfraYaml(): string {
336
404
  build: { context: '.', dockerfile: 'docker/Dockerfile.celilo-website-sim' },
337
405
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.WEBSITE } },
338
406
  cap_add: ['NET_ADMIN'],
339
- dns: ['100.100.0.1'],
407
+ dns: ['203.0.113.1'],
340
408
  });
341
409
 
342
410
  // npm-compat registry simulator — serves @celilo/* tarballs to install.sh's
@@ -413,7 +481,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
413
481
  // Only when celilo-mgr lives off the internal LAN — keeps the default
414
482
  // topology's generated compose byte-identical for every existing test.
415
483
  ...(needsSecureMgmt ? { 'secure-mgmt': zoneNetworkDef('secure-mgmt') } : {}),
416
- 'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
484
+ 'isp-external': networkDef('203.0.113.0/24', '203.0.113.250'),
417
485
  // internet-external is owned by shared infra; real-internet is per-test
418
486
  // (it just bridges fw-ext ↔ comcast-resolver, no shared service uses it).
419
487
  // real-internet uses Docker's default gateway (.1) because fw-ext's
@@ -532,7 +600,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
532
600
 
533
601
  // --- Topology-specific infrastructure (south of fw-ext) ---
534
602
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
535
- Object.assign(services, topologyFn());
603
+ Object.assign(services, topologyFn(config));
536
604
 
537
605
  // Give fw-main a leg on the control-plane network when celilo-mgr lives there,
538
606
  // so the management box is routed like any other segmented network rather than
@@ -549,7 +617,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
549
617
  services['fw-ext'] = baseService({
550
618
  build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
551
619
  networks: {
552
- 'isp-external': { ipv4_address: '100.100.0.101' },
620
+ 'isp-external': { ipv4_address: '203.0.113.101' },
553
621
  'internet-external': { ipv4_address: '100.64.0.1' },
554
622
  'real-internet': { ipv4_address: '172.30.0.5' },
555
623
  },
@@ -560,7 +628,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
560
628
  services['comcast-resolver'] = baseService({
561
629
  build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
562
630
  networks: {
563
- 'isp-external': { ipv4_address: '100.100.0.1' },
631
+ 'isp-external': { ipv4_address: '203.0.113.1' },
564
632
  'real-internet': { ipv4_address: '172.30.0.4' },
565
633
  },
566
634
  cap_add: ['NET_ADMIN'],
@@ -681,7 +749,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
681
749
  dmz: zoneNetworkDef('dmz'),
682
750
  app: zoneNetworkDef('app'),
683
751
  secure: zoneNetworkDef('secure'),
684
- 'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
752
+ 'isp-external': networkDef('203.0.113.0/24', '203.0.113.250'),
685
753
  'internet-external': networkDef('100.64.0.0/24', '100.64.0.250'),
686
754
  'real-internet': {
687
755
  driver: 'bridge',
@@ -715,7 +783,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
715
783
  });
716
784
 
717
785
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
718
- Object.assign(services, topologyFn());
786
+ Object.assign(services, topologyFn(config));
719
787
 
720
788
  const legacyCaddyIp =
721
789
  config.dmzMachines.find((m) => m.name === 'caddy')?.ip ??
@@ -735,7 +803,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
735
803
  services['fw-ext'] = baseService({
736
804
  build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
737
805
  networks: {
738
- 'isp-external': { ipv4_address: '100.100.0.101' },
806
+ 'isp-external': { ipv4_address: '203.0.113.101' },
739
807
  'internet-external': { ipv4_address: '100.64.0.1' },
740
808
  'real-internet': { ipv4_address: '172.30.0.5' },
741
809
  },
@@ -746,7 +814,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
746
814
  services['comcast-resolver'] = baseService({
747
815
  build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
748
816
  networks: {
749
- 'isp-external': { ipv4_address: '100.100.0.1' },
817
+ 'isp-external': { ipv4_address: '203.0.113.1' },
750
818
  'real-internet': { ipv4_address: '172.30.0.4' },
751
819
  },
752
820
  cap_add: ['NET_ADMIN'],
@@ -808,7 +876,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
808
876
  build: { context: '.', dockerfile: 'docker/Dockerfile.pebble' },
809
877
  networks: { 'internet-external': { ipv4_address: SIMULATOR_IPS.PEBBLE } },
810
878
  cap_add: ['NET_ADMIN'],
811
- command: '-config /config/pebble-config.json -dnsserver 100.100.0.1:53',
879
+ command: '-config /config/pebble-config.json -dnsserver 203.0.113.1:53',
812
880
  volumes: [
813
881
  './config/pebble/pebble-config.json:/config/pebble-config.json:ro',
814
882
  './config/pebble/pebble-tls.crt:/config/pebble-tls.crt:ro',
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Recurrence gates for the cele2e preflight.
3
+ *
4
+ * Each of these conditions was, at least once, diagnosed as a product bug: the
5
+ * mistake was silent and the failure surfaced later and somewhere unrelated.
6
+ * Every test here breaks ONE condition deliberately and asserts the specific
7
+ * message the operator gets — per Rule 7.6, a gate nobody has seen fail is not
8
+ * a gate, so each assertion is written against the broken state, not the happy
9
+ * one.
10
+ */
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+ import {
14
+ BAKED_MANAGEMENT_IMAGES,
15
+ type DockerfileBases,
16
+ type DoctorProbe,
17
+ EXPECTED_MANAGEMENT_CMD,
18
+ checkBaseImages,
19
+ checkDiskPressure,
20
+ checkManagementImage,
21
+ checkRunLock,
22
+ checkStaleContainers,
23
+ diagnose,
24
+ explainBuildFailure,
25
+ parseBaseImages,
26
+ } from './doctor';
27
+ import type { LockHolder, LockStatus } from './run-lock';
28
+
29
+ /** A probe where everything is healthy; each test breaks exactly one thing. */
30
+ function healthyProbe(overrides: Partial<DoctorProbe> = {}): DoctorProbe {
31
+ return {
32
+ imageExists: () => true,
33
+ imageCmd: () => [EXPECTED_MANAGEMENT_CMD],
34
+ staleContainers: () => [],
35
+ reclaimableImageBytes: () => 0,
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function holder(overrides: Partial<LockHolder> = {}): LockHolder {
41
+ return {
42
+ pid: 4242,
43
+ hostname: 'testhost',
44
+ session: 'branch (/work/tree)',
45
+ test: 'caddy-direct-internet',
46
+ runId: 'r1',
47
+ startedAt: new Date().toISOString(),
48
+ beatAt: Date.now(),
49
+ state: 'running',
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ function lockState(overrides: Partial<LockStatus> = {}): LockStatus {
55
+ return {
56
+ free: true,
57
+ holder: null,
58
+ heartbeatAgeMs: null,
59
+ suspect: false,
60
+ ownKept: false,
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ describe('management image (problem 1: the bake fails as an SSH error)', () => {
66
+ test('a missing baked image fails preflight naming build-infra as the fix', () => {
67
+ const check = checkManagementImage(
68
+ healthyProbe({ imageExists: (ref) => ref !== 'celilo-e2e/management:latest' }),
69
+ );
70
+ expect(check.status).toBe('fail');
71
+ expect(check.detail).toContain('celilo-e2e/management:latest');
72
+ expect(check.detail).toContain('cannot build');
73
+ expect(check.remedy).toBe('cele2e build-infra');
74
+ });
75
+
76
+ test('a bake that lost its Cmd fails, and says so as the SSH error it would become', () => {
77
+ // The bake container runs `sleep infinity`; docker commit persists that Cmd
78
+ // unless overridden. Without /startup.sh the ssh-keys volume is never
79
+ // populated and `machine add` dies with a connection error against a
80
+ // firewall IP — which reads as a network bug and is not one.
81
+ const check = checkManagementImage(healthyProbe({ imageCmd: () => ['sleep', 'infinity'] }));
82
+ expect(check.status).toBe('fail');
83
+ expect(check.detail).toContain(EXPECTED_MANAGEMENT_CMD);
84
+ expect(check.detail).toContain('ssh-keys');
85
+ expect(check.detail).toContain('Cannot connect to root@');
86
+ expect(check.remedy).toBe('cele2e build-infra');
87
+ });
88
+
89
+ test('both images present and correctly baked passes', () => {
90
+ const check = checkManagementImage(healthyProbe());
91
+ expect(check.status).toBe('ok');
92
+ expect(check.detail).toContain(String(BAKED_MANAGEMENT_IMAGES.length));
93
+ });
94
+ });
95
+
96
+ describe('base images (problem 3: a missing image reads as a network failure)', () => {
97
+ const dockerfiles: DockerfileBases[] = [
98
+ { derived: 'celilo-e2e/observer', bases: ['ubuntu:22.04'] },
99
+ { derived: 'celilo-e2e/pebble', bases: ['ghcr.io/letsencrypt/pebble:latest'] },
100
+ ];
101
+
102
+ test('a base image missing with its derived image ALSO missing fails, with the pull', () => {
103
+ // Post `docker image prune`: nothing local, so the build must go to a
104
+ // registry — the exact state that produced "failed to solve: ubuntu:22.04:
105
+ // net/http: TLS handshake timeout" 16 images into a rebuild.
106
+ const check = checkBaseImages(healthyProbe({ imageExists: () => false }), dockerfiles);
107
+ expect(check.status).toBe('fail');
108
+ expect(check.detail).toContain('ubuntu:22.04');
109
+ expect(check.remedy).toContain('docker pull ubuntu:22.04');
110
+ expect(check.remedy).toContain('docker pull ghcr.io/letsencrypt/pebble:latest');
111
+ });
112
+
113
+ test('a base image missing but its derived image present passes — the build is cached', () => {
114
+ // This is the ordinary steady state on a working machine, and a check that
115
+ // failed here would refuse environments that run fine today.
116
+ const check = checkBaseImages(
117
+ healthyProbe({ imageExists: (ref) => ref.startsWith('celilo-e2e/') }),
118
+ dockerfiles,
119
+ );
120
+ expect(check.status).toBe('ok');
121
+ });
122
+
123
+ test('only the images that actually need fetching are named', () => {
124
+ const check = checkBaseImages(
125
+ healthyProbe({
126
+ imageExists: (ref) => ref !== 'ubuntu:22.04' && ref !== 'celilo-e2e/observer',
127
+ }),
128
+ dockerfiles,
129
+ );
130
+ expect(check.status).toBe('fail');
131
+ expect(check.detail).toContain('ubuntu:22.04');
132
+ expect(check.detail).not.toContain('pebble');
133
+ });
134
+ });
135
+
136
+ describe('run-lock (problems 2 and 5)', () => {
137
+ test('a stale heartbeat with a live PID is SUSPECT, not merely busy', () => {
138
+ // The one hang PID-liveness cannot see: build-infra wedged at step [16/27],
139
+ // process alive and sleeping, heartbeat 1913s stale. Nothing surfaced it.
140
+ const check = checkRunLock(
141
+ lockState({
142
+ free: false,
143
+ holder: holder({ beatAt: Date.now() - 1_913_000 }),
144
+ heartbeatAgeMs: 1_913_000,
145
+ suspect: true,
146
+ }),
147
+ );
148
+ expect(check.status).toBe('fail');
149
+ expect(check.detail).toContain('SUSPECT');
150
+ expect(check.detail).toContain('31m');
151
+ expect(check.detail).toContain('not progressing');
152
+ expect(check.remedy).toContain('4242');
153
+ });
154
+
155
+ test("this session's own kept lock is a warning, not contention", () => {
156
+ const check = checkRunLock(
157
+ lockState({
158
+ free: false,
159
+ holder: holder({ state: 'kept' }),
160
+ heartbeatAgeMs: 0,
161
+ ownKept: true,
162
+ }),
163
+ );
164
+ expect(check.status).toBe('warn');
165
+ expect(check.detail).toContain('auto-releases');
166
+ });
167
+
168
+ test("another session's kept lock still fails", () => {
169
+ const check = checkRunLock(
170
+ lockState({ free: false, holder: holder({ state: 'kept' }), heartbeatAgeMs: 0 }),
171
+ );
172
+ expect(check.status).toBe('fail');
173
+ expect(check.detail).toContain('another session');
174
+ });
175
+
176
+ test('a healthy busy lock reports the heartbeat age', () => {
177
+ const check = checkRunLock(
178
+ lockState({ free: false, holder: holder(), heartbeatAgeMs: 11_000 }),
179
+ );
180
+ expect(check.status).toBe('fail');
181
+ expect(check.detail).toContain('11s ago');
182
+ expect(check.detail).not.toContain('SUSPECT');
183
+ });
184
+ });
185
+
186
+ describe('stale containers and disk pressure', () => {
187
+ test('leftover containers with no live run warn and point at `cele2e down`', () => {
188
+ const check = checkStaleContainers(
189
+ healthyProbe({ staleContainers: () => ['celilo-e2e-1_management', 'celilo-e2e-1_fw-main'] }),
190
+ lockState(),
191
+ );
192
+ expect(check.status).toBe('warn');
193
+ expect(check.remedy).toContain('cele2e down');
194
+ });
195
+
196
+ test('containers belonging to a live holder are not debris', () => {
197
+ const check = checkStaleContainers(
198
+ healthyProbe({ staleContainers: () => ['celilo-e2e-1_management'] }),
199
+ lockState({ free: false, holder: holder(), heartbeatAgeMs: 0 }),
200
+ );
201
+ expect(check.status).toBe('ok');
202
+ });
203
+
204
+ test('large reclaimable space steers away from `docker image prune`', () => {
205
+ const check = checkDiskPressure(healthyProbe({ reclaimableImageBytes: () => 40 * 1024 ** 3 }));
206
+ expect(check.status).toBe('warn');
207
+ expect(check.detail).toContain('docker image prune');
208
+ expect(check.remedy).toContain('name=celilo-e2e');
209
+ });
210
+
211
+ test('disk pressure never blocks a run', () => {
212
+ const report = diagnose({
213
+ pkgDir: '/nonexistent',
214
+ probe: healthyProbe({ reclaimableImageBytes: () => 99 * 1024 ** 3 }),
215
+ lock: lockState(),
216
+ });
217
+ expect(report.checks.find((c) => c.name === 'disk')?.status).toBe('warn');
218
+ expect(report.ok).toBe(true);
219
+ });
220
+ });
221
+
222
+ describe('Dockerfile base parsing', () => {
223
+ test("ignores references to the Dockerfile's own build stages", () => {
224
+ const bases = parseBaseImages(
225
+ [
226
+ 'FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS fetch',
227
+ 'RUN apt-get update',
228
+ 'FROM --platform=linux/amd64 eclipse-temurin:25-jre',
229
+ 'COPY --from=fetch /out /out',
230
+ ].join('\n'),
231
+ );
232
+ expect(bases).toEqual(['debian:bookworm-slim', 'eclipse-temurin:25-jre']);
233
+ });
234
+
235
+ test('a plain single-stage Dockerfile yields its one base', () => {
236
+ expect(parseBaseImages('FROM ubuntu:22.04\nRUN echo hi\n')).toEqual(['ubuntu:22.04']);
237
+ });
238
+ });
239
+
240
+ describe('build failure translation', () => {
241
+ test('a "failed to solve: <image>" is explained as a missing local image', () => {
242
+ const text = explainBuildFailure(
243
+ 'ERROR: failed to solve: ubuntu:22.04: failed to authorize: ... net/http: TLS handshake timeout',
244
+ );
245
+ expect(text).toContain('not in the local image store');
246
+ expect(text).toContain('docker pull ubuntu:22.04');
247
+ });
248
+
249
+ test('a genuine RUN-step failure is left alone', () => {
250
+ expect(
251
+ explainBuildFailure(
252
+ 'ERROR: failed to solve: process "/bin/sh -c apt-get update" did not complete successfully: exit code: 100',
253
+ ),
254
+ ).toBe('');
255
+ });
256
+ });
257
+
258
+ describe('diagnose', () => {
259
+ test('any failing check makes the whole report not ok', () => {
260
+ const report = diagnose({
261
+ pkgDir: '/nonexistent',
262
+ probe: healthyProbe({ imageExists: () => false }),
263
+ lock: lockState(),
264
+ });
265
+ expect(report.ok).toBe(false);
266
+ expect(report.checks.find((c) => c.name === 'management-image')?.status).toBe('fail');
267
+ });
268
+
269
+ test('skipLock omits the lock check — run holds the lock itself', () => {
270
+ const report = diagnose({
271
+ pkgDir: '/nonexistent',
272
+ probe: healthyProbe(),
273
+ lock: lockState({ free: false, holder: holder(), heartbeatAgeMs: 0 }),
274
+ skipLock: true,
275
+ });
276
+ expect(report.checks.some((c) => c.name === 'run-lock')).toBe(false);
277
+ expect(report.ok).toBe(true);
278
+ });
279
+ });