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
|
|
|
// Smoke tests for bastion CLI commands.
|
|
|
|
|
// These tests spawn real processes and verify they work end-to-end.
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, afterEach } from "vitest";
|
|
|
|
|
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
|
|
|
|
import { existsSync, readFileSync, mkdirSync, rmSync } from "node:fs";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
import { tmpdir } from "node:os";
|
|
|
|
|
|
|
|
|
|
const CLI_PATH = join(import.meta.dirname, "..", "src", "index.ts");
|
|
|
|
|
const TEST_DIR = join(tmpdir(), `lab-bastion-smoke-${process.pid}`);
|
|
|
|
|
const PID_FILE = join(TEST_DIR, "bastion.pid");
|
|
|
|
|
const LOG_FILE = join(TEST_DIR, "bastion.log");
|
|
|
|
|
const TEST_PORT = 18932; // Unlikely to conflict
|
|
|
|
|
|
|
|
|
|
function runCli(args: string[], timeoutMs = 10_000): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const child = spawn("node", ["--import", "tsx", CLI_PATH, ...args], {
|
|
|
|
|
timeout: timeoutMs,
|
|
|
|
|
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let stdout = "";
|
|
|
|
|
let stderr = "";
|
|
|
|
|
child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
|
|
|
|
|
child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
|
|
|
|
|
|
|
|
child.on("close", (code) => {
|
|
|
|
|
resolve({ code: code ?? 1, stdout, stderr });
|
|
|
|
|
});
|
|
|
|
|
child.on("error", reject);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
|
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function killPid(pid: number): void {
|
|
|
|
|
try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("bastion smoke tests", () => {
|
|
|
|
|
let daemonPid: number | undefined;
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
// Kill any daemon we started
|
|
|
|
|
if (daemonPid) {
|
|
|
|
|
killPid(daemonPid);
|
|
|
|
|
daemonPid = undefined;
|
|
|
|
|
}
|
|
|
|
|
// Also try PID file
|
|
|
|
|
try {
|
|
|
|
|
const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
|
|
|
if (!isNaN(pid)) killPid(pid);
|
|
|
|
|
} catch { /* no pid file */ }
|
|
|
|
|
|
|
|
|
|
// Clean up test directory
|
|
|
|
|
try { rmSync(TEST_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("--help prints usage without error", async () => {
|
|
|
|
|
const result = await runCli(["--help"]);
|
|
|
|
|
expect(result.code).toBe(0);
|
|
|
|
|
expect(result.stdout).toContain("labctl");
|
|
|
|
|
expect(result.stdout).toContain("Commands:");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("--version prints version", async () => {
|
|
|
|
|
const result = await runCli(["--version"]);
|
|
|
|
|
expect(result.code).toBe(0);
|
|
|
|
|
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("version subcommand prints detailed info", async () => {
|
|
|
|
|
const result = await runCli(["version"]);
|
|
|
|
|
expect(result.code).toBe(0);
|
|
|
|
|
expect(result.stdout).toContain("labctl");
|
|
|
|
|
expect(result.stdout).toContain("node");
|
|
|
|
|
expect(result.stdout).toContain("platform");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("config list works without config file", async () => {
|
|
|
|
|
const result = await runCli(["config", "list"]);
|
|
|
|
|
expect(result.code).toBe(0);
|
|
|
|
|
expect(result.stdout).toContain("labdUrl");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("config path prints a path", async () => {
|
|
|
|
|
const result = await runCli(["config", "path"]);
|
|
|
|
|
expect(result.code).toBe(0);
|
|
|
|
|
expect(result.stdout.trim()).toContain(".labctl");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("start without root prints helpful error", async () => {
|
|
|
|
|
// Only run if we're NOT root (CI may run as root)
|
|
|
|
|
if (process.getuid?.() === 0) return;
|
|
|
|
|
|
|
|
|
|
const result = await runCli([
|
|
|
|
|
"init", "bastion", "standalone", "start",
|
|
|
|
|
"--dir", TEST_DIR,
|
|
|
|
|
"--port", String(TEST_PORT),
|
|
|
|
|
]);
|
|
|
|
|
expect(result.code).toBe(1);
|
|
|
|
|
expect(result.stderr).toContain("root");
|
|
|
|
|
expect(result.stderr).toContain("sudo");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("foreground start with --skip-dnsmasq --skip-artifacts works and stays alive", async () => {
|
|
|
|
|
mkdirSync(TEST_DIR, { recursive: true });
|
|
|
|
|
|
|
|
|
|
// Start in foreground as a child process
|
|
|
|
|
const child = spawn(
|
|
|
|
|
"node",
|
|
|
|
|
[
|
|
|
|
|
"--import", "tsx",
|
|
|
|
|
CLI_PATH,
|
|
|
|
|
"init", "bastion", "standalone", "start", "--foreground",
|
|
|
|
|
"--skip-dnsmasq", "--skip-artifacts",
|
|
|
|
|
"--dir", TEST_DIR,
|
|
|
|
|
"--port", String(TEST_PORT),
|
|
|
|
|
],
|
|
|
|
|
{
|
|
|
|
|
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
|
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
daemonPid = child.pid;
|
|
|
|
|
|
|
|
|
|
// Collect output
|
|
|
|
|
let stdout = "";
|
|
|
|
|
child.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
|
|
|
|
|
|
|
|
|
|
let stderr = "";
|
|
|
|
|
child.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
|
|
|
|
|
|
|
|
|
|
// Wait for the server to start (look for the banner)
|
|
|
|
|
const startedAt = Date.now();
|
2026-03-31 03:20:12 +01:00
|
|
|
const maxWait = 15_000;
|
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
|
|
|
while (Date.now() - startedAt < maxWait) {
|
|
|
|
|
if (stdout.includes("Waiting for PXE boot requests")) break;
|
|
|
|
|
await sleep(200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(stdout).toContain("Waiting for PXE boot requests");
|
|
|
|
|
expect(stdout).toContain("HTTP server listening");
|
|
|
|
|
|
|
|
|
|
// Verify the process is still alive after startup
|
|
|
|
|
await sleep(1000);
|
|
|
|
|
let alive = false;
|
|
|
|
|
try {
|
|
|
|
|
process.kill(child.pid!, 0);
|
|
|
|
|
alive = true;
|
|
|
|
|
} catch { /* dead */ }
|
|
|
|
|
expect(alive).toBe(true);
|
|
|
|
|
|
|
|
|
|
// Verify PID file was created
|
|
|
|
|
expect(existsSync(PID_FILE)).toBe(true);
|
|
|
|
|
const pidFromFile = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
|
|
|
expect(pidFromFile).toBe(child.pid);
|
|
|
|
|
|
|
|
|
|
// Verify HTTP server responds
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`http://127.0.0.1:${TEST_PORT}/api/machines`);
|
|
|
|
|
expect(resp.ok).toBe(true);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// If fetch fails, that's a real problem
|
|
|
|
|
throw new Error(`HTTP server not responding: ${err}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clean shutdown
|
|
|
|
|
child.kill("SIGTERM");
|
|
|
|
|
await new Promise<void>((resolve) => {
|
|
|
|
|
child.on("close", () => resolve());
|
|
|
|
|
setTimeout(resolve, 3000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
daemonPid = undefined;
|
|
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
it("status shows bastion info or reports labd unreachable", async () => {
|
|
|
|
|
const result = await runCli([
|
|
|
|
|
"init", "bastion", "standalone", "status",
|
|
|
|
|
]);
|
|
|
|
|
// Status queries labd — may show bastions (if labd running) or error (if not)
|
|
|
|
|
const output = result.stdout + result.stderr;
|
|
|
|
|
expect(output).toMatch(/HOSTNAME|Cannot reach labd|No bastions/i);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("doctor runs without crashing", async () => {
|
|
|
|
|
const result = await runCli(["doctor"]);
|
|
|
|
|
// Doctor may report errors (no labd running) but should not crash
|
|
|
|
|
expect(result.code).toBeLessThanOrEqual(1); // 0 = all ok, 1 = errors found
|
|
|
|
|
expect(result.stdout).toContain("diagnostics");
|
|
|
|
|
});
|
|
|
|
|
});
|