Files
lab/bastion/tests/integration/helpers/pxe-vm.ts

239 lines
8.9 KiB
TypeScript
Raw Normal View History

feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
// Create a blank UEFI VM for PXE boot testing.
// Unlike cloud image VMs, these have an empty disk and boot from network.
// Each VM gets a serial console on a TCP port for debugging without network/SSH.
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
import { execSync, spawnSync, type SpawnSyncReturns } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createConnection } from "node:net";
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
import { log } from "./libvirt.js";
const IMAGE_DIR = "/var/lib/libvirt/images";
const IS_ROOT = process.getuid?.() === 0;
function run(cmd: string, opts?: { timeout?: number }): string {
const full = IS_ROOT ? cmd : `sudo ${cmd}`;
return execSync(full, { encoding: "utf-8", stdio: "pipe", timeout: opts?.timeout ?? 60_000 });
}
function virsh(...args: string[]): SpawnSyncReturns<string> {
const cmd = IS_ROOT ? "virsh" : "sudo";
const finalArgs = IS_ROOT ? args : ["virsh", ...args];
return spawnSync(cmd, finalArgs, { encoding: "utf-8", stdio: "pipe", timeout: 30_000 });
}
export interface PxeVmConfig {
name: string;
memory: number; // MB
vcpus: number;
diskSize: number; // GB
network: string; // libvirt network name
arch?: "x86_64" | "aarch64";
fix(bastion): pin the VyOS boot NIC by MAC, and detect pre-installer stalls Both Protectli VP2440s failed to install on real hardware: they fetched kernel+initrd and then went silent. The console showed why — Looking for a connected Ethernet interface ... e2 ? e3 ? e4 ? e5 ? Connected e4 found Connected e5 found [4.595647] igc 0000:02:00.0 e2: NIC Link is Up IP-Config: e4 ... no response after 15 secs - giving up Unable to find a live file system on the network live-boot picks the first *connected* interface. The i40e SFP+ pair links before the igc copper port (up at 4.6s), so it chose the fiber ports, which have no DHCP, and never tried the NIC that actually PXE booted. Fix: pass BOOTIF=01-<mac> on the kernel cmdline. live-boot's Device_from_bootif() (verified present in this image) matches it against /sys/class/net and sets DEVICE directly. The MAC comes from the dispatch key — i.e. exactly the NIC that PXE booted — which is more reliable than iPXE's ${net0} on a box where the booting NIC may not be net0. Why the integration test missed it: the VM had ONE NIC, so "first connected interface" was trivially correct, and virtio links instantly so there was no negotiation race. createPxeVm now takes decoyNics, attaching extra NICs ahead of the PXE NIC on a network with no route to the bastion; the VyOS test uses 2. Without BOOTIF that reproduces the hardware failure. getVmMac is network-aware so it still returns the booting NIC. Also: the bastion had every clue and said nothing — it logged INSTALL STARTED, served kernel+initrd, then nothing for 7 minutes. dispatch now stamps dispatched_at, and /api/logs/:mac returns stalled_for_s / stalled (8 min threshold, sized for the ~600MB squashfs fetch), so a machine wedged before the installer environment comes up is diagnosable without a console. Verified on hardware: both firewalls installed, bond0 802.3ad + VLANs 2/3/9/10/200 + VRRP (priority 200/100, VIP .254 per VLAN) applied, and /config/lab-provisioned written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-12 12:35:09 +01:00
/**
* Extra NICs enumerated BEFORE the PXE NIC, on a network with no route to
* the bastion (defaults to libvirt's "default").
*
* Real multi-NIC boxes expose a class of bug a single-NIC VM cannot: an
* initramfs that picks "the first connected interface" grabs one of these
* instead of the NIC that PXE booted, and then cannot reach the bastion.
* Defaults to 0 (single NIC).
*/
decoyNics?: number;
decoyNetwork?: string;
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
}
/** Create a blank UEFI VM that PXE boots from the network. */
export function createPxeVm(config: PxeVmConfig): void {
destroyPxeVm(config.name);
const arch = config.arch ?? "x86_64";
log(`Creating PXE VM: ${config.name} (${arch}, ${config.memory}MB RAM, ${config.vcpus} vCPU, ${config.diskSize}GB disk)`);
// Create blank disk
const diskPath = join(IMAGE_DIR, `${config.name}.qcow2`);
run(`qemu-img create -f qcow2 "${diskPath}" ${config.diskSize}G`);
// UEFI firmware paths (Fedora)
if (arch === "aarch64") {
const aavmf = "/usr/share/edk2/aarch64/QEMU_EFI.fd";
if (!existsSync(aavmf)) {
throw new Error(`AAVMF firmware not found at ${aavmf}. Install: sudo dnf install edk2-aarch64`);
}
} else {
const ovmf = "/usr/share/edk2/ovmf/OVMF_CODE.fd";
if (!existsSync(ovmf)) {
throw new Error(`OVMF firmware not found at ${ovmf}. Install: sudo dnf install edk2-ovmf`);
}
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
}
const virtInstallArgs = [
"virt-install",
`--name=${config.name}`,
`--memory=${config.memory}`,
`--vcpus=${config.vcpus}`,
`--disk=path=${diskPath},format=qcow2,bus=virtio`,
fix(bastion): pin the VyOS boot NIC by MAC, and detect pre-installer stalls Both Protectli VP2440s failed to install on real hardware: they fetched kernel+initrd and then went silent. The console showed why — Looking for a connected Ethernet interface ... e2 ? e3 ? e4 ? e5 ? Connected e4 found Connected e5 found [4.595647] igc 0000:02:00.0 e2: NIC Link is Up IP-Config: e4 ... no response after 15 secs - giving up Unable to find a live file system on the network live-boot picks the first *connected* interface. The i40e SFP+ pair links before the igc copper port (up at 4.6s), so it chose the fiber ports, which have no DHCP, and never tried the NIC that actually PXE booted. Fix: pass BOOTIF=01-<mac> on the kernel cmdline. live-boot's Device_from_bootif() (verified present in this image) matches it against /sys/class/net and sets DEVICE directly. The MAC comes from the dispatch key — i.e. exactly the NIC that PXE booted — which is more reliable than iPXE's ${net0} on a box where the booting NIC may not be net0. Why the integration test missed it: the VM had ONE NIC, so "first connected interface" was trivially correct, and virtio links instantly so there was no negotiation race. createPxeVm now takes decoyNics, attaching extra NICs ahead of the PXE NIC on a network with no route to the bastion; the VyOS test uses 2. Without BOOTIF that reproduces the hardware failure. getVmMac is network-aware so it still returns the booting NIC. Also: the bastion had every clue and said nothing — it logged INSTALL STARTED, served kernel+initrd, then nothing for 7 minutes. dispatch now stamps dispatched_at, and /api/logs/:mac returns stalled_for_s / stalled (8 min threshold, sized for the ~600MB squashfs fetch), so a machine wedged before the installer environment comes up is diagnosable without a console. Verified on hardware: both firewalls installed, bond0 802.3ad + VLANs 2/3/9/10/200 + VRRP (priority 200/100, VIP .254 per VLAN) applied, and /config/lab-provisioned written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-12 12:35:09 +01:00
// Decoys first so they enumerate ahead of the PXE NIC. They are up and
// carry a lease, but have no route to the bastion.
...Array.from({ length: config.decoyNics ?? 0 }, () =>
`--network=network=${config.decoyNetwork ?? "default"},model=virtio`),
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
`--network=network=${config.network},model=virtio`,
// UEFI firmware — required for PXE boot in modern mode
`--boot=uefi,network,hd`,
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
// No OS to install — PXE provides everything
"--os-variant=generic",
"--noautoconsole",
"--wait=0",
// Graphics for debugging (VNC, connect with virt-viewer if needed)
"--graphics=vnc,listen=127.0.0.1",
// Serial console via TCP — allows exec without network/SSH
// Connect: socat - TCP:127.0.0.1:4555
"--serial=tcp,host=127.0.0.1:4555,mode=bind,protocol=telnet",
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
];
if (arch === "aarch64") {
virtInstallArgs.push("--arch=aarch64", "--machine=virt");
}
log(`Running: virt-install --name=${config.name} --boot=uefi,network ...`);
run(virtInstallArgs.join(" "), { timeout: 30_000 });
log(`PXE VM ${config.name} created (serial: telnet 127.0.0.1 4555)`);
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
}
/** Destroy a PXE VM and clean up its disk. */
export function destroyPxeVm(name: string): void {
const result = virsh("dominfo", name);
if (result.status !== 0) return;
log(`Destroying PXE VM: ${name}`);
virsh("destroy", name);
virsh("undefine", name, "--remove-all-storage", "--nvram");
}
/** Get the MAC address of a VM's first NIC. */
fix(bastion): pin the VyOS boot NIC by MAC, and detect pre-installer stalls Both Protectli VP2440s failed to install on real hardware: they fetched kernel+initrd and then went silent. The console showed why — Looking for a connected Ethernet interface ... e2 ? e3 ? e4 ? e5 ? Connected e4 found Connected e5 found [4.595647] igc 0000:02:00.0 e2: NIC Link is Up IP-Config: e4 ... no response after 15 secs - giving up Unable to find a live file system on the network live-boot picks the first *connected* interface. The i40e SFP+ pair links before the igc copper port (up at 4.6s), so it chose the fiber ports, which have no DHCP, and never tried the NIC that actually PXE booted. Fix: pass BOOTIF=01-<mac> on the kernel cmdline. live-boot's Device_from_bootif() (verified present in this image) matches it against /sys/class/net and sets DEVICE directly. The MAC comes from the dispatch key — i.e. exactly the NIC that PXE booted — which is more reliable than iPXE's ${net0} on a box where the booting NIC may not be net0. Why the integration test missed it: the VM had ONE NIC, so "first connected interface" was trivially correct, and virtio links instantly so there was no negotiation race. createPxeVm now takes decoyNics, attaching extra NICs ahead of the PXE NIC on a network with no route to the bastion; the VyOS test uses 2. Without BOOTIF that reproduces the hardware failure. getVmMac is network-aware so it still returns the booting NIC. Also: the bastion had every clue and said nothing — it logged INSTALL STARTED, served kernel+initrd, then nothing for 7 minutes. dispatch now stamps dispatched_at, and /api/logs/:mac returns stalled_for_s / stalled (8 min threshold, sized for the ~600MB squashfs fetch), so a machine wedged before the installer environment comes up is diagnosable without a console. Verified on hardware: both firewalls installed, bond0 802.3ad + VLANs 2/3/9/10/200 + VRRP (priority 200/100, VIP .254 per VLAN) applied, and /config/lab-provisioned written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-12 12:35:09 +01:00
export function getVmMac(name: string, network?: string): string | null {
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
const result = virsh("domiflist", name);
if (result.status !== 0) return null;
// Output format: Interface Type Source Model MAC
fix(bastion): pin the VyOS boot NIC by MAC, and detect pre-installer stalls Both Protectli VP2440s failed to install on real hardware: they fetched kernel+initrd and then went silent. The console showed why — Looking for a connected Ethernet interface ... e2 ? e3 ? e4 ? e5 ? Connected e4 found Connected e5 found [4.595647] igc 0000:02:00.0 e2: NIC Link is Up IP-Config: e4 ... no response after 15 secs - giving up Unable to find a live file system on the network live-boot picks the first *connected* interface. The i40e SFP+ pair links before the igc copper port (up at 4.6s), so it chose the fiber ports, which have no DHCP, and never tried the NIC that actually PXE booted. Fix: pass BOOTIF=01-<mac> on the kernel cmdline. live-boot's Device_from_bootif() (verified present in this image) matches it against /sys/class/net and sets DEVICE directly. The MAC comes from the dispatch key — i.e. exactly the NIC that PXE booted — which is more reliable than iPXE's ${net0} on a box where the booting NIC may not be net0. Why the integration test missed it: the VM had ONE NIC, so "first connected interface" was trivially correct, and virtio links instantly so there was no negotiation race. createPxeVm now takes decoyNics, attaching extra NICs ahead of the PXE NIC on a network with no route to the bastion; the VyOS test uses 2. Without BOOTIF that reproduces the hardware failure. getVmMac is network-aware so it still returns the booting NIC. Also: the bastion had every clue and said nothing — it logged INSTALL STARTED, served kernel+initrd, then nothing for 7 minutes. dispatch now stamps dispatched_at, and /api/logs/:mac returns stalled_for_s / stalled (8 min threshold, sized for the ~600MB squashfs fetch), so a machine wedged before the installer environment comes up is diagnosable without a console. Verified on hardware: both firewalls installed, bond0 802.3ad + VLANs 2/3/9/10/200 + VRRP (priority 200/100, VIP .254 per VLAN) applied, and /config/lab-provisioned written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
2026-08-12 12:35:09 +01:00
// With decoy NICs present, match the line for the PXE network so we return
// the NIC that actually boots rather than whichever is listed first.
const lines = result.stdout.split("\n");
const candidates = network === undefined
? lines
: lines.filter((l) => l.split(/\s+/).includes(network));
for (const line of candidates.length > 0 ? candidates : lines) {
const m = line.match(/([0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2})/i);
if (m) return m[1].toLowerCase();
}
return null;
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
}
/** Reboot a VM (force off + start). */
export function rebootPxeVm(name: string): void {
log(`Rebooting PXE VM: ${name}`);
virsh("destroy", name);
// Brief pause to let resources release
spawnSync("sleep", ["2"]);
virsh("start", name);
log(`PXE VM ${name} restarted`);
}
/**
* Read raw output from the VM's serial console (telnet TCP port).
* Returns the last N lines. Useful for diagnostics when SSH isn't available.
*/
export async function readSerialLog(
port: number,
opts: { lastLines?: number; timeoutMs?: number } = {},
): Promise<string> {
const { lastLines = 50, timeoutMs = 10_000 } = opts;
return new Promise((resolve) => {
const sock = createConnection({ host: "127.0.0.1", port });
let buf = "";
const timer = setTimeout(() => { sock.destroy(); resolve(buf); }, timeoutMs);
sock.on("data", (d: Buffer) => { buf += d.toString(); });
sock.on("error", () => { clearTimeout(timer); resolve(`(connection error) ${buf}`); });
sock.on("close", () => { clearTimeout(timer); resolve(buf); });
// Send a newline to trigger any buffered output / prompt
setTimeout(() => sock.write("\r\n"), 500);
}).then((raw: unknown) => {
const lines = (raw as string).split("\n").map(l => l.trimEnd()).filter(Boolean);
return lines.slice(-lastLines).join("\n");
});
}
/**
* Execute a command on the VM's serial console via socat.
* Requires auto-login root shell on the serial port.
*/
export function serialExec(
port: number,
command: string,
timeoutMs = 15_000,
): string {
const marker = `__END_${Date.now()}__`;
// Use socat to handle telnet negotiation properly
const input = `\r\n${command}; echo '${marker}'\r\n`;
const result = spawnSync("bash", ["-c",
`echo -e '${input.replace(/'/g, "\\'")}' | socat -T${Math.ceil(timeoutMs / 1000)} - TCP:127.0.0.1:${port} 2>/dev/null`
], { encoding: "utf-8", stdio: "pipe", timeout: timeoutMs + 5000 });
const output = result.stdout ?? "";
const markerIdx = output.indexOf(marker);
if (markerIdx < 0) return `(no marker) ${output.slice(-500)}`;
// Get lines between command echo and marker
const before = output.substring(0, markerIdx);
const lines = before.split("\n");
// Skip everything up to and including the command echo line
const cmdIdx = lines.findIndex(l => l.includes(command.substring(0, 20)));
return lines.slice(cmdIdx >= 0 ? cmdIdx + 1 : 1).join("\n").trim();
}
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
export interface IsoVmConfig {
name: string;
memory: number; // MB
vcpus: number;
diskSize: number; // GB
network: string; // libvirt network name
isoPath: string; // path to boot ISO
arch?: "x86_64" | "aarch64";
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
}
/** Create a UEFI VM that boots from a CD-ROM ISO (not PXE). */
export function createIsoVm(config: IsoVmConfig): void {
destroyPxeVm(config.name);
const arch = config.arch ?? "x86_64";
log(`Creating ISO boot VM: ${config.name} (${arch}, ${config.memory}MB RAM, ${config.vcpus} vCPU, ${config.diskSize}GB disk)`);
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
// Create blank disk
const diskPath = join(IMAGE_DIR, `${config.name}.qcow2`);
run(`qemu-img create -f qcow2 "${diskPath}" ${config.diskSize}G`);
const virtInstallArgs = [
"virt-install",
`--name=${config.name}`,
`--memory=${config.memory}`,
`--vcpus=${config.vcpus}`,
`--disk=path=${diskPath},format=qcow2,bus=virtio`,
// Boot ISO as CD-ROM
`--disk=path=${config.isoPath},device=cdrom,readonly=on`,
`--network=network=${config.network},model=virtio`,
// UEFI firmware, boot from cdrom (not network)
"--boot=uefi,cdrom",
"--os-variant=generic",
"--noautoconsole",
"--wait=0",
"--graphics=vnc,listen=127.0.0.1",
// Serial console via TCP (port 4556 to avoid conflict with PXE VM)
"--serial=tcp,host=127.0.0.1:4556,mode=bind,protocol=telnet",
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
];
if (arch === "aarch64") {
virtInstallArgs.push("--arch=aarch64", "--machine=virt");
}
feat: install logging, error trapping, PXE/ISO integration tests Kickstart installs on real hardware failed silently — no error reporting, only 3 progress callbacks, zero log streaming. This overhaul makes every install fully observable. Kickstart improvements: - Error trapping in %pre and %post (trap ERR sends failure details to bastion) - 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata - Background log streamer: tails %post output and batch-sends to /api/log - bastion_log() function for explicit log lines from kickstart scripts Bastion API: - POST /api/log — receives raw log lines from kickstart (single or batch) - InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence - GET /api/logs/:mac — now returns log_lines + log_total alongside stages - SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log) - Progress events forwarded to labd via bastion-progress WebSocket message - Post-provision k3s logs routed through progressBus (was console-only) dnsmasq fixes found during VM testing: - HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach) - pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode) - PXEClient vendor class echo for UEFI firmware compatibility Integration tests: - PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install - ISO boot test: blank VM boots from bastion-generated ISO → same flow - Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot) - test-provision.sh: runs both PXE + ISO tests with prerequisite checks - 250GB sparse QCOW2 disk (LVM layout needs ~204GB) 201 unit tests passing (11 new). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:26:33 +00:00
log(`Running: virt-install --name=${config.name} --boot=uefi,cdrom ...`);
run(virtInstallArgs.join(" "), { timeout: 60_000 });
log(`ISO boot VM ${config.name} created (serial: telnet 127.0.0.1 4556)`);
}