import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { startNetwork } from './container-manager'; import type { MachineSpec, NetworkConfig, NetworkHandle, TopologyPreset, Vantage, Zone, } from './types'; /** * If a mounted path looks like a celilo module (has scripts/package.json), * ensure its dependencies are installed on the host before Docker starts. */ async function ensureModuleDeps(hostPath: string): Promise { // Check common locations for a module's scripts dir for (const scriptsDir of [join(hostPath, 'scripts'), join(hostPath, 'celilo', 'scripts')]) { const pkgJson = join(scriptsDir, 'package.json'); const nodeModules = join(scriptsDir, 'node_modules'); if (existsSync(pkgJson) && !existsSync(nodeModules)) { console.log(`[progress:start] installing deps in ${scriptsDir} | deps installed`); try { execSync('bun install', { cwd: scriptsDir, timeout: 30_000, stdio: 'pipe' }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error( `Failed to install module dependencies in ${scriptsDir}:\n${msg}\n\n` + `Fix: cd ${scriptsDir} && bun install`, ); } } } } export class NetworkBuilder { private config: NetworkConfig = { topology: 'default', // The production topology: celilo-mgr sits on its own control-plane // network. A suite that genuinely needs the single-network legacy // topology opts out with `.managementZone('internal')` and says why. managementZone: 'secure-mgmt', dmzMachines: [], appMachines: [], secureMachines: [], internalMachines: [], secureMgmtMachines: [], dhcpClient: false, signalCli: false, signalSim: false, signalRelease: false, observers: [], domain: 'iamtheinternet.org', ddnsPassword: 'test123', verifyRouting: process.env.VERIFY_ROUTING === '1', managementVolumes: [], }; topology(preset: TopologyPreset): this { this.config.topology = preset; return this; } /** * Make the `fw-isp` simulator stand in for an Axon Networks Q1000K rather * than the default GreenWave C4000XG. The two speak an identical CGI * protocol; only the TR-181 vendor extension prefix differs, so this swaps * `X_GWS_Via`/`X_LANTIQ_COM_INTERFACE` for `X_AXON_Via`/`X_AXON_INTERFACE`. */ axonRouter(): this { this.config.routerVendorPrefix = 'X_AXON_'; return this; } /** * Place the celilo management container in a given zone. Defaults to * `secure-mgmt` (the production topology, celilo-mgr on its own * control-plane network). `internal` opts back into the legacy * single-network topology, where "trusted subnet == network.internal.subnet" * comes out true by construction — only for a suite that genuinely tests a * behaviour on that topology, and says so. */ managementZone(zone: 'internal' | 'secure-mgmt'): this { this.config.managementZone = zone; return this; } /** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */ private addMachines( target: MachineSpec[], zone: Zone, machines: Record, extra?: Pick, ): this { for (const [name, ip] of Object.entries(machines)) { target.push({ name, ip, zone, ...extra }); } return this; } dmz(machines: Record): this { return this.addMachines(this.config.dmzMachines, 'dmz', machines); } app(machines: Record): this { return this.addMachines(this.config.appMachines, 'app', machines); } secure(machines: Record): this { return this.addMachines(this.config.secureMachines, 'secure', machines); } internal(machines: Record): this { return this.addMachines(this.config.internalMachines, 'internal', machines); } /** * Add machines on celilo's control plane (`secure-mgmt`). * * For a module whose manifest declares `zone: secure-mgmt` — the signal * transport does, because it holds the operator's Signal identity and must * depend on nothing it reports about. Without this the module deploys onto * whatever zone a test happened to add a machine in, which is the placement * defect #435 is about, or fails selection outright. */ secureMgmt(machines: Record): this { return this.addMachines(this.config.secureMgmtMachines, 'secure-mgmt', machines); } /** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */ internalDocker(machines: Record): this { return this.addMachines(this.config.internalMachines, 'internal', machines, { docker: true }); } /** Add a Technitium DNS machine using the dedicated pre-installed image */ technitium(machines: Record): this { return this.addMachines(this.config.internalMachines, 'internal', machines, { dockerfile: 'docker/Dockerfile.target-technitium', }); } withDhcpClient(): this { this.config.dhcpClient = true; return this; } /** * Stop the simulated ISP router serving DHCP on `internal`. * * For suites where CELILO serves DHCP (`modules/dnsmasq-dhcp`). Two servers * on one broadcast domain race, so without this a lease assertion is a coin * toss. It is also what an operator does when moving DHCP to celilo, so the * suite models the migration rather than dodging a conflict. * * NOTE: `withDhcpClient()`'s start-up lease wait expects SOMETHING to answer. * Pair this with a suite that deploys celilo's DHCP server, or the client * gets no lease and the network never comes up. */ withoutRouterDhcp(): this { this.config.routerDhcp = false; return this; } /** * Add the Proxmox API simulator on `secure-mgmt`. * * Turns on the only path in the suite that deploys a module through a * `container_service` rather than the machine pool: IPAM allocation, the * deployed-system recording, the infrastructure-variable resolver and the * DNS-ingress reservation. Creating an LXC against it starts a real * container, so what Ansible then configures is a genuinely reachable host. */ withProxmoxSim(): this { this.config.proxmoxSim = true; return this; } /** * Add a real, UNLINKED signal-cli daemon on the internal network. * * For verifying the JSON-RPC contract against the actual binary. It cannot * send to a phone (no account, and CDSI/SVR2 attestation makes a local * Signal substitute impossible), but every endpoint, envelope and error * shape celilo's client relies on is checkable without one. */ withSignalCli(): this { this.config.signalCli = true; return this; } /** * Add the signal-cli SIMULATOR on the internal network. * * Use this for anything that drives the delivery or ack loop: unlike the * real daemon it exposes a control surface (`/_control/inbound`, * `/_control/sent`, `/_control/unlink`) so a test can make a human "reply" * and then assert on what celilo did about it. */ withSignalSim(): this { this.config.signalSim = true; return this; } /** * Serve the signal-cli release tarball from the simulated internet. * * Needed to DEPLOY the signal module: its ansible role downloads signal-cli * at deploy time, and the e2e network is sealed. Point the module at it with * `module config set signal release_base_url http://signal-release.lab`. */ withSignalRelease(): this { this.config.signalRelease = true; return this; } /** * Inject one or more observer vantages (ISS-0117) — passive spies carrying the probe * toolbox, each placed at its vantage's network location with a routing profile that * mirrors a real device there. Drive them via `createObserverTransport(handle)` + * `VantageProbe`. `management` is not injectable (that vantage reuses the real * management container). * * network().dmz({ caddy: '10.226.10.10' }).observe('internalDevice', 'publicInternet') */ observe(...vantages: Vantage[]): this { this.config.observers ??= []; for (const vantage of vantages) { this.config.observers.push({ vantage }); } return this; } domain(domain: string): this { this.config.domain = domain; return this; } withRoutingVerification(): this { this.config.verifyRouting = true; return this; } /** Mount a host directory into the management container */ mount(hostPath: string, containerPath: string): this { this.config.managementVolumes.push(`${hostPath}:${containerPath}`); return this; } /** * Set the path to the Celilo project root. This directory is mounted * into the management container at /celilo, providing access to the * Celilo CLI and module source code. * * If not set, auto-detected by walking up from the @celilo/e2e package. */ celilo(rootPath: string): this { this.config.celiloRoot = rootPath; return this; } /** * Use the vanilla management image — bun + unzip, no celilo. The test * is responsible for installing celilo at runtime (typically by running * install.sh inside the container). The image must already exist locally * as `celilo-e2e/management:vanilla`; built by `cele2e build-infra`. */ managementVariant(variant: 'default' | 'vanilla'): this { this.config.managementVariant = variant; return this; } /** * Bring up a SECOND management-class box named `celilo-mgr-2` (10.226.1.101) * using the given image variant. The migration restore e2e uses this to get a * fresh target box alongside the primary backup source. The second box is not * auto-init'd (container-manager keys off the `management` name), so it stays * an empty restore target until the test `apt install celilo` + `restore`s it. * Reach it with `net.exec('celilo-mgr-2', …)`. */ secondaryManagementVariant(variant: 'default' | 'vanilla'): this { this.config.secondaryManagementVariant = variant; return this; } /** * The config this builder will hand to `startNetwork`, read back for tests * that assert on the DEFAULTS rather than construct a literal. Without this * a default is invisible to every gate (the literal-shaped configs in the * unit tests never exercise it). */ snapshotConfig(): Readonly { return { ...this.config }; } async start(): Promise { // Pre-flight: ensure mounted module directories have deps installed. // Without this, `module import` tries to `bun install` inside Docker // through the simulated internet, which is slow or fails. for (const vol of this.config.managementVolumes) { const [hostPath] = vol.split(':'); await ensureModuleDeps(hostPath); } return startNetwork(this.config); } } export function network(): NetworkBuilder { return new NetworkBuilder(); }