@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.
- package/README.md +5 -5
- package/bin/e2e-status +1 -1
- package/bin/e2e-up +2 -2
- package/config/dns/tangohost.com.zone +1 -1
- package/config/dns/templates/example.net.zone +3 -3
- package/config/dns/templates/iamtheinternet.org.zone +2 -2
- package/config/resolver/unbound.conf +4 -0
- package/config/routing/fw-ext-routes.sh +8 -8
- package/config/routing/fw-isp-routes.sh +2 -2
- package/config/routing/fw-main-routes.sh +5 -5
- package/config/routing/management-routes.sh +1 -1
- package/config/routing/observer-setup.sh +1 -1
- package/config/routing/public-resolver-routes.sh +33 -0
- package/config/routing/public-sim-entrypoint.sh +1 -1
- package/config/routing/resolver-internal-routes.sh +7 -2
- package/config/routing/resolver-routes.sh +1 -1
- package/config/routing/target-routes.sh +1 -1
- package/config/routing/target-setup.sh +1 -1
- package/config/socks/startup.sh +2 -2
- package/docker/Dockerfile.ip-echo +20 -0
- package/docker/Dockerfile.resolver +5 -1
- package/package.json +5 -4
- package/simulators/greenwave/server.ts +35 -0
- package/simulators/greenwave/state.ts +79 -7
- package/simulators/ip-echo/server.ts +76 -0
- package/src/address-plan.test.ts +4 -4
- package/src/cli/build.ts +30 -0
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/completion.ts +16 -1
- package/src/cli/index.ts +85 -4
- package/src/container-manager.ts +75 -12
- package/src/docker-compose-generator.ts +97 -29
- package/src/doctor.test.ts +279 -0
- package/src/doctor.ts +421 -0
- package/src/extract-failure.ts +41 -0
- package/src/index.ts +12 -0
- package/src/last-run.test.ts +62 -0
- package/src/last-run.ts +54 -0
- package/src/network-builder.ts +11 -0
- package/src/observer.test.ts +2 -2
- package/src/observer.ts +1 -1
- package/src/router-swap.ts +121 -0
- package/src/run-lock.test.ts +73 -2
- package/src/run-lock.ts +110 -14
- package/src/runner.ts +99 -5
- package/src/simulator-ips.ts +21 -0
- package/src/socks-proxy.ts +2 -2
- package/src/types.ts +34 -4
- package/src/vantage.test.ts +1 -1
- package/src/zone-classifier.test.ts +11 -16
- 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
|
-
|
|
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: '
|
|
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<
|
|
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
|
|
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
|
|
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: ['
|
|
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: ['
|
|
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('
|
|
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: '
|
|
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: '
|
|
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('
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
+
});
|