@celilo/e2e 0.7.17 → 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/config/routing/fw-main-routes.sh +26 -9
- package/package.json +3 -5
- 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 +11 -3
- package/src/cli/completion.ts +9 -2
- package/src/cli/index.ts +59 -3
- package/src/cli-version-contract.test.ts +22 -0
- package/src/cli-version-contract.ts +37 -0
- package/src/container-manager.ts +522 -606
- package/src/docker-compose-generator.ts +5 -3
- package/src/network-builder.ts +20 -28
- package/src/run-lock.test.ts +105 -0
- package/src/run-lock.ts +252 -0
- package/src/runner.ts +121 -13
- package/src/shared-infra.ts +36 -58
- package/src/types.ts +16 -1
- package/AGENTS.md +0 -117
- package/COVERAGE.md +0 -60
- package/config/dns/iamtheinternet.org.zone +0 -16
package/src/container-manager.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type ExecSyncOptions, execSync, spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { basename, join, resolve } from 'node:path';
|
|
5
5
|
import { startBrowser } from './browser';
|
|
6
|
+
import { MIN_CLI_VERSION, checkCliVersion } from './cli-version-contract';
|
|
6
7
|
import { SHARED_PROJECT_NAME, generateTestComposeYaml } from './docker-compose-generator';
|
|
7
8
|
import { ensureSharedInfra } from './shared-infra';
|
|
8
9
|
import { SIMULATOR_IPS } from './simulator-ips';
|
|
@@ -16,7 +17,7 @@ import type {
|
|
|
16
17
|
ProxyOptions,
|
|
17
18
|
SocksProxyHandle,
|
|
18
19
|
} from './types';
|
|
19
|
-
import { CeliloCommandError,
|
|
20
|
+
import { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS, internalNatIp } from './types';
|
|
20
21
|
|
|
21
22
|
/** Package root — where docker/, config/, simulators/ live */
|
|
22
23
|
const PACKAGE_ROOT = join(__dirname, '..');
|
|
@@ -126,45 +127,68 @@ const SHARED_CONTAINERS = new Set([
|
|
|
126
127
|
]);
|
|
127
128
|
|
|
128
129
|
/**
|
|
129
|
-
*
|
|
130
|
+
* Reset the SHARED authoritative DNS (namecheap-dns) to its pristine seed
|
|
131
|
+
* before a test starts, so a prior test's DDNS writes don't bleed forward.
|
|
130
132
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
133
|
+
* namecheap-dns is SHARED across the whole suite. Knot serves the WRITABLE
|
|
134
|
+
* `/config/<zone>.zone` copies, which the DDNS simulator mutates at runtime;
|
|
135
|
+
* `/seed/<zone>.zone` is the read-only pristine mount, copied to `/config` once
|
|
136
|
+
* by namecheap-startup.sh at container start. So the only correct reset is to
|
|
137
|
+
* re-copy `/seed -> /config` inside the container and reload Knot.
|
|
135
138
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
139
|
+
* History: the previous implementation rewrote the HOST-side zone file (which
|
|
140
|
+
* is bind-mounted to `/seed`, not `/config`) and reloaded — Knot never re-read
|
|
141
|
+
* it, so the scrub was a silent no-op. iamtheinternet.org survived only because
|
|
142
|
+
* every test re-registers its own hosts; celilo.computer's apex is never
|
|
143
|
+
* re-registered, so once a celilo.computer-managed test clobbered it (apex ->
|
|
144
|
+
* the fw-ext source IP instead of the 100.64.0.58 website-sim) it stayed broken
|
|
145
|
+
* for the rest of the run — silently failing install-sh. (ISS: cele2e DNS bleed.)
|
|
146
|
+
*
|
|
147
|
+
* Called automatically by startNetwork unless config.skipDnsScrub is set
|
|
148
|
+
* (useful for --keep debugging).
|
|
149
|
+
*
|
|
150
|
+
* Exported for the effect-proving integration test (e2e-confidence #256).
|
|
138
151
|
*/
|
|
139
|
-
async function scrubDnsZones(): Promise<void> {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
152
|
+
export async function scrubDnsZones(): Promise<void> {
|
|
153
|
+
const exec = (cmd: string): string =>
|
|
154
|
+
run(
|
|
155
|
+
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns sh -c ${JSON.stringify(
|
|
156
|
+
cmd,
|
|
157
|
+
)}`,
|
|
158
|
+
{ cwd: PACKAGE_ROOT },
|
|
159
|
+
);
|
|
146
160
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// if it fails (template missing, permissions), tests can still run
|
|
155
|
-
// with stale DNS state.
|
|
156
|
-
console.warn(`[dns-scrub] Failed to copy ${zone} template: ${err}`);
|
|
157
|
-
}
|
|
161
|
+
try {
|
|
162
|
+
exec('cp -f /seed/*.zone /config/ && knotc zone-reload');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Non-fatal hygiene measure: log and continue. A failed reset just means a
|
|
165
|
+
// test may see stale DNS — better than aborting the run.
|
|
166
|
+
console.warn(`[dns-scrub] Failed to reset zones to seed: ${err}`);
|
|
167
|
+
return;
|
|
158
168
|
}
|
|
159
169
|
|
|
160
|
-
//
|
|
170
|
+
// Verify the reset ACTUALLY took effect by querying the RUNNING authoritative
|
|
171
|
+
// server (not a file): the old scrub silently wrote /seed while Knot served
|
|
172
|
+
// /config, so a file check would have looked fine while the server served
|
|
173
|
+
// stale records. Assert celilo.computer's apex now resolves to the website-sim
|
|
174
|
+
// and throw loudly on a confirmed mismatch, so a broken reset can never again
|
|
175
|
+
// silently bleed DNS state across tests. (e2e-confidence #253.)
|
|
176
|
+
const expected = SIMULATOR_IPS.WEBSITE;
|
|
177
|
+
let served: string | null = null;
|
|
161
178
|
try {
|
|
162
|
-
|
|
163
|
-
`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns knotc zone-reload`,
|
|
164
|
-
{ cwd: composeDir },
|
|
165
|
-
);
|
|
179
|
+
served = exec('kdig @127.0.0.1 celilo.computer A +short').trim();
|
|
166
180
|
} catch (err) {
|
|
167
|
-
|
|
181
|
+
// Couldn't run the verification query (e.g. the query tool is unavailable).
|
|
182
|
+
// Warn rather than abort the suite over a missing diagnostic — we only fail
|
|
183
|
+
// on a CONFIRMED wrong answer below.
|
|
184
|
+
console.warn(`[dns-scrub] could not verify reset (DNS query failed): ${err}`);
|
|
185
|
+
}
|
|
186
|
+
if (served !== null && !served.split(/\s+/).includes(expected)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`[dns-scrub] post-reset verification FAILED: celilo.computer apex should serve ${expected} ` +
|
|
189
|
+
`(website-sim) after scrub, but namecheap-dns returns "${served || '(empty)'}". ` +
|
|
190
|
+
`The DNS reset did not take effect — shared DNS would bleed across tests.`,
|
|
191
|
+
);
|
|
168
192
|
}
|
|
169
193
|
}
|
|
170
194
|
|
|
@@ -187,15 +211,48 @@ function dockerExec(
|
|
|
187
211
|
);
|
|
188
212
|
return { stdout, stderr: '', exitCode: 0 };
|
|
189
213
|
} catch (err: unknown) {
|
|
190
|
-
const e = err as {
|
|
214
|
+
const e = err as {
|
|
215
|
+
stdout?: string;
|
|
216
|
+
stderr?: string;
|
|
217
|
+
status?: number;
|
|
218
|
+
code?: string;
|
|
219
|
+
signal?: string;
|
|
220
|
+
killed?: boolean;
|
|
221
|
+
};
|
|
222
|
+
// execSync's timeout surfaces as an opaque error (ETIMEDOUT / SIGTERM /
|
|
223
|
+
// Bun's "canceled") with empty stderr — the classic e2e time-sink where a
|
|
224
|
+
// hung command reads as a mystery. Replace it with what actually happened.
|
|
225
|
+
const timedOut = e.code === 'ETIMEDOUT' || e.signal === 'SIGTERM' || e.killed === true;
|
|
226
|
+
const stderr = timedOut
|
|
227
|
+
? `timed out after ${Math.round(timeoutMs / 1000)}s running: ${cmd}`
|
|
228
|
+
: (e.stderr?.toString() ?? '');
|
|
191
229
|
return {
|
|
192
230
|
stdout: e.stdout?.toString() ?? '',
|
|
193
|
-
stderr
|
|
194
|
-
exitCode: e.status ?? 1,
|
|
231
|
+
stderr,
|
|
232
|
+
exitCode: e.status ?? (timedOut ? 124 : 1),
|
|
195
233
|
};
|
|
196
234
|
}
|
|
197
235
|
}
|
|
198
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Fail fast if the management image's baked celilo CLI is older than the
|
|
239
|
+
* harness needs (ce-5qp). Runs before `system init` so a version mismatch
|
|
240
|
+
* surfaces in <10s with an actionable message instead of a 90s hang ending
|
|
241
|
+
* in "canceled".
|
|
242
|
+
*/
|
|
243
|
+
function assertCliVersion(projectName: string, composeDir: string): void {
|
|
244
|
+
const result = dockerExec(projectName, composeDir, 'management', 'celilo --version', 8_000);
|
|
245
|
+
if (result.exitCode !== 0) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Could not read celilo version from the management image ` +
|
|
248
|
+
`(harness requires >=${MIN_CLI_VERSION}). ` +
|
|
249
|
+
`\`celilo --version\` exited ${result.exitCode}: ${result.stderr || result.stdout || '(no output)'}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const problem = checkCliVersion(result.stdout);
|
|
253
|
+
if (problem) throw new Error(problem);
|
|
254
|
+
}
|
|
255
|
+
|
|
199
256
|
function dockerExecAsync(
|
|
200
257
|
projectName: string,
|
|
201
258
|
composeDir: string,
|
|
@@ -255,6 +312,7 @@ async function waitFor(
|
|
|
255
312
|
check: () => Promise<boolean>,
|
|
256
313
|
timeoutMs: number,
|
|
257
314
|
label: string,
|
|
315
|
+
onTimeout?: () => string | Promise<string>,
|
|
258
316
|
): Promise<void> {
|
|
259
317
|
const start = Date.now();
|
|
260
318
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -265,7 +323,20 @@ async function waitFor(
|
|
|
265
323
|
}
|
|
266
324
|
await new Promise((r) => setTimeout(r, 2000));
|
|
267
325
|
}
|
|
268
|
-
|
|
326
|
+
// Self-diagnosing timeout (e2e-confidence #255): a readiness wait must attach
|
|
327
|
+
// EVIDENCE, never speculate ("X likely stalled or Y is down"). When the caller
|
|
328
|
+
// supplies an onTimeout collector, capture the live state and append it so the
|
|
329
|
+
// failure pinpoints the layer on first occurrence — no hand-instrumentation
|
|
330
|
+
// after the fact.
|
|
331
|
+
let diagnostics = '';
|
|
332
|
+
if (onTimeout) {
|
|
333
|
+
try {
|
|
334
|
+
diagnostics = `\n${await onTimeout()}`;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
diagnostics = `\n(onTimeout diagnostics failed: ${err instanceof Error ? err.message : String(err)})`;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
throw new Error(`Timeout waiting for ${label} after ${timeoutMs}ms${diagnostics}`);
|
|
269
340
|
}
|
|
270
341
|
|
|
271
342
|
/**
|
|
@@ -328,359 +399,132 @@ async function streamingBuild(
|
|
|
328
399
|
});
|
|
329
400
|
}
|
|
330
401
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
402
|
+
/**
|
|
403
|
+
* Build the NetworkHandle shared by startNetwork (fresh network) and
|
|
404
|
+
* reconnectNetwork (--keep reuse). Both expose the identical method set;
|
|
405
|
+
* only the setup that precedes handle creation differs, so the handle
|
|
406
|
+
* methods live here once instead of being duplicated in both functions.
|
|
407
|
+
*/
|
|
408
|
+
function buildNetworkHandle(
|
|
409
|
+
projectName: string,
|
|
410
|
+
composeDir: string,
|
|
411
|
+
celiloRoot: string | undefined,
|
|
412
|
+
): NetworkHandle {
|
|
413
|
+
// Track host-side resources spawned via the handle (SOCKS proxies,
|
|
414
|
+
// Playwright browsers). stop() tears these down before the docker
|
|
415
|
+
// network itself.
|
|
416
|
+
const activeProxies: SocksProxyHandle[] = [];
|
|
417
|
+
const activeBrowsers: BrowserHandle[] = [];
|
|
348
418
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
await ensureSharedInfra();
|
|
419
|
+
const handle: NetworkHandle = {
|
|
420
|
+
projectName,
|
|
352
421
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
422
|
+
async celilo(
|
|
423
|
+
cmd: string,
|
|
424
|
+
optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
|
|
425
|
+
): Promise<ExecResult> {
|
|
426
|
+
const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
|
|
427
|
+
const timeoutMs =
|
|
428
|
+
typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
|
|
359
429
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
run(`docker rm -f ${containers.replace(/\n/g, ' ')}`, { timeout: 30_000 });
|
|
368
|
-
}
|
|
369
|
-
} catch {}
|
|
430
|
+
const result = await dockerExecAsync(
|
|
431
|
+
projectName,
|
|
432
|
+
composeDir,
|
|
433
|
+
'management',
|
|
434
|
+
`celilo ${cmd}`,
|
|
435
|
+
timeoutMs,
|
|
436
|
+
);
|
|
370
437
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
|
|
374
|
-
.split('\n')
|
|
375
|
-
.filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
|
|
376
|
-
if (networks.length > 0) {
|
|
377
|
-
const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
|
|
378
|
-
for (const project of projects) {
|
|
379
|
-
try {
|
|
380
|
-
run(`docker compose -p ${project} down --volumes --remove-orphans`, {
|
|
381
|
-
timeout: 30_000,
|
|
382
|
-
});
|
|
383
|
-
} catch {}
|
|
384
|
-
}
|
|
385
|
-
for (const net of networks) {
|
|
386
|
-
try {
|
|
387
|
-
run(`docker network rm ${net}`, { timeout: 5_000 });
|
|
388
|
-
} catch {}
|
|
389
|
-
}
|
|
438
|
+
if (check && result.exitCode !== 0) {
|
|
439
|
+
throw new CeliloCommandError(cmd, result);
|
|
390
440
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const projectName = `celilo-e2e-${Date.now()}`;
|
|
395
|
-
activeProjects.add(projectName);
|
|
396
|
-
|
|
397
|
-
const composeDir = PACKAGE_ROOT;
|
|
398
|
-
const celiloRoot = config.celiloRoot ?? findCeliloRoot();
|
|
399
|
-
|
|
400
|
-
// Generate per-test compose (references shared infra networks as external)
|
|
401
|
-
const yaml = generateTestComposeYaml(config, celiloRoot);
|
|
402
|
-
writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
|
|
403
|
-
|
|
404
|
-
// Build and start per-test containers only — stream output so the runner
|
|
405
|
-
// can show which service is currently being built (can take minutes on cold cache).
|
|
406
|
-
await streamingBuild(projectName, composeDir, COMPOSE_FILE);
|
|
441
|
+
return result;
|
|
442
|
+
},
|
|
407
443
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
timeout: 120_000,
|
|
412
|
-
});
|
|
444
|
+
exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
|
|
445
|
+
return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
|
|
446
|
+
},
|
|
413
447
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
448
|
+
async respondWith(values): Promise<void> {
|
|
449
|
+
// Write the values JSON inside the management container at a
|
|
450
|
+
// stable path, then start a detached `celilo events respond
|
|
451
|
+
// --values <path>` process. The responder polls the in-container
|
|
452
|
+
// bus and answers config/secret/ensure events as the deploy
|
|
453
|
+
// emits them. The responder dies with the container at network
|
|
454
|
+
// teardown, so no explicit cleanup hook is needed.
|
|
455
|
+
//
|
|
456
|
+
// Long timeouts: the responder's --idle-timeout has to outlast
|
|
457
|
+
// any quiet stretch between deploys (a long `module deploy`
|
|
458
|
+
// might not emit interview events for several minutes if it's
|
|
459
|
+
// building images). 1h idle / 2h max-duration covers any single
|
|
460
|
+
// test comfortably without leaking. The container goes away at
|
|
461
|
+
// network stop regardless.
|
|
462
|
+
const valuesJson = JSON.stringify(values).replace(/'/g, "'\\''");
|
|
463
|
+
const valuesPath = '/tmp/cele2e-responder-values.json';
|
|
464
|
+
const writeCmd = `printf '%s' '${valuesJson}' > ${valuesPath}`;
|
|
465
|
+
const writeResult = dockerExec(projectName, composeDir, 'management', writeCmd, 10_000);
|
|
466
|
+
if (writeResult.exitCode !== 0) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`respondWith: failed to write values file inside management container: ${writeResult.stderr}`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
// Kill any prior responder (idempotent: replaces prior values map).
|
|
472
|
+
dockerExec(
|
|
419
473
|
projectName,
|
|
420
474
|
composeDir,
|
|
421
|
-
'
|
|
422
|
-
'
|
|
475
|
+
'management',
|
|
476
|
+
'pkill -f "celilo events respond" 2>/dev/null || true',
|
|
477
|
+
5_000,
|
|
423
478
|
);
|
|
424
|
-
|
|
479
|
+
// Spawn detached. nohup + & + disown cleanly survives the
|
|
480
|
+
// dockerExec session ending.
|
|
481
|
+
const spawnCmd =
|
|
482
|
+
`nohup celilo events respond --values ${valuesPath} ` +
|
|
483
|
+
'--idle-timeout 1h --max-duration 2h ' +
|
|
484
|
+
'--emittedBy cele2e-responder ' +
|
|
485
|
+
'> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
|
|
486
|
+
const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
|
|
487
|
+
if (spawnResult.exitCode !== 0) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
`respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
// Brief settle so the responder has registered its watches
|
|
493
|
+
// before any deploy fires events. Without this, a fast deploy
|
|
494
|
+
// could emit before the responder polls.
|
|
495
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
425
496
|
},
|
|
426
|
-
60_000,
|
|
427
|
-
'DNS convergence',
|
|
428
|
-
);
|
|
429
497
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
console.log('[progress:start] waiting for internal resolver | internal resolver ready');
|
|
440
|
-
await waitFor(
|
|
441
|
-
async () => {
|
|
442
|
-
const result = dockerExec(
|
|
443
|
-
projectName,
|
|
444
|
-
composeDir,
|
|
445
|
-
'dns-int',
|
|
446
|
-
'dig @127.0.0.1 iamtheinternet.org A +short +timeout=2',
|
|
447
|
-
);
|
|
448
|
-
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
449
|
-
},
|
|
450
|
-
30_000,
|
|
451
|
-
'internal DNS resolver',
|
|
452
|
-
);
|
|
453
|
-
}
|
|
498
|
+
async deployFirewall(opts = {}): Promise<void> {
|
|
499
|
+
const zones = opts.zones ?? ['dmz', 'app', 'secure'];
|
|
500
|
+
const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
|
|
501
|
+
const natIp = opts.natIp ?? internalNatIp();
|
|
502
|
+
const providedNetworks = zones.map((zone) => ({
|
|
503
|
+
zone,
|
|
504
|
+
subnet: ZONE_SUBNETS[zone],
|
|
505
|
+
gateway: ZONE_GATEWAYS[zone],
|
|
506
|
+
}));
|
|
454
507
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
...config.dmzMachines,
|
|
476
|
-
...config.appMachines,
|
|
477
|
-
...config.secureMachines,
|
|
478
|
-
...config.internalMachines,
|
|
479
|
-
];
|
|
480
|
-
if (allMachines.length > 0) {
|
|
481
|
-
console.log('[progress:start] waiting for target machines | target machines ready');
|
|
482
|
-
}
|
|
483
|
-
for (const machine of allMachines) {
|
|
484
|
-
await waitFor(
|
|
485
|
-
async () => {
|
|
486
|
-
const result = dockerExec(
|
|
487
|
-
projectName,
|
|
488
|
-
composeDir,
|
|
489
|
-
machine.name,
|
|
490
|
-
'systemctl is-active target-setup 2>/dev/null',
|
|
491
|
-
);
|
|
492
|
-
return result.stdout.trim() === 'active';
|
|
493
|
-
},
|
|
494
|
-
// App-zone machines use Dockerfile.target-machine-docker which bakes in
|
|
495
|
-
// dockerd; systemd boot + dockerd init takes ~25-35s, leaving a tight
|
|
496
|
-
// margin against a 30s timeout. 60s covers the observed worst case
|
|
497
|
-
// without slowing down dmz/internal-only tests (which still hit this
|
|
498
|
-
// in ~2-5s).
|
|
499
|
-
60_000,
|
|
500
|
-
`${machine.name} target-setup`,
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// Wait for DHCP client lease
|
|
505
|
-
if (config.dhcpClient) {
|
|
506
|
-
console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
|
|
507
|
-
await waitFor(
|
|
508
|
-
async () => {
|
|
509
|
-
const result = dockerExec(
|
|
510
|
-
projectName,
|
|
511
|
-
composeDir,
|
|
512
|
-
'dhcp-client',
|
|
513
|
-
'test -f /var/lib/dhcp/dhclient.leases && grep -c lease /var/lib/dhcp/dhclient.leases',
|
|
514
|
-
);
|
|
515
|
-
return result.exitCode === 0 && Number.parseInt(result.stdout.trim()) > 0;
|
|
516
|
-
},
|
|
517
|
-
60_000,
|
|
518
|
-
'DHCP client lease',
|
|
519
|
-
);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Wait for routing to Pebble (through shared infra networks)
|
|
523
|
-
if (allMachines.length > 0) {
|
|
524
|
-
await waitFor(
|
|
525
|
-
async () => {
|
|
526
|
-
const result = dockerExec(
|
|
527
|
-
projectName,
|
|
528
|
-
composeDir,
|
|
529
|
-
'management',
|
|
530
|
-
`ping -c1 -W2 ${SIMULATOR_IPS.PEBBLE}`,
|
|
531
|
-
);
|
|
532
|
-
return result.exitCode === 0;
|
|
533
|
-
},
|
|
534
|
-
30_000,
|
|
535
|
-
'routing to Pebble',
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// Initialize celilo. Skipped for the vanilla management variant —
|
|
540
|
-
// there's no celilo binary baked into that image; the caller is
|
|
541
|
-
// responsible for installing it (typically by running install.sh)
|
|
542
|
-
// and then calling `celilo system init` themselves.
|
|
543
|
-
if (config.managementVariant !== 'vanilla') {
|
|
544
|
-
console.log('[progress:start] initializing celilo | celilo initialized');
|
|
545
|
-
// Honest harness (v2/NETWORK_CONFIG_TO_FIREWALL.md): seed ONLY the
|
|
546
|
-
// `internal` zone — the network the management box is genuinely on —
|
|
547
|
-
// plus DNS. dmz/app/secure are NOT pre-seeded; they come into being
|
|
548
|
-
// when a test deploys the firewall (net.deployFirewall()), exactly as
|
|
549
|
-
// in production. A test that places services in those zones must call
|
|
550
|
-
// deployFirewall first; internal-only tests need nothing more.
|
|
551
|
-
const initResult = dockerExec(
|
|
552
|
-
projectName,
|
|
553
|
-
composeDir,
|
|
554
|
-
'management',
|
|
555
|
-
`celilo system init --accept-defaults \
|
|
556
|
-
network.internal.subnet=${ZONE_SUBNETS.internal} \
|
|
557
|
-
network.internal.gateway=${ZONE_GATEWAYS.internal} \
|
|
558
|
-
dns.primary=100.100.0.1 \
|
|
559
|
-
dns.fallback=1.0.0.1,8.8.8.8`,
|
|
560
|
-
);
|
|
561
|
-
if (initResult.exitCode !== 0) {
|
|
562
|
-
throw new Error(`Celilo init failed: ${initResult.stderr}`);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// Start the event-bus dispatcher (ISS-0035 / ISS-0042), now that `system
|
|
566
|
-
// init` has created the bus DB. Deploys emit bus events —
|
|
567
|
-
// public_web.routes_changed (caddy route reconcile), system.created (DNS
|
|
568
|
-
// providers) — that only take effect when a dispatcher delivers them.
|
|
569
|
-
// Production runs this as a systemd unit; the e2e mgmt box has no systemd,
|
|
570
|
-
// so run it detached (nohup + disown, like the cele2e responder). One
|
|
571
|
-
// dispatcher per management container serves every deploy in the test, so
|
|
572
|
-
// individual tests don't need to start their own (delivery claims are
|
|
573
|
-
// atomic, so a test that still does won't double-deliver).
|
|
574
|
-
console.log('[progress:start] starting event dispatcher | dispatcher running');
|
|
575
|
-
const dispatcherResult = dockerExec(
|
|
576
|
-
projectName,
|
|
577
|
-
composeDir,
|
|
578
|
-
'management',
|
|
579
|
-
'nohup celilo events run --poll-ms 500 --concurrency 4 > /tmp/cele2e-dispatcher.log 2>&1 < /dev/null & disown',
|
|
580
|
-
);
|
|
581
|
-
if (dispatcherResult.exitCode !== 0) {
|
|
582
|
-
throw new Error(`Failed to start event dispatcher: ${dispatcherResult.stderr}`);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
console.log('[progress:done] network ready');
|
|
587
|
-
// Emit project name so the runner can persist it if --keep is set
|
|
588
|
-
console.log(`[e2e:project] ${projectName}`);
|
|
589
|
-
|
|
590
|
-
// Track host-side resources spawned via the handle (SOCKS proxies,
|
|
591
|
-
// Playwright browsers). stop() tears these down before the docker
|
|
592
|
-
// network itself.
|
|
593
|
-
const activeProxies: SocksProxyHandle[] = [];
|
|
594
|
-
const activeBrowsers: BrowserHandle[] = [];
|
|
595
|
-
|
|
596
|
-
const handle: NetworkHandle = {
|
|
597
|
-
projectName,
|
|
598
|
-
|
|
599
|
-
async celilo(
|
|
600
|
-
cmd: string,
|
|
601
|
-
optsOrTimeout?: number | { check?: boolean; timeoutMs?: number },
|
|
602
|
-
): Promise<ExecResult> {
|
|
603
|
-
const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
|
|
604
|
-
const timeoutMs =
|
|
605
|
-
typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
|
|
606
|
-
|
|
607
|
-
const result = await dockerExecAsync(
|
|
608
|
-
projectName,
|
|
609
|
-
composeDir,
|
|
610
|
-
'management',
|
|
611
|
-
`celilo ${cmd}`,
|
|
612
|
-
timeoutMs,
|
|
613
|
-
);
|
|
614
|
-
|
|
615
|
-
if (check && result.exitCode !== 0) {
|
|
616
|
-
throw new CeliloCommandError(cmd, result);
|
|
617
|
-
}
|
|
618
|
-
return result;
|
|
619
|
-
},
|
|
620
|
-
|
|
621
|
-
exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
|
|
622
|
-
return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
|
|
623
|
-
},
|
|
624
|
-
|
|
625
|
-
async respondWith(values): Promise<void> {
|
|
626
|
-
// Write the values JSON inside the management container at a
|
|
627
|
-
// stable path, then start a detached `celilo events respond
|
|
628
|
-
// --values <path>` process. The responder polls the in-container
|
|
629
|
-
// bus and answers config/secret/ensure events as the deploy
|
|
630
|
-
// emits them. The responder dies with the container at network
|
|
631
|
-
// teardown, so no explicit cleanup hook is needed.
|
|
632
|
-
//
|
|
633
|
-
// Long timeouts: the responder's --idle-timeout has to outlast
|
|
634
|
-
// any quiet stretch between deploys (a long `module deploy`
|
|
635
|
-
// might not emit interview events for several minutes if it's
|
|
636
|
-
// building images). 1h idle / 2h max-duration covers any single
|
|
637
|
-
// test comfortably without leaking. The container goes away at
|
|
638
|
-
// network stop regardless.
|
|
639
|
-
const valuesJson = JSON.stringify(values).replace(/'/g, "'\\''");
|
|
640
|
-
const valuesPath = '/tmp/cele2e-responder-values.json';
|
|
641
|
-
const writeCmd = `printf '%s' '${valuesJson}' > ${valuesPath}`;
|
|
642
|
-
const writeResult = dockerExec(projectName, composeDir, 'management', writeCmd, 10_000);
|
|
643
|
-
if (writeResult.exitCode !== 0) {
|
|
644
|
-
throw new Error(
|
|
645
|
-
`respondWith: failed to write values file inside management container: ${writeResult.stderr}`,
|
|
646
|
-
);
|
|
647
|
-
}
|
|
648
|
-
// Kill any prior responder (idempotent: replaces prior values map).
|
|
649
|
-
dockerExec(
|
|
650
|
-
projectName,
|
|
651
|
-
composeDir,
|
|
652
|
-
'management',
|
|
653
|
-
'pkill -f "celilo events respond" 2>/dev/null || true',
|
|
654
|
-
5_000,
|
|
655
|
-
);
|
|
656
|
-
// Spawn detached. nohup + & + disown cleanly survives the
|
|
657
|
-
// dockerExec session ending.
|
|
658
|
-
const spawnCmd =
|
|
659
|
-
`nohup celilo events respond --values ${valuesPath} ` +
|
|
660
|
-
'--idle-timeout 1h --max-duration 2h ' +
|
|
661
|
-
'--emittedBy cele2e-responder ' +
|
|
662
|
-
'> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
|
|
663
|
-
const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
|
|
664
|
-
if (spawnResult.exitCode !== 0) {
|
|
665
|
-
throw new Error(
|
|
666
|
-
`respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
// Brief settle so the responder has registered its watches
|
|
670
|
-
// before any deploy fires events. Without this, a fast deploy
|
|
671
|
-
// could emit before the responder polls.
|
|
672
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
673
|
-
},
|
|
674
|
-
|
|
675
|
-
async deployFirewall(opts = {}): Promise<void> {
|
|
676
|
-
const zones = opts.zones ?? ['dmz', 'app', 'secure'];
|
|
677
|
-
const firewallIp = ZONE_GATEWAYS.internal; // fw-main on internal
|
|
678
|
-
const natIp = opts.natIp ?? internalNatIp();
|
|
679
|
-
const providedNetworks = zones.map((zone) => ({
|
|
680
|
-
zone,
|
|
681
|
-
subnet: ZONE_SUBNETS[zone],
|
|
682
|
-
gateway: ZONE_GATEWAYS[zone],
|
|
683
|
-
}));
|
|
508
|
+
// fw-main is a firewall container, not a target machine, so the
|
|
509
|
+
// network readiness wait (target-setup) does NOT cover its sshd. Poll
|
|
510
|
+
// until the firewall accepts SSH before `machine add` — otherwise a
|
|
511
|
+
// transient first-connect times out (spawnSync ETIMEDOUT) and aborts the
|
|
512
|
+
// whole deploy, cascading into unrelated "module not found" failures
|
|
513
|
+
// (#222). This is a readiness wait on the real prerequisite, not a sleep.
|
|
514
|
+
await waitFor(
|
|
515
|
+
async () => {
|
|
516
|
+
const probe = dockerExec(
|
|
517
|
+
projectName,
|
|
518
|
+
composeDir,
|
|
519
|
+
'management',
|
|
520
|
+
`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -i /root/.ssh/id_ed25519 root@${firewallIp} hostname`,
|
|
521
|
+
15_000,
|
|
522
|
+
);
|
|
523
|
+
return probe.exitCode === 0;
|
|
524
|
+
},
|
|
525
|
+
60_000,
|
|
526
|
+
`firewall ${firewallIp} sshd`,
|
|
527
|
+
);
|
|
684
528
|
|
|
685
529
|
// fw-main is registered as an internal-zone machine; iptables
|
|
686
530
|
// deploys to it and (Phase 2) writes the provided zones to system
|
|
@@ -707,12 +551,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
707
551
|
},
|
|
708
552
|
|
|
709
553
|
async browser(options: BrowserOptions): Promise<BrowserHandle> {
|
|
710
|
-
const
|
|
711
|
-
activeBrowsers.push(
|
|
554
|
+
const browserHandle = await startBrowser(projectName, options);
|
|
555
|
+
activeBrowsers.push(browserHandle);
|
|
712
556
|
// browser owns its proxy lifecycle; track it here too so stop()
|
|
713
557
|
// doesn't try to stop it twice (idempotent stop() handles this)
|
|
714
|
-
activeProxies.push(
|
|
715
|
-
return
|
|
558
|
+
activeProxies.push(browserHandle.proxy);
|
|
559
|
+
return browserHandle;
|
|
716
560
|
},
|
|
717
561
|
|
|
718
562
|
async dig(name: string): Promise<string> {
|
|
@@ -833,257 +677,329 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
833
677
|
return handle;
|
|
834
678
|
}
|
|
835
679
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
* Returns a NetworkHandle pointing to the existing Docker project. Skips
|
|
839
|
-
* all the setup (image build, container start, celilo init) — assumes the
|
|
840
|
-
* network is already running and healthy.
|
|
841
|
-
*
|
|
842
|
-
* If the project doesn't exist, throws with an actionable error.
|
|
843
|
-
*/
|
|
844
|
-
export function reconnectNetwork(projectName: string): NetworkHandle {
|
|
845
|
-
const composeDir = PACKAGE_ROOT;
|
|
846
|
-
|
|
847
|
-
// Verify the project actually exists
|
|
680
|
+
export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle> {
|
|
681
|
+
// Pre-flight: fail if live containers are running that will compete for resources.
|
|
848
682
|
try {
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
throw new Error(
|
|
683
|
+
const running = run('docker ps --format "{{.Names}}" 2>/dev/null').split('\n').filter(Boolean);
|
|
684
|
+
const heavyPatterns = ['authentik-', 'caddy-', 'build-your-own-internet-'];
|
|
685
|
+
const heavy = running.filter((name) => heavyPatterns.some((p) => name.startsWith(p)));
|
|
686
|
+
if (heavy.length > 0) {
|
|
687
|
+
const names = heavy.map((c) => ` - ${c}`).join('\n');
|
|
688
|
+
throw new Error(
|
|
689
|
+
`\nCannot start e2e tests: ${heavy.length} live container(s) are running:\n${names}\n\n` +
|
|
690
|
+
'Live and e2e environments are mutually exclusive.\n' +
|
|
691
|
+
'Stop live containers first: cele2e down --all\n',
|
|
692
|
+
);
|
|
855
693
|
}
|
|
856
|
-
} catch (
|
|
857
|
-
|
|
858
|
-
`Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
|
|
859
|
-
`Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
|
|
860
|
-
);
|
|
694
|
+
} catch (e) {
|
|
695
|
+
if (e instanceof Error && e.message.includes('Cannot start e2e tests')) throw e;
|
|
861
696
|
}
|
|
862
697
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
698
|
+
// Ensure shared infrastructure (DNS, Pebble, registry, etc.) is running.
|
|
699
|
+
// This is idempotent — if already up, it just verifies health.
|
|
700
|
+
await ensureSharedInfra();
|
|
866
701
|
|
|
867
|
-
//
|
|
868
|
-
|
|
869
|
-
|
|
702
|
+
// Scrub DNS zone files to baseline before each test (H5 in E2E_TEST_UPDATES.md).
|
|
703
|
+
// Prevents DDNS-written records from one test leaking into the next.
|
|
704
|
+
// Tests can opt out with config.skipDnsScrub (e.g., for --reuse debugging).
|
|
705
|
+
if (!config.skipDnsScrub) {
|
|
706
|
+
await scrubDnsZones();
|
|
707
|
+
}
|
|
870
708
|
|
|
871
|
-
|
|
872
|
-
|
|
709
|
+
// Clean up stale per-test containers (self-healing from prior crashes)
|
|
710
|
+
console.log('[progress:start] cleaning up stale test resources | cleanup complete');
|
|
711
|
+
try {
|
|
712
|
+
// Force-kill per-test containers (not shared infra)
|
|
713
|
+
try {
|
|
714
|
+
const containers = run('docker ps -aq --filter "name=celilo-e2e-1" 2>/dev/null');
|
|
715
|
+
if (containers.trim()) {
|
|
716
|
+
run(`docker rm -f ${containers.replace(/\n/g, ' ')}`, { timeout: 30_000 });
|
|
717
|
+
}
|
|
718
|
+
} catch {}
|
|
873
719
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
720
|
+
// Remove stale per-test networks
|
|
721
|
+
try {
|
|
722
|
+
const networks = run('docker network ls --format "{{.Name}}" 2>/dev/null')
|
|
723
|
+
.split('\n')
|
|
724
|
+
.filter((n) => n.startsWith('celilo-e2e-1')); // per-test projects start with timestamp
|
|
725
|
+
if (networks.length > 0) {
|
|
726
|
+
const projects = [...new Set(networks.map((n) => n.replace(/_[^_]+$/, '')))];
|
|
727
|
+
for (const project of projects) {
|
|
728
|
+
try {
|
|
729
|
+
run(`docker compose -p ${project} down --volumes --remove-orphans`, {
|
|
730
|
+
timeout: 30_000,
|
|
731
|
+
});
|
|
732
|
+
} catch {}
|
|
733
|
+
}
|
|
734
|
+
for (const net of networks) {
|
|
735
|
+
try {
|
|
736
|
+
run(`docker network rm ${net}`, { timeout: 5_000 });
|
|
737
|
+
} catch {}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
} catch {}
|
|
741
|
+
} catch {}
|
|
881
742
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
composeDir,
|
|
885
|
-
'management',
|
|
886
|
-
`celilo ${cmd}`,
|
|
887
|
-
timeoutMs,
|
|
888
|
-
);
|
|
743
|
+
const projectName = `celilo-e2e-${Date.now()}`;
|
|
744
|
+
activeProjects.add(projectName);
|
|
889
745
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
return result;
|
|
894
|
-
},
|
|
746
|
+
const composeDir = PACKAGE_ROOT;
|
|
747
|
+
const celiloRoot = config.celiloRoot ?? findCeliloRoot();
|
|
895
748
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
749
|
+
// Generate per-test compose (references shared infra networks as external)
|
|
750
|
+
const yaml = generateTestComposeYaml(config, celiloRoot);
|
|
751
|
+
writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
|
|
899
752
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
// --values <path>` process. The responder polls the in-container
|
|
904
|
-
// bus and answers config/secret/ensure events as the deploy
|
|
905
|
-
// emits them. The responder dies with the container at network
|
|
906
|
-
// teardown, so no explicit cleanup hook is needed.
|
|
907
|
-
//
|
|
908
|
-
// Long timeouts: the responder's --idle-timeout has to outlast
|
|
909
|
-
// any quiet stretch between deploys (a long `module deploy`
|
|
910
|
-
// might not emit interview events for several minutes if it's
|
|
911
|
-
// building images). 1h idle / 2h max-duration covers any single
|
|
912
|
-
// test comfortably without leaking. The container goes away at
|
|
913
|
-
// network stop regardless.
|
|
914
|
-
const valuesJson = JSON.stringify(values).replace(/'/g, "'\\''");
|
|
915
|
-
const valuesPath = '/tmp/cele2e-responder-values.json';
|
|
916
|
-
const writeCmd = `printf '%s' '${valuesJson}' > ${valuesPath}`;
|
|
917
|
-
const writeResult = dockerExec(projectName, composeDir, 'management', writeCmd, 10_000);
|
|
918
|
-
if (writeResult.exitCode !== 0) {
|
|
919
|
-
throw new Error(
|
|
920
|
-
`respondWith: failed to write values file inside management container: ${writeResult.stderr}`,
|
|
921
|
-
);
|
|
922
|
-
}
|
|
923
|
-
// Kill any prior responder (idempotent: replaces prior values map).
|
|
924
|
-
dockerExec(
|
|
925
|
-
projectName,
|
|
926
|
-
composeDir,
|
|
927
|
-
'management',
|
|
928
|
-
'pkill -f "celilo events respond" 2>/dev/null || true',
|
|
929
|
-
5_000,
|
|
930
|
-
);
|
|
931
|
-
// Spawn detached. nohup + & + disown cleanly survives the
|
|
932
|
-
// dockerExec session ending.
|
|
933
|
-
const spawnCmd =
|
|
934
|
-
`nohup celilo events respond --values ${valuesPath} ` +
|
|
935
|
-
'--idle-timeout 1h --max-duration 2h ' +
|
|
936
|
-
'--emittedBy cele2e-responder ' +
|
|
937
|
-
'> /tmp/cele2e-responder.log 2>&1 < /dev/null & disown';
|
|
938
|
-
const spawnResult = dockerExec(projectName, composeDir, 'management', spawnCmd, 5_000);
|
|
939
|
-
if (spawnResult.exitCode !== 0) {
|
|
940
|
-
throw new Error(
|
|
941
|
-
`respondWith: failed to spawn responder inside management container: ${spawnResult.stderr}`,
|
|
942
|
-
);
|
|
943
|
-
}
|
|
944
|
-
// Brief settle so the responder has registered its watches
|
|
945
|
-
// before any deploy fires events. Without this, a fast deploy
|
|
946
|
-
// could emit before the responder polls.
|
|
947
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
948
|
-
},
|
|
753
|
+
// Build and start per-test containers only — stream output so the runner
|
|
754
|
+
// can show which service is currently being built (can take minutes on cold cache).
|
|
755
|
+
await streamingBuild(projectName, composeDir, COMPOSE_FILE);
|
|
949
756
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
zone,
|
|
956
|
-
subnet: ZONE_SUBNETS[zone],
|
|
957
|
-
gateway: ZONE_GATEWAYS[zone],
|
|
958
|
-
}));
|
|
757
|
+
console.log('[progress:start] starting containers | containers running');
|
|
758
|
+
run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, {
|
|
759
|
+
cwd: composeDir,
|
|
760
|
+
timeout: 120_000,
|
|
761
|
+
});
|
|
959
762
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
await handle.celilo(
|
|
970
|
-
`module config set iptables provided_networks '${JSON.stringify(providedNetworks)}'`,
|
|
763
|
+
// Wait for DNS convergence (comcast-resolver is per-test, needs to reach shared DNS)
|
|
764
|
+
console.log('[progress:start] waiting for DNS convergence | DNS converged');
|
|
765
|
+
await waitFor(
|
|
766
|
+
async () => {
|
|
767
|
+
const result = dockerExec(
|
|
768
|
+
projectName,
|
|
769
|
+
composeDir,
|
|
770
|
+
'comcast-resolver',
|
|
771
|
+
'dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2',
|
|
971
772
|
);
|
|
972
|
-
|
|
773
|
+
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
973
774
|
},
|
|
775
|
+
60_000,
|
|
776
|
+
'DNS convergence',
|
|
777
|
+
);
|
|
974
778
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
779
|
+
// Wait for internal resolver (split-horizon DNS for management container).
|
|
780
|
+
// Skipped when the test specifies its own machine named `dns-int` — that
|
|
781
|
+
// means the test is replacing the infra resolver with a module under test
|
|
782
|
+
// (e.g., knot-unbound-internal). The bare target machine has no DNS server
|
|
783
|
+
// until its module deploys, so this wait would always time out. Management
|
|
784
|
+
// can still resolve names via the fallback `nameserver 100.100.0.1` in
|
|
785
|
+
// its resolv.conf during deploy.
|
|
786
|
+
//
|
|
787
|
+
// The resolver-under-test may sit in ANY zone, not just `internal`: ISS-0156
|
|
788
|
+
// places the dns_internal provider in a PROTECTED zone (dmz) so it can see
|
|
789
|
+
// protected-zone query sources for split-horizon views. So check every zone's
|
|
790
|
+
// machines for a `dns-int`, not only internalMachines.
|
|
791
|
+
const dnsIntReplaced = [
|
|
792
|
+
...config.internalMachines,
|
|
793
|
+
...config.dmzMachines,
|
|
794
|
+
...config.appMachines,
|
|
795
|
+
...config.secureMachines,
|
|
796
|
+
].some((m) => m.name === 'dns-int');
|
|
797
|
+
if (!dnsIntReplaced) {
|
|
798
|
+
console.log('[progress:start] waiting for internal resolver | internal resolver ready');
|
|
799
|
+
await waitFor(
|
|
800
|
+
async () => {
|
|
801
|
+
const result = dockerExec(
|
|
802
|
+
projectName,
|
|
803
|
+
composeDir,
|
|
804
|
+
'dns-int',
|
|
805
|
+
'dig @127.0.0.1 iamtheinternet.org A +short +timeout=2',
|
|
806
|
+
);
|
|
807
|
+
return result.exitCode === 0 && result.stdout.trim().length > 0;
|
|
808
|
+
},
|
|
809
|
+
30_000,
|
|
810
|
+
'internal DNS resolver',
|
|
811
|
+
);
|
|
812
|
+
}
|
|
980
813
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
814
|
+
// Optional routing verification
|
|
815
|
+
if (config.verifyRouting) {
|
|
816
|
+
console.log('[progress:start] verifying routing | routing verified');
|
|
817
|
+
await waitFor(
|
|
818
|
+
async () => {
|
|
819
|
+
const result = dockerExec(
|
|
820
|
+
projectName,
|
|
821
|
+
composeDir,
|
|
822
|
+
'management',
|
|
823
|
+
`ping -c1 -W2 ${SIMULATOR_IPS.ROOT_DNS}`,
|
|
824
|
+
);
|
|
825
|
+
return result.exitCode === 0;
|
|
826
|
+
},
|
|
827
|
+
30_000,
|
|
828
|
+
'routing verification',
|
|
829
|
+
);
|
|
830
|
+
}
|
|
987
831
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
832
|
+
// Wait for all target machines to complete setup (systemd boot + routing)
|
|
833
|
+
const allMachines = [
|
|
834
|
+
...config.dmzMachines,
|
|
835
|
+
...config.appMachines,
|
|
836
|
+
...config.secureMachines,
|
|
837
|
+
...config.internalMachines,
|
|
838
|
+
];
|
|
839
|
+
if (allMachines.length > 0) {
|
|
840
|
+
console.log('[progress:start] waiting for target machines | target machines ready');
|
|
841
|
+
}
|
|
842
|
+
for (const machine of allMachines) {
|
|
843
|
+
await waitFor(
|
|
844
|
+
async () => {
|
|
845
|
+
const result = dockerExec(
|
|
846
|
+
projectName,
|
|
847
|
+
composeDir,
|
|
848
|
+
machine.name,
|
|
849
|
+
'systemctl is-active target-setup 2>/dev/null',
|
|
850
|
+
);
|
|
851
|
+
return result.stdout.trim() === 'active';
|
|
852
|
+
},
|
|
853
|
+
// App-zone machines use Dockerfile.target-machine-docker which bakes in
|
|
854
|
+
// dockerd; systemd boot + dockerd init takes ~25-35s, leaving a tight
|
|
855
|
+
// margin against a 30s timeout. 60s covers the observed worst case
|
|
856
|
+
// without slowing down dmz/internal-only tests (which still hit this
|
|
857
|
+
// in ~2-5s).
|
|
858
|
+
60_000,
|
|
859
|
+
`${machine.name} target-setup`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
996
862
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
863
|
+
// Wait for DHCP client lease
|
|
864
|
+
if (config.dhcpClient) {
|
|
865
|
+
console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
|
|
866
|
+
// Self-diagnosing via waitFor's onTimeout (e2e-confidence #255): a DHCP-lease
|
|
867
|
+
// timeout is an intermittent in-suite flake (passes solo). On timeout, dump
|
|
868
|
+
// the dhcp-client's interface + dhclient transcript so the failure shows
|
|
869
|
+
// whether DISCOVER/OFFER/REQUEST/ACK completed (server silent) vs. a lease
|
|
870
|
+
// that landed but wasn't recorded.
|
|
871
|
+
await waitFor(
|
|
872
|
+
async () => {
|
|
873
|
+
const result = dockerExec(
|
|
874
|
+
projectName,
|
|
875
|
+
composeDir,
|
|
876
|
+
'dhcp-client',
|
|
877
|
+
'test -f /var/lib/dhcp/dhclient.leases && grep -c lease /var/lib/dhcp/dhclient.leases',
|
|
878
|
+
);
|
|
879
|
+
return result.exitCode === 0 && Number.parseInt(result.stdout.trim()) > 0;
|
|
880
|
+
},
|
|
881
|
+
60_000,
|
|
882
|
+
'DHCP client lease',
|
|
883
|
+
() => {
|
|
884
|
+
const dump = (label: string, cmd: string): string => {
|
|
885
|
+
const r = dockerExec(projectName, composeDir, 'dhcp-client', cmd);
|
|
886
|
+
return `--- ${label} ---\n${(r.stdout || r.stderr || '(no output)').trim()}`;
|
|
887
|
+
};
|
|
888
|
+
return [
|
|
889
|
+
'=== DHCP diagnostics (dhcp-client) ===',
|
|
890
|
+
dump('ip addr', 'ip -o addr show 2>&1'),
|
|
891
|
+
dump('dhclient.leases', 'cat /var/lib/dhcp/dhclient.leases 2>&1 | tail -25'),
|
|
892
|
+
dump(
|
|
893
|
+
'dhclient transcript',
|
|
894
|
+
"journalctl -u dhclient --no-pager 2>/dev/null | tail -25 || cat /var/log/dhclient.log 2>/dev/null | tail -25 || echo '(no dhclient log)'",
|
|
895
|
+
),
|
|
896
|
+
].join('\n');
|
|
897
|
+
},
|
|
898
|
+
);
|
|
899
|
+
}
|
|
1010
900
|
|
|
1011
|
-
|
|
901
|
+
// Wait for routing to Pebble (through shared infra networks)
|
|
902
|
+
if (allMachines.length > 0) {
|
|
903
|
+
await waitFor(
|
|
904
|
+
async () => {
|
|
905
|
+
const result = dockerExec(
|
|
906
|
+
projectName,
|
|
907
|
+
composeDir,
|
|
908
|
+
'management',
|
|
909
|
+
`ping -c1 -W2 ${SIMULATOR_IPS.PEBBLE}`,
|
|
910
|
+
);
|
|
911
|
+
return result.exitCode === 0;
|
|
912
|
+
},
|
|
913
|
+
30_000,
|
|
914
|
+
'routing to Pebble',
|
|
915
|
+
);
|
|
916
|
+
}
|
|
1012
917
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
918
|
+
// Initialize celilo. Skipped for the vanilla management variant —
|
|
919
|
+
// there's no celilo binary baked into that image; the caller is
|
|
920
|
+
// responsible for installing it (typically by running install.sh)
|
|
921
|
+
// and then calling `celilo system init` themselves.
|
|
922
|
+
if (config.managementVariant !== 'vanilla') {
|
|
923
|
+
assertCliVersion(projectName, composeDir);
|
|
924
|
+
console.log('[progress:start] initializing celilo | celilo initialized');
|
|
925
|
+
// Honest harness (v2/NETWORK_CONFIG_TO_FIREWALL.md): seed ONLY the
|
|
926
|
+
// `internal` zone — the network the management box is genuinely on —
|
|
927
|
+
// plus DNS. dmz/app/secure are NOT pre-seeded; they come into being
|
|
928
|
+
// when a test deploys the firewall (net.deployFirewall()), exactly as
|
|
929
|
+
// in production. A test that places services in those zones must call
|
|
930
|
+
// deployFirewall first; internal-only tests need nothing more.
|
|
931
|
+
const initResult = dockerExec(
|
|
932
|
+
projectName,
|
|
933
|
+
composeDir,
|
|
934
|
+
'management',
|
|
935
|
+
`celilo system init --accept-defaults \
|
|
936
|
+
network.internal.subnet=${ZONE_SUBNETS.internal} \
|
|
937
|
+
network.internal.gateway=${ZONE_GATEWAYS.internal} \
|
|
938
|
+
dns.primary=100.100.0.1 \
|
|
939
|
+
dns.fallback=1.0.0.1,8.8.8.8`,
|
|
940
|
+
);
|
|
941
|
+
if (initResult.exitCode !== 0) {
|
|
942
|
+
throw new Error(`Celilo init failed: ${initResult.stderr}`);
|
|
943
|
+
}
|
|
1018
944
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
945
|
+
// Start the event-bus dispatcher (ISS-0035 / ISS-0042), now that `system
|
|
946
|
+
// init` has created the bus DB. Deploys emit bus events —
|
|
947
|
+
// public_web.routes_changed (caddy route reconcile), system.created (DNS
|
|
948
|
+
// providers) — that only take effect when a dispatcher delivers them.
|
|
949
|
+
// Production runs this as a systemd unit; the e2e mgmt box has no systemd,
|
|
950
|
+
// so run it detached (nohup + disown, like the cele2e responder). One
|
|
951
|
+
// dispatcher per management container serves every deploy in the test, so
|
|
952
|
+
// individual tests don't need to start their own (delivery claims are
|
|
953
|
+
// atomic, so a test that still does won't double-deliver).
|
|
954
|
+
console.log('[progress:start] starting event dispatcher | dispatcher running');
|
|
955
|
+
const dispatcherResult = dockerExec(
|
|
956
|
+
projectName,
|
|
957
|
+
composeDir,
|
|
958
|
+
'management',
|
|
959
|
+
'nohup celilo events run --poll-ms 500 --concurrency 4 > /tmp/cele2e-dispatcher.log 2>&1 < /dev/null & disown',
|
|
960
|
+
);
|
|
961
|
+
if (dispatcherResult.exitCode !== 0) {
|
|
962
|
+
throw new Error(`Failed to start event dispatcher: ${dispatcherResult.stderr}`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
1023
965
|
|
|
1024
|
-
|
|
1025
|
-
|
|
966
|
+
console.log('[progress:done] network ready');
|
|
967
|
+
// Emit project name so the runner can persist it if --keep is set
|
|
968
|
+
console.log(`[e2e:project] ${projectName}`);
|
|
1026
969
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
} else {
|
|
1030
|
-
netappPath = join(tmpdir(), `${moduleId}-${Date.now()}.netapp`);
|
|
1031
|
-
cleanup = true;
|
|
1032
|
-
const resolvedCeliloRoot = findCeliloRoot();
|
|
1033
|
-
const celiloCliPath = join(
|
|
1034
|
-
resolvedCeliloRoot ?? PACKAGE_ROOT,
|
|
1035
|
-
'apps/celilo/src/cli/index.ts',
|
|
1036
|
-
);
|
|
1037
|
-
try {
|
|
1038
|
-
run(
|
|
1039
|
-
`bun run ${JSON.stringify(celiloCliPath)} package ${JSON.stringify(absPath)} --output ${JSON.stringify(netappPath)}`,
|
|
1040
|
-
{ timeout: 60_000 },
|
|
1041
|
-
);
|
|
1042
|
-
} catch (err) {
|
|
1043
|
-
throw new Error(`Failed to package module at ${absPath}: ${String(err)}`);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
970
|
+
return buildNetworkHandle(projectName, composeDir, celiloRoot);
|
|
971
|
+
}
|
|
1046
972
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
}
|
|
1058
|
-
},
|
|
973
|
+
/**
|
|
974
|
+
* Reconnect to an existing test network (previously kept alive with --keep).
|
|
975
|
+
* Returns a NetworkHandle pointing to the existing Docker project. Skips
|
|
976
|
+
* all the setup (image build, container start, celilo init) — assumes the
|
|
977
|
+
* network is already running and healthy.
|
|
978
|
+
*
|
|
979
|
+
* If the project doesn't exist, throws with an actionable error.
|
|
980
|
+
*/
|
|
981
|
+
export function reconnectNetwork(projectName: string): NetworkHandle {
|
|
982
|
+
const composeDir = PACKAGE_ROOT;
|
|
1059
983
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
984
|
+
// Verify the project actually exists
|
|
985
|
+
try {
|
|
986
|
+
const result = run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`, {
|
|
987
|
+
cwd: composeDir,
|
|
988
|
+
timeout: 10_000,
|
|
989
|
+
});
|
|
990
|
+
if (!result.trim()) {
|
|
991
|
+
throw new Error(`No containers found for project ${projectName}`);
|
|
992
|
+
}
|
|
993
|
+
} catch (err) {
|
|
994
|
+
throw new Error(
|
|
995
|
+
`Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
|
|
996
|
+
`Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
|
|
997
|
+
);
|
|
998
|
+
}
|
|
1071
999
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
activeProjects.delete(projectName);
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
try {
|
|
1079
|
-
run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`, {
|
|
1080
|
-
cwd: composeDir,
|
|
1081
|
-
timeout: 60_000,
|
|
1082
|
-
});
|
|
1083
|
-
} catch {}
|
|
1084
|
-
activeProjects.delete(projectName);
|
|
1085
|
-
},
|
|
1086
|
-
};
|
|
1000
|
+
activeProjects.add(projectName);
|
|
1001
|
+
console.log(`[progress:done] reconnected to existing network (${projectName})`);
|
|
1002
|
+
console.log(`[e2e:project] ${projectName}`);
|
|
1087
1003
|
|
|
1088
|
-
return
|
|
1004
|
+
return buildNetworkHandle(projectName, composeDir, findCeliloRoot());
|
|
1089
1005
|
}
|