feat: ARM ISO boot integration test, OVMF boot fixes
Some checks failed
CI/CD / typecheck (pull_request) Failing after 9s
CI/CD / test (pull_request) Failing after 10s
CI/CD / lint (pull_request) Failing after 22s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
Some checks failed
CI/CD / typecheck (pull_request) Failing after 9s
CI/CD / test (pull_request) Failing after 10s
CI/CD / lint (pull_request) Failing after 22s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
ARM integration test: - arm-iso-provision.test.ts: aarch64 VM boots from bastion-generated ISO - Uses QEMU aarch64 emulation (slow but validates the R1 scenario) - Generous timeouts for emulated boot (15min discovery, 60min install) - test-provision.sh updated: `sudo ./scripts/test-provision.sh arm` VM boot fixes: - setBootDisk() preserves UEFI loader/nvram when switching to disk boot - /boot/efi mount gets nofail in fstab (prevents emergency mode in VMs) - chronyd enable uses || true (fails in kickstart chroot) - createIsoVm supports arch parameter for ARM VMs Note: SSH-after-reboot in OVMF VMs still fails — OVMF doesn't respect efibootmgr changes and loops PXE/HTTP Boot. Real hardware works fine. The install flow itself (discovery → kickstart → complete) is validated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,9 @@
|
|||||||
"test:integration:pxe": "vitest run -c tests/integration/vitest.config.ts -t 'PXE boot'",
|
"test:integration:pxe": "vitest run -c tests/integration/vitest.config.ts -t 'PXE boot'",
|
||||||
"test:integration:pxe:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'PXE boot'",
|
"test:integration:pxe:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'PXE boot'",
|
||||||
"test:integration:iso": "vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'",
|
"test:integration:iso": "vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'",
|
||||||
"test:integration:iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'"
|
"test:integration:iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'",
|
||||||
|
"test:integration:arm-iso": "vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'",
|
||||||
|
"test:integration:arm-iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": ">=20.0.0",
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
# Run PXE and/or ISO boot integration tests.
|
# Run PXE and/or ISO boot integration tests.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# sudo ./scripts/test-provision.sh # run both PXE + ISO tests
|
# sudo ./scripts/test-provision.sh # run PXE + ISO (x86_64)
|
||||||
# sudo ./scripts/test-provision.sh pxe # PXE only
|
# sudo ./scripts/test-provision.sh pxe # PXE only
|
||||||
# sudo ./scripts/test-provision.sh iso # ISO only
|
# sudo ./scripts/test-provision.sh iso # ISO only (x86_64)
|
||||||
|
# sudo ./scripts/test-provision.sh arm # ARM ISO boot (emulated, SLOW ~60min)
|
||||||
|
# sudo ./scripts/test-provision.sh all # all tests including ARM
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# libvirtd, OVMF (edk2-ovmf), iPXE (ipxe-bootimgs-x86),
|
# libvirtd, OVMF (edk2-ovmf), iPXE (ipxe-bootimgs-x86),
|
||||||
# dnsmasq, xorriso, mtools, virt-install, qemu-img
|
# dnsmasq, xorriso, mtools, virt-install, qemu-img
|
||||||
|
# ARM: qemu-system-aarch64, edk2-aarch64
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
@@ -112,12 +115,30 @@ case "$MODE" in
|
|||||||
iso)
|
iso)
|
||||||
run_test "ISO boot" "ISO boot" || FAILED=1
|
run_test "ISO boot" "ISO boot" || FAILED=1
|
||||||
;;
|
;;
|
||||||
both|all)
|
arm|arm-iso)
|
||||||
|
if ! command -v qemu-system-aarch64 &>/dev/null; then
|
||||||
|
echo -e "${RED}qemu-system-aarch64 not found.${RESET} Install: sudo dnf install qemu-system-aarch64 edk2-aarch64"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${YELLOW}ARM emulation is ~10x slower than native. Expect 30-60 minutes.${RESET}"
|
||||||
|
run_test "ARM ISO boot" "ARM ISO" || FAILED=1
|
||||||
|
;;
|
||||||
|
both)
|
||||||
run_test "PXE boot" "PXE boot" || FAILED=1
|
run_test "PXE boot" "PXE boot" || FAILED=1
|
||||||
run_test "ISO boot" "ISO boot" || FAILED=1
|
run_test "ISO boot" "ISO boot" || FAILED=1
|
||||||
;;
|
;;
|
||||||
|
all)
|
||||||
|
run_test "PXE boot" "PXE boot" || FAILED=1
|
||||||
|
run_test "ISO boot" "ISO boot" || FAILED=1
|
||||||
|
if command -v qemu-system-aarch64 &>/dev/null; then
|
||||||
|
echo -e "${YELLOW}ARM emulation is ~10x slower than native.${RESET}"
|
||||||
|
run_test "ARM ISO boot" "ARM ISO" || FAILED=1
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Skipping ARM test (qemu-system-aarch64 not installed)${RESET}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: $0 [pxe|iso|both]"
|
echo "Usage: $0 [pxe|iso|arm|both|all]"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -409,6 +409,9 @@ hostnamectl set-hostname ${fqdn}
|
|||||||
# -- tmpfs for /tmp --
|
# -- tmpfs for /tmp --
|
||||||
echo "tmpfs /tmp tmpfs defaults,noatime,nosuid,nodev,size=4G 0 0" >> /etc/fstab
|
echo "tmpfs /tmp tmpfs defaults,noatime,nosuid,nodev,size=4G 0 0" >> /etc/fstab
|
||||||
|
|
||||||
|
# Make /boot/efi mount non-fatal (prevents emergency mode if EFI partition isn't found)
|
||||||
|
sed -i '/\\/boot\\/efi/ s/defaults/defaults,nofail/' /etc/fstab
|
||||||
|
|
||||||
${isVanilla ? `# -- vanilla role: skip k3s kernel/sysctl/firewall setup --
|
${isVanilla ? `# -- vanilla role: skip k3s kernel/sysctl/firewall setup --
|
||||||
bastion_progress "post-install" "vanilla role -- skipping k3s setup"
|
bastion_progress "post-install" "vanilla role -- skipping k3s setup"
|
||||||
# -- Enable chronyd for time sync --
|
# -- Enable chronyd for time sync --
|
||||||
|
|||||||
334
bastion/tests/integration/arm-iso-provision.test.ts
Normal file
334
bastion/tests/integration/arm-iso-provision.test.ts
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
// Integration test: ARM (aarch64) boot ISO provisioning.
|
||||||
|
//
|
||||||
|
// Simulates the MinisForum R1 scenario: ARM machine without PXE support,
|
||||||
|
// booting from the bastion-generated ISO via QEMU aarch64 emulation.
|
||||||
|
//
|
||||||
|
// IMPORTANT: This runs under emulation (not KVM), so it's SLOW (~30-60 min).
|
||||||
|
// The ISO contains an embedded aarch64 kernel+initrd for the fallback path.
|
||||||
|
//
|
||||||
|
// Prerequisites:
|
||||||
|
// - qemu-system-aarch64 installed (sudo dnf install qemu-system-aarch64)
|
||||||
|
// - edk2-aarch64 installed (sudo dnf install edk2-aarch64)
|
||||||
|
// - xorriso, mtools for ISO generation
|
||||||
|
// - sudo access, libvirtd running
|
||||||
|
//
|
||||||
|
// Run: sudo pnpm run test:integration:arm-iso
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { readFileSync, existsSync, mkdirSync, rmSync, copyFileSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { homedir, tmpdir } from "node:os";
|
||||||
|
import { log, waitForSsh } from "./helpers/libvirt.js";
|
||||||
|
import { ensurePxeNetwork, destroyPxeNetwork, PXE_NETWORK_NAME, PXE_GATEWAY, PXE_SUBNET } from "./helpers/pxe-network.js";
|
||||||
|
import { createIsoVm, destroyPxeVm, getVmMac, rebootPxeVm, setBootDisk } from "./helpers/pxe-vm.js";
|
||||||
|
import { sshExec } from "./helpers/ssh.js";
|
||||||
|
|
||||||
|
const VM_NAME = "lab-arm-iso-test";
|
||||||
|
const VM_MEMORY = 4096;
|
||||||
|
const VM_VCPUS = 2;
|
||||||
|
const VM_DISK_GB = 250; // LVM layout needs ~204GB, QCOW2 is sparse
|
||||||
|
const HTTP_PORT = 8097; // different from x86 tests
|
||||||
|
const SSH_USER = "michal";
|
||||||
|
const BASTION_IP = PXE_GATEWAY;
|
||||||
|
const DHCP_RANGE_START = `${PXE_SUBNET}.100`;
|
||||||
|
const DHCP_RANGE_END = `${PXE_SUBNET}.200`;
|
||||||
|
|
||||||
|
// ARM emulation is SLOW — generous timeouts
|
||||||
|
const DISCOVERY_TIMEOUT_MS = 15 * 60_000; // 15 min for emulated boot + discovery
|
||||||
|
const INSTALL_TIMEOUT_MS = 60 * 60_000; // 60 min for emulated Fedora install
|
||||||
|
const SSH_TIMEOUT_MS = 15 * 60_000; // 15 min for emulated reboot + SSH
|
||||||
|
|
||||||
|
function findSshKey(): { pubKey: string; keyPath: string } {
|
||||||
|
const homes = [homedir()];
|
||||||
|
const sudoUser = process.env["SUDO_USER"];
|
||||||
|
if (sudoUser) homes.push(join("/home", sudoUser));
|
||||||
|
if (process.env["SSH_KEY_PATH"]) {
|
||||||
|
const keyPath = process.env["SSH_KEY_PATH"];
|
||||||
|
const pubPath = `${keyPath}.pub`;
|
||||||
|
if (existsSync(keyPath) && existsSync(pubPath)) {
|
||||||
|
return { pubKey: readFileSync(pubPath, "utf-8").trim(), keyPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const home of homes) {
|
||||||
|
for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) {
|
||||||
|
const keyPath = join(home, ".ssh", name);
|
||||||
|
const pubPath = `${keyPath}.pub`;
|
||||||
|
if (existsSync(keyPath) && existsSync(pubPath)) {
|
||||||
|
return { pubKey: readFileSync(pubPath, "utf-8").trim(), keyPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("No SSH key found");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollApi<T>(
|
||||||
|
url: string,
|
||||||
|
check: (data: T) => boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
intervalMs = 10000,
|
||||||
|
): Promise<T> {
|
||||||
|
const start = Date.now();
|
||||||
|
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 { /* bastion not ready */ }
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
throw new Error(`Timeout after ${timeoutMs}ms polling ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ARM ISO boot provisioning", () => {
|
||||||
|
let bastionApp: { close: () => Promise<void> };
|
||||||
|
let testDir: string;
|
||||||
|
let vmMac: string;
|
||||||
|
let vmIp: string;
|
||||||
|
let sshKeyPath: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Check ARM prerequisites
|
||||||
|
if (!existsSync("/usr/bin/qemu-system-aarch64")) {
|
||||||
|
throw new Error("qemu-system-aarch64 not installed. Run: sudo dnf install qemu-system-aarch64");
|
||||||
|
}
|
||||||
|
if (!existsSync("/usr/share/edk2/aarch64/QEMU_EFI.fd")) {
|
||||||
|
throw new Error("AAVMF not installed. Run: sudo dnf install edk2-aarch64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pubKey, keyPath } = findSshKey();
|
||||||
|
sshKeyPath = keyPath;
|
||||||
|
|
||||||
|
// 1. Network
|
||||||
|
log("Setting up PXE test network (for ARM ISO test)...");
|
||||||
|
ensurePxeNetwork();
|
||||||
|
|
||||||
|
// 2. Bastion dirs
|
||||||
|
testDir = join(tmpdir(), `lab-arm-iso-test-${Date.now()}`);
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
mkdirSync(join(testDir, "tftp"), { recursive: true });
|
||||||
|
mkdirSync(join(testDir, "http"), { recursive: true });
|
||||||
|
mkdirSync(join(testDir, "logs"), { recursive: true });
|
||||||
|
|
||||||
|
// 3. Start bastion with ISO generation (needs both x86_64 and aarch64 artifacts)
|
||||||
|
log("Starting bastion with ARM boot ISO generation...");
|
||||||
|
|
||||||
|
const { createApp } = await import("../../src/bastion/src/server.js");
|
||||||
|
const { loadConfig } = await import("../../src/bastion/src/config.js");
|
||||||
|
const { generateDnsmasqConf, startDnsmasq } = 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 { ensureBootIso } = await import("../../src/bastion/src/routes/boot-iso.js");
|
||||||
|
|
||||||
|
const config = loadConfig({
|
||||||
|
bastionDir: testDir,
|
||||||
|
httpPort: HTTP_PORT,
|
||||||
|
iface: "virbr-pxe",
|
||||||
|
serverIp: BASTION_IP,
|
||||||
|
network: `${PXE_SUBNET}.0`,
|
||||||
|
gateway: BASTION_IP,
|
||||||
|
dhcpMode: "full",
|
||||||
|
dhcpRangeStart: DHCP_RANGE_START,
|
||||||
|
dhcpRangeEnd: DHCP_RANGE_END,
|
||||||
|
domain: "arm-test.local",
|
||||||
|
sshKeys: [pubKey],
|
||||||
|
adminUser: SSH_USER,
|
||||||
|
});
|
||||||
|
|
||||||
|
// iPXE for TFTP (x86_64 — still needed for dnsmasq config)
|
||||||
|
const ipxeSrc = "/usr/share/ipxe/ipxe-snponly-x86_64.efi";
|
||||||
|
if (existsSync(ipxeSrc)) {
|
||||||
|
copyFileSync(ipxeSrc, join(config.tftpDir, "ipxe.efi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fedora kernel + initrd for BOTH architectures
|
||||||
|
const cacheDir = "/var/lib/libvirt/images/lab-pxe-cache";
|
||||||
|
execSync(`mkdir -p "${cacheDir}"`, { stdio: "pipe" });
|
||||||
|
|
||||||
|
// x86_64 (for the bastion HTTP serving)
|
||||||
|
for (const [arch, prefix] of [["x86_64", ""], ["aarch64", "aarch64-"]] as const) {
|
||||||
|
const mirror = `https://download.fedoraproject.org/pub/fedora/linux/releases/${config.fedoraVersion}/Everything/${arch}/os`;
|
||||||
|
const kernel = join(cacheDir, `vmlinuz-${arch}`);
|
||||||
|
const initrd = join(cacheDir, `initrd-${arch}.img`);
|
||||||
|
|
||||||
|
if (!existsSync(kernel)) {
|
||||||
|
log(`Downloading Fedora ${config.fedoraVersion} ${arch} kernel...`);
|
||||||
|
execSync(`curl -# -L -f -o "${kernel}" "${mirror}/images/pxeboot/vmlinuz"`, { stdio: "inherit", timeout: 300_000 });
|
||||||
|
} else {
|
||||||
|
log(`Fedora ${arch} kernel cached`);
|
||||||
|
}
|
||||||
|
if (!existsSync(initrd)) {
|
||||||
|
log(`Downloading Fedora ${config.fedoraVersion} ${arch} initrd...`);
|
||||||
|
execSync(`curl -# -L -f -o "${initrd}" "${mirror}/images/pxeboot/initrd.img"`, { stdio: "inherit", timeout: 300_000 });
|
||||||
|
} else {
|
||||||
|
log(`Fedora ${arch} initrd cached`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// x86_64 artifacts go to httpDir for normal PXE serving
|
||||||
|
if (arch === "x86_64") {
|
||||||
|
copyFileSync(kernel, join(config.httpDir, "vmlinuz"));
|
||||||
|
copyFileSync(initrd, join(config.httpDir, "initrd.img"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try { symlinkSync(join(config.tftpDir, "ipxe.efi"), join(config.httpDir, "ipxe.efi")); } catch { /* exists */ }
|
||||||
|
|
||||||
|
// Generate boot scripts
|
||||||
|
const discoverKs = generateDiscoverKickstart(config);
|
||||||
|
writeFileSync(join(config.httpDir, "discover.ks"), discoverKs);
|
||||||
|
const bootIpxe = renderBootIpxe({ serverIp: config.serverIp, httpPort: config.httpPort });
|
||||||
|
writeFileSync(join(config.httpDir, "boot.ipxe"), bootIpxe);
|
||||||
|
|
||||||
|
// Generate the boot ISO — includes both x86_64 and aarch64 payloads
|
||||||
|
log("Generating boot ISO with ARM support...");
|
||||||
|
ensureBootIso(config);
|
||||||
|
|
||||||
|
const isoPath = join(config.httpDir, "boot.iso");
|
||||||
|
if (!existsSync(isoPath)) {
|
||||||
|
throw new Error("Boot ISO was not generated");
|
||||||
|
}
|
||||||
|
const { statSync } = await import("node:fs");
|
||||||
|
const isoSize = statSync(isoPath).size;
|
||||||
|
log(`Boot ISO generated: ${(isoSize / 1024 / 1024).toFixed(1)}MB`);
|
||||||
|
|
||||||
|
// dnsmasq + HTTP server
|
||||||
|
generateDnsmasqConf(config);
|
||||||
|
const { app } = createApp(config);
|
||||||
|
bastionApp = app;
|
||||||
|
await app.listen({ port: config.httpPort, host: "0.0.0.0" });
|
||||||
|
log(`Bastion HTTP listening on :${HTTP_PORT}`);
|
||||||
|
|
||||||
|
void startDnsmasq(config);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
// 4. Create ARM VM booting from ISO
|
||||||
|
log("Creating ARM ISO boot VM (aarch64 emulation — this will be SLOW)...");
|
||||||
|
createIsoVm({
|
||||||
|
name: VM_NAME,
|
||||||
|
memory: VM_MEMORY,
|
||||||
|
vcpus: VM_VCPUS,
|
||||||
|
diskSize: VM_DISK_GB,
|
||||||
|
network: PXE_NETWORK_NAME,
|
||||||
|
isoPath,
|
||||||
|
arch: "aarch64",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mac = getVmMac(VM_NAME);
|
||||||
|
if (!mac) throw new Error("Could not determine VM MAC address");
|
||||||
|
vmMac = mac;
|
||||||
|
log(`ARM VM MAC: ${vmMac}`);
|
||||||
|
|
||||||
|
// 5. Wait for discovery (ARM emulation is slow)
|
||||||
|
log("Waiting for ARM VM to boot ISO -> iPXE -> DHCP -> bastion -> discover...");
|
||||||
|
log("(ARM emulation is ~10x slower than native — be patient)");
|
||||||
|
type MachinesResponse = { discovered: Record<string, unknown> };
|
||||||
|
await pollApi<MachinesResponse>(
|
||||||
|
`http://${BASTION_IP}:${HTTP_PORT}/api/machines`,
|
||||||
|
(data) => vmMac in data.discovered,
|
||||||
|
DISCOVERY_TIMEOUT_MS,
|
||||||
|
15_000,
|
||||||
|
);
|
||||||
|
log("ARM VM discovered via ISO boot!");
|
||||||
|
|
||||||
|
// 6. Queue install
|
||||||
|
log("Queueing ARM machine for install...");
|
||||||
|
await fetch(`http://${BASTION_IP}:${HTTP_PORT}/api/install`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mac: vmMac, hostname: VM_NAME, disk: "", role: "vanilla" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Reboot for install
|
||||||
|
log("Waiting for discovery reboot...");
|
||||||
|
await sleep(30_000); // ARM is slow
|
||||||
|
rebootPxeVm(VM_NAME);
|
||||||
|
|
||||||
|
// 8. Wait for install (ARM emulation makes this very slow)
|
||||||
|
log("Waiting for ARM install (emulated — expect 30-60 minutes)...");
|
||||||
|
type LogsResponse = { status: string; progress: string; ip?: string };
|
||||||
|
const finalState = await pollApi<LogsResponse>(
|
||||||
|
`http://${BASTION_IP}:${HTTP_PORT}/api/logs/${encodeURIComponent(vmMac)}`,
|
||||||
|
(data) => data.status === "installed" || data.progress === "error",
|
||||||
|
INSTALL_TIMEOUT_MS,
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (finalState.progress === "error") {
|
||||||
|
const logsRes = await fetch(`http://${BASTION_IP}:${HTTP_PORT}/api/logs/${encodeURIComponent(vmMac)}`);
|
||||||
|
const logs = await logsRes.json();
|
||||||
|
log(`ARM INSTALL FAILED. State: ${JSON.stringify(logs, null, 2)}`);
|
||||||
|
throw new Error("ARM install failed — check logs above");
|
||||||
|
}
|
||||||
|
|
||||||
|
vmIp = finalState.ip ?? "";
|
||||||
|
log(`ARM install complete! VM IP: ${vmIp}`);
|
||||||
|
|
||||||
|
// 9. Switch boot to disk
|
||||||
|
log("Switching ARM VM boot order to disk...");
|
||||||
|
await sleep(15_000);
|
||||||
|
setBootDisk(VM_NAME);
|
||||||
|
|
||||||
|
// 10. Wait for SSH (ARM reboot is slow)
|
||||||
|
log("Waiting for SSH on ARM VM...");
|
||||||
|
await waitForSsh(vmIp, SSH_USER, SSH_TIMEOUT_MS, sshKeyPath);
|
||||||
|
log("ARM ISO boot provision test setup complete.");
|
||||||
|
|
||||||
|
}, DISCOVERY_TIMEOUT_MS + INSTALL_TIMEOUT_MS + SSH_TIMEOUT_MS + 300_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
log("Cleaning up ARM test...");
|
||||||
|
if (bastionApp) await bastionApp.close().catch(() => {});
|
||||||
|
const { stopDnsmasq } = await import("../../src/bastion/src/services/dnsmasq.js");
|
||||||
|
stopDnsmasq();
|
||||||
|
destroyPxeVm(VM_NAME);
|
||||||
|
destroyPxeNetwork();
|
||||||
|
if (testDir) rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ARM machine was discovered and installed", async () => {
|
||||||
|
const res = await fetch(`http://${BASTION_IP}:${HTTP_PORT}/api/machines`);
|
||||||
|
const data = (await res.json()) as { installed: Record<string, { hostname: string }> };
|
||||||
|
expect(data.installed[vmMac]).toBeDefined();
|
||||||
|
expect(data.installed[vmMac].hostname).toBe(VM_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("architecture is aarch64", async () => {
|
||||||
|
const result = sshExec(vmIp, SSH_USER, "uname -m", { keyPath: sshKeyPath, timeout: 30_000 });
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout.trim()).toBe("aarch64");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SSH works", () => {
|
||||||
|
const result = sshExec(vmIp, SSH_USER, "whoami", { keyPath: sshKeyPath, timeout: 30_000 });
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout.trim()).toBe(SSH_USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hostname is correct", () => {
|
||||||
|
const result = sshExec(vmIp, SSH_USER, "hostname -f", { keyPath: sshKeyPath, timeout: 30_000 });
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout.trim()).toContain(VM_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provisioning metadata exists", () => {
|
||||||
|
const result = sshExec(vmIp, SSH_USER, "cat /etc/lab-provisioned", { keyPath: sshKeyPath, timeout: 30_000 });
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout).toContain(`hostname: ${VM_NAME}`);
|
||||||
|
expect(result.stdout).toContain("role: vanilla");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("LVM layout is correct", () => {
|
||||||
|
const result = sshExec(vmIp, SSH_USER, "sudo lvs labvg --noheadings -o lv_name", { keyPath: sshKeyPath, timeout: 30_000 });
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
const lvs = result.stdout.trim().split("\n").map((l: string) => l.trim());
|
||||||
|
for (const expected of ["root", "var", "varlog", "swap", "home", "srv"]) {
|
||||||
|
expect(lvs).toContain(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,13 +40,17 @@ export function createPxeVm(config: PxeVmConfig): void {
|
|||||||
const diskPath = join(IMAGE_DIR, `${config.name}.qcow2`);
|
const diskPath = join(IMAGE_DIR, `${config.name}.qcow2`);
|
||||||
run(`qemu-img create -f qcow2 "${diskPath}" ${config.diskSize}G`);
|
run(`qemu-img create -f qcow2 "${diskPath}" ${config.diskSize}G`);
|
||||||
|
|
||||||
// OVMF firmware paths (Fedora)
|
// UEFI firmware paths (Fedora)
|
||||||
const ovmfCode = arch === "aarch64"
|
if (arch === "aarch64") {
|
||||||
? "/usr/share/edk2/aarch64/QEMU_EFI-pflash.raw"
|
const aavmf = "/usr/share/edk2/aarch64/QEMU_EFI.fd";
|
||||||
: "/usr/share/edk2/ovmf/OVMF_CODE.fd";
|
if (!existsSync(aavmf)) {
|
||||||
|
throw new Error(`AAVMF firmware not found at ${aavmf}. Install: sudo dnf install edk2-aarch64`);
|
||||||
if (!existsSync(ovmfCode)) {
|
}
|
||||||
throw new Error(`OVMF firmware not found at ${ovmfCode}. Install: sudo dnf install edk2-ovmf`);
|
} 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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const virtInstallArgs = [
|
const virtInstallArgs = [
|
||||||
@@ -104,6 +108,31 @@ export function rebootPxeVm(name: string): void {
|
|||||||
log(`PXE VM ${name} restarted`);
|
log(`PXE VM ${name} restarted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Change VM boot order to disk first (skip PXE on next boot). */
|
||||||
|
export function setBootDisk(name: string): void {
|
||||||
|
log(`Setting ${name} boot order to disk first`);
|
||||||
|
virsh("destroy", name);
|
||||||
|
spawnSync("sleep", ["2"]);
|
||||||
|
// Get current XML, replace boot dev='network' with boot dev='hd'
|
||||||
|
// This preserves UEFI loader/nvram settings (virt-xml --boot hd can break them)
|
||||||
|
const dumpXml = virsh("dumpxml", name);
|
||||||
|
if (dumpXml.status !== 0) throw new Error("Failed to dump VM XML");
|
||||||
|
let xml = dumpXml.stdout;
|
||||||
|
// Replace any <boot dev='...' /> entries with hd
|
||||||
|
xml = xml.replace(/<boot dev='[^']*'\/>/g, "<boot dev='hd'/>");
|
||||||
|
// If no boot dev entry, add one before </os>
|
||||||
|
if (!xml.includes("<boot dev=")) {
|
||||||
|
xml = xml.replace("</os>", " <boot dev='hd'/>\n </os>");
|
||||||
|
}
|
||||||
|
const xmlPath = `/tmp/${name}-bootfix.xml`;
|
||||||
|
const { writeFileSync: writeFs, unlinkSync: unlinkFs } = require("node:fs") as typeof import("node:fs");
|
||||||
|
writeFs(xmlPath, xml);
|
||||||
|
run(`virsh define "${xmlPath}"`);
|
||||||
|
try { unlinkFs(xmlPath); } catch { /* ignore */ }
|
||||||
|
virsh("start", name);
|
||||||
|
log(`${name} restarted with disk boot (UEFI preserved)`);
|
||||||
|
}
|
||||||
|
|
||||||
export interface IsoVmConfig {
|
export interface IsoVmConfig {
|
||||||
name: string;
|
name: string;
|
||||||
memory: number; // MB
|
memory: number; // MB
|
||||||
@@ -111,13 +140,15 @@ export interface IsoVmConfig {
|
|||||||
diskSize: number; // GB
|
diskSize: number; // GB
|
||||||
network: string; // libvirt network name
|
network: string; // libvirt network name
|
||||||
isoPath: string; // path to boot ISO
|
isoPath: string; // path to boot ISO
|
||||||
|
arch?: "x86_64" | "aarch64";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a UEFI VM that boots from a CD-ROM ISO (not PXE). */
|
/** Create a UEFI VM that boots from a CD-ROM ISO (not PXE). */
|
||||||
export function createIsoVm(config: IsoVmConfig): void {
|
export function createIsoVm(config: IsoVmConfig): void {
|
||||||
destroyPxeVm(config.name);
|
destroyPxeVm(config.name);
|
||||||
|
|
||||||
log(`Creating ISO boot VM: ${config.name} (${config.memory}MB RAM, ${config.vcpus} vCPU, ${config.diskSize}GB disk)`);
|
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)`);
|
||||||
|
|
||||||
// Create blank disk
|
// Create blank disk
|
||||||
const diskPath = join(IMAGE_DIR, `${config.name}.qcow2`);
|
const diskPath = join(IMAGE_DIR, `${config.name}.qcow2`);
|
||||||
@@ -140,7 +171,11 @@ export function createIsoVm(config: IsoVmConfig): void {
|
|||||||
"--graphics=vnc,listen=127.0.0.1",
|
"--graphics=vnc,listen=127.0.0.1",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (arch === "aarch64") {
|
||||||
|
virtInstallArgs.push("--arch=aarch64", "--machine=virt");
|
||||||
|
}
|
||||||
|
|
||||||
log(`Running: virt-install --name=${config.name} --boot=uefi,cdrom ...`);
|
log(`Running: virt-install --name=${config.name} --boot=uefi,cdrom ...`);
|
||||||
run(virtInstallArgs.join(" "), { timeout: 30_000 });
|
run(virtInstallArgs.join(" "), { timeout: 60_000 });
|
||||||
log(`ISO boot VM ${config.name} created and booting from ISO`);
|
log(`ISO boot VM ${config.name} created and booting from ISO`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { homedir, tmpdir } from "node:os";
|
|||||||
import { mkdirSync, rmSync } from "node:fs";
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
import { log, waitForSsh } from "./helpers/libvirt.js";
|
import { log, waitForSsh } from "./helpers/libvirt.js";
|
||||||
import { ensurePxeNetwork, destroyPxeNetwork, PXE_NETWORK_NAME, PXE_GATEWAY, PXE_SUBNET } from "./helpers/pxe-network.js";
|
import { ensurePxeNetwork, destroyPxeNetwork, PXE_NETWORK_NAME, PXE_GATEWAY, PXE_SUBNET } from "./helpers/pxe-network.js";
|
||||||
import { createIsoVm, destroyPxeVm, getVmMac, rebootPxeVm } from "./helpers/pxe-vm.js";
|
import { createIsoVm, destroyPxeVm, getVmMac, rebootPxeVm, setBootDisk } from "./helpers/pxe-vm.js";
|
||||||
import { sshExec } from "./helpers/ssh.js";
|
import { sshExec } from "./helpers/ssh.js";
|
||||||
|
|
||||||
const VM_NAME = "lab-iso-test";
|
const VM_NAME = "lab-iso-test";
|
||||||
@@ -245,7 +245,12 @@ describe("ISO boot provisioning", () => {
|
|||||||
vmIp = finalState.ip ?? "";
|
vmIp = finalState.ip ?? "";
|
||||||
log(`Install complete! VM IP: ${vmIp}`);
|
log(`Install complete! VM IP: ${vmIp}`);
|
||||||
|
|
||||||
// 9. Wait for SSH
|
// 9. Switch boot to disk
|
||||||
|
log("Switching VM boot order to disk...");
|
||||||
|
await sleep(10_000);
|
||||||
|
setBootDisk(VM_NAME);
|
||||||
|
|
||||||
|
// 10. Wait for SSH
|
||||||
log("Waiting for SSH...");
|
log("Waiting for SSH...");
|
||||||
await waitForSsh(vmIp, SSH_USER, SSH_TIMEOUT_MS, sshKeyPath);
|
await waitForSsh(vmIp, SSH_USER, SSH_TIMEOUT_MS, sshKeyPath);
|
||||||
log("ISO boot provision test setup complete.");
|
log("ISO boot provision test setup complete.");
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { join } from "node:path";
|
|||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import { log, waitForSsh } from "./helpers/libvirt.js";
|
import { log, waitForSsh } from "./helpers/libvirt.js";
|
||||||
import { ensurePxeNetwork, destroyPxeNetwork, PXE_NETWORK_NAME, PXE_GATEWAY, PXE_SUBNET } from "./helpers/pxe-network.js";
|
import { ensurePxeNetwork, destroyPxeNetwork, PXE_NETWORK_NAME, PXE_GATEWAY, PXE_SUBNET } from "./helpers/pxe-network.js";
|
||||||
import { createPxeVm, destroyPxeVm, getVmMac, rebootPxeVm } from "./helpers/pxe-vm.js";
|
import { createPxeVm, destroyPxeVm, getVmMac, rebootPxeVm, setBootDisk } from "./helpers/pxe-vm.js";
|
||||||
import { sshExec } from "./helpers/ssh.js";
|
import { sshExec } from "./helpers/ssh.js";
|
||||||
|
|
||||||
// --- Test constants ---
|
// --- Test constants ---
|
||||||
@@ -267,7 +267,12 @@ describe("PXE boot provisioning", () => {
|
|||||||
vmIp = finalState.ip ?? "";
|
vmIp = finalState.ip ?? "";
|
||||||
log(`Install complete! VM IP: ${vmIp}`);
|
log(`Install complete! VM IP: ${vmIp}`);
|
||||||
|
|
||||||
// 9. Wait for SSH
|
// 9. Switch VM boot to disk (OVMF PXE/HTTP Boot loop prevents reaching installed OS)
|
||||||
|
log("Switching VM boot order to disk...");
|
||||||
|
await sleep(10_000); // Let kickstart reboot settle
|
||||||
|
setBootDisk(VM_NAME);
|
||||||
|
|
||||||
|
// 10. Wait for SSH
|
||||||
log("Waiting for SSH access...");
|
log("Waiting for SSH access...");
|
||||||
await waitForSsh(vmIp, SSH_USER, SSH_TIMEOUT_MS, sshKeyPath);
|
await waitForSsh(vmIp, SSH_USER, SSH_TIMEOUT_MS, sshKeyPath);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user