@celilo/e2e 0.10.4 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -28
- package/bin/e2e-status +1 -1
- package/bin/e2e-up +14 -14
- package/config/dhcp/dnsmasq.conf +3 -3
- package/config/resolver/unbound.conf +2 -2
- package/config/routing/fw-ext-routes.sh +4 -4
- package/config/routing/fw-isp-routes.sh +3 -3
- package/config/routing/fw-main-routes.sh +15 -4
- package/config/routing/management-routes.sh +11 -11
- package/config/routing/observer-setup.sh +3 -3
- package/config/routing/resolver-internal-routes.sh +5 -5
- package/config/routing/target-routes.sh +1 -1
- package/config/routing/target-setup.sh +18 -16
- package/config/socks/startup.sh +3 -3
- package/docker/Dockerfile.target-machine +15 -1
- package/docker/Dockerfile.target-machine-docker +6 -0
- package/docker/Dockerfile.target-technitium +6 -0
- package/npm-registry-server/src/packument.test.ts +2 -5
- package/npm-registry-server/src/packument.ts +3 -3
- package/npm-registry-server/src/server.ts +1 -1
- package/package.json +3 -3
- package/registry-server/src/bootstrap.ts +1 -1
- package/registry-server/src/introspection.test.ts +8 -12
- package/registry-server/src/server.test.ts +2 -2
- package/registry-server/src/server.ts +1 -1
- package/registry-server/src/storage.test.ts +8 -4
- package/registry-server/src/storage.ts +1 -1
- package/registry-server/src/validation.ts +2 -4
- package/scripts/pack-celilo-packages.ts +1 -4
- package/scripts/stage-apt-repo.ts +1 -1
- package/scripts/stage-libsignal.ts +1 -3
- package/simulators/greenwave/server.ts +4 -2
- package/simulators/greenwave/state.ts +24 -17
- package/simulators/isitup/server.ts +1 -5
- package/simulators/namecheap-ddns/server.ts +2 -4
- package/simulators/namecheap-ddns/zone-updater.test.ts +3 -1
- package/simulators/namecheap-ddns/zone-updater.ts +6 -10
- package/simulators/signal-cli/server.ts +3 -1
- package/src/address-plan.test.ts +95 -0
- package/src/browser.ts +3 -3
- package/src/bus-events.ts +2 -2
- package/src/cli/command-tree-parser.ts +8 -2
- package/src/cli/completion.ts +5 -8
- package/src/cli/index.ts +9 -2
- package/src/cli/module-discovery.ts +3 -1
- package/src/cli/scaffold.ts +2 -2
- package/src/cli-version-contract.ts +5 -6
- package/src/container-manager.ts +25 -8
- package/src/docker-compose-generator.ts +67 -39
- package/src/extract-failure.ts +92 -0
- package/src/fixtures.ts +10 -17
- package/src/index.ts +2 -2
- package/src/network-builder.ts +14 -9
- package/src/observer.test.ts +50 -19
- package/src/observer.ts +15 -8
- package/src/registry-bundle.ts +1 -3
- package/src/run-lock.test.ts +2 -2
- package/src/run-lock.ts +19 -5
- package/src/runner.ts +9 -67
- package/src/shared-infra.ts +50 -22
- package/src/socks-proxy.ts +16 -19
- package/src/types.ts +67 -17
- package/src/vantage-helpers.ts +6 -2
- package/src/vantage.test.ts +94 -40
- package/src/vantage.ts +24 -17
- package/src/wait-for-run.ts +1 -1
- package/src/zone-classifier.test.ts +32 -21
- package/src/zone-classifier.ts +7 -6
package/src/container-manager.ts
CHANGED
|
@@ -186,18 +186,20 @@ process.on('SIGINT', () => {
|
|
|
186
186
|
// FAILURE path, where the test never reaches `handle.stop()`, teardown was
|
|
187
187
|
// never running and the per-test networks leaked.
|
|
188
188
|
//
|
|
189
|
-
// That leak
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
189
|
+
// That leak used not to be confined to the run that caused it. The sim's
|
|
190
|
+
// networks were numbered exactly like a real celilo fleet's zones, so on a host
|
|
191
|
+
// that lives in those subnets — celilo's own forgejo-builder does — a leaked
|
|
192
|
+
// bridge left a DUPLICATE route for a production prefix, and
|
|
193
|
+
// traffic from any later container to the real host at that address was routed
|
|
194
194
|
// into the dead simulated network and blackholed. That took out three
|
|
195
195
|
// consecutive release runs, each dying in `git fetch` after a ~132s connect
|
|
196
196
|
// timeout, long after the e2e run that stranded the network had ended.
|
|
197
197
|
//
|
|
198
|
-
// The
|
|
199
|
-
//
|
|
200
|
-
//
|
|
198
|
+
// The sim now lives in SIM_PRIVATE_SUPERNET (#539), so a leak can no longer
|
|
199
|
+
// collide with a fleet address — but a leak is still a leak. The start-of-run
|
|
200
|
+
// prefix sweep stays as the crash-recovery backstop (it is the only thing that
|
|
201
|
+
// can clean up after SIGKILL); this makes the common case clean up after itself
|
|
202
|
+
// instead of leaving containers and volumes behind. See #508.
|
|
201
203
|
process.on('exit', () => {
|
|
202
204
|
cleanupOnExit();
|
|
203
205
|
});
|
|
@@ -961,6 +963,21 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
961
963
|
// in ~2-5s).
|
|
962
964
|
60_000,
|
|
963
965
|
`${machine.name} target-setup`,
|
|
966
|
+
// Self-diagnosing via waitFor's onTimeout (e2e-confidence #255). This
|
|
967
|
+
// timeout has twice been read as a simulator or deployment bug when the
|
|
968
|
+
// real cause was inside the unit — target-setup fetching from a slow
|
|
969
|
+
// third party (#560). The unit's own journal says which line it is on.
|
|
970
|
+
() => {
|
|
971
|
+
const dump = (label: string, cmd: string): string => {
|
|
972
|
+
const r = dockerExec(projectName, composeDir, machine.name, cmd);
|
|
973
|
+
return `--- ${label} ---\n${(r.stdout || r.stderr || '(no output)').trim()}`;
|
|
974
|
+
};
|
|
975
|
+
return [
|
|
976
|
+
`=== target-setup diagnostics (${machine.name}) ===`,
|
|
977
|
+
dump('systemctl status', 'systemctl status target-setup --no-pager 2>&1 | head -20'),
|
|
978
|
+
dump('journal', 'journalctl -u target-setup --no-pager 2>&1 | tail -30'),
|
|
979
|
+
].join('\n');
|
|
980
|
+
},
|
|
964
981
|
);
|
|
965
982
|
}
|
|
966
983
|
|
|
@@ -3,8 +3,14 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { stringify } from 'yaml';
|
|
4
4
|
import { normalizeObservers, observerEnv, observerPlacement } from './observer';
|
|
5
5
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
6
|
-
import type { MachineSpec, NetworkConfig, TopologyPreset } from './types';
|
|
7
|
-
import {
|
|
6
|
+
import type { MachineSpec, NetworkConfig, TopologyPreset, Zone } from './types';
|
|
7
|
+
import {
|
|
8
|
+
ZONE_GATEWAYS,
|
|
9
|
+
ZONE_SUBNETS,
|
|
10
|
+
greenwaveRouterIp,
|
|
11
|
+
internalResolverIp,
|
|
12
|
+
zoneIp,
|
|
13
|
+
} from './types';
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Volumes for the registry container. Picks between source-bootstrap and
|
|
@@ -52,6 +58,15 @@ function networkDef(subnet: string, gateway: string) {
|
|
|
52
58
|
};
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
/**
|
|
62
|
+
* A zone's bridge, derived entirely from the address plan in `types.ts`. The
|
|
63
|
+
* bridge itself takes `.250` (Docker's own gateway address on the network — NOT
|
|
64
|
+
* the routing gateway, which is the firewall at `ZONE_GATEWAYS[zone]`).
|
|
65
|
+
*/
|
|
66
|
+
function zoneNetworkDef(zone: Zone) {
|
|
67
|
+
return networkDef(ZONE_SUBNETS[zone], zoneIp(zone, 250));
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
/**
|
|
56
71
|
* Image tag prefix for pre-built e2e images. When images with these
|
|
57
72
|
* tags exist locally, `docker compose build` skips rebuilding them.
|
|
@@ -117,19 +132,29 @@ export const SHARED_NETWORKS = {
|
|
|
117
132
|
* where celilo-mgr itself lives, mirroring the production topology the suite
|
|
118
133
|
* previously could not express.
|
|
119
134
|
*/
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
135
|
+
const SECURE_MGMT_GATEWAY = ZONE_GATEWAYS['secure-mgmt'];
|
|
136
|
+
const SECURE_MGMT_MANAGEMENT_IP = zoneIp('secure-mgmt', 100);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The zones fw-main routes between WITHOUT NAT, so the dmz-resident resolver
|
|
140
|
+
* sees each client's real zone (source-based split-horizon, ISS-0156).
|
|
141
|
+
* `internal` is deliberately absent: a LAN device's query must arrive SNAT'd to
|
|
142
|
+
* a zone-gateway address so it matches the passthrough view and gets the natIp
|
|
143
|
+
* answer, not the protected one.
|
|
144
|
+
*/
|
|
145
|
+
const PROTECTED_SUBNETS = (['dmz', 'app', 'secure', 'secure-mgmt'] as const)
|
|
146
|
+
.map((zone) => ZONE_SUBNETS[zone])
|
|
147
|
+
.join(' ');
|
|
123
148
|
|
|
124
149
|
function defaultTopologyServices(): Record<string, unknown> {
|
|
125
150
|
return {
|
|
126
151
|
'fw-main': baseService({
|
|
127
152
|
build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
|
|
128
153
|
networks: {
|
|
129
|
-
internal: { ipv4_address:
|
|
130
|
-
dmz: { ipv4_address:
|
|
131
|
-
app: { ipv4_address:
|
|
132
|
-
secure: { ipv4_address:
|
|
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 },
|
|
133
158
|
},
|
|
134
159
|
cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
|
|
135
160
|
// A firewall may also terminate a VPN (the admin tunnel the `wireguard`
|
|
@@ -138,11 +163,12 @@ function defaultTopologyServices(): Record<string, unknown> {
|
|
|
138
163
|
devices: ['/dev/net/tun'],
|
|
139
164
|
sysctls: ROUTER_SYSCTLS,
|
|
140
165
|
volumes: ['ssh-keys:/ssh-keys:ro'],
|
|
166
|
+
environment: { PROTECTED_SUBNETS },
|
|
141
167
|
}),
|
|
142
168
|
'fw-isp': baseService({
|
|
143
169
|
build: { context: '.', dockerfile: 'docker/Dockerfile.greenwave-sim' },
|
|
144
170
|
networks: {
|
|
145
|
-
internal: { ipv4_address:
|
|
171
|
+
internal: { ipv4_address: greenwaveRouterIp() },
|
|
146
172
|
'isp-external': { ipv4_address: '100.100.0.100' },
|
|
147
173
|
},
|
|
148
174
|
cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
|
|
@@ -166,10 +192,10 @@ function directInternetTopologyServices(): Record<string, unknown> {
|
|
|
166
192
|
'fw-main': baseService({
|
|
167
193
|
build: { context: '.', dockerfile: 'docker/Dockerfile.firewall' },
|
|
168
194
|
networks: {
|
|
169
|
-
internal: { ipv4_address:
|
|
170
|
-
dmz: { ipv4_address:
|
|
171
|
-
app: { ipv4_address:
|
|
172
|
-
secure: { ipv4_address:
|
|
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 },
|
|
173
199
|
'isp-external': { ipv4_address: '100.100.0.100' },
|
|
174
200
|
},
|
|
175
201
|
cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
|
|
@@ -179,6 +205,7 @@ function directInternetTopologyServices(): Record<string, unknown> {
|
|
|
179
205
|
devices: ['/dev/net/tun'],
|
|
180
206
|
sysctls: ROUTER_SYSCTLS,
|
|
181
207
|
volumes: ['ssh-keys:/ssh-keys:ro'],
|
|
208
|
+
environment: { PROTECTED_SUBNETS },
|
|
182
209
|
}),
|
|
183
210
|
// No fw-isp — fw-main handles external routing directly
|
|
184
211
|
};
|
|
@@ -379,15 +406,13 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
379
406
|
// land whether or not the management box shares the network (#436).
|
|
380
407
|
const needsSecureMgmt = mgmtOnOwnNetwork || (config.secureMgmtMachines ?? []).length > 0;
|
|
381
408
|
const networks: Record<string, unknown> = {
|
|
382
|
-
internal:
|
|
383
|
-
dmz:
|
|
384
|
-
app:
|
|
385
|
-
secure:
|
|
409
|
+
internal: zoneNetworkDef('internal'),
|
|
410
|
+
dmz: zoneNetworkDef('dmz'),
|
|
411
|
+
app: zoneNetworkDef('app'),
|
|
412
|
+
secure: zoneNetworkDef('secure'),
|
|
386
413
|
// Only when celilo-mgr lives off the internal LAN — keeps the default
|
|
387
414
|
// topology's generated compose byte-identical for every existing test.
|
|
388
|
-
...(needsSecureMgmt
|
|
389
|
-
? { 'secure-mgmt': networkDef(SECURE_MGMT_SUBNET, '10.0.120.250') }
|
|
390
|
-
: {}),
|
|
415
|
+
...(needsSecureMgmt ? { 'secure-mgmt': zoneNetworkDef('secure-mgmt') } : {}),
|
|
391
416
|
'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
|
|
392
417
|
// internet-external is owned by shared infra; real-internet is per-test
|
|
393
418
|
// (it just bridges fw-ext ↔ comcast-resolver, no shared service uses it).
|
|
@@ -411,7 +436,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
411
436
|
|
|
412
437
|
// --- Management (always present) ---
|
|
413
438
|
const managementDefaultGw =
|
|
414
|
-
config.topology === 'direct-internet' ?
|
|
439
|
+
config.topology === 'direct-internet' ? ZONE_GATEWAYS.internal : greenwaveRouterIp();
|
|
415
440
|
|
|
416
441
|
// Pick the management image based on variant. Both branches use
|
|
417
442
|
// `image:` (not `build:`) so docker compose uses the existing tag —
|
|
@@ -476,24 +501,27 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
476
501
|
|
|
477
502
|
services.management = mgmtOnOwnNetwork
|
|
478
503
|
? emitManagement(config.managementVariant, SECURE_MGMT_MANAGEMENT_IP, 'secure-mgmt')
|
|
479
|
-
: emitManagement(config.managementVariant, '
|
|
504
|
+
: emitManagement(config.managementVariant, zoneIp('internal', 100));
|
|
480
505
|
|
|
481
506
|
if (config.secondaryManagementVariant) {
|
|
482
|
-
services['celilo-mgr-2'] = emitManagement(
|
|
507
|
+
services['celilo-mgr-2'] = emitManagement(
|
|
508
|
+
config.secondaryManagementVariant,
|
|
509
|
+
zoneIp('internal', 101),
|
|
510
|
+
);
|
|
483
511
|
}
|
|
484
512
|
|
|
485
513
|
// --- Internal split-horizon DNS resolver ---
|
|
486
514
|
// Resolves the test domain to caddy's internal DMZ IP so management can
|
|
487
515
|
// reach it via a direct route rather than through the external interface
|
|
488
516
|
// (hairpin NAT would be needed otherwise). Management already has
|
|
489
|
-
//
|
|
517
|
+
// `internalResolverIp()` as its primary resolver in /etc/resolv.conf.
|
|
490
518
|
const caddyIp =
|
|
491
519
|
config.dmzMachines.find((m) => m.name === 'caddy')?.ip ??
|
|
492
520
|
config.dmzMachines[0]?.ip ??
|
|
493
|
-
'10
|
|
521
|
+
zoneIp('dmz', 10);
|
|
494
522
|
services['dns-int'] = baseService({
|
|
495
523
|
build: { context: '.', dockerfile: 'docker/Dockerfile.internal-resolver' },
|
|
496
|
-
networks: { internal: { ipv4_address:
|
|
524
|
+
networks: { internal: { ipv4_address: internalResolverIp() } },
|
|
497
525
|
cap_add: ['NET_ADMIN'],
|
|
498
526
|
environment: {
|
|
499
527
|
CADDY_IP: caddyIp,
|
|
@@ -574,7 +602,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
574
602
|
if (config.dhcpClient) {
|
|
575
603
|
services['dhcp-client'] = baseService({
|
|
576
604
|
build: { context: '.', dockerfile: 'docker/Dockerfile.dhcp-client' },
|
|
577
|
-
networks: { internal: { ipv4_address: '
|
|
605
|
+
networks: { internal: { ipv4_address: zoneIp('internal', 210) } },
|
|
578
606
|
cap_add: ['NET_ADMIN'],
|
|
579
607
|
});
|
|
580
608
|
}
|
|
@@ -586,7 +614,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
586
614
|
if (config.signalCli) {
|
|
587
615
|
services['signal-cli'] = baseService({
|
|
588
616
|
build: { context: '.', dockerfile: 'docker/Dockerfile.signal-cli' },
|
|
589
|
-
networks: { internal: { ipv4_address: '
|
|
617
|
+
networks: { internal: { ipv4_address: zoneIp('internal', 90) } },
|
|
590
618
|
cap_add: ['NET_ADMIN'],
|
|
591
619
|
});
|
|
592
620
|
}
|
|
@@ -608,7 +636,7 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
608
636
|
if (config.signalSim) {
|
|
609
637
|
services['signal-sim'] = baseService({
|
|
610
638
|
build: { context: '.', dockerfile: 'docker/Dockerfile.signal-sim' },
|
|
611
|
-
networks: { internal: { ipv4_address: '
|
|
639
|
+
networks: { internal: { ipv4_address: zoneIp('internal', 91) } },
|
|
612
640
|
cap_add: ['NET_ADMIN'],
|
|
613
641
|
environment: {
|
|
614
642
|
SIGNAL_ACCOUNT: '+15551234567',
|
|
@@ -649,10 +677,10 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
|
|
|
649
677
|
|
|
650
678
|
export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): string {
|
|
651
679
|
const networks: Record<string, unknown> = {
|
|
652
|
-
internal:
|
|
653
|
-
dmz:
|
|
654
|
-
app:
|
|
655
|
-
secure:
|
|
680
|
+
internal: zoneNetworkDef('internal'),
|
|
681
|
+
dmz: zoneNetworkDef('dmz'),
|
|
682
|
+
app: zoneNetworkDef('app'),
|
|
683
|
+
secure: zoneNetworkDef('secure'),
|
|
656
684
|
'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
|
|
657
685
|
'internet-external': networkDef('100.64.0.0/24', '100.64.0.250'),
|
|
658
686
|
'real-internet': {
|
|
@@ -668,11 +696,11 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
|
|
|
668
696
|
const services: Record<string, unknown> = {};
|
|
669
697
|
|
|
670
698
|
const managementDefaultGw =
|
|
671
|
-
config.topology === 'direct-internet' ?
|
|
699
|
+
config.topology === 'direct-internet' ? ZONE_GATEWAYS.internal : greenwaveRouterIp();
|
|
672
700
|
|
|
673
701
|
services.management = baseService({
|
|
674
702
|
build: { context: '.', dockerfile: 'docker/Dockerfile.management' },
|
|
675
|
-
networks: { internal: { ipv4_address: '
|
|
703
|
+
networks: { internal: { ipv4_address: zoneIp('internal', 100) } },
|
|
676
704
|
cap_add: ['NET_ADMIN'],
|
|
677
705
|
volumes: [
|
|
678
706
|
...(celiloRoot ? [`${celiloRoot}:/celilo`] : []),
|
|
@@ -692,10 +720,10 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
|
|
|
692
720
|
const legacyCaddyIp =
|
|
693
721
|
config.dmzMachines.find((m) => m.name === 'caddy')?.ip ??
|
|
694
722
|
config.dmzMachines[0]?.ip ??
|
|
695
|
-
'10
|
|
723
|
+
zoneIp('dmz', 10);
|
|
696
724
|
services['dns-int'] = baseService({
|
|
697
725
|
build: { context: '.', dockerfile: 'docker/Dockerfile.internal-resolver' },
|
|
698
|
-
networks: { internal: { ipv4_address:
|
|
726
|
+
networks: { internal: { ipv4_address: internalResolverIp() } },
|
|
699
727
|
cap_add: ['NET_ADMIN'],
|
|
700
728
|
environment: {
|
|
701
729
|
CADDY_IP: legacyCaddyIp,
|
|
@@ -831,7 +859,7 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
|
|
|
831
859
|
if (config.dhcpClient) {
|
|
832
860
|
services['dhcp-client'] = baseService({
|
|
833
861
|
build: { context: '.', dockerfile: 'docker/Dockerfile.dhcp-client' },
|
|
834
|
-
networks: { internal: { ipv4_address: '
|
|
862
|
+
networks: { internal: { ipv4_address: zoneIp('internal', 210) } },
|
|
835
863
|
cap_add: ['NET_ADMIN'],
|
|
836
864
|
});
|
|
837
865
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a failed test file's raw output into the one line a human reads first.
|
|
3
|
+
*
|
|
4
|
+
* Pure (Rule 10.1), and split out of runner.ts so it can be tested — runner.ts
|
|
5
|
+
* calls main() at import time, so nothing inside it is reachable from a test.
|
|
6
|
+
* Sibling of parse-line.ts / parse-line.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Strips ANSI escape sequences so pattern matching works on bun's coloured output
|
|
10
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
11
|
+
export function stripAnsi(s: string): string {
|
|
12
|
+
return s.replace(ANSI_RE, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractFailureMessage(lines: string[], exitCode: number): string {
|
|
16
|
+
const plain = lines.map(stripAnsi);
|
|
17
|
+
|
|
18
|
+
// The FIRST failing test, in bun's "(fail) name [Xms]" format, wins over
|
|
19
|
+
// everything below. In a staged suite every later stage fails too — on
|
|
20
|
+
// purpose, via requireStage — so any rule that picks by content rather than
|
|
21
|
+
// by order reports a downstream symptom as the cause.
|
|
22
|
+
//
|
|
23
|
+
// That is not hypothetical. technitium-pipeline's stage 0 timed out; the
|
|
24
|
+
// reported failure was "Module 'namecheap' not found", from a stage that had
|
|
25
|
+
// only ever been reached because stage 0 died. Three people read that as a
|
|
26
|
+
// module-import bug. The line that actually mattered — "this test timed out
|
|
27
|
+
// after 120000ms" — was 60 lines up in output.log and in no summary at all.
|
|
28
|
+
//
|
|
29
|
+
// The "✗ name" branch below never fired there: bun prints "✗" only in its
|
|
30
|
+
// per-file summary, and prints "(fail)" as each test completes.
|
|
31
|
+
const failIdx = plain.findIndex((l) => /^\(fail\)\s+\S/.test(l));
|
|
32
|
+
if (failIdx >= 0) {
|
|
33
|
+
const testName = plain[failIdx]
|
|
34
|
+
.replace(/^\(fail\)\s+/, '')
|
|
35
|
+
.replace(/\s*\[[\d.]+ms\].*$/, '')
|
|
36
|
+
.trim();
|
|
37
|
+
// bun puts the reason on the next line, indented and marked with "^".
|
|
38
|
+
const reason = (plain[failIdx + 1] ?? '').trim().replace(/^\^\s*/, '');
|
|
39
|
+
return reason ? `${testName}: ${reason}` : testName;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// bun's own test failure: "✗ test name [Xms]" — extract the failing test name
|
|
43
|
+
const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed ('));
|
|
44
|
+
if (bunFailIdx >= 0) {
|
|
45
|
+
const testName = plain[bunFailIdx]
|
|
46
|
+
.replace(/^\s*✗\s+/, '')
|
|
47
|
+
.replace(/\s*\[\d+ms\].*$/, '')
|
|
48
|
+
.trim();
|
|
49
|
+
// Grab the error detail on the next few lines (bun indents it under the test)
|
|
50
|
+
const detail = plain
|
|
51
|
+
.slice(bunFailIdx + 1, bunFailIdx + 6)
|
|
52
|
+
.map((l) => l.trim())
|
|
53
|
+
.filter((l) => l.length > 0 && !l.startsWith('at ') && !l.startsWith('│') && l !== '└─')
|
|
54
|
+
.slice(0, 2)
|
|
55
|
+
.join(' — ');
|
|
56
|
+
return detail ? `${testName}: ${detail}` : testName;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// "error: ..." lines (may be indented in bun output)
|
|
60
|
+
const errorIdx = plain.findIndex(
|
|
61
|
+
(l) => l.trimStart().startsWith('error:') && !l.includes('bun test'),
|
|
62
|
+
);
|
|
63
|
+
if (errorIdx >= 0) {
|
|
64
|
+
const context = plain
|
|
65
|
+
.slice(errorIdx, errorIdx + 5)
|
|
66
|
+
.map((l) => l.trim())
|
|
67
|
+
.filter((l) => l.length > 0 && !l.startsWith('at '));
|
|
68
|
+
return context.join('\n ');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const celiloError = plain.find(
|
|
72
|
+
(l) => l.includes('CeliloCommandError') || (l.includes('celilo') && l.includes('failed')),
|
|
73
|
+
);
|
|
74
|
+
if (celiloError) return celiloError.slice(0, 120);
|
|
75
|
+
|
|
76
|
+
const timeout = plain.find((l) => l.includes('Timeout') || l.includes('timed out'));
|
|
77
|
+
if (timeout) return timeout.slice(0, 120);
|
|
78
|
+
|
|
79
|
+
// Last resort: print the last few non-empty, non-progress lines from the output
|
|
80
|
+
const tail = plain
|
|
81
|
+
.filter(
|
|
82
|
+
(l) =>
|
|
83
|
+
l.length > 0 &&
|
|
84
|
+
!l.startsWith('[progress') &&
|
|
85
|
+
!l.startsWith('[e2e:') &&
|
|
86
|
+
!l.startsWith('[ansible:'),
|
|
87
|
+
)
|
|
88
|
+
.slice(-3)
|
|
89
|
+
.map((l) => l.trim())
|
|
90
|
+
.join(' | ');
|
|
91
|
+
return tail || `exit code ${exitCode}`;
|
|
92
|
+
}
|
package/src/fixtures.ts
CHANGED
|
@@ -1,40 +1,33 @@
|
|
|
1
|
-
import { NetworkBuilder, network } from './network-builder';
|
|
1
|
+
import { type NetworkBuilder, network } from './network-builder';
|
|
2
2
|
|
|
3
3
|
/** Single caddy machine in the DMZ — basic web server deployment */
|
|
4
|
-
export const CADDY_DEPLOYMENT = (): NetworkBuilder => network().dmz({ caddy: '10.
|
|
4
|
+
export const CADDY_DEPLOYMENT = (): NetworkBuilder => network().dmz({ caddy: '10.226.10.10' });
|
|
5
5
|
|
|
6
6
|
/** Full stack: web server, identity provider, database */
|
|
7
7
|
export const FULL_STACK = (): NetworkBuilder =>
|
|
8
8
|
network()
|
|
9
|
-
.dmz({ caddy: '10.
|
|
10
|
-
.app({ idp: '10.
|
|
11
|
-
.secure({ db: '10.
|
|
9
|
+
.dmz({ caddy: '10.226.10.10' })
|
|
10
|
+
.app({ idp: '10.226.20.100' })
|
|
11
|
+
.secure({ db: '10.226.30.50' });
|
|
12
12
|
|
|
13
13
|
/** Minimal: no dynamic machines, just fixed infrastructure */
|
|
14
14
|
export const INFRASTRUCTURE_ONLY = (): NetworkBuilder => network();
|
|
15
15
|
|
|
16
16
|
/** Caddy + Authentik: web server in DMZ, identity provider in app zone */
|
|
17
17
|
export const AUTHENTIK_DEPLOYMENT = (): NetworkBuilder =>
|
|
18
|
-
network()
|
|
19
|
-
.dmz({ caddy: '10.0.10.10' })
|
|
20
|
-
.app({ authentik: '10.0.20.30' });
|
|
18
|
+
network().dmz({ caddy: '10.226.10.10' }).app({ authentik: '10.226.20.30' });
|
|
21
19
|
|
|
22
20
|
/** Multiple DMZ machines for testing multi-service deployments */
|
|
23
21
|
export const MULTI_DMZ = (): NetworkBuilder =>
|
|
24
22
|
network().dmz({
|
|
25
|
-
caddy: '10.
|
|
26
|
-
'web-app': '10.
|
|
23
|
+
caddy: '10.226.10.10',
|
|
24
|
+
'web-app': '10.226.10.20',
|
|
27
25
|
});
|
|
28
26
|
|
|
29
27
|
/** Technitium DNS + Caddy: internal DNS resolver + full web stack */
|
|
30
28
|
export const TECHNITIUM_STACK = (): NetworkBuilder =>
|
|
31
|
-
network()
|
|
32
|
-
.internal({ 'dns-int': '192.168.0.53' })
|
|
33
|
-
.dmz({ caddy: '10.0.10.10' });
|
|
29
|
+
network().internal({ 'dns-int': '10.226.1.53' }).dmz({ caddy: '10.226.10.10' });
|
|
34
30
|
|
|
35
31
|
/** Technitium DNS with DHCP client: tests DHCP DNS propagation */
|
|
36
32
|
export const TECHNITIUM_DHCP_STACK = (): NetworkBuilder =>
|
|
37
|
-
network()
|
|
38
|
-
.internal({ 'dns-int': '192.168.0.53' })
|
|
39
|
-
.dmz({ caddy: '10.0.10.10' })
|
|
40
|
-
.withDhcpClient();
|
|
33
|
+
network().internal({ 'dns-int': '10.226.1.53' }).dmz({ caddy: '10.226.10.10' }).withDhcpClient();
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* import { network } from '@celilo/e2e';
|
|
13
13
|
*
|
|
14
14
|
* const net = await network()
|
|
15
|
-
* .dmz({ caddy: '10.
|
|
16
|
-
* .app({ myapp: '10.
|
|
15
|
+
* .dmz({ caddy: '10.226.10.10' })
|
|
16
|
+
* .app({ myapp: '10.226.20.10' })
|
|
17
17
|
* .start();
|
|
18
18
|
*
|
|
19
19
|
* await net.celilo('module deploy myapp');
|
package/src/network-builder.ts
CHANGED
|
@@ -2,7 +2,14 @@ import { execSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { startNetwork } from './container-manager';
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
MachineSpec,
|
|
7
|
+
NetworkConfig,
|
|
8
|
+
NetworkHandle,
|
|
9
|
+
TopologyPreset,
|
|
10
|
+
Vantage,
|
|
11
|
+
Zone,
|
|
12
|
+
} from './types';
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* If a mounted path looks like a celilo module (has scripts/package.json),
|
|
@@ -10,10 +17,7 @@ import type { MachineSpec, NetworkConfig, NetworkHandle, TopologyPreset, Vantage
|
|
|
10
17
|
*/
|
|
11
18
|
async function ensureModuleDeps(hostPath: string): Promise<void> {
|
|
12
19
|
// Check common locations for a module's scripts dir
|
|
13
|
-
for (const scriptsDir of [
|
|
14
|
-
join(hostPath, 'scripts'),
|
|
15
|
-
join(hostPath, 'celilo', 'scripts'),
|
|
16
|
-
]) {
|
|
20
|
+
for (const scriptsDir of [join(hostPath, 'scripts'), join(hostPath, 'celilo', 'scripts')]) {
|
|
17
21
|
const pkgJson = join(scriptsDir, 'package.json');
|
|
18
22
|
const nodeModules = join(scriptsDir, 'node_modules');
|
|
19
23
|
if (existsSync(pkgJson) && !existsSync(nodeModules)) {
|
|
@@ -24,7 +28,7 @@ async function ensureModuleDeps(hostPath: string): Promise<void> {
|
|
|
24
28
|
const msg = err instanceof Error ? err.message : String(err);
|
|
25
29
|
throw new Error(
|
|
26
30
|
`Failed to install module dependencies in ${scriptsDir}:\n${msg}\n\n` +
|
|
27
|
-
|
|
31
|
+
`Fix: cd ${scriptsDir} && bun install`,
|
|
28
32
|
);
|
|
29
33
|
}
|
|
30
34
|
}
|
|
@@ -170,11 +174,12 @@ export class NetworkBuilder {
|
|
|
170
174
|
* `VantageProbe`. `management` is not injectable (that vantage reuses the real
|
|
171
175
|
* management container).
|
|
172
176
|
*
|
|
173
|
-
* network().dmz({ caddy: '10.
|
|
177
|
+
* network().dmz({ caddy: '10.226.10.10' }).observe('internalDevice', 'publicInternet')
|
|
174
178
|
*/
|
|
175
179
|
observe(...vantages: Vantage[]): this {
|
|
180
|
+
this.config.observers ??= [];
|
|
176
181
|
for (const vantage of vantages) {
|
|
177
|
-
|
|
182
|
+
this.config.observers.push({ vantage });
|
|
178
183
|
}
|
|
179
184
|
return this;
|
|
180
185
|
}
|
|
@@ -219,7 +224,7 @@ export class NetworkBuilder {
|
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
/**
|
|
222
|
-
* Bring up a SECOND management-class box named `celilo-mgr-2` (
|
|
227
|
+
* Bring up a SECOND management-class box named `celilo-mgr-2` (10.226.1.101)
|
|
223
228
|
* using the given image variant. The migration restore e2e uses this to get a
|
|
224
229
|
* fresh target box alongside the primary backup source. The second box is not
|
|
225
230
|
* auto-init'd (container-manager keys off the `management` name), so it stays
|
package/src/observer.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import {
|
|
3
|
+
OBSERVER_PLACEMENTS,
|
|
3
4
|
createObserverTransport,
|
|
4
5
|
normalizeObservers,
|
|
5
|
-
OBSERVER_PLACEMENTS,
|
|
6
6
|
observerEnv,
|
|
7
7
|
vantageContainer,
|
|
8
8
|
} from './observer';
|
|
@@ -38,7 +38,9 @@ describe('placement faithfulness (the load-bearing invariants)', () => {
|
|
|
38
38
|
test('segmented-zone observer IPs derive from their zone gateway (no pinned subnets)', () => {
|
|
39
39
|
expect(OBSERVER_PLACEMENTS.dmzSystem.gateway).toBe(ZONE_GATEWAYS.dmz);
|
|
40
40
|
expect(OBSERVER_PLACEMENTS.dmzSystem.ip).toBe(ZONE_GATEWAYS.dmz.replace(/\.\d+$/, '.240'));
|
|
41
|
-
expect(OBSERVER_PLACEMENTS.secureSystem.ip).toBe(
|
|
41
|
+
expect(OBSERVER_PLACEMENTS.secureSystem.ip).toBe(
|
|
42
|
+
ZONE_GATEWAYS.secure.replace(/\.\d+$/, '.240'),
|
|
43
|
+
);
|
|
42
44
|
});
|
|
43
45
|
|
|
44
46
|
test('publicInternet uses only the public resolver (never the internal split-horizon view)', () => {
|
|
@@ -55,7 +57,9 @@ describe('placement faithfulness (the load-bearing invariants)', () => {
|
|
|
55
57
|
|
|
56
58
|
describe('normalizeObservers + vantageContainer', () => {
|
|
57
59
|
test('dedupes and rejects management as an injectable observer', () => {
|
|
58
|
-
expect(
|
|
60
|
+
expect(
|
|
61
|
+
normalizeObservers([{ vantage: 'internalDevice' }, { vantage: 'internalDevice' }]),
|
|
62
|
+
).toHaveLength(1);
|
|
59
63
|
expect(() => normalizeObservers([{ vantage: 'management' }])).toThrow(/management/);
|
|
60
64
|
});
|
|
61
65
|
test('management vantage maps to the real management container; others to observers', () => {
|
|
@@ -67,36 +71,55 @@ describe('normalizeObservers + vantageContainer', () => {
|
|
|
67
71
|
describe('observer transport — parsing + container targeting', () => {
|
|
68
72
|
test('resolve(system) parses getent first column and targets the right observer', async () => {
|
|
69
73
|
const { handle, calls } = fakeHandle((_c, cmd) =>
|
|
70
|
-
cmd.startsWith('getent') ? { stdout: '
|
|
74
|
+
cmd.startsWith('getent') ? { stdout: '10.226.1.253 www.iamtheinternet.org' } : {},
|
|
71
75
|
);
|
|
72
76
|
const t = createObserverTransport(handle);
|
|
73
|
-
const r = await t.resolve({
|
|
74
|
-
|
|
77
|
+
const r = await t.resolve({
|
|
78
|
+
from: 'internalDevice',
|
|
79
|
+
name: 'www.iamtheinternet.org',
|
|
80
|
+
method: 'system',
|
|
81
|
+
});
|
|
82
|
+
expect(r.ips).toEqual(['10.226.1.253']);
|
|
75
83
|
expect(calls[0].container).toBe('observer-internal');
|
|
76
84
|
});
|
|
77
85
|
|
|
78
86
|
test('resolve(dns) with explicit server', async () => {
|
|
79
|
-
const { handle, calls } = fakeHandle((_c, cmd) =>
|
|
87
|
+
const { handle, calls } = fakeHandle((_c, cmd) =>
|
|
88
|
+
cmd.includes('dig') ? { stdout: '10.226.10.10\n' } : {},
|
|
89
|
+
);
|
|
80
90
|
const t = createObserverTransport(handle);
|
|
81
|
-
const r = await t.resolve({
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
const r = await t.resolve({
|
|
92
|
+
from: 'dmzSystem',
|
|
93
|
+
name: 'auth.x',
|
|
94
|
+
method: 'dns',
|
|
95
|
+
server: '10.226.1.10',
|
|
96
|
+
});
|
|
97
|
+
expect(r.ips).toEqual(['10.226.10.10']);
|
|
98
|
+
expect(calls[0].cmd).toContain('@10.226.1.10');
|
|
84
99
|
});
|
|
85
100
|
|
|
86
101
|
test('resolve(hostfiles) parses /etc/hosts first column', async () => {
|
|
87
102
|
const { handle } = fakeHandle((_c, cmd) =>
|
|
88
|
-
cmd.startsWith('grep -F') ? { stdout: '10.
|
|
103
|
+
cmd.startsWith('grep -F') ? { stdout: '10.226.10.10 auth.iamtheinternet.org' } : {},
|
|
89
104
|
);
|
|
90
105
|
const t = createObserverTransport(handle);
|
|
91
|
-
const r = await t.resolve({
|
|
92
|
-
|
|
106
|
+
const r = await t.resolve({
|
|
107
|
+
from: 'dmzSystem',
|
|
108
|
+
name: 'auth.iamtheinternet.org',
|
|
109
|
+
method: 'hostfiles',
|
|
110
|
+
});
|
|
111
|
+
expect(r.ips).toEqual(['10.226.10.10']);
|
|
93
112
|
});
|
|
94
113
|
|
|
95
114
|
test('tcpConnect maps the container exit code', async () => {
|
|
96
115
|
const { handle } = fakeHandle((_c, cmd) => ({ exitCode: cmd.includes('443') ? 0 : 1 }));
|
|
97
116
|
const t = createObserverTransport(handle);
|
|
98
|
-
expect(
|
|
99
|
-
|
|
117
|
+
expect(
|
|
118
|
+
(await t.tcpConnect({ from: 'internalDevice', host: '10.226.1.253', port: 443 })).connected,
|
|
119
|
+
).toBe(true);
|
|
120
|
+
expect(
|
|
121
|
+
(await t.tcpConnect({ from: 'internalDevice', host: '10.226.10.10', port: 22 })).connected,
|
|
122
|
+
).toBe(false);
|
|
100
123
|
});
|
|
101
124
|
|
|
102
125
|
test('httpsRequest parses cert (CN/issuer/SAN/selfSigned) + status', async () => {
|
|
@@ -113,7 +136,10 @@ describe('observer transport — parsing + container targeting', () => {
|
|
|
113
136
|
return { stdout: 'Caddy reverse proxy is running' };
|
|
114
137
|
});
|
|
115
138
|
const t = createObserverTransport(handle);
|
|
116
|
-
const r = await t.httpsRequest({
|
|
139
|
+
const r = await t.httpsRequest({
|
|
140
|
+
from: 'publicInternet',
|
|
141
|
+
url: 'https://deathstar.iamtheinternet.org',
|
|
142
|
+
});
|
|
117
143
|
expect(r.status).toBe(200);
|
|
118
144
|
expect(r.cert.cn).toBe('deathstar.iamtheinternet.org');
|
|
119
145
|
expect(r.cert.issuer).toContain('Pebble');
|
|
@@ -140,10 +166,15 @@ describe('host-shell footgun guard', () => {
|
|
|
140
166
|
const { handle, calls } = fakeHandle(() => ({ stdout: '200', exitCode: 0 }));
|
|
141
167
|
const t = createObserverTransport(handle);
|
|
142
168
|
await t.resolve({ from: 'internalDevice', name: 'www.x', method: 'system' });
|
|
143
|
-
await t.resolve({
|
|
169
|
+
await t.resolve({
|
|
170
|
+
from: 'internalDevice',
|
|
171
|
+
name: 'www.x',
|
|
172
|
+
method: 'dns',
|
|
173
|
+
server: '10.226.1.10',
|
|
174
|
+
});
|
|
144
175
|
await t.resolve({ from: 'internalDevice', name: 'www.x', method: 'hostfiles' });
|
|
145
|
-
await t.tcpConnect({ from: 'internalDevice', host: '10.
|
|
146
|
-
await t.ping({ from: 'internalDevice', target: '
|
|
176
|
+
await t.tcpConnect({ from: 'internalDevice', host: '10.226.10.10', port: 443 });
|
|
177
|
+
await t.ping({ from: 'internalDevice', target: '10.226.1.253' });
|
|
147
178
|
await t.httpsRequest({ from: 'internalDevice', url: 'https://www.x' });
|
|
148
179
|
await t.inspectResolution({ from: 'internalDevice', name: 'www.x' });
|
|
149
180
|
expect(calls.length).toBeGreaterThan(0);
|