fix: PXE boot debugging — bisect root cause, syslog logging, serial console #3

Merged
michal merged 31 commits from wip/ks-debugging into main 2026-03-29 00:50:05 +00:00
13 changed files with 673 additions and 24 deletions
Showing only changes of commit 62f896593d - Show all commits

View File

@@ -1,7 +1,7 @@
// Entry point for the bastion server. // Entry point for the bastion server.
// Starts the Fastify HTTP server, dnsmasq, and handles graceful shutdown. // Starts the Fastify HTTP server, dnsmasq, and handles graceful shutdown.
import { mkdirSync, writeFileSync, existsSync, copyFileSync, symlinkSync } from "node:fs"; import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync, symlinkSync, unlinkSync } from "node:fs";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import type { BastionConfig } from "@lab/shared"; import type { BastionConfig } from "@lab/shared";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
@@ -50,6 +50,26 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
let config = loadConfig(overrides); let config = loadConfig(overrides);
config = populateNetworkConfig(config); config = populateNetworkConfig(config);
// PID file management: kill old instance if running
const pidFile = `${config.bastionDir}/bastion.pid`;
mkdirSync(config.bastionDir, { recursive: true });
if (existsSync(pidFile)) {
const oldPid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
if (!isNaN(oldPid)) {
try {
process.kill(oldPid, "SIGTERM");
logger.info(`Killed old bastion process (PID ${oldPid})`);
await new Promise((r) => setTimeout(r, 1000));
} catch {
// Process already dead, continue
}
}
}
// Write current PID
writeFileSync(pidFile, String(process.pid));
// Prepare directories // Prepare directories
mkdirSync(config.tftpDir, { recursive: true }); mkdirSync(config.tftpDir, { recursive: true });
mkdirSync(config.httpDir, { recursive: true }); mkdirSync(config.httpDir, { recursive: true });
@@ -148,6 +168,7 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
logger.info("Shutting down..."); logger.info("Shutting down...");
if (!config.skipDnsmasq) stopDnsmasq(); if (!config.skipDnsmasq) stopDnsmasq();
await app.close(); await app.close();
try { unlinkSync(pidFile); } catch { /* ignore */ }
logger.info(`State preserved in ${config.stateFile}`); logger.info(`State preserved in ${config.stateFile}`);
process.exit(0); process.exit(0);
}; };

View File

@@ -108,6 +108,40 @@ export function registerApiRoutes(
return reply.send({ status: "ok" }); return reply.send({ status: "ok" });
}); });
// Delete a machine from all state
app.delete<{
Params: { mac: string };
}>("/api/machines/:mac", async (request, reply) => {
const mac = request.params.mac.toLowerCase().replace(/-/g, ":");
if (!mac) {
return reply.status(400).send({ error: "mac is required" });
}
let found = false;
state.update((s) => {
if (s.discovered[mac]) {
delete s.discovered[mac];
found = true;
}
if (s.install_queue[mac]) {
delete s.install_queue[mac];
found = true;
}
if (s.installed[mac]) {
delete s.installed[mac];
found = true;
}
});
if (!found) {
return reply.status(404).send({ error: "machine not found", mac });
}
logger.info(`MACHINE FORGOTTEN: ${mac}`);
return reply.send({ status: "forgotten", mac });
});
// Receive discovery reports // Receive discovery reports
app.post<{ app.post<{
Body: { Body: {

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { BastionConfig } from "@lab/shared";
import { createApp } from "../src/server.js";
import type { FastifyInstance } from "fastify";
import type { StateManager } from "../src/services/state.js";
function createTestConfig(testDir: string): BastionConfig {
return {
fedoraVersion: "43",
arch: "x86_64",
httpPort: 0,
timezone: "Europe/London",
locale: "en_GB.UTF-8",
bastionDir: testDir,
domain: "test.local",
dhcpMode: "proxy",
dhcpRangeStart: "",
dhcpRangeEnd: "",
iface: "eth0",
serverIp: "10.0.0.1",
network: "10.0.0.0",
gateway: "10.0.0.1",
sshKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST test@test"],
adminUser: "testadmin",
skipDnsmasq: true,
skipArtifacts: true,
fedoraMirror: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os",
tftpDir: join(testDir, "tftp"),
httpDir: join(testDir, "http"),
stateFile: join(testDir, "state.json"),
};
}
describe("dispatch routes", () => {
let testDir: string;
let app: FastifyInstance;
let state: StateManager;
beforeEach(() => {
testDir = join(tmpdir(), `bastion-dispatch-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(testDir, { recursive: true });
mkdirSync(join(testDir, "http"), { recursive: true });
mkdirSync(join(testDir, "tftp"), { recursive: true });
const config = createTestConfig(testDir);
const result = createApp(config);
app = result.app;
state = result.state;
});
afterEach(async () => {
await app.close();
rmSync(testDir, { recursive: true, force: true });
});
it("unknown MAC returns discovery iPXE script", async () => {
const response = await app.inject({
method: "GET",
url: "/dispatch?mac=aa:bb:cc:dd:ee:ff",
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("text/plain");
const body = response.body;
expect(body).toContain("#!ipxe");
expect(body).toContain("DISCOVERY MODE");
expect(body).toContain("discover.ks");
});
it("MAC in install_queue returns install iPXE script", async () => {
const mac = "aa:bb:cc:dd:ee:ff";
state.update((s) => {
s.install_queue[mac] = {
hostname: "worker-1",
disk: "/dev/sda",
role: "worker",
queued_at: new Date().toISOString(),
};
});
const response = await app.inject({
method: "GET",
url: `/dispatch?mac=${mac}`,
});
expect(response.statusCode).toBe(200);
const body = response.body;
expect(body).toContain("#!ipxe");
expect(body).toContain("INSTALLING");
expect(body).toContain("worker-1");
expect(body).toContain(`ks?mac=${mac}`);
});
it("MAC in installed returns local boot (exit) script", async () => {
const mac = "aa:bb:cc:dd:ee:ff";
state.update((s) => {
s.installed[mac] = {
hostname: "installed-node",
role: "worker",
ip: "10.0.0.50",
installed_at: new Date().toISOString(),
};
});
const response = await app.inject({
method: "GET",
url: `/dispatch?mac=${mac}`,
});
expect(response.statusCode).toBe(200);
const body = response.body;
expect(body).toContain("#!ipxe");
expect(body).toContain("installed-node");
expect(body).toContain("Already installed");
expect(body).toContain("exit");
});
it("progress endpoint updates state", async () => {
const mac = "aa:bb:cc:dd:ee:ff";
state.update((s) => {
s.install_queue[mac] = {
hostname: "worker-1",
disk: "/dev/sda",
role: "worker",
queued_at: new Date().toISOString(),
};
});
const response = await app.inject({
method: "POST",
url: "/api/progress",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mac,
stage: "post-install",
detail: "configuring system",
}),
});
expect(response.statusCode).toBe(200);
const result = JSON.parse(response.body);
expect(result.status).toBe("ok");
// Verify state was updated
const currentState = state.load();
expect(currentState.install_queue[mac]?.progress).toBe("post-install");
expect(currentState.install_queue[mac]?.progress_detail).toBe("configuring system");
});
it("progress endpoint with 'complete' stage moves machine to installed", async () => {
const mac = "aa:bb:cc:dd:ee:ff";
state.update((s) => {
s.install_queue[mac] = {
hostname: "worker-1",
disk: "/dev/sda",
role: "worker",
queued_at: new Date().toISOString(),
};
});
const response = await app.inject({
method: "POST",
url: "/api/progress",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mac,
stage: "complete",
detail: "ready at 10.0.0.50",
}),
});
expect(response.statusCode).toBe(200);
const currentState = state.load();
expect(currentState.install_queue[mac]).toBeUndefined();
expect(currentState.installed[mac]).toBeDefined();
expect(currentState.installed[mac]?.hostname).toBe("worker-1");
expect(currentState.installed[mac]?.ip).toBe("10.0.0.50");
});
it("DELETE /api/machines/:mac removes machine from state", async () => {
const mac = "aa:bb:cc:dd:ee:ff";
state.update((s) => {
s.discovered[mac] = {
mac,
product: "TestBox",
board: "TestBoard",
serial: "SN123",
manufacturer: "TestCorp",
cpu_model: "Test CPU",
cpu_cores: 4,
memory_gb: 16,
arch: "x86_64",
disks: [],
nics: [],
first_seen: new Date().toISOString(),
last_seen: new Date().toISOString(),
};
});
const response = await app.inject({
method: "DELETE",
url: `/api/machines/${encodeURIComponent(mac)}`,
});
expect(response.statusCode).toBe(200);
const result = JSON.parse(response.body);
expect(result.status).toBe("forgotten");
const currentState = state.load();
expect(currentState.discovered[mac]).toBeUndefined();
});
it("DELETE /api/machines/:mac returns 404 for unknown machine", async () => {
const response = await app.inject({
method: "DELETE",
url: "/api/machines/ff:ff:ff:ff:ff:ff",
});
expect(response.statusCode).toBe(404);
const result = JSON.parse(response.body);
expect(result.error).toBe("machine not found");
});
});

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from "vitest";
import { renderInstallKickstart, type InstallKickstartParams } from "../src/templates/install.ks.js";
function baseParams(overrides: Partial<InstallKickstartParams> = {}): InstallKickstartParams {
return {
hostname: "testnode",
disk: "",
role: "worker",
domain: "lab.local",
fedoraVersion: "43",
timezone: "Europe/London",
locale: "en_GB.UTF-8",
serverIp: "192.168.1.100",
httpPort: 8080,
sshKeys: [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST1 user1@host",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQTEST2 user2@host",
],
adminUser: "admin",
...overrides,
};
}
describe("renderInstallKickstart", () => {
it("worker role includes longhorn partition", () => {
const ks = renderInstallKickstart(baseParams({ role: "worker" }));
expect(ks).toContain("longhorn");
expect(ks).toContain("/var/lib/longhorn");
});
it("infra role does NOT include longhorn partition", () => {
const ks = renderInstallKickstart(baseParams({ role: "infra" }));
// The fresh install longhorn line should not be present
expect(ks).not.toContain("logvol /var/lib/longhorn --vgname=labvg --name=longhorn --fstype=xfs --grow --size=1");
});
it("all SSH keys appear between SSHKEYS markers", () => {
const keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST1 user1@host",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQTEST2 user2@host",
];
const ks = renderInstallKickstart(baseParams({ sshKeys: keys }));
// Both keys should appear between the SSHKEYS markers
const sshkeysMatch = ks.match(/cat > \/root\/\.ssh\/authorized_keys << 'SSHKEYS'\n([\s\S]*?)\nSSHKEYS/);
expect(sshkeysMatch).not.toBeNull();
const keysBlock = sshkeysMatch![1]!;
for (const key of keys) {
expect(keysBlock).toContain(key);
}
});
it("admin user directive appears when adminUser is set", () => {
const ks = renderInstallKickstart(baseParams({ adminUser: "myadmin" }));
expect(ks).toContain("user --name=myadmin --groups=wheel --lock");
});
it("no admin user directive when adminUser is empty", () => {
const ks = renderInstallKickstart(baseParams({ adminUser: "" }));
expect(ks).not.toContain("user --name=");
});
it("FQDN is hostname.domain", () => {
const ks = renderInstallKickstart(baseParams({
hostname: "myhost",
domain: "example.com",
}));
expect(ks).toContain("myhost.example.com");
expect(ks).toContain("--hostname=myhost.example.com");
});
it("restorecon is present", () => {
const ks = renderInstallKickstart(baseParams());
expect(ks).toContain("restorecon");
});
it("sudoers line for admin user", () => {
const ks = renderInstallKickstart(baseParams({ adminUser: "admin" }));
expect(ks).toContain("admin ALL=(ALL) NOPASSWD: ALL");
expect(ks).toContain("/etc/sudoers.d/admin");
});
it("efibootmgr section present", () => {
const ks = renderInstallKickstart(baseParams());
expect(ks).toContain("efibootmgr");
expect(ks).toContain("FEDORA_ENTRY");
});
it("progress callback URLs use correct serverIp and httpPort", () => {
const ks = renderInstallKickstart(baseParams({
serverIp: "10.0.0.5",
httpPort: 9090,
}));
expect(ks).toContain("http://10.0.0.5:9090/api/progress");
});
it("partition sizes are correct", () => {
const ks = renderInstallKickstart(baseParams());
// root = 33792
expect(ks).toContain("--name=root --fstype=xfs --size=33792");
// var = 102400
expect(ks).toContain("--name=var --fstype=xfs --size=102400");
// varlog = 10240
expect(ks).toContain("--name=varlog --fstype=xfs --size=10240");
// home = 10240
expect(ks).toContain("--name=home --fstype=xfs --size=10240");
// srv = 20480
expect(ks).toContain("--name=srv --fstype=xfs --size=20480");
// swap = 27648
expect(ks).toContain("--name=swap --fstype=swap --size=27648");
});
});

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { StateManager } from "../src/services/state.js";
describe("StateManager", () => {
let testDir: string;
let stateFile: string;
let state: StateManager;
beforeEach(() => {
testDir = join(tmpdir(), `bastion-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(testDir, { recursive: true });
stateFile = join(testDir, "state.json");
state = new StateManager(stateFile);
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it("creates empty state on first load", () => {
const loaded = state.load();
expect(loaded).toEqual({
discovered: {},
install_queue: {},
installed: {},
});
});
it("init creates the state file", () => {
expect(existsSync(stateFile)).toBe(false);
state.init();
expect(existsSync(stateFile)).toBe(true);
const content = JSON.parse(readFileSync(stateFile, "utf-8"));
expect(content).toEqual({
discovered: {},
install_queue: {},
installed: {},
});
});
it("saves and loads state correctly", () => {
state.init();
state.update((s) => {
s.discovered["aa:bb:cc:dd:ee:ff"] = {
mac: "aa:bb:cc:dd:ee:ff",
product: "TestBox",
board: "TestBoard",
serial: "SN123",
manufacturer: "TestCorp",
cpu_model: "Test CPU",
cpu_cores: 8,
memory_gb: 32,
arch: "x86_64",
disks: [{ name: "sda", size_gb: 500, model: "TestDisk" }],
nics: [{ name: "eth0", mac: "aa:bb:cc:dd:ee:ff", state: "UP" }],
first_seen: "2025-01-01T00:00:00Z",
last_seen: "2025-01-01T00:00:00Z",
};
s.install_queue["11:22:33:44:55:66"] = {
hostname: "worker-1",
disk: "/dev/sda",
role: "worker",
queued_at: "2025-01-01T01:00:00Z",
};
});
// Load in a fresh StateManager to verify persistence
const state2 = new StateManager(stateFile);
const loaded = state2.load();
expect(loaded.discovered["aa:bb:cc:dd:ee:ff"]?.product).toBe("TestBox");
expect(loaded.discovered["aa:bb:cc:dd:ee:ff"]?.cpu_cores).toBe(8);
expect(loaded.install_queue["11:22:33:44:55:66"]?.hostname).toBe("worker-1");
expect(loaded.installed).toEqual({});
});
it("uses atomic writes (tmp file + rename)", () => {
state.init();
// After save, there should be no .tmp file left behind
state.update((s) => {
s.installed["aa:bb:cc:dd:ee:ff"] = {
hostname: "node1",
role: "worker",
ip: "10.0.0.1",
installed_at: "2025-01-01T00:00:00Z",
};
});
const tmpFile = `${stateFile}.tmp`;
expect(existsSync(tmpFile)).toBe(false);
expect(existsSync(stateFile)).toBe(true);
// Verify data was written correctly
const raw = readFileSync(stateFile, "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.installed["aa:bb:cc:dd:ee:ff"].hostname).toBe("node1");
});
});

View File

@@ -0,0 +1,36 @@
// CLI command: provision forget
// Remove a machine from all bastion state.
import type { Command } from "commander";
export function registerForgetCommand(parent: Command): void {
parent
.command("forget <mac>")
.description("Remove a machine from bastion state")
.option("--port <port>", "Bastion HTTP port", "8080")
.action(async (mac: string, opts: { port: string }) => {
const port = parseInt(opts.port, 10);
const normalizedMac = mac.toLowerCase().replace(/-/g, ":");
try {
const response = await fetch(
`http://localhost:${port}/api/machines/${encodeURIComponent(normalizedMac)}`,
{ method: "DELETE" },
);
const result = await response.json() as Record<string, unknown>;
if (!response.ok) {
console.error(
`Error: ${result["error"] ?? `HTTP ${response.status}`}`,
);
process.exit(1);
}
console.log(JSON.stringify(result, null, 2));
} catch {
console.error(`Cannot reach bastion at localhost:${port}. Is it running?`);
process.exit(1);
}
});
}

View File

@@ -1,10 +1,10 @@
// CLI command: install // CLI command: provision install
// Queue a discovered machine for Fedora installation. // Queue a discovered machine for Fedora installation.
import type { Command } from "commander"; import type { Command } from "commander";
export function registerInstallCommand(program: Command): void { export function registerInstallCommand(parent: Command): void {
program parent
.command("install <mac> <hostname>") .command("install <mac> <hostname>")
.description("Queue a discovered machine for Fedora installation") .description("Queue a discovered machine for Fedora installation")
.option("--role <role>", "Machine role: worker or infra", "worker") .option("--role <role>", "Machine role: worker or infra", "worker")

View File

@@ -1,4 +1,4 @@
// CLI command: list // CLI command: provision list
// Merged view of all known machines with hardware + install info. // Merged view of all known machines with hardware + install info.
import type { Command } from "commander"; import type { Command } from "commander";
@@ -20,8 +20,8 @@ function statusColor(status: string): string {
} }
} }
export function registerListCommand(program: Command): void { export function registerListCommand(parent: Command): void {
program parent
.command("list") .command("list")
.description("List all known machines") .description("List all known machines")
.option("--port <port>", "Bastion HTTP port", "8080") .option("--port <port>", "Bastion HTTP port", "8080")

View File

@@ -1,12 +1,12 @@
// CLI command: reprovision // CLI command: provision reprovision
// Queue a machine for reinstall and attempt SSH reboot into PXE. // Queue a machine for reinstall and attempt SSH reboot into PXE.
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import type { Command } from "commander"; import type { Command } from "commander";
import type { BastionState } from "@lab/shared"; import type { BastionState } from "@lab/shared";
export function registerReprovisionCommand(program: Command): void { export function registerReprovisionCommand(parent: Command): void {
program parent
.command("reprovision <mac> <hostname>") .command("reprovision <mac> <hostname>")
.description("Queue install + SSH reboot into PXE for reprovision") .description("Queue install + SSH reboot into PXE for reprovision")
.option("--role <role>", "Machine role: worker or infra", "worker") .option("--role <role>", "Machine role: worker or infra", "worker")

View File

@@ -1,12 +1,12 @@
// CLI command: serve // CLI command: init bastion standalone start
// Start the bastion server (HTTP + dnsmasq). // Start the bastion server (HTTP + dnsmasq).
import type { Command } from "commander"; import type { Command } from "commander";
import { startBastion } from "@lab/bastion"; import { startBastion } from "@lab/bastion";
export function registerServeCommand(program: Command): void { export function registerStartCommand(parent: Command): void {
program parent
.command("serve") .command("start")
.description("Start the bastion server (HTTP + dnsmasq PXE)") .description("Start the bastion server (HTTP + dnsmasq PXE)")
.option("--port <port>", "HTTP port", "8080") .option("--port <port>", "HTTP port", "8080")
.option("--dir <dir>", "Bastion data directory", "/tmp/lab-bastion") .option("--dir <dir>", "Bastion data directory", "/tmp/lab-bastion")

View File

@@ -0,0 +1,63 @@
// CLI command: init bastion standalone status
// Check if bastion is running, show port/uptime/machine count.
import { readFileSync, existsSync, statSync } from "node:fs";
import type { Command } from "commander";
import type { BastionState } from "@lab/shared";
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function registerStatusCommand(parent: Command): void {
parent
.command("status")
.description("Show bastion server status")
.option("--dir <dir>", "Bastion data directory", "/tmp/lab-bastion")
.option("--port <port>", "Bastion HTTP port", "8080")
.action(async (opts: { dir: string; port: string }) => {
const pidFile = `${opts.dir}/bastion.pid`;
const port = parseInt(opts.port, 10);
if (!existsSync(pidFile)) {
console.log("Bastion is not running (no PID file).");
return;
}
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
if (isNaN(pid) || !isProcessAlive(pid)) {
console.log("Bastion is not running (stale PID file).");
return;
}
// Calculate uptime from PID file mtime
const pidStat = statSync(pidFile);
const uptimeMs = Date.now() - pidStat.mtimeMs;
const uptimeMin = Math.floor(uptimeMs / 60_000);
const uptimeHr = Math.floor(uptimeMin / 60);
const uptimeStr = uptimeHr > 0
? `${uptimeHr}h ${uptimeMin % 60}m`
: `${uptimeMin}m`;
console.log(`Bastion is running (PID ${pid})`);
console.log(` Port: ${port}`);
console.log(` Uptime: ${uptimeStr}`);
// Try to fetch machine count
try {
const response = await fetch(`http://localhost:${port}/api/machines`);
const state = (await response.json()) as BastionState;
const discovered = Object.keys(state.discovered).length;
const queued = Object.keys(state.install_queue).length;
const installed = Object.keys(state.installed).length;
console.log(` Machines: ${discovered} discovered, ${queued} queued, ${installed} installed`);
} catch {
console.log(" Machines: (could not reach API)");
}
});
}

View File

@@ -0,0 +1,34 @@
// CLI command: init bastion standalone stop
// Read PID from bastion.pid and send SIGTERM.
import { readFileSync, existsSync } from "node:fs";
import type { Command } from "commander";
export function registerStopCommand(parent: Command): void {
parent
.command("stop")
.description("Stop a running bastion server")
.option("--dir <dir>", "Bastion data directory", "/tmp/lab-bastion")
.action((opts: { dir: string }) => {
const pidFile = `${opts.dir}/bastion.pid`;
if (!existsSync(pidFile)) {
console.error("No bastion PID file found. Is the server running?");
process.exit(1);
}
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
if (isNaN(pid)) {
console.error(`Invalid PID in ${pidFile}`);
process.exit(1);
}
try {
process.kill(pid, "SIGTERM");
console.log(`Sent SIGTERM to bastion process (PID ${pid})`);
} catch {
console.error(`Cannot signal PID ${pid}. Process may have already exited.`);
process.exit(1);
}
});
}

View File

@@ -1,13 +1,18 @@
#!/usr/bin/env node #!/usr/bin/env node
// CLI entry point for lab-bastion. // CLI entry point for lab-bastion.
// Commands: serve, install, list, reprovision // Commands:
// init bastion standalone start/stop/status
// provision list/install/reprovision/forget
import { Command } from "commander"; import { Command } from "commander";
import { APP_VERSION } from "@lab/shared"; import { APP_VERSION } from "@lab/shared";
import { registerServeCommand } from "./commands/serve.js"; import { registerStartCommand } from "./commands/serve.js";
import { registerStopCommand } from "./commands/stop.js";
import { registerStatusCommand } from "./commands/status.js";
import { registerInstallCommand } from "./commands/install.js"; import { registerInstallCommand } from "./commands/install.js";
import { registerListCommand } from "./commands/list.js"; import { registerListCommand } from "./commands/list.js";
import { registerReprovisionCommand } from "./commands/reprovision.js"; import { registerReprovisionCommand } from "./commands/reprovision.js";
import { registerForgetCommand } from "./commands/forget.js";
const program = new Command(); const program = new Command();
@@ -16,14 +21,27 @@ program
.description("Lab PXE Bastion -- discover-first bare-metal provisioning") .description("Lab PXE Bastion -- discover-first bare-metal provisioning")
.version(APP_VERSION); .version(APP_VERSION);
registerServeCommand(program); // init bastion standalone start/stop/status
registerInstallCommand(program); const initCmd = program.command("init");
registerListCommand(program); initCmd.description("Initialise infrastructure components");
registerReprovisionCommand(program);
// Default to serve if no command given const bastionCmd = initCmd.command("bastion");
program.action(() => { bastionCmd.description("Bastion PXE server management");
program.commands.find((c) => c.name() === "serve")?.parseAsync(process.argv);
}); const standaloneCmd = bastionCmd.command("standalone");
standaloneCmd.description("Standalone bastion server lifecycle");
registerStartCommand(standaloneCmd);
registerStopCommand(standaloneCmd);
registerStatusCommand(standaloneCmd);
// provision list/install/reprovision/forget
const provisionCmd = program.command("provision");
provisionCmd.description("Machine provisioning operations");
registerListCommand(provisionCmd);
registerInstallCommand(provisionCmd);
registerReprovisionCommand(provisionCmd);
registerForgetCommand(provisionCmd);
program.parse(); program.parse();