fix: PXE boot debugging — bisect root cause, syslog logging, serial console #3
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
227
bastion/src/bastion/tests/dispatch.test.ts
Normal file
227
bastion/src/bastion/tests/dispatch.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
111
bastion/src/bastion/tests/kickstart.test.ts
Normal file
111
bastion/src/bastion/tests/kickstart.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
105
bastion/src/bastion/tests/state.test.ts
Normal file
105
bastion/src/bastion/tests/state.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
36
bastion/src/cli/src/commands/forget.ts
Normal file
36
bastion/src/cli/src/commands/forget.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
63
bastion/src/cli/src/commands/status.ts
Normal file
63
bastion/src/cli/src/commands/status.ts
Normal 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)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
34
bastion/src/cli/src/commands/stop.ts
Normal file
34
bastion/src/cli/src/commands/stop.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user