// Reusable bastion + VM provisioning harness for partition-layout tests. // // pxe-provision.test.ts grew a ~200-line beforeAll that stands up a real // bastion, caches Fedora boot artifacts, and drives one VM through discovery // and install. Testing a partition MATRIX needs that same sequence for several // VMs and several roles, so it lives here instead of being copy-pasted per // topology. import { existsSync, mkdirSync, copyFileSync, symlinkSync, writeFileSync } from "node:fs"; import { execSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { BastionConfig } from "../../../src/shared/src/types/config.js"; import type { Role } from "../../../src/shared/src/types/state.js"; import { log, waitForSsh } from "./libvirt.js"; import { ensurePxeNetwork, deleteNftablesRejectRules, PXE_NETWORK_NAME, PXE_BRIDGE, PXE_GATEWAY, PXE_SUBNET } from "./pxe-network.js"; import { createPxeVm, destroyPxeVm, getVmMac, rebootPxeVm, readSerialLog } from "./pxe-vm.js"; import { sshExec } from "./ssh.js"; export const DISCOVERY_TIMEOUT_MS = 6 * 60_000; export const INSTALL_TIMEOUT_MS = 60 * 60_000; export const SSH_TIMEOUT_MS = 12 * 60_000; export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export interface Harness { config: BastionConfig; close: () => Promise; httpPort: number; sshUser: string; sshKeyPath: string; } async function pollUntil(url: string, check: (d: T) => boolean, timeoutMs: number, intervalMs = 5000): Promise { const start = Date.now(); let lastErr = ""; while (Date.now() - start < timeoutMs) { try { const res = await fetch(url); if (res.ok) { const data = (await res.json()) as T; if (check(data)) return data; } } catch (e) { lastErr = e instanceof Error ? e.message : String(e); } await sleep(intervalMs); } throw new Error(`Timeout after ${timeoutMs}ms polling ${url}${lastErr ? ` (last error: ${lastErr})` : ""}`); } /** * Stand up a real bastion on the isolated PXE network. * * FEDORA_MIRROR is honoured (config.ts now reads it) -- point it at a local * mirror before running a multi-VM matrix, or every install pulls the full * package set from download.fedoraproject.org. */ export async function startTestBastion(opts: { httpPort: number; sshUser: string; sshPubKey: string; sshKeyPath: string; label: string; /** * Leave the post-provision k3s auto-install ON (the default) or suppress it. * * Any test that forms a cluster ITSELF must pass false. The auto-install runs * a bare `INSTALL_K3S_EXEC=server|agent` with no server URL and no token, so * three combo nodes become three separate single-node clusters -- and it * stops k3s and deletes CNI state minutes after an install lands, landing on * top of whatever the test is doing by then. */ autoK3s?: boolean; }): Promise { ensurePxeNetwork(); const testDir = join(tmpdir(), `lab-${opts.label}-${Date.now()}`); for (const sub of ["tftp", "http", "logs"]) mkdirSync(join(testDir, sub), { recursive: true }); const { createApp } = await import("../../../src/bastion/src/server.js"); const { loadConfig } = await import("../../../src/bastion/src/config.js"); const { generateDnsmasqConf, startDnsmasq, stopDnsmasq } = await import("../../../src/bastion/src/services/dnsmasq.js"); const { generateDiscoverKickstart } = await import("../../../src/bastion/src/services/kickstart-generator.js"); const { renderBootIpxe } = await import("../../../src/bastion/src/templates/boot.ipxe.js"); const config = loadConfig({ bastionDir: testDir, httpPort: opts.httpPort, iface: "virbr-pxe", serverIp: PXE_GATEWAY, network: `${PXE_SUBNET}.0`, gateway: PXE_GATEWAY, dhcpMode: "full", dhcpRangeStart: `${PXE_SUBNET}.100`, dhcpRangeEnd: `${PXE_SUBNET}.200`, domain: "pxe-test.local", sshKeys: [opts.sshPubKey], adminUser: opts.sshUser, ...(opts.autoK3s === undefined ? {} : { autoK3s: opts.autoK3s }), }); const ipxeSrc = "/usr/share/ipxe/ipxe-snponly-x86_64.efi"; if (!existsSync(ipxeSrc)) throw new Error(`iPXE not found: ${ipxeSrc}. Install: sudo dnf install ipxe-bootimgs-x86`); const ipxeDest = join(config.tftpDir, "ipxe.efi"); copyFileSync(ipxeSrc, ipxeDest); const cacheDir = "/var/lib/libvirt/images/lab-pxe-cache"; execSync(`mkdir -p "${cacheDir}"`, { stdio: "pipe" }); const kernel = join(cacheDir, `vmlinuz-${config.fedoraVersion}`); const initrd = join(cacheDir, `initrd-${config.fedoraVersion}.img`); if (!existsSync(kernel)) { log(`Downloading Fedora ${config.fedoraVersion} kernel from ${config.fedoraMirror}...`); execSync(`curl -sS -L -f -o "${kernel}" "${config.fedoraMirror}/images/pxeboot/vmlinuz"`, { stdio: "inherit", timeout: 300_000 }); } if (!existsSync(initrd)) { log(`Downloading Fedora ${config.fedoraVersion} initrd...`); execSync(`curl -sS -L -f -o "${initrd}" "${config.fedoraMirror}/images/pxeboot/initrd.img"`, { stdio: "inherit", timeout: 300_000 }); } copyFileSync(kernel, join(config.httpDir, "vmlinuz")); copyFileSync(initrd, join(config.httpDir, "initrd.img")); try { symlinkSync(ipxeDest, join(config.httpDir, "ipxe.efi")); } catch { /* exists */ } writeFileSync(join(config.httpDir, "discover.ks"), generateDiscoverKickstart(config)); writeFileSync(join(config.httpDir, "boot.ipxe"), renderBootIpxe({ serverIp: config.serverIp, httpPort: config.httpPort })); generateDnsmasqConf(config); // Kill dnsmasq processes left behind by EARLIER TEST RUNS. // // startTestBastion's close() shuts down the HTTP app but never stopped // dnsmasq, so every run leaked one. A leaked dnsmasq keeps :67 on the bridge // and keeps answering DHCP -- with the PREVIOUS run's config, pointing PXE // clients at a bastion port that is no longer listening. The next run then // sees perfect DHCP (offer, request, ack, even the right bootfile name) and // no machine ever reaches it. That is not a failure mode anyone guesses from // "discovery timed out". killOrphanedDnsmasq(); const { app, syslog } = createApp(config); await app.listen({ port: config.httpPort, host: "0.0.0.0" }); syslog.start(); // NOT awaited, deliberately. // // startDnsmasq returns the execa child handle, and because the function is // `async` the promise it returns ADOPTS that thenable -- which settles only // when dnsmasq EXITS. With --no-daemon that is never, so `await` here hangs // the harness silently and forever, with the log frozen right after // "TFTP root is ...". (Learned by doing exactly that.) The rejection still // has to be handled or an early bind failure becomes an unhandled rejection. startDnsmasq(config).catch((err) => log(`dnsmasq: ${err instanceof Error ? err.message : String(err)}`)); await sleep(1000); // Prove DHCP is actually listening before handing the harness back. // // startDnsmasq's rejection is swallowed into a log line, so a dnsmasq that // failed to bind looks exactly like one that started. The symptom then // arrives six minutes later as four VMs timing out in discovery, which // reads as a PXE or kickstart problem rather than "there was never any // DHCP". Fail here, immediately, with the real reason. await assertDhcpListening(join(testDir, "dnsmasq.conf")); log(`Bastion up on :${opts.httpPort} (mirror: ${config.fedoraMirror}, autoK3s=${config.autoK3s})`); return { config, httpPort: opts.httpPort, sshUser: opts.sshUser, sshKeyPath: opts.sshKeyPath, close: async () => { // Stop OUR dnsmasq. close() never used to, which is how a leaked one // ended up owning :67 and serving the PREVIOUS run's TFTP root and // bastion port to the NEXT run's VMs -- they booted toward a dead port // and simply never appeared, while DHCP looked perfect. try { stopDnsmasq(); } catch { /* already gone */ } await app.close(); // Belt and braces: if that did not stop it, sweep by config path. killOrphanedDnsmasq(); }, }; } /** * Kill any dnsmasq started by a previous (or current) lab test run. * * Matched on the config path, which is always /tmp/lab-