From 62f896593d72120be4b19dbceccb00fc8b390683 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 17 Mar 2026 11:12:17 +0000 Subject: [PATCH] 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) --- bastion/src/bastion/src/main.ts | 23 +- bastion/src/bastion/src/routes/api.ts | 34 +++ bastion/src/bastion/tests/dispatch.test.ts | 227 ++++++++++++++++++++ bastion/src/bastion/tests/kickstart.test.ts | 111 ++++++++++ bastion/src/bastion/tests/state.test.ts | 105 +++++++++ bastion/src/cli/src/commands/forget.ts | 36 ++++ bastion/src/cli/src/commands/install.ts | 6 +- bastion/src/cli/src/commands/list.ts | 6 +- bastion/src/cli/src/commands/reprovision.ts | 6 +- bastion/src/cli/src/commands/serve.ts | 8 +- bastion/src/cli/src/commands/status.ts | 63 ++++++ bastion/src/cli/src/commands/stop.ts | 34 +++ bastion/src/cli/src/index.ts | 38 +++- 13 files changed, 673 insertions(+), 24 deletions(-) create mode 100644 bastion/src/bastion/tests/dispatch.test.ts create mode 100644 bastion/src/bastion/tests/kickstart.test.ts create mode 100644 bastion/src/bastion/tests/state.test.ts create mode 100644 bastion/src/cli/src/commands/forget.ts create mode 100644 bastion/src/cli/src/commands/status.ts create mode 100644 bastion/src/cli/src/commands/stop.ts diff --git a/bastion/src/bastion/src/main.ts b/bastion/src/bastion/src/main.ts index 21b470e..0b3fd51 100644 --- a/bastion/src/bastion/src/main.ts +++ b/bastion/src/bastion/src/main.ts @@ -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 = {}): 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 = {}): 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); }; diff --git a/bastion/src/bastion/src/routes/api.ts b/bastion/src/bastion/src/routes/api.ts index 8133558..9647893 100644 --- a/bastion/src/bastion/src/routes/api.ts +++ b/bastion/src/bastion/src/routes/api.ts @@ -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: { diff --git a/bastion/src/bastion/tests/dispatch.test.ts b/bastion/src/bastion/tests/dispatch.test.ts new file mode 100644 index 0000000..bd3a432 --- /dev/null +++ b/bastion/src/bastion/tests/dispatch.test.ts @@ -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"); + }); +}); diff --git a/bastion/src/bastion/tests/kickstart.test.ts b/bastion/src/bastion/tests/kickstart.test.ts new file mode 100644 index 0000000..8c39635 --- /dev/null +++ b/bastion/src/bastion/tests/kickstart.test.ts @@ -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 { + 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"); + }); +}); diff --git a/bastion/src/bastion/tests/state.test.ts b/bastion/src/bastion/tests/state.test.ts new file mode 100644 index 0000000..1ff18ec --- /dev/null +++ b/bastion/src/bastion/tests/state.test.ts @@ -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"); + }); +}); diff --git a/bastion/src/cli/src/commands/forget.ts b/bastion/src/cli/src/commands/forget.ts new file mode 100644 index 0000000..8e834b4 --- /dev/null +++ b/bastion/src/cli/src/commands/forget.ts @@ -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 ") + .description("Remove a machine from bastion state") + .option("--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; + + 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); + } + }); +} diff --git a/bastion/src/cli/src/commands/install.ts b/bastion/src/cli/src/commands/install.ts index 6b6ef53..5f5d05f 100644 --- a/bastion/src/cli/src/commands/install.ts +++ b/bastion/src/cli/src/commands/install.ts @@ -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 ") .description("Queue a discovered machine for Fedora installation") .option("--role ", "Machine role: worker or infra", "worker") diff --git a/bastion/src/cli/src/commands/list.ts b/bastion/src/cli/src/commands/list.ts index 68c9997..14b57bd 100644 --- a/bastion/src/cli/src/commands/list.ts +++ b/bastion/src/cli/src/commands/list.ts @@ -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 ", "Bastion HTTP port", "8080") diff --git a/bastion/src/cli/src/commands/reprovision.ts b/bastion/src/cli/src/commands/reprovision.ts index e5ca203..149807b 100644 --- a/bastion/src/cli/src/commands/reprovision.ts +++ b/bastion/src/cli/src/commands/reprovision.ts @@ -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 ") .description("Queue install + SSH reboot into PXE for reprovision") .option("--role ", "Machine role: worker or infra", "worker") diff --git a/bastion/src/cli/src/commands/serve.ts b/bastion/src/cli/src/commands/serve.ts index 4b60bac..4be6aaf 100644 --- a/bastion/src/cli/src/commands/serve.ts +++ b/bastion/src/cli/src/commands/serve.ts @@ -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 ", "HTTP port", "8080") .option("--dir ", "Bastion data directory", "/tmp/lab-bastion") diff --git a/bastion/src/cli/src/commands/status.ts b/bastion/src/cli/src/commands/status.ts new file mode 100644 index 0000000..0583348 --- /dev/null +++ b/bastion/src/cli/src/commands/status.ts @@ -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 ", "Bastion data directory", "/tmp/lab-bastion") + .option("--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)"); + } + }); +} diff --git a/bastion/src/cli/src/commands/stop.ts b/bastion/src/cli/src/commands/stop.ts new file mode 100644 index 0000000..b1ed687 --- /dev/null +++ b/bastion/src/cli/src/commands/stop.ts @@ -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 ", "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); + } + }); +} diff --git a/bastion/src/cli/src/index.ts b/bastion/src/cli/src/index.ts index 3a8f90e..fa1adfa 100644 --- a/bastion/src/cli/src/index.ts +++ b/bastion/src/cli/src/index.ts @@ -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();