@celilo/e2e 0.10.3 → 0.11.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 +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/package.json +2 -2
- package/simulators/greenwave/server.ts +1 -1
- package/simulators/greenwave/state.ts +7 -7
- package/src/address-plan.test.ts +95 -0
- package/src/cli/scaffold.ts +2 -2
- package/src/container-manager.ts +77 -42
- package/src/docker-compose-generator.ts +67 -39
- package/src/exit-cleanup.test.ts +16 -2
- package/src/fixtures.ts +12 -12
- package/src/index.ts +2 -2
- package/src/network-builder.ts +2 -2
- package/src/observer.test.ts +13 -13
- package/src/observer.ts +3 -3
- package/src/socks-proxy.ts +2 -2
- package/src/types.ts +67 -17
- package/src/vantage-helpers.ts +1 -1
- package/src/vantage.test.ts +24 -24
- package/src/zone-classifier.test.ts +25 -20
- package/src/zone-classifier.ts +5 -4
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recurrence gate for #539.
|
|
3
|
+
*
|
|
4
|
+
* The sim used to number its zones exactly like a real celilo fleet, so a
|
|
5
|
+
* leaked stack on celilo's own forgejo-builder (which lives at 10.0.10.12 in
|
|
6
|
+
* the REAL dmz) put a second interface on the builder's own /24 and blackholed
|
|
7
|
+
* every containerized CI job's route to the forge. These tests make the two
|
|
8
|
+
* properties that prevent it non-negotiable:
|
|
9
|
+
*
|
|
10
|
+
* 1. every simulated private zone lives inside SIM_PRIVATE_SUPERNET, and
|
|
11
|
+
* 2. the retired fleet-colliding prefixes appear NOWHERE in the suite.
|
|
12
|
+
*
|
|
13
|
+
* (2) is the one that matters in practice: the plan itself is one table, but
|
|
14
|
+
* shell scripts, Knot zones and dnsmasq configs can't import TypeScript and so
|
|
15
|
+
* hard-code the values. This is what catches "I changed it in one place".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from 'bun:test';
|
|
19
|
+
import { execSync } from 'node:child_process';
|
|
20
|
+
import { SIM_PRIVATE_SUPERNET, ZONE_GATEWAYS, ZONE_SUBNETS, type Zone } from './types';
|
|
21
|
+
import { inCidr } from './zone-classifier';
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = execSync('git rev-parse --show-toplevel', {
|
|
24
|
+
cwd: import.meta.dir,
|
|
25
|
+
encoding: 'utf-8',
|
|
26
|
+
}).trim();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Prefixes the sim must never use again: the fleet's zone /24s and the
|
|
30
|
+
* operator's LAN. Prefix match — `10.0.10.` catches an address, a CIDR and a
|
|
31
|
+
* prose mention alike, which is what we want.
|
|
32
|
+
*/
|
|
33
|
+
const RETIRED_PREFIXES = ['10.0.10.', '10.0.20.', '10.0.30.', '10.0.120.', '192.168.'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Match the plain form AND the regex-escaped form (`192\.168\.0\.10`). Two
|
|
37
|
+
* escaped literals inside `toThrow(/…/)` survived the #539 sweep precisely
|
|
38
|
+
* because a naive search for the plain text does not see them.
|
|
39
|
+
*/
|
|
40
|
+
function toPattern(prefix: string): string {
|
|
41
|
+
return prefix.replace(/\./g, '\\\\?\\.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Everything that can put an address on a simulated network or assert one. */
|
|
45
|
+
const SCANNED_PATHS = ['packages/e2e', 'e2e/tests', 'modules/*/e2e'];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* CHANGELOG is history — it describes what the addresses WERE. This test file
|
|
49
|
+
* necessarily names the retired prefixes to ban them.
|
|
50
|
+
*/
|
|
51
|
+
const EXCLUDED = [/^packages\/e2e\/CHANGELOG\.md$/, /address-plan\.test\.ts$/];
|
|
52
|
+
|
|
53
|
+
function grepRetired(): string[] {
|
|
54
|
+
const pattern = RETIRED_PREFIXES.map(toPattern).join('|');
|
|
55
|
+
let out = '';
|
|
56
|
+
try {
|
|
57
|
+
out = execSync(`git grep -nE '${pattern}' -- ${SCANNED_PATHS.join(' ')}`, {
|
|
58
|
+
cwd: REPO_ROOT,
|
|
59
|
+
encoding: 'utf-8',
|
|
60
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
// git grep exits 1 when there are no matches — that is the passing case.
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
.split('\n')
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.filter((line) => !EXCLUDED.some((re) => re.test(line.split(':')[0])));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('simulated address plan (#539)', () => {
|
|
73
|
+
test('every zone lives inside the sim supernet', () => {
|
|
74
|
+
for (const zone of Object.keys(ZONE_SUBNETS) as Zone[]) {
|
|
75
|
+
const base = ZONE_SUBNETS[zone].split('/')[0];
|
|
76
|
+
expect(inCidr(base, SIM_PRIVATE_SUPERNET)).toBe(true);
|
|
77
|
+
expect(inCidr(ZONE_GATEWAYS[zone], SIM_PRIVATE_SUPERNET)).toBe(true);
|
|
78
|
+
expect(inCidr(ZONE_GATEWAYS[zone], ZONE_SUBNETS[zone])).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('zones do not overlap each other', () => {
|
|
83
|
+
const subnets = Object.values(ZONE_SUBNETS);
|
|
84
|
+
expect(new Set(subnets).size).toBe(subnets.length);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the fleet-colliding prefixes appear nowhere in the suite', () => {
|
|
88
|
+
const hits = grepRetired();
|
|
89
|
+
expect(
|
|
90
|
+
hits,
|
|
91
|
+
`The e2e suite must not use a real fleet's addresses (#539). Offending lines:\n${hits.join('\n')}\n\n` +
|
|
92
|
+
`Use zoneIp()/ZONE_SUBNETS from packages/e2e/src/types.ts, or an address inside ${SIM_PRIVATE_SUPERNET}.`,
|
|
93
|
+
).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/cli/scaffold.ts
CHANGED
|
@@ -60,8 +60,8 @@ describe('${name}', () => {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
net = await network({ projectName: PROJECT_NAME })
|
|
63
|
-
.dmz({ 'machine-1': '10.
|
|
64
|
-
// .app({ 'app-1': '10.
|
|
63
|
+
.dmz({ 'machine-1': '10.226.10.10' })
|
|
64
|
+
// .app({ 'app-1': '10.226.20.10' })
|
|
65
65
|
.start();
|
|
66
66
|
}, 120_000);
|
|
67
67
|
|
package/src/container-manager.ts
CHANGED
|
@@ -93,14 +93,23 @@ const activeProjects: Set<string> = new Set();
|
|
|
93
93
|
export function projectTeardownCommands(project: string): {
|
|
94
94
|
listContainers: string;
|
|
95
95
|
listNetworks: string;
|
|
96
|
+
listVolumes: string;
|
|
96
97
|
} {
|
|
97
98
|
return {
|
|
98
99
|
listContainers: `docker ps -aq --filter name=${project}`,
|
|
99
100
|
listNetworks: `docker network ls --format {{.Name}} --filter name=${project}`,
|
|
101
|
+
// Volumes were missing here, and the compose path that would have removed
|
|
102
|
+
// them (`down --volumes`) is the same best-effort call that silently does
|
|
103
|
+
// nothing without a resolvable compose file. So every run leaked its
|
|
104
|
+
// volumes: 58 `celilo-e2e-<ts>_ssh-keys` volumes were found on the builder,
|
|
105
|
+
// the oldest months old. Nothing breaks from a leaked volume the way a
|
|
106
|
+
// leaked network breaks routing — it just accumulates unbounded, and the
|
|
107
|
+
// pile is the evidence that teardown has not been running.
|
|
108
|
+
listVolumes: `docker volume ls -q --filter name=${project}`,
|
|
100
109
|
};
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
/** Force-remove a project's containers and
|
|
112
|
+
/** Force-remove a project's containers, networks and volumes by name. Never throws. */
|
|
104
113
|
function forceRemoveProject(project: string): void {
|
|
105
114
|
const cmds = projectTeardownCommands(project);
|
|
106
115
|
try {
|
|
@@ -123,6 +132,19 @@ function forceRemoveProject(project: string): void {
|
|
|
123
132
|
} catch {}
|
|
124
133
|
}
|
|
125
134
|
} catch {}
|
|
135
|
+
// Volumes last: a volume still attached to a container cannot be removed, so
|
|
136
|
+
// this must follow the container sweep above.
|
|
137
|
+
try {
|
|
138
|
+
const vols = execSync(cmds.listVolumes, { timeout: 15_000, stdio: 'pipe' })
|
|
139
|
+
.toString()
|
|
140
|
+
.split('\n')
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
for (const vol of vols) {
|
|
143
|
+
try {
|
|
144
|
+
execSync(`docker volume rm ${vol}`, { timeout: 10_000, stdio: 'pipe' });
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
} catch {}
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
function cleanupOnExit() {
|
|
@@ -164,18 +186,20 @@ process.on('SIGINT', () => {
|
|
|
164
186
|
// FAILURE path, where the test never reaches `handle.stop()`, teardown was
|
|
165
187
|
// never running and the per-test networks leaked.
|
|
166
188
|
//
|
|
167
|
-
// That leak
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
|
172
194
|
// into the dead simulated network and blackholed. That took out three
|
|
173
195
|
// consecutive release runs, each dying in `git fetch` after a ~132s connect
|
|
174
196
|
// timeout, long after the e2e run that stranded the network had ended.
|
|
175
197
|
//
|
|
176
|
-
// The
|
|
177
|
-
//
|
|
178
|
-
//
|
|
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.
|
|
179
203
|
process.on('exit', () => {
|
|
180
204
|
cleanupOnExit();
|
|
181
205
|
});
|
|
@@ -271,9 +295,7 @@ export async function scrubDnsZones(): Promise<void> {
|
|
|
271
295
|
}
|
|
272
296
|
if (served !== null && !served.split(/\s+/).includes(expected)) {
|
|
273
297
|
throw new Error(
|
|
274
|
-
`[dns-scrub] post-reset verification FAILED: celilo.computer apex should serve ${expected}
|
|
275
|
-
`(website-sim) after scrub, but namecheap-dns returns "${served || '(empty)'}". ` +
|
|
276
|
-
`The DNS reset did not take effect — shared DNS would bleed across tests.`,
|
|
298
|
+
`[dns-scrub] post-reset verification FAILED: celilo.computer apex should serve ${expected} (website-sim) after scrub, but namecheap-dns returns "${served || '(empty)'}". The DNS reset did not take effect — shared DNS would bleed across tests.`,
|
|
277
299
|
);
|
|
278
300
|
}
|
|
279
301
|
}
|
|
@@ -330,9 +352,7 @@ function assertCliVersion(projectName: string, composeDir: string): void {
|
|
|
330
352
|
const result = dockerExec(projectName, composeDir, 'management', 'celilo --version', 8_000);
|
|
331
353
|
if (result.exitCode !== 0) {
|
|
332
354
|
throw new Error(
|
|
333
|
-
`Could not read celilo version from the management image
|
|
334
|
-
`(harness requires >=${MIN_CLI_VERSION}). ` +
|
|
335
|
-
`\`celilo --version\` exited ${result.exitCode}: ${result.stderr || result.stdout || '(no output)'}`,
|
|
355
|
+
`Could not read celilo version from the management image (harness requires >=${MIN_CLI_VERSION}). \`celilo --version\` exited ${result.exitCode}: ${result.stderr || result.stdout || '(no output)'}`,
|
|
336
356
|
);
|
|
337
357
|
}
|
|
338
358
|
const problem = checkCliVersion(result.stdout);
|
|
@@ -564,11 +584,7 @@ function buildNetworkHandle(
|
|
|
564
584
|
);
|
|
565
585
|
// Spawn detached. nohup + & + disown cleanly survives the
|
|
566
586
|
// dockerExec session ending.
|
|
567
|
-
const spawnCmd =
|
|
568
|
-
`nohup celilo events respond --values ${valuesPath} ` +
|
|
569
|
-
'--idle-timeout 1h --max-duration 2h ' +
|
|
570
|
-
'--emittedBy cele2e-responder ' +
|
|
571
|
-
'> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
|
|
587
|
+
const spawnCmd = `nohup celilo events respond --values ${valuesPath} --idle-timeout 1h --max-duration 2h --emittedBy cele2e-responder > /tmp/cele2e-responder.log 2>&1 < /dev/null & disown`;
|
|
572
588
|
const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
|
|
573
589
|
if (spawnResult.exitCode !== 0) {
|
|
574
590
|
throw new Error(
|
|
@@ -772,9 +788,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
772
788
|
if (heavy.length > 0) {
|
|
773
789
|
const names = heavy.map((c) => ` - ${c}`).join('\n');
|
|
774
790
|
throw new Error(
|
|
775
|
-
`\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\n
|
|
776
|
-
'Live and e2e environments are mutually exclusive.\n' +
|
|
777
|
-
'Stop live containers first: cele2e down --all\n',
|
|
791
|
+
`\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\nLive and e2e environments are mutually exclusive.\nStop live containers first: cele2e down --all\n`,
|
|
778
792
|
);
|
|
779
793
|
}
|
|
780
794
|
} catch (e) {
|
|
@@ -803,25 +817,33 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
803
817
|
}
|
|
804
818
|
} catch {}
|
|
805
819
|
|
|
806
|
-
// Remove stale per-test networks
|
|
820
|
+
// Remove stale per-test networks AND their volumes, via the same
|
|
821
|
+
// by-name teardown the exit handler uses. This used to lean on
|
|
822
|
+
// `docker compose -p <project> down --volumes` for the volume half, which
|
|
823
|
+
// has no `-f` and no reliable cwd here either — compose exits "no
|
|
824
|
+
// configuration file provided" and the catch swallows it, so volumes were
|
|
825
|
+
// never removed on this path either. Reusing forceRemoveProject keeps the
|
|
826
|
+
// crash-recovery sweep and the exit handler from drifting apart.
|
|
807
827
|
try {
|
|
808
828
|
const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
|
|
809
829
|
.split('\n')
|
|
810
830
|
.filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
831
|
+
const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
|
|
832
|
+
for (const project of projects) {
|
|
833
|
+
forceRemoveProject(project);
|
|
834
|
+
}
|
|
835
|
+
} catch {}
|
|
836
|
+
// Volumes can outlive every container and network of their project — that
|
|
837
|
+
// is exactly the 58-volume pile — so sweep by prefix too, not only for
|
|
838
|
+
// projects that still have a network to be discovered by.
|
|
839
|
+
try {
|
|
840
|
+
const vols = run('docker volume ls -q --filter name=celilo-e2e-1 2>/dev/null')
|
|
841
|
+
.split('\n')
|
|
842
|
+
.filter(Boolean);
|
|
843
|
+
for (const vol of vols) {
|
|
844
|
+
try {
|
|
845
|
+
run(`docker volume rm ${vol}`, { timeout: 5_000 });
|
|
846
|
+
} catch {}
|
|
825
847
|
}
|
|
826
848
|
} catch {}
|
|
827
849
|
} catch {}
|
|
@@ -941,6 +963,21 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
941
963
|
// in ~2-5s).
|
|
942
964
|
60_000,
|
|
943
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
|
+
},
|
|
944
981
|
);
|
|
945
982
|
}
|
|
946
983
|
|
|
@@ -1023,8 +1060,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
1023
1060
|
// The control plane needs its subnet declared if celilo-mgr lives
|
|
1024
1061
|
// there OR if a machine does — infrastructure selection for a module
|
|
1025
1062
|
// declaring `zone: secure-mgmt` reads it either way (#436).
|
|
1026
|
-
config.managementZone === 'secure-mgmt' ||
|
|
1027
|
-
(config.secureMgmtMachines ?? []).length > 0
|
|
1063
|
+
config.managementZone === 'secure-mgmt' || (config.secureMgmtMachines ?? []).length > 0
|
|
1028
1064
|
? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
|
|
1029
1065
|
: ''
|
|
1030
1066
|
}dns.primary=100.100.0.1 \
|
|
@@ -1084,8 +1120,7 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
|
|
|
1084
1120
|
}
|
|
1085
1121
|
} catch (err) {
|
|
1086
1122
|
throw new Error(
|
|
1087
|
-
`Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\
|
|
1088
|
-
`Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
|
|
1123
|
+
`Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\nFix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
|
|
1089
1124
|
);
|
|
1090
1125
|
}
|
|
1091
1126
|
|
|
@@ -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
|
}
|
package/src/exit-cleanup.test.ts
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* The leak's mechanism was a wiring bug, so the gate is on the wiring.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { expect, test } from 'bun:test';
|
|
14
15
|
import { readFileSync } from 'node:fs';
|
|
15
16
|
import { dirname, join } from 'node:path';
|
|
16
17
|
import { fileURLToPath } from 'node:url';
|
|
17
|
-
import { expect, test } from 'bun:test';
|
|
18
18
|
import { projectTeardownCommands } from './container-manager';
|
|
19
19
|
|
|
20
20
|
const SRC = readFileSync(
|
|
@@ -34,7 +34,7 @@ test('cleanup honors --keep / --reuse so a failed run stays debuggable', () => {
|
|
|
34
34
|
// an operator asked to keep, since --keep matters most after a failure.
|
|
35
35
|
const body = SRC.slice(
|
|
36
36
|
SRC.indexOf('function cleanupOnExit'),
|
|
37
|
-
SRC.indexOf(
|
|
37
|
+
SRC.indexOf("process.on('SIGTERM'"),
|
|
38
38
|
);
|
|
39
39
|
expect(body).toContain('CELILO_E2E_KEEP');
|
|
40
40
|
expect(body).toContain('CELILO_E2E_REUSE');
|
|
@@ -54,6 +54,20 @@ test('teardown does not depend on a compose file being findable', () => {
|
|
|
54
54
|
}
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
// Containers and networks were removed; volumes were not. The only thing that
|
|
58
|
+
// would have removed them was `docker compose down --volumes`, which is the
|
|
59
|
+
// same best-effort call that silently does nothing without a resolvable compose
|
|
60
|
+
// file — so in practice they were never removed at all. 58 leaked
|
|
61
|
+
// `celilo-e2e-<ts>_ssh-keys` volumes were found on the builder, the oldest
|
|
62
|
+
// months old. A leaked volume breaks nothing on its own, which is why it went
|
|
63
|
+
// unnoticed for months; the pile is the evidence teardown was not running.
|
|
64
|
+
test('teardown removes volumes, not just containers and networks', () => {
|
|
65
|
+
const cmds = projectTeardownCommands('celilo-e2e-1785546029645');
|
|
66
|
+
expect(cmds.listVolumes).toContain('docker volume ls');
|
|
67
|
+
expect(cmds.listVolumes).toContain('celilo-e2e-1785546029645');
|
|
68
|
+
expect(cmds.listVolumes).not.toContain('docker compose');
|
|
69
|
+
});
|
|
70
|
+
|
|
57
71
|
test('teardown cannot match the shared project', () => {
|
|
58
72
|
// Per-test projects carry a timestamp; `celilo-e2e-shared` does not, so a
|
|
59
73
|
// name filter for one can never match the other. Shared infra must survive —
|
package/src/fixtures.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { 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();
|
|
@@ -16,25 +16,25 @@ export const INFRASTRUCTURE_ONLY = (): NetworkBuilder => network();
|
|
|
16
16
|
/** Caddy + Authentik: web server in DMZ, identity provider in app zone */
|
|
17
17
|
export const AUTHENTIK_DEPLOYMENT = (): NetworkBuilder =>
|
|
18
18
|
network()
|
|
19
|
-
.dmz({ caddy: '10.
|
|
20
|
-
.app({ authentik: '10.
|
|
19
|
+
.dmz({ caddy: '10.226.10.10' })
|
|
20
|
+
.app({ authentik: '10.226.20.30' });
|
|
21
21
|
|
|
22
22
|
/** Multiple DMZ machines for testing multi-service deployments */
|
|
23
23
|
export const MULTI_DMZ = (): NetworkBuilder =>
|
|
24
24
|
network().dmz({
|
|
25
|
-
caddy: '10.
|
|
26
|
-
'web-app': '10.
|
|
25
|
+
caddy: '10.226.10.10',
|
|
26
|
+
'web-app': '10.226.10.20',
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
/** Technitium DNS + Caddy: internal DNS resolver + full web stack */
|
|
30
30
|
export const TECHNITIUM_STACK = (): NetworkBuilder =>
|
|
31
31
|
network()
|
|
32
|
-
.internal({ 'dns-int': '
|
|
33
|
-
.dmz({ caddy: '10.
|
|
32
|
+
.internal({ 'dns-int': '10.226.1.53' })
|
|
33
|
+
.dmz({ caddy: '10.226.10.10' });
|
|
34
34
|
|
|
35
35
|
/** Technitium DNS with DHCP client: tests DHCP DNS propagation */
|
|
36
36
|
export const TECHNITIUM_DHCP_STACK = (): NetworkBuilder =>
|
|
37
37
|
network()
|
|
38
|
-
.internal({ 'dns-int': '
|
|
39
|
-
.dmz({ caddy: '10.
|
|
38
|
+
.internal({ 'dns-int': '10.226.1.53' })
|
|
39
|
+
.dmz({ caddy: '10.226.10.10' })
|
|
40
40
|
.withDhcpClient();
|