@celilo/e2e 0.8.0 → 0.9.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 +26 -0
- package/bin/e2e-bake-management +214 -85
- package/package.json +2 -2
- package/simulators/namecheap-ddns/zone-updater.test.ts +31 -0
- package/simulators/namecheap-ddns/zone-updater.ts +26 -0
- package/src/cli/build.test.ts +47 -0
- package/src/cli/build.ts +283 -11
- package/src/cli/command-registry.ts +3 -1
- package/src/cli/completion.ts +2 -0
- package/src/cli/index.ts +4 -1
- package/src/cli-version-contract.test.ts +22 -0
- package/src/cli-version-contract.ts +37 -0
- package/src/container-manager.ts +503 -618
- package/src/docker-compose-generator.ts +5 -3
- package/src/network-builder.ts +20 -28
- package/src/runner.ts +74 -0
- package/src/shared-infra.ts +2 -28
- package/src/types.ts +16 -1
- package/config/dns/example.net.zone +0 -16
- package/config/dns/iamtheinternet.org.zone +0 -16
|
@@ -18,9 +18,11 @@ import { ZONE_GATEWAYS } from './types';
|
|
|
18
18
|
* - **npm-installed consumer** (lunacycle and friends) — `../../modules`
|
|
19
19
|
* would resolve to `<consumer>/node_modules/modules`, which doesn't
|
|
20
20
|
* exist. Docker fails the mount with "permission denied" trying to
|
|
21
|
-
* create that path. Instead we bind the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* create that path. Instead we bind the local `./netapps/` dir to
|
|
22
|
+
* `/uploads`, and the registry's scanUploadsDir picks up the .netapp
|
|
23
|
+
* files. That dir is populated at `cele2e build-infra` time by fetching
|
|
24
|
+
* the standard-module netapps from the public registry (see
|
|
25
|
+
* stageNetappsFromRegistry in cli/build.ts) — consumers vendor nothing.
|
|
24
26
|
*
|
|
25
27
|
* Detection: check whether the would-be monorepo modules dir is real.
|
|
26
28
|
* `import.meta.dir` is `<pkgDir>/src/`, so `../../../modules` from there
|
package/src/network-builder.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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 { NetworkConfig, NetworkHandle, TopologyPreset, Vantage } from './types';
|
|
5
|
+
import type { MachineSpec, NetworkConfig, NetworkHandle, TopologyPreset, Vantage, Zone } from './types';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* If a mounted path looks like a celilo module (has scripts/package.json),
|
|
@@ -51,53 +51,45 @@ export class NetworkBuilder {
|
|
|
51
51
|
return this;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */
|
|
55
|
+
private addMachines(
|
|
56
|
+
target: MachineSpec[],
|
|
57
|
+
zone: Zone,
|
|
58
|
+
machines: Record<string, string>,
|
|
59
|
+
extra?: Pick<MachineSpec, 'docker' | 'dockerfile'>,
|
|
60
|
+
): this {
|
|
55
61
|
for (const [name, ip] of Object.entries(machines)) {
|
|
56
|
-
|
|
62
|
+
target.push({ name, ip, zone, ...extra });
|
|
57
63
|
}
|
|
58
64
|
return this;
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
dmz(machines: Record<string, string>): this {
|
|
68
|
+
return this.addMachines(this.config.dmzMachines, 'dmz', machines);
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
app(machines: Record<string, string>): this {
|
|
62
|
-
|
|
63
|
-
this.config.appMachines.push({ name, ip, zone: 'app' });
|
|
64
|
-
}
|
|
65
|
-
return this;
|
|
72
|
+
return this.addMachines(this.config.appMachines, 'app', machines);
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
secure(machines: Record<string, string>): this {
|
|
69
|
-
|
|
70
|
-
this.config.secureMachines.push({ name, ip, zone: 'secure' });
|
|
71
|
-
}
|
|
72
|
-
return this;
|
|
76
|
+
return this.addMachines(this.config.secureMachines, 'secure', machines);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
internal(machines: Record<string, string>): this {
|
|
76
|
-
|
|
77
|
-
this.config.internalMachines.push({ name, ip, zone: 'internal' });
|
|
78
|
-
}
|
|
79
|
-
return this;
|
|
80
|
+
return this.addMachines(this.config.internalMachines, 'internal', machines);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */
|
|
83
84
|
internalDocker(machines: Record<string, string>): this {
|
|
84
|
-
|
|
85
|
-
this.config.internalMachines.push({ name, ip, zone: 'internal', docker: true });
|
|
86
|
-
}
|
|
87
|
-
return this;
|
|
85
|
+
return this.addMachines(this.config.internalMachines, 'internal', machines, { docker: true });
|
|
88
86
|
}
|
|
89
87
|
|
|
90
88
|
/** Add a Technitium DNS machine using the dedicated pre-installed image */
|
|
91
89
|
technitium(machines: Record<string, string>): this {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
ip,
|
|
96
|
-
zone: 'internal',
|
|
97
|
-
dockerfile: 'docker/Dockerfile.target-technitium',
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
return this;
|
|
90
|
+
return this.addMachines(this.config.internalMachines, 'internal', machines, {
|
|
91
|
+
dockerfile: 'docker/Dockerfile.target-technitium',
|
|
92
|
+
});
|
|
101
93
|
}
|
|
102
94
|
|
|
103
95
|
withDhcpClient(): this {
|
package/src/runner.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from './bus-events';
|
|
32
32
|
import { parseLine } from './parse-line';
|
|
33
33
|
import { acquireRunLock, E2eBusyError, markKept } from './run-lock';
|
|
34
|
+
import { SIMULATOR_IPS } from './simulator-ips';
|
|
34
35
|
|
|
35
36
|
// ─── Config ──────────────────────────────────────────────────────────
|
|
36
37
|
|
|
@@ -91,6 +92,32 @@ const flagCiSafe = rawArgs.includes('--ci-safe');
|
|
|
91
92
|
// --notify: fire a desktop notification when the run finishes (handy for long
|
|
92
93
|
// detached/background runs). Best-effort — never affects the exit code.
|
|
93
94
|
const flagNotify = rawArgs.includes('--notify');
|
|
95
|
+
// --shuffle [--seed=N]: randomize test order to surface order-dependent bugs
|
|
96
|
+
// (e2e-confidence #258). The install-sh DNS-pollution bug only manifested in a
|
|
97
|
+
// specific order — a shuffle lane flushes that class out. Seeded + logged so a
|
|
98
|
+
// failing order is reproducible: replay with the printed --seed=N.
|
|
99
|
+
const flagShuffle = rawArgs.includes('--shuffle');
|
|
100
|
+
const seedFlag = rawArgs.find((a) => a.startsWith('--seed='));
|
|
101
|
+
const seedArg = seedFlag ? Number.parseInt(seedFlag.split('=')[1] ?? '', 10) : undefined;
|
|
102
|
+
|
|
103
|
+
/** Deterministic Fisher-Yates shuffle seeded by a 32-bit value (mulberry32), so
|
|
104
|
+
* a randomized test order is reproducible via --seed=<n>. */
|
|
105
|
+
function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
|
|
106
|
+
let s = seed >>> 0;
|
|
107
|
+
const rand = (): number => {
|
|
108
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
109
|
+
let t = s;
|
|
110
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
111
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
112
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
113
|
+
};
|
|
114
|
+
const out = [...arr];
|
|
115
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
116
|
+
const j = Math.floor(rand() * (i + 1));
|
|
117
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
94
121
|
|
|
95
122
|
function notify(title: string, message: string): void {
|
|
96
123
|
if (!flagNotify) return;
|
|
@@ -411,6 +438,31 @@ function stopSharedInfra(): void {
|
|
|
411
438
|
try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
|
|
412
439
|
}
|
|
413
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Detect cross-test pollution of the SHARED authoritative DNS at the test
|
|
443
|
+
* boundary (e2e-confidence #254). A test that leaves a protected sim record
|
|
444
|
+
* clobbered — canonically celilo.computer's apex off the website-sim, the bug
|
|
445
|
+
* that silently broke install-sh ~15 tests downstream — has violated the shared
|
|
446
|
+
* contract. Checked AFTER each test so the polluter is named HERE, at the cause,
|
|
447
|
+
* instead of a later test failing mysteriously. On detection we re-scrub
|
|
448
|
+
* (cp /seed -> /config, mirroring scrubDnsZones) so the damage doesn't cascade.
|
|
449
|
+
*
|
|
450
|
+
* Returns a description of the violation (and heals) if polluted, else null.
|
|
451
|
+
* A query failure (infra down / tool missing) is not treated as pollution.
|
|
452
|
+
*/
|
|
453
|
+
function detectSharedDnsPollution(): string | null {
|
|
454
|
+
const served = dockerComposeShared(
|
|
455
|
+
`exec -T namecheap-dns sh -c ${JSON.stringify('kdig @127.0.0.1 celilo.computer A +short')}`,
|
|
456
|
+
).trim();
|
|
457
|
+
if (!served) return null;
|
|
458
|
+
if (served.split(/\s+/).includes(SIMULATOR_IPS.WEBSITE)) return null;
|
|
459
|
+
// Polluted — heal so the next test starts from a clean baseline.
|
|
460
|
+
dockerComposeShared(
|
|
461
|
+
`exec -T namecheap-dns sh -c ${JSON.stringify('cp -f /seed/*.zone /config/ && knotc zone-reload')}`,
|
|
462
|
+
);
|
|
463
|
+
return `celilo.computer apex resolves to "${served}" (expected the website-sim ${SIMULATOR_IPS.WEBSITE})`;
|
|
464
|
+
}
|
|
465
|
+
|
|
414
466
|
// ─── Docker Check ────────────────────────────────────────────────────
|
|
415
467
|
|
|
416
468
|
function assertDockerAvailable(): void {
|
|
@@ -545,6 +597,17 @@ async function main() {
|
|
|
545
597
|
process.exit(1);
|
|
546
598
|
}
|
|
547
599
|
|
|
600
|
+
// e2e-confidence #258: optionally randomize order to surface order-dependent
|
|
601
|
+
// bugs (the install-sh DNS bleed only manifested in a specific order). Seeded
|
|
602
|
+
// + logged so a failing order replays exactly via --seed=<n>.
|
|
603
|
+
if (flagShuffle) {
|
|
604
|
+
const seed = (seedArg !== undefined && Number.isFinite(seedArg) ? seedArg : Date.now()) >>> 0;
|
|
605
|
+
testFiles = seededShuffle(testFiles, seed);
|
|
606
|
+
console.log(
|
|
607
|
+
`${dim}--shuffle: randomized test order (seed=${seed}) — replay this exact order with --seed=${seed}${reset}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
548
611
|
// --keep keeps a single test network alive after the run; only one network
|
|
549
612
|
// can live in `.e2e-persistent.json` at a time, so multiple tests would
|
|
550
613
|
// either fight for that slot or run with no functional --keep at all. Bail
|
|
@@ -664,6 +727,17 @@ async function main() {
|
|
|
664
727
|
|
|
665
728
|
emitTestStarted({ runId, name, expectedDurationS: expected });
|
|
666
729
|
const result = await runTest(file, name, expected, logDir, display);
|
|
730
|
+
|
|
731
|
+
// e2e-confidence #254: name a test that leaves shared infra polluted, at the
|
|
732
|
+
// boundary — before a later test fails mysteriously downstream. detect…()
|
|
733
|
+
// also heals (re-scrubs), so a passing-but-polluting test is failed here AND
|
|
734
|
+
// the next test still starts clean.
|
|
735
|
+
const pollution = detectSharedDnsPollution();
|
|
736
|
+
if (pollution && result.status === 'pass') {
|
|
737
|
+
result.status = 'fail';
|
|
738
|
+
result.error = `cross-test pollution (#254): this test left shared DNS dirty — ${pollution}`;
|
|
739
|
+
}
|
|
740
|
+
|
|
667
741
|
emitTestCompleted({
|
|
668
742
|
runId,
|
|
669
743
|
name,
|
package/src/shared-infra.ts
CHANGED
|
@@ -28,8 +28,6 @@ function run(cmd: string, opts?: { cwd?: string; timeout?: number }): string {
|
|
|
28
28
|
}).trim();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
let sharedInfraRunning = false;
|
|
32
|
-
|
|
33
31
|
/**
|
|
34
32
|
* Remove ALL celilo-e2e-* Docker resources by name prefix (#212). Used at
|
|
35
33
|
* start-of-run, where the run-lock guarantees no other session's stack is live.
|
|
@@ -74,10 +72,8 @@ function nukeE2eResources(e2eDir: string): void {
|
|
|
74
72
|
* per-test stack failed with "network … declared as external,
|
|
75
73
|
* but could not be found" when starting per-test services).
|
|
76
74
|
*
|
|
77
|
-
* Source of truth is Docker
|
|
78
|
-
*
|
|
79
|
-
* fresh bun process so that flag is always `false` at startup. The
|
|
80
|
-
* flag exists only as a within-process cache for redundant calls.
|
|
75
|
+
* Source of truth is always Docker — each test file runs in a fresh
|
|
76
|
+
* bun process, so there is no reliable in-process cache to lean on.
|
|
81
77
|
*/
|
|
82
78
|
export function isSharedInfraRunning(): boolean {
|
|
83
79
|
try {
|
|
@@ -86,20 +82,16 @@ export function isSharedInfraRunning(): boolean {
|
|
|
86
82
|
{ cwd: getE2eDir() },
|
|
87
83
|
);
|
|
88
84
|
if (containers.trim().length === 0) {
|
|
89
|
-
sharedInfraRunning = false;
|
|
90
85
|
return false;
|
|
91
86
|
}
|
|
92
87
|
const networks = run('docker network ls --format "{{.Name}}"');
|
|
93
88
|
const networkLines = networks.split('\n');
|
|
94
89
|
const haveAll = Object.values(SHARED_NETWORKS).every((n) => networkLines.includes(n));
|
|
95
90
|
if (!haveAll) {
|
|
96
|
-
sharedInfraRunning = false;
|
|
97
91
|
return false;
|
|
98
92
|
}
|
|
99
|
-
sharedInfraRunning = true;
|
|
100
93
|
return true;
|
|
101
94
|
} catch {
|
|
102
|
-
sharedInfraRunning = false;
|
|
103
95
|
return false;
|
|
104
96
|
}
|
|
105
97
|
}
|
|
@@ -192,7 +184,6 @@ export async function ensureSharedInfra(): Promise<void> {
|
|
|
192
184
|
{ cwd: e2eDir, timeout: 10_000 },
|
|
193
185
|
);
|
|
194
186
|
if (result.trim().length > 0) {
|
|
195
|
-
sharedInfraRunning = true;
|
|
196
187
|
console.log('[progress:done] shared infrastructure ready');
|
|
197
188
|
return;
|
|
198
189
|
}
|
|
@@ -241,21 +232,4 @@ export async function stopSharedInfra(): Promise<void> {
|
|
|
241
232
|
} catch {
|
|
242
233
|
// Best effort
|
|
243
234
|
}
|
|
244
|
-
sharedInfraRunning = false;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Execute a command in a shared infrastructure container.
|
|
249
|
-
*/
|
|
250
|
-
export function sharedExec(container: string, cmd: string, timeoutMs = 60_000): { stdout: string; exitCode: number } {
|
|
251
|
-
try {
|
|
252
|
-
const stdout = run(
|
|
253
|
-
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T ${container} bash -c ${JSON.stringify(cmd)}`,
|
|
254
|
-
{ cwd: getE2eDir(), timeout: timeoutMs },
|
|
255
|
-
);
|
|
256
|
-
return { stdout, exitCode: 0 };
|
|
257
|
-
} catch (err: unknown) {
|
|
258
|
-
const e = err as { stdout?: string; status?: number };
|
|
259
|
-
return { stdout: e.stdout?.toString() ?? '', exitCode: e.status ?? 1 };
|
|
260
|
-
}
|
|
261
235
|
}
|
package/src/types.ts
CHANGED
|
@@ -193,11 +193,17 @@ export interface NetworkHandle {
|
|
|
193
193
|
/** Resolve a DNS name from the management machine */
|
|
194
194
|
dig(name: string): Promise<string>;
|
|
195
195
|
|
|
196
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Wait for a condition with timeout. On timeout, `onTimeout` (if given) is
|
|
198
|
+
* invoked to collect live-state diagnostics that are appended to the error —
|
|
199
|
+
* so a readiness failure self-diagnoses instead of speculating
|
|
200
|
+
* (e2e-confidence #255).
|
|
201
|
+
*/
|
|
197
202
|
waitFor(
|
|
198
203
|
check: () => Promise<boolean>,
|
|
199
204
|
timeoutMs: number,
|
|
200
205
|
label: string,
|
|
206
|
+
onTimeout?: () => string | Promise<string>,
|
|
201
207
|
): Promise<void>;
|
|
202
208
|
|
|
203
209
|
/**
|
|
@@ -342,6 +348,15 @@ export interface ResponderValues {
|
|
|
342
348
|
secretValues?: Record<string, string>;
|
|
343
349
|
}
|
|
344
350
|
>;
|
|
351
|
+
/**
|
|
352
|
+
* Aspect-consent decisions (ISS-0027 / #262). When a headless deploy emits
|
|
353
|
+
* `aspect.required.<module>.<role>`, the responder replies `{ consented }`
|
|
354
|
+
* so a module's base_module_aspect fan-out is approved without a TTY and
|
|
355
|
+
* without `--accept-aspects` at import. Lookup precedence: `<module>.<role>`,
|
|
356
|
+
* then `<module>`, then the `'*'` wildcard. Absent → skipped (never
|
|
357
|
+
* silently approved). Passed straight through to `celilo events respond`.
|
|
358
|
+
*/
|
|
359
|
+
aspects?: Record<string, boolean>;
|
|
345
360
|
}
|
|
346
361
|
|
|
347
362
|
export const ZONE_GATEWAYS: Record<Zone, string> = {
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
$ORIGIN example.net.
|
|
2
|
-
$TTL 300
|
|
3
|
-
|
|
4
|
-
@ IN SOA ns1.example.net. admin.example.net. (
|
|
5
|
-
2024020108 300 60 604800 300
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
|
|
9
|
-
; the e2e simulation is to model real internet connectivity, where
|
|
10
|
-
; private IPs are unreachable from outside. All public-facing names
|
|
11
|
-
; resolve to the firewall's external IP (100.100.0.100), which DNATs
|
|
12
|
-
; inbound to the right internal host.
|
|
13
|
-
@ IN NS ns1.example.net.
|
|
14
|
-
ns1 IN A 100.64.0.55
|
|
15
|
-
@ IN A 100.100.0.100
|
|
16
|
-
www IN A 100.100.0.100
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
$ORIGIN iamtheinternet.org.
|
|
2
|
-
$TTL 300
|
|
3
|
-
|
|
4
|
-
@ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
|
|
5
|
-
2024020247 300 60 604800 300
|
|
6
|
-
)
|
|
7
|
-
|
|
8
|
-
; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
|
|
9
|
-
; the e2e simulation is to model real internet connectivity, where
|
|
10
|
-
; private IPs are unreachable from outside. All public-facing names
|
|
11
|
-
; resolve to the firewall's external IP (100.100.0.100), which DNATs
|
|
12
|
-
; inbound to the right internal host.
|
|
13
|
-
@ IN NS ns1.iamtheinternet.org.
|
|
14
|
-
ns1 IN A 100.64.0.55
|
|
15
|
-
@ IN A 0.0.0.0
|
|
16
|
-
www IN A 100.100.0.100
|