@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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IP echo simulator — the rig's stand-in for api.ipify.org.
|
|
3
|
+
*
|
|
4
|
+
* Reports the source address a request appears to come from, and nothing else.
|
|
5
|
+
* celilo's `public_dns` check uses it for the ONE thing it cannot ask itself:
|
|
6
|
+
* what address the fleet is currently reachable at.
|
|
7
|
+
*
|
|
8
|
+
* Why an independent service rather than the registrar's own answer: the DDNS
|
|
9
|
+
* response carries the address the registrar says it published, so comparing
|
|
10
|
+
* public DNS against it is self-agreement. Worse than useless, in fact —
|
|
11
|
+
* Namecheap returns `ErrCount 0` with the requested address echoed back for
|
|
12
|
+
* `www` updates it does not apply, so the response can be actively false. The
|
|
13
|
+
* expectation has to come from somewhere that has no stake in the answer.
|
|
14
|
+
*
|
|
15
|
+
* This sits on `internet-external`, so a request from the customer fleet
|
|
16
|
+
* arrives having been SNAT'd by the customer firewall — the source address it
|
|
17
|
+
* sees IS the firewall's external address, which is the address the internet
|
|
18
|
+
* must dial. That is the same mechanism DDNS source-IP detection relies on,
|
|
19
|
+
* observed independently.
|
|
20
|
+
*
|
|
21
|
+
* Plain HTTP on purpose. Real ipify is HTTPS, but TLS here would mean an ACME
|
|
22
|
+
* dance (Caddy + Pebble, as the isitup sim does) to test nothing this check
|
|
23
|
+
* cares about: the property under test is "an off-fleet observer reports our
|
|
24
|
+
* egress", not certificate validation. The path from the fleet to here still
|
|
25
|
+
* crosses both firewalls exactly as a real request would.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Marks this file a module rather than a script. Without it the simulators
|
|
29
|
+
// share one global scope at typecheck time, and `PORT`/`server` collide with
|
|
30
|
+
// the identically-named top-level bindings in simulators/isitup/server.ts.
|
|
31
|
+
export {};
|
|
32
|
+
|
|
33
|
+
interface RequestIpProvider {
|
|
34
|
+
requestIP(req: Request): { address: string } | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PORT = Number(process.env.ECHO_PORT) || 80;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strip the IPv6-mapped-IPv4 prefix Bun returns for IPv4 connections over a
|
|
41
|
+
* dual-stack socket (`::ffff:203.0.113.100` → `203.0.113.100`). Same fix as
|
|
42
|
+
* the namecheap-ddns simulator: the mapped form is not a valid A-record value,
|
|
43
|
+
* and here it would simply never compare equal to what DNS serves.
|
|
44
|
+
*/
|
|
45
|
+
function normalizeIp(address: string): string {
|
|
46
|
+
const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
47
|
+
return mapped ? mapped[1] : address;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sourceIp(req: Request, server: RequestIpProvider): string | null {
|
|
51
|
+
const forwarded = req.headers.get('x-forwarded-for');
|
|
52
|
+
if (forwarded) return normalizeIp(forwarded.split(',')[0].trim());
|
|
53
|
+
const remote = server.requestIP(req);
|
|
54
|
+
return remote?.address ? normalizeIp(remote.address) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const server = Bun.serve({
|
|
58
|
+
port: PORT,
|
|
59
|
+
fetch(req, server) {
|
|
60
|
+
const url = new URL(req.url);
|
|
61
|
+
|
|
62
|
+
if (url.pathname === '/health') return Response.json({ ok: true });
|
|
63
|
+
|
|
64
|
+
const ip = sourceIp(req, server);
|
|
65
|
+
if (!ip) {
|
|
66
|
+
// NEVER a sentinel. A prior version of the DDNS simulator returned
|
|
67
|
+
// `0.0.0.0` when it could not determine the source, and that value went
|
|
68
|
+
// straight into a public zone file. An unanswerable probe must read as
|
|
69
|
+
// an error, which celilo counts as *undetermined* — never as a pass.
|
|
70
|
+
return new Response('source address could not be determined', { status: 500 });
|
|
71
|
+
}
|
|
72
|
+
return new Response(ip, { headers: { 'Content-Type': 'text/plain' } });
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
console.log(`[ip-echo] listening on port ${server.port}`);
|
package/src/address-plan.test.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import { describe, expect, test } from 'bun:test';
|
|
19
19
|
import { execSync } from 'node:child_process';
|
|
20
|
+
import { subnetContains } from '@celilo/capabilities';
|
|
20
21
|
import { SIM_PRIVATE_SUPERNET, ZONE_GATEWAYS, ZONE_SUBNETS, type Zone } from './types';
|
|
21
|
-
import { inCidr } from './zone-classifier';
|
|
22
22
|
|
|
23
23
|
const REPO_ROOT = execSync('git rev-parse --show-toplevel', {
|
|
24
24
|
cwd: import.meta.dir,
|
|
@@ -73,9 +73,9 @@ describe('simulated address plan (#539)', () => {
|
|
|
73
73
|
test('every zone lives inside the sim supernet', () => {
|
|
74
74
|
for (const zone of Object.keys(ZONE_SUBNETS) as Zone[]) {
|
|
75
75
|
const base = ZONE_SUBNETS[zone].split('/')[0];
|
|
76
|
-
expect(
|
|
77
|
-
expect(
|
|
78
|
-
expect(
|
|
76
|
+
expect(subnetContains(SIM_PRIVATE_SUPERNET, base)).toBe(true);
|
|
77
|
+
expect(subnetContains(SIM_PRIVATE_SUPERNET, ZONE_GATEWAYS[zone])).toBe(true);
|
|
78
|
+
expect(subnetContains(ZONE_SUBNETS[zone], ZONE_GATEWAYS[zone])).toBe(true);
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
81
|
|
package/src/cli/build.ts
CHANGED
|
@@ -24,8 +24,16 @@ import { basename, join, resolve } from 'node:path';
|
|
|
24
24
|
import { gunzipSync } from 'node:zlib';
|
|
25
25
|
import { stageAptRepo } from '../../scripts/stage-apt-repo';
|
|
26
26
|
import { stageLibsignal } from '../../scripts/stage-libsignal';
|
|
27
|
+
import { explainBuildFailure } from '../doctor';
|
|
27
28
|
import { ensureRegistryServerBundle } from '../registry-bundle';
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Ceiling on a single `docker build`. The slowest image here is a few minutes
|
|
32
|
+
* cold; 15 leaves generous headroom while bounding a wedged build, which
|
|
33
|
+
* otherwise hangs the whole invocation with no signal at all.
|
|
34
|
+
*/
|
|
35
|
+
const PER_IMAGE_BUILD_TIMEOUT_MS = 15 * 60_000;
|
|
36
|
+
|
|
29
37
|
/**
|
|
30
38
|
* Walk up from pkgDir to find the celilo monorepo root.
|
|
31
39
|
*
|
|
@@ -533,12 +541,34 @@ function buildDockerImages(pkgDir: string): void {
|
|
|
533
541
|
const result = spawnSync('docker', ['build', '-t', tag, '-f', join(dockerDir, file), context], {
|
|
534
542
|
cwd: pkgDir,
|
|
535
543
|
stdio: 'pipe',
|
|
544
|
+
timeout: PER_IMAGE_BUILD_TIMEOUT_MS,
|
|
536
545
|
});
|
|
537
546
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
538
547
|
|
|
548
|
+
// A wedged `docker build` used to hang here forever: spawnSync blocks the
|
|
549
|
+
// event loop, so even the run-lock heartbeat stops — the holder looks alive
|
|
550
|
+
// (its PID is) while beating nothing, and the only way anyone noticed was
|
|
551
|
+
// comparing beatAt to wall-clock by hand. Twice in one evening, ~20 and ~32
|
|
552
|
+
// minutes. A bounded wait turns that into an immediate, nameable failure.
|
|
553
|
+
if (result.error && (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
|
554
|
+
console.log(`${red}✗ TIMED OUT${reset} ${dim}${elapsed}s${reset}`);
|
|
555
|
+
console.error(
|
|
556
|
+
`\n${red}Building ${name} exceeded ${Math.round(PER_IMAGE_BUILD_TIMEOUT_MS / 60_000)} minutes and was killed.${reset}`,
|
|
557
|
+
);
|
|
558
|
+
console.error(
|
|
559
|
+
`${dim}Docker itself is usually still responsive. Reproduce it alone with:${reset}`,
|
|
560
|
+
);
|
|
561
|
+
console.error(`${dim} docker build -f docker/${file} -t ${tag} .${reset}`);
|
|
562
|
+
console.error(
|
|
563
|
+
`${dim}If that succeeds, the stall was transient — re-run \`cele2e build-infra\`.${reset}\n`,
|
|
564
|
+
);
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
|
|
539
568
|
if (result.status !== 0) {
|
|
540
569
|
console.log(`${red}✗${reset} ${dim}${elapsed}s${reset}`);
|
|
541
570
|
console.error(result.stderr?.toString());
|
|
571
|
+
console.error(explainBuildFailure(result.stderr?.toString() ?? ''));
|
|
542
572
|
process.exit(1);
|
|
543
573
|
}
|
|
544
574
|
|
|
@@ -99,6 +99,22 @@ export const COMMANDS: CommandDef[] = [
|
|
|
99
99
|
description: 'Show run-lock holder + network status (exit 3 if busy)',
|
|
100
100
|
flags: [{ name: '--json', description: 'Emit a machine-readable lock snapshot for polling' }],
|
|
101
101
|
},
|
|
102
|
+
{
|
|
103
|
+
name: 'doctor',
|
|
104
|
+
description:
|
|
105
|
+
"Check everything a run needs (baked images, base images, lock, disk) — run's implicit preflight",
|
|
106
|
+
flags: [{ name: '--json', description: 'Emit the full report as JSON' }],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'last',
|
|
110
|
+
description: "Show the most recent run's results dir and counts",
|
|
111
|
+
flags: [
|
|
112
|
+
{
|
|
113
|
+
name: '--json',
|
|
114
|
+
description: 'Emit {runId, resultsDir, total, passed, failed} for scripting',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
102
118
|
{
|
|
103
119
|
name: 'release',
|
|
104
120
|
description: 'Free a run-lock left by --keep/up (does not tear down)',
|
|
@@ -141,4 +157,7 @@ export const RUN_FLAGS = [
|
|
|
141
157
|
|
|
142
158
|
export const DOWN_FLAGS = ['--keep', '--all'];
|
|
143
159
|
|
|
160
|
+
/** Read-only reporters whose only flag is `--json`. */
|
|
161
|
+
export const JSON_FLAG_COMMANDS = ['status', 'doctor', 'last'];
|
|
162
|
+
|
|
144
163
|
export const BUILD_INFRA_FLAGS = ['--save', '--skip-modules', '--published'];
|
package/src/cli/completion.ts
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
* All completions are static (no DB lookups needed).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
BUILD_INFRA_FLAGS,
|
|
8
|
+
COMMANDS,
|
|
9
|
+
DOWN_FLAGS,
|
|
10
|
+
JSON_FLAG_COMMANDS,
|
|
11
|
+
RUN_FLAGS,
|
|
12
|
+
UP_PRESETS,
|
|
13
|
+
} from './command-registry';
|
|
7
14
|
import { findAllTestNames } from './module-discovery';
|
|
8
15
|
|
|
9
16
|
export function getCompletions(words: string[], current: number): string[] {
|
|
@@ -62,6 +69,14 @@ export function getCompletions(words: string[], current: number): string[] {
|
|
|
62
69
|
return filterSuggestions(['zsh', 'bash'], args[1] || '');
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
if (JSON_FLAG_COMMANDS.includes(command) && currentIndex >= 1) {
|
|
73
|
+
const used = new Set(args.slice(1));
|
|
74
|
+
return filterSuggestions(
|
|
75
|
+
['--json'].filter((f) => !used.has(f)),
|
|
76
|
+
args[currentIndex] || '',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
return [];
|
|
66
81
|
}
|
|
67
82
|
|
package/src/cli/index.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* cele2e version
|
|
12
12
|
* cele2e shell [container]
|
|
13
13
|
* cele2e status
|
|
14
|
+
* cele2e doctor
|
|
15
|
+
* cele2e last [--json]
|
|
14
16
|
* cele2e load
|
|
15
17
|
* cele2e scaffold <test-name>
|
|
16
18
|
*/
|
|
@@ -19,11 +21,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
19
21
|
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
20
22
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
21
23
|
import { parse as parseYaml } from 'yaml';
|
|
24
|
+
import { diagnose, formatHolderLine, formatReport } from '../doctor';
|
|
25
|
+
import { readLastRun } from '../last-run';
|
|
22
26
|
import {
|
|
23
27
|
E2eBusyError,
|
|
28
|
+
type LockHolder,
|
|
24
29
|
acquireRunLock,
|
|
25
30
|
clearLock,
|
|
26
|
-
formatBusy,
|
|
27
31
|
lockStatus,
|
|
28
32
|
markKept,
|
|
29
33
|
} from '../run-lock';
|
|
@@ -129,6 +133,19 @@ function discoverSuiteTests(suiteRoot: string): { name: string; file: string; gr
|
|
|
129
133
|
return out;
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Say out loud when we cleared this session's own kept stack. Silence would be
|
|
138
|
+
* worse than the old refusal: the operator asked for a kept stack, and it is
|
|
139
|
+
* gone now for a reason they should be able to read.
|
|
140
|
+
*/
|
|
141
|
+
function noteAutoRelease(holder: LockHolder | null): void {
|
|
142
|
+
if (!holder) return;
|
|
143
|
+
console.log(
|
|
144
|
+
`Auto-released your own kept stack (${holder.test}, held since ${holder.startedAt}) and continuing.`,
|
|
145
|
+
);
|
|
146
|
+
console.log('Use `cele2e run --reuse` instead if you meant to run against it.');
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
function runScript(script: string, args: string[], env?: Record<string, string>): never {
|
|
133
150
|
const result = spawnSync(join(BIN_DIR, script), args, {
|
|
134
151
|
stdio: 'inherit',
|
|
@@ -155,6 +172,8 @@ Commands:
|
|
|
155
172
|
version Show cele2e version
|
|
156
173
|
shell [container] Shell into a running container (default: management)
|
|
157
174
|
status Show run-lock holder + network status (exit 3 if busy)
|
|
175
|
+
doctor Check the environment a run needs (run's implicit preflight)
|
|
176
|
+
last Show the most recent run's results dir + counts
|
|
158
177
|
release Free a run-lock left by --keep/up (does not tear down)
|
|
159
178
|
load Load cached Docker images from tarball
|
|
160
179
|
scaffold <name> Generate a new test file from template
|
|
@@ -180,6 +199,9 @@ Options for \`down\`:
|
|
|
180
199
|
--keep Stop containers but preserve volumes
|
|
181
200
|
--all Also stop competing module containers
|
|
182
201
|
|
|
202
|
+
Options for \`doctor\` / \`last\` / \`status\`:
|
|
203
|
+
--json Machine-readable output
|
|
204
|
+
|
|
183
205
|
Options for \`build-infra\`:
|
|
184
206
|
--save Save Docker images to tarball after build
|
|
185
207
|
--skip-modules Skip .netapp packaging (only rebuild Docker images)
|
|
@@ -341,7 +363,11 @@ switch (command) {
|
|
|
341
363
|
// kept state (cleared by `cele2e down`/`release`) to stop a concurrent run
|
|
342
364
|
// from wiping it.
|
|
343
365
|
try {
|
|
344
|
-
|
|
366
|
+
const outcome = acquireRunLock({
|
|
367
|
+
test: 'up',
|
|
368
|
+
runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID(),
|
|
369
|
+
});
|
|
370
|
+
noteAutoRelease(outcome.autoReleasedOwnKept);
|
|
345
371
|
markKept();
|
|
346
372
|
} catch (err) {
|
|
347
373
|
if (err instanceof E2eBusyError) {
|
|
@@ -385,7 +411,19 @@ switch (command) {
|
|
|
385
411
|
if (lock.free) {
|
|
386
412
|
console.log(' free');
|
|
387
413
|
} else if (lock.holder) {
|
|
388
|
-
|
|
414
|
+
// Heartbeat age is always printed: a wedged holder keeps a live PID, so
|
|
415
|
+
// the only thing separating "slow" from "hung for 32 minutes" is how long
|
|
416
|
+
// ago it last beat — previously visible only by reading beatAt by hand.
|
|
417
|
+
console.log(` BUSY — ${formatHolderLine(lock.holder)}`);
|
|
418
|
+
if (lock.suspect) {
|
|
419
|
+
console.log(
|
|
420
|
+
' ⚠ SUSPECT: the process is alive but has stopped beating. It is probably wedged;',
|
|
421
|
+
);
|
|
422
|
+
console.log(` inspect or kill pid ${lock.holder.pid}, then \`cele2e down\`.`);
|
|
423
|
+
}
|
|
424
|
+
if (lock.ownKept) {
|
|
425
|
+
console.log(' This is YOUR OWN kept stack — the next `cele2e run` auto-releases it.');
|
|
426
|
+
}
|
|
389
427
|
}
|
|
390
428
|
console.log('');
|
|
391
429
|
|
|
@@ -410,6 +448,48 @@ switch (command) {
|
|
|
410
448
|
break;
|
|
411
449
|
}
|
|
412
450
|
|
|
451
|
+
case 'doctor': {
|
|
452
|
+
// The single "is my environment sane?" answer. `run` calls the same
|
|
453
|
+
// diagnose() as its preflight, so what doctor reports is exactly what a run
|
|
454
|
+
// will refuse on — no second, drifting copy of the rules.
|
|
455
|
+
const report = diagnose({ pkgDir: PKG_DIR });
|
|
456
|
+
if (args.includes('--json')) {
|
|
457
|
+
console.log(JSON.stringify(report, null, 2));
|
|
458
|
+
process.exit(report.ok ? 0 : 1);
|
|
459
|
+
}
|
|
460
|
+
console.log('=== cele2e doctor ===');
|
|
461
|
+
for (const line of formatReport(report)) console.log(line);
|
|
462
|
+
console.log('');
|
|
463
|
+
console.log(
|
|
464
|
+
report.ok ? 'Environment looks runnable.' : 'Environment is NOT runnable — see fixes above.',
|
|
465
|
+
);
|
|
466
|
+
process.exit(report.ok ? 0 : 1);
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
case 'last': {
|
|
471
|
+
// Which results directory was MINE? Inferring it from `ls -t results` returns
|
|
472
|
+
// the newest run, which after a refused start is somebody else's — a suite
|
|
473
|
+
// that never executed then reads as a clean pass.
|
|
474
|
+
const last = readLastRun();
|
|
475
|
+
if (args.includes('--json')) {
|
|
476
|
+
console.log(JSON.stringify(last));
|
|
477
|
+
process.exit(last ? 0 : 1);
|
|
478
|
+
}
|
|
479
|
+
if (!last) {
|
|
480
|
+
console.error('No cele2e run recorded yet.');
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
console.log(`runId: ${last.runId}`);
|
|
484
|
+
console.log(`resultsDir: ${last.resultsDir}`);
|
|
485
|
+
console.log(`status: ${last.status}`);
|
|
486
|
+
console.log(
|
|
487
|
+
`results: ${last.passed} passed, ${last.failed} failed${last.skipped > 0 ? `, ${last.skipped} skipped (blocked by an earlier stage)` : ''} of ${last.total}`,
|
|
488
|
+
);
|
|
489
|
+
process.exit(0);
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
|
|
413
493
|
case 'load':
|
|
414
494
|
runScript('e2e-load', args);
|
|
415
495
|
break;
|
|
@@ -422,10 +502,11 @@ switch (command) {
|
|
|
422
502
|
// build-infra mutates the shared Docker images — serialize against runs.
|
|
423
503
|
// Released via the lock's exit handler (runBuild may process.exit on failure).
|
|
424
504
|
try {
|
|
425
|
-
acquireRunLock({
|
|
505
|
+
const outcome = acquireRunLock({
|
|
426
506
|
test: 'build-infra',
|
|
427
507
|
runId: process.env.CELE2E_RUN_ID ?? crypto.randomUUID(),
|
|
428
508
|
});
|
|
509
|
+
noteAutoRelease(outcome.autoReleasedOwnKept);
|
|
429
510
|
} catch (err) {
|
|
430
511
|
if (err instanceof E2eBusyError) {
|
|
431
512
|
console.error(err.message);
|
package/src/container-manager.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { type ExecSyncOptions, execSync, spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { basename, join, resolve } from 'node:path';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
6
|
import { startBrowser } from './browser';
|
|
6
7
|
import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
|
|
7
8
|
import {
|
|
8
9
|
SHARED_PROJECT_NAME,
|
|
10
|
+
firewallZoneLegs,
|
|
9
11
|
generateTestComposeYaml,
|
|
10
12
|
getAllMachines,
|
|
11
13
|
} from './docker-compose-generator';
|
|
14
|
+
import { explainBuildFailure } from './doctor';
|
|
12
15
|
import { ensureSharedInfra } from './shared-infra';
|
|
13
16
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
14
17
|
import { startSocksProxy } from './socks-proxy';
|
|
@@ -20,8 +23,15 @@ import type {
|
|
|
20
23
|
NetworkHandle,
|
|
21
24
|
ProxyOptions,
|
|
22
25
|
SocksProxyHandle,
|
|
26
|
+
TopologyPreset,
|
|
27
|
+
} from './types';
|
|
28
|
+
import {
|
|
29
|
+
CeliloCommandError,
|
|
30
|
+
ZONE_GATEWAYS,
|
|
31
|
+
ZONE_SUBNETS,
|
|
32
|
+
greenwaveRouterIp,
|
|
33
|
+
internalNatIp,
|
|
23
34
|
} from './types';
|
|
24
|
-
import { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS, internalNatIp } from './types';
|
|
25
35
|
|
|
26
36
|
/** Package root — where docker/, config/, simulators/ live */
|
|
27
37
|
const PACKAGE_ROOT = join(__dirname, '..');
|
|
@@ -499,7 +509,11 @@ async function streamingBuild(
|
|
|
499
509
|
console.log('[progress:done] images built');
|
|
500
510
|
resolve();
|
|
501
511
|
} else {
|
|
502
|
-
reject(
|
|
512
|
+
reject(
|
|
513
|
+
new Error(
|
|
514
|
+
`docker compose build failed (exit ${code}):\n${stderr.slice(-500)}${explainBuildFailure(stderr)}`,
|
|
515
|
+
),
|
|
516
|
+
);
|
|
503
517
|
}
|
|
504
518
|
});
|
|
505
519
|
});
|
|
@@ -515,6 +529,7 @@ function buildNetworkHandle(
|
|
|
515
529
|
projectName: string,
|
|
516
530
|
composeDir: string,
|
|
517
531
|
celiloRoot: string | undefined,
|
|
532
|
+
topology: TopologyPreset,
|
|
518
533
|
): NetworkHandle {
|
|
519
534
|
// Track host-side resources spawned via the handle (SOCKS proxies,
|
|
520
535
|
// Playwright browsers). stop() tears these down before the docker
|
|
@@ -598,10 +613,21 @@ function buildNetworkHandle(
|
|
|
598
613
|
},
|
|
599
614
|
|
|
600
615
|
async deployFirewall(opts = {}): Promise<void> {
|
|
601
|
-
|
|
616
|
+
// `opts.zones` says which zones get `provided_networks` written to system
|
|
617
|
+
// config. It does NOT say which NICs the box has — the compose topology
|
|
618
|
+
// decides that, and it wires fw-main to every segmented zone regardless of
|
|
619
|
+
// what a test passed. Roughly twenty call sites passed `['dmz']` against a
|
|
620
|
+
// four-leg firewall, so every one of them under-declared the hardware.
|
|
621
|
+
//
|
|
622
|
+
// Deriving the declared list from the topology makes under-declaring
|
|
623
|
+
// structurally impossible rather than something each future test author has
|
|
624
|
+
// to remember, and removes one more hand-maintained copy of a list the
|
|
625
|
+
// compose generator already owns.
|
|
626
|
+
const providedZones = opts.zones ?? ['dmz', 'app', 'secure'];
|
|
627
|
+
const declaredZones = firewallZoneLegs(topology);
|
|
602
628
|
const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
|
|
603
629
|
const natIp = opts.natIp ?? internalNatIp();
|
|
604
|
-
const providedNetworks =
|
|
630
|
+
const providedNetworks = providedZones.map((zone) => ({
|
|
605
631
|
zone,
|
|
606
632
|
subnet: ZONE_SUBNETS[zone],
|
|
607
633
|
gateway: ZONE_GATEWAYS[zone],
|
|
@@ -637,15 +663,23 @@ function buildNetworkHandle(
|
|
|
637
663
|
await handle.celilo('module import iptables');
|
|
638
664
|
await handle.celilo(`module config set iptables firewall_ip ${firewallIp}`);
|
|
639
665
|
await handle.celilo(`module config set iptables nat_ip ${natIp}`);
|
|
640
|
-
await handle.celilo(
|
|
641
|
-
`module config set iptables zones '${JSON.stringify(['internal', ...zones])}'`,
|
|
642
|
-
);
|
|
666
|
+
await handle.celilo(`module config set iptables zones '${JSON.stringify(declaredZones)}'`);
|
|
643
667
|
await handle.celilo(
|
|
644
668
|
`module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
|
|
645
669
|
);
|
|
646
670
|
await handle.celilo('module deploy iptables', 180_000);
|
|
647
671
|
},
|
|
648
672
|
|
|
673
|
+
async deployGreenwave(): Promise<void> {
|
|
674
|
+
const routerIp = greenwaveRouterIp();
|
|
675
|
+
await handle.celilo(`machine add ${routerIp} --ssh-user root --earmark greenwave`);
|
|
676
|
+
await handle.celilo('module import greenwave');
|
|
677
|
+
await handle.celilo(`module config set greenwave router_ip ${routerIp}`);
|
|
678
|
+
await handle.celilo('module secret set greenwave router_username admin');
|
|
679
|
+
await handle.celilo('module secret set greenwave router_password admin');
|
|
680
|
+
await handle.celilo('module deploy greenwave', 180_000);
|
|
681
|
+
},
|
|
682
|
+
|
|
649
683
|
async socksProxy(options: ProxyOptions = {}): Promise<SocksProxyHandle> {
|
|
650
684
|
const proxy = await startSocksProxy(projectName, options);
|
|
651
685
|
activeProxies.push(proxy);
|
|
@@ -889,7 +923,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
889
923
|
// means the test is replacing the infra resolver with a module under test
|
|
890
924
|
// (e.g., knot-unbound-internal). The bare target machine has no DNS server
|
|
891
925
|
// until its module deploys, so this wait would always time out. Management
|
|
892
|
-
// can still resolve names via the fallback `nameserver
|
|
926
|
+
// can still resolve names via the fallback `nameserver 203.0.113.1` in
|
|
893
927
|
// its resolv.conf during deploy.
|
|
894
928
|
//
|
|
895
929
|
// The resolver-under-test may sit in ANY zone, not just `internal`: ISS-0156
|
|
@@ -1063,7 +1097,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
1063
1097
|
config.managementZone === 'secure-mgmt' || (config.secureMgmtMachines ?? []).length > 0
|
|
1064
1098
|
? `network.secure-mgmt.subnet=${ZONE_SUBNETS['secure-mgmt']} network.secure-mgmt.gateway=${ZONE_GATEWAYS['secure-mgmt']} `
|
|
1065
1099
|
: ''
|
|
1066
|
-
}dns.primary=
|
|
1100
|
+
}dns.primary=203.0.113.1 \
|
|
1067
1101
|
dns.fallback=1.0.0.1,8.8.8.8`,
|
|
1068
1102
|
);
|
|
1069
1103
|
if (initResult.exitCode !== 0) {
|
|
@@ -1095,7 +1129,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
1095
1129
|
// Emit project name so the runner can persist it if --keep is set
|
|
1096
1130
|
console.log(`[e2e:project] ${projectName}`);
|
|
1097
1131
|
|
|
1098
|
-
return buildNetworkHandle(projectName, composeDir, celiloRoot);
|
|
1132
|
+
return buildNetworkHandle(projectName, composeDir, celiloRoot, config.topology);
|
|
1099
1133
|
}
|
|
1100
1134
|
|
|
1101
1135
|
/**
|
|
@@ -1106,6 +1140,26 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
1106
1140
|
*
|
|
1107
1141
|
* If the project doesn't exist, throws with an actionable error.
|
|
1108
1142
|
*/
|
|
1143
|
+
/**
|
|
1144
|
+
* Which topology a already-running stack was built with, read back from its
|
|
1145
|
+
* generated compose file. `direct-internet` is the one where fw-main holds a leg
|
|
1146
|
+
* on `isp-external`; everything else is `default`.
|
|
1147
|
+
*/
|
|
1148
|
+
function topologyFromComposeFile(composePath: string): TopologyPreset {
|
|
1149
|
+
try {
|
|
1150
|
+
const compose = parseYaml(readFileSync(composePath, 'utf-8')) as {
|
|
1151
|
+
services?: Record<string, { networks?: Record<string, unknown> }>;
|
|
1152
|
+
};
|
|
1153
|
+
return compose.services?.['fw-main']?.networks?.['isp-external']
|
|
1154
|
+
? 'direct-internet'
|
|
1155
|
+
: 'default';
|
|
1156
|
+
} catch {
|
|
1157
|
+
// An unreadable compose file means the stack was not built by this
|
|
1158
|
+
// generator; `default` is the topology the fleet runs and the safer guess.
|
|
1159
|
+
return 'default';
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1109
1163
|
export function reconnectNetwork(projectName: string): NetworkHandle {
|
|
1110
1164
|
const composeDir = PACKAGE_ROOT;
|
|
1111
1165
|
|
|
@@ -1128,5 +1182,14 @@ export function reconnectNetwork(projectName: string): NetworkHandle {
|
|
|
1128
1182
|
console.log(`[progress:done] reconnected to existing network (${projectName})`);
|
|
1129
1183
|
console.log(`[e2e:project] ${projectName}`);
|
|
1130
1184
|
|
|
1131
|
-
|
|
1185
|
+
// `--reuse` has no NetworkConfig to read, so the topology is recovered from the
|
|
1186
|
+
// compose file that built the running stack — the same artifact the generator
|
|
1187
|
+
// wrote. Guessing a default here would silently mis-declare the firewall's legs
|
|
1188
|
+
// on exactly the runs used for debugging.
|
|
1189
|
+
return buildNetworkHandle(
|
|
1190
|
+
projectName,
|
|
1191
|
+
composeDir,
|
|
1192
|
+
findCeliloRoot(),
|
|
1193
|
+
topologyFromComposeFile(join(composeDir, COMPOSE_FILE)),
|
|
1194
|
+
);
|
|
1132
1195
|
}
|