feat: CLI subcommands, PID self-restart, unit tests (22 passing)
CLI restructured: lab init bastion standalone start/stop/status lab provision list/install/reprovision/forget - Nested commander subcommand groups (init > bastion > standalone, provision) - PID file management: auto-kills old bastion on start, cleans up on stop - stop command reads PID file and sends SIGTERM - status command shows running state, port, machine counts - forget command (DELETE /api/machines/:mac) removes from all state Unit tests (22 tests, 3 files): - kickstart.test.ts: worker/infra roles, SSH keys, partitions, admin user - state.test.ts: load/save, atomic writes - dispatch.test.ts: install/discover/local-boot routing, progress, forget Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// Entry point for the bastion server.
|
||||
// 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 type { BastionConfig } from "@lab/shared";
|
||||
import { loadConfig } from "./config.js";
|
||||
@@ -50,6 +50,26 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
||||
let config = loadConfig(overrides);
|
||||
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
|
||||
mkdirSync(config.tftpDir, { recursive: true });
|
||||
mkdirSync(config.httpDir, { recursive: true });
|
||||
@@ -148,6 +168,7 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
||||
logger.info("Shutting down...");
|
||||
if (!config.skipDnsmasq) stopDnsmasq();
|
||||
await app.close();
|
||||
try { unlinkSync(pidFile); } catch { /* ignore */ }
|
||||
logger.info(`State preserved in ${config.stateFile}`);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
@@ -108,6 +108,40 @@ export function registerApiRoutes(
|
||||
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
|
||||
app.post<{
|
||||
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.
|
||||
|
||||
import type { Command } from "commander";
|
||||
|
||||
export function registerInstallCommand(program: Command): void {
|
||||
program
|
||||
export function registerInstallCommand(parent: Command): void {
|
||||
parent
|
||||
.command("install <mac> <hostname>")
|
||||
.description("Queue a discovered machine for Fedora installation")
|
||||
.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.
|
||||
|
||||
import type { Command } from "commander";
|
||||
@@ -20,8 +20,8 @@ function statusColor(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerListCommand(program: Command): void {
|
||||
program
|
||||
export function registerListCommand(parent: Command): void {
|
||||
parent
|
||||
.command("list")
|
||||
.description("List all known machines")
|
||||
.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.
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import type { Command } from "commander";
|
||||
import type { BastionState } from "@lab/shared";
|
||||
|
||||
export function registerReprovisionCommand(program: Command): void {
|
||||
program
|
||||
export function registerReprovisionCommand(parent: Command): void {
|
||||
parent
|
||||
.command("reprovision <mac> <hostname>")
|
||||
.description("Queue install + SSH reboot into PXE for reprovision")
|
||||
.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).
|
||||
|
||||
import type { Command } from "commander";
|
||||
import { startBastion } from "@lab/bastion";
|
||||
|
||||
export function registerServeCommand(program: Command): void {
|
||||
program
|
||||
.command("serve")
|
||||
export function registerStartCommand(parent: Command): void {
|
||||
parent
|
||||
.command("start")
|
||||
.description("Start the bastion server (HTTP + dnsmasq PXE)")
|
||||
.option("--port <port>", "HTTP port", "8080")
|
||||
.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
|
||||
// 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 { 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 { registerListCommand } from "./commands/list.js";
|
||||
import { registerReprovisionCommand } from "./commands/reprovision.js";
|
||||
import { registerForgetCommand } from "./commands/forget.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
@@ -16,14 +21,27 @@ program
|
||||
.description("Lab PXE Bastion -- discover-first bare-metal provisioning")
|
||||
.version(APP_VERSION);
|
||||
|
||||
registerServeCommand(program);
|
||||
registerInstallCommand(program);
|
||||
registerListCommand(program);
|
||||
registerReprovisionCommand(program);
|
||||
// init bastion standalone start/stop/status
|
||||
const initCmd = program.command("init");
|
||||
initCmd.description("Initialise infrastructure components");
|
||||
|
||||
// Default to serve if no command given
|
||||
program.action(() => {
|
||||
program.commands.find((c) => c.name() === "serve")?.parseAsync(process.argv);
|
||||
});
|
||||
const bastionCmd = initCmd.command("bastion");
|
||||
bastionCmd.description("Bastion PXE server management");
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user