feat: ESLint, shell completions, Docker, nfpm packaging, CI/CD
- ESLint with typescript-eslint + prettier (eslint.config.js) - Shell completions for bash and fish (scripts/generate-completions.ts) - Multi-stage Dockerfile for bastion (fedora:43 + dnsmasq + node) - nfpm.yaml for RPM/DEB packaging with bun-compiled binary - Build scripts: build-rpm.sh, build-bastion.sh, publish-rpm/deb.sh - Gitea Actions CI/CD: lint, typecheck, test, build, publish Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -116,7 +116,7 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
||||
mkdirSync(config.httpDir, { recursive: true });
|
||||
|
||||
// Prepare boot artifacts
|
||||
if (!config.skipArtifacts) {
|
||||
if (config.skipArtifacts !== true) {
|
||||
logger.info(`Preparing boot artifacts (Fedora ${config.fedoraVersion} ${config.arch})...`);
|
||||
|
||||
copyIfMissing(
|
||||
@@ -177,7 +177,7 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
||||
generateDnsmasqConf(config);
|
||||
|
||||
// Open firewall ports
|
||||
if (!config.skipDnsmasq) {
|
||||
if (config.skipDnsmasq !== true) {
|
||||
openFirewall(config);
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
||||
logger.info(`HTTP server listening on :${config.httpPort}`);
|
||||
|
||||
// Start dnsmasq (unless skipped)
|
||||
if (!config.skipDnsmasq) {
|
||||
if (config.skipDnsmasq !== true) {
|
||||
const dnsmasqProc = startDnsmasq(config);
|
||||
|
||||
// Monitor dnsmasq
|
||||
@@ -210,9 +210,9 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
||||
printBanner(config);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
const shutdown = async (): Promise<void> => {
|
||||
logger.info("Shutting down...");
|
||||
if (!config.skipDnsmasq) stopDnsmasq();
|
||||
if (config.skipDnsmasq !== true) stopDnsmasq();
|
||||
closeFirewall(config);
|
||||
await app.close();
|
||||
try { unlinkSync(pidFile); } catch { /* ignore */ }
|
||||
|
||||
@@ -30,7 +30,7 @@ export function registerApiRoutes(
|
||||
const { mac: rawMac, hostname, disk, role } = request.body ?? {};
|
||||
const mac = (rawMac ?? "").toLowerCase().replace(/-/g, ":");
|
||||
|
||||
if (!mac) {
|
||||
if (mac === "") {
|
||||
return reply.status(400).send({ error: "mac is required" });
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function registerApiRoutes(
|
||||
if (queueEntry) {
|
||||
queueEntry.progress = stageName;
|
||||
queueEntry.progress_at = new Date().toISOString();
|
||||
if (detailStr) {
|
||||
if (detailStr !== "") {
|
||||
queueEntry.progress_detail = detailStr;
|
||||
}
|
||||
|
||||
@@ -111,8 +111,9 @@ export function registerApiRoutes(
|
||||
};
|
||||
s.installed[mac] = installedInfo;
|
||||
|
||||
const admin = state.load().installed[mac]?.role ? "michal" : "root";
|
||||
console.log(`\n \x1b[0;32m\x1b[1m ssh ${admin}@${ip}\x1b[0m\n`);
|
||||
const installedRole = state.load().installed[mac]?.role;
|
||||
const admin = installedRole !== undefined && installedRole !== "" ? "michal" : "root";
|
||||
console.log(`\n \x1b[0;32m\x1b[1m ssh ${admin}@${ip}\x1b[0m\n`); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -126,21 +127,21 @@ export function registerApiRoutes(
|
||||
}>("/api/machines/:mac", async (request, reply) => {
|
||||
const mac = request.params.mac.toLowerCase().replace(/-/g, ":");
|
||||
|
||||
if (!mac) {
|
||||
if (mac === "") {
|
||||
return reply.status(400).send({ error: "mac is required" });
|
||||
}
|
||||
|
||||
let found = false;
|
||||
state.update((s) => {
|
||||
if (s.discovered[mac]) {
|
||||
if (s.discovered[mac] !== undefined) {
|
||||
delete s.discovered[mac];
|
||||
found = true;
|
||||
}
|
||||
if (s.install_queue[mac]) {
|
||||
if (s.install_queue[mac] !== undefined) {
|
||||
delete s.install_queue[mac];
|
||||
found = true;
|
||||
}
|
||||
if (s.installed[mac]) {
|
||||
if (s.installed[mac] !== undefined) {
|
||||
delete s.installed[mac];
|
||||
found = true;
|
||||
}
|
||||
@@ -171,7 +172,7 @@ export function registerApiRoutes(
|
||||
};
|
||||
}>("/api/discover", async (request, reply) => {
|
||||
const data = request.body;
|
||||
if (!data) {
|
||||
if (data === null || data === undefined) {
|
||||
return reply.status(400).send({ error: "invalid JSON" });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { registerDispatchRoutes } from "./routes/dispatch.js";
|
||||
import { registerKickstartRoutes } from "./routes/kickstart.js";
|
||||
import { registerApiRoutes } from "./routes/api.js";
|
||||
|
||||
export function createApp(config: BastionConfig) {
|
||||
export function createApp(config: BastionConfig): { app: ReturnType<typeof Fastify>; state: StateManager } {
|
||||
const app = Fastify({
|
||||
logger: false, // We use winston instead
|
||||
});
|
||||
|
||||
@@ -13,10 +13,11 @@ import { logger } from "./logger.js";
|
||||
export function detectInterface(): string {
|
||||
const output = execSync("ip route", { encoding: "utf-8" });
|
||||
const match = output.match(/default\s+.*\s+dev\s+(\S+)/);
|
||||
if (!match?.[1]) {
|
||||
const ifaceMatch = match?.[1];
|
||||
if (ifaceMatch === undefined) {
|
||||
throw new Error("Cannot detect default network interface");
|
||||
}
|
||||
return match[1];
|
||||
return ifaceMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,10 +26,11 @@ export function detectInterface(): string {
|
||||
export function detectIp(iface: string): string {
|
||||
const output = execSync(`ip -4 addr show ${iface}`, { encoding: "utf-8" });
|
||||
const match = output.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (!match?.[1]) {
|
||||
const ipMatch = match?.[1];
|
||||
if (ipMatch === undefined) {
|
||||
throw new Error(`Cannot detect IP on interface ${iface}`);
|
||||
}
|
||||
return match[1];
|
||||
return ipMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,10 +47,11 @@ export function deriveNetwork(ip: string): string {
|
||||
export function detectGateway(): string {
|
||||
const output = execSync("ip route", { encoding: "utf-8" });
|
||||
const match = output.match(/default\s+via\s+(\S+)/);
|
||||
if (!match?.[1]) {
|
||||
const gwMatch = match?.[1];
|
||||
if (gwMatch === undefined) {
|
||||
throw new Error("Cannot detect default gateway");
|
||||
}
|
||||
return match[1];
|
||||
return gwMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,11 +59,16 @@ export function detectGateway(): string {
|
||||
* Sources: authorized_keys, then id_ed25519.pub, id_rsa.pub, id_ecdsa.pub (deduplicated).
|
||||
*/
|
||||
export function collectSshKeys(bastionDir: string): { keys: string[]; source: string } {
|
||||
const realHome = process.env["SUDO_USER"]
|
||||
? execSync(`getent passwd ${process.env["SUDO_USER"]}`, { encoding: "utf-8" })
|
||||
.split(":")[5]
|
||||
?.trim() ?? homedir()
|
||||
: homedir();
|
||||
const sudoUser = process.env["SUDO_USER"];
|
||||
let realHome: string;
|
||||
if (sudoUser !== undefined) {
|
||||
const passwdEntry = execSync(`getent passwd ${sudoUser}`, { encoding: "utf-8" })
|
||||
.split(":")[5]
|
||||
?.trim();
|
||||
realHome = passwdEntry !== undefined && passwdEntry !== "" ? passwdEntry : homedir();
|
||||
} else {
|
||||
realHome = homedir();
|
||||
}
|
||||
|
||||
const keys: string[] = [];
|
||||
const fingerprints = new Set<string>();
|
||||
@@ -74,7 +82,7 @@ export function collectSshKeys(bastionDir: string): { keys: string[]; source: st
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const fp = trimmed.split(/\s+/)[1];
|
||||
if (fp && !fingerprints.has(fp)) {
|
||||
if (fp !== undefined && fp !== "" && !fingerprints.has(fp)) {
|
||||
keys.push(trimmed);
|
||||
fingerprints.add(fp);
|
||||
}
|
||||
@@ -90,7 +98,7 @@ export function collectSshKeys(bastionDir: string): { keys: string[]; source: st
|
||||
if (existsSync(keyPath)) {
|
||||
const keyData = readFileSync(keyPath, "utf-8").trim();
|
||||
const fp = keyData.split(/\s+/)[1];
|
||||
if (fp && !fingerprints.has(fp)) {
|
||||
if (fp !== undefined && fp !== "" && !fingerprints.has(fp)) {
|
||||
keys.push(keyData);
|
||||
fingerprints.add(fp);
|
||||
source = source ? `${source} + ${keyPath}` : keyPath;
|
||||
@@ -131,18 +139,18 @@ export function detectAdminUser(): string {
|
||||
* Populate runtime network config fields on the config object.
|
||||
*/
|
||||
export function populateNetworkConfig(config: BastionConfig): BastionConfig {
|
||||
const iface = config.iface || detectInterface();
|
||||
const serverIp = config.serverIp || detectIp(iface);
|
||||
const network = config.network || deriveNetwork(serverIp);
|
||||
const gateway = config.gateway || detectGateway();
|
||||
const iface = config.iface !== "" ? config.iface : detectInterface();
|
||||
const serverIp = config.serverIp !== "" ? config.serverIp : detectIp(iface);
|
||||
const network = config.network !== "" ? config.network : deriveNetwork(serverIp);
|
||||
const gateway = config.gateway !== "" ? config.gateway : detectGateway();
|
||||
const { keys: sshKeys, source: sshSource } = config.sshKeys.length > 0
|
||||
? { keys: config.sshKeys, source: "config" }
|
||||
: collectSshKeys(config.bastionDir);
|
||||
const adminUser = config.adminUser || detectAdminUser();
|
||||
const adminUser = config.adminUser !== "" ? config.adminUser : detectAdminUser();
|
||||
|
||||
logger.info(`Interface: ${iface} IP: ${serverIp} Network: ${network}`);
|
||||
logger.info(`SSH keys: ${sshKeys.length} key(s) from ${sshSource}`);
|
||||
if (adminUser) {
|
||||
if (adminUser !== "") {
|
||||
logger.info(`Admin user: ${adminUser} (will be created on installed machines)`);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function registerInstallCommand(parent: Command): void {
|
||||
hostname,
|
||||
role: opts.role,
|
||||
};
|
||||
if (opts.disk) {
|
||||
if (opts.disk !== undefined) {
|
||||
payload["disk"] = opts.disk;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,12 +62,12 @@ export function registerListCommand(parent: Command): void {
|
||||
|
||||
// Determine status
|
||||
let status = "discovered";
|
||||
if (queued) {
|
||||
status = queued.progress && queued.progress !== "waiting"
|
||||
if (queued !== undefined) {
|
||||
status = queued.progress !== undefined && queued.progress !== "" && queued.progress !== "waiting"
|
||||
? "installing"
|
||||
: "queued";
|
||||
}
|
||||
if (inst) status = "installed";
|
||||
if (inst !== undefined) status = "installed";
|
||||
|
||||
const hostname = inst?.hostname ?? queued?.hostname ?? "-";
|
||||
const role = inst?.role ?? queued?.role ?? "-";
|
||||
|
||||
@@ -28,7 +28,7 @@ export function registerReprovisionCommand(parent: Command): void {
|
||||
hostname,
|
||||
role: opts.role,
|
||||
};
|
||||
if (opts.disk) {
|
||||
if (opts.disk !== undefined) {
|
||||
payload["disk"] = opts.disk;
|
||||
}
|
||||
|
||||
@@ -61,13 +61,14 @@ export function registerReprovisionCommand(parent: Command): void {
|
||||
const adminUser = process.env["SUDO_USER"] ?? process.env["USER"] ?? "";
|
||||
const effectiveUser = adminUser === "root" ? "" : adminUser;
|
||||
|
||||
if (ip && effectiveUser) {
|
||||
if (ip !== "" && effectiveUser !== "") {
|
||||
console.log("");
|
||||
console.log(`Attempting SSH reboot into PXE (${effectiveUser}@${ip})...`);
|
||||
|
||||
// Find SSH key
|
||||
const realHome = process.env["SUDO_USER"]
|
||||
? join("/home", process.env["SUDO_USER"])
|
||||
const sudoUser = process.env["SUDO_USER"];
|
||||
const realHome = sudoUser !== undefined
|
||||
? join("/home", sudoUser)
|
||||
: homedir();
|
||||
const keyPaths = [
|
||||
join(realHome, ".ssh", "id_ed25519"),
|
||||
@@ -79,7 +80,7 @@ export function registerReprovisionCommand(parent: Command): void {
|
||||
const sshArgs = [
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "ConnectTimeout=10",
|
||||
...(sshKey ? ["-i", sshKey] : []),
|
||||
...(sshKey !== undefined ? ["-i", sshKey] : []),
|
||||
`${effectiveUser}@${ip}`,
|
||||
'PXE_ENTRY=$(sudo efibootmgr | grep -iE "pxe|network|ipv4" | head -1 | grep -oP "Boot\\K[0-9A-F]+"); if [ -n "$PXE_ENTRY" ]; then sudo efibootmgr --bootnext "$PXE_ENTRY" && echo "PXE set as next boot" && sudo reboot; else echo "No PXE boot entry found, rebooting anyway..." && sudo reboot; fi',
|
||||
];
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// init bastion standalone start/stop/status
|
||||
// provision list/install/reprovision/forget
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { APP_VERSION } from "@lab/shared";
|
||||
import { registerStartCommand } from "./commands/serve.js";
|
||||
@@ -14,34 +15,47 @@ import { registerListCommand } from "./commands/list.js";
|
||||
import { registerReprovisionCommand } from "./commands/reprovision.js";
|
||||
import { registerForgetCommand } from "./commands/forget.js";
|
||||
|
||||
const program = new Command();
|
||||
export function createProgram(): Command {
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("lab")
|
||||
.description("Lab PXE Bastion -- discover-first bare-metal provisioning")
|
||||
.version(APP_VERSION);
|
||||
program
|
||||
.name("lab")
|
||||
.description("Lab PXE Bastion -- discover-first bare-metal provisioning")
|
||||
.version(APP_VERSION);
|
||||
|
||||
// init bastion standalone start/stop/status
|
||||
const initCmd = program.command("init");
|
||||
initCmd.description("Initialise infrastructure components");
|
||||
// init bastion standalone start/stop/status
|
||||
const initCmd = program.command("init");
|
||||
initCmd.description("Initialise infrastructure components");
|
||||
|
||||
const bastionCmd = initCmd.command("bastion");
|
||||
bastionCmd.description("Bastion PXE server management");
|
||||
const bastionCmd = initCmd.command("bastion");
|
||||
bastionCmd.description("Bastion PXE server management");
|
||||
|
||||
const standaloneCmd = bastionCmd.command("standalone");
|
||||
standaloneCmd.description("Standalone bastion server lifecycle");
|
||||
const standaloneCmd = bastionCmd.command("standalone");
|
||||
standaloneCmd.description("Standalone bastion server lifecycle");
|
||||
|
||||
registerStartCommand(standaloneCmd);
|
||||
registerStopCommand(standaloneCmd);
|
||||
registerStatusCommand(standaloneCmd);
|
||||
registerStartCommand(standaloneCmd);
|
||||
registerStopCommand(standaloneCmd);
|
||||
registerStatusCommand(standaloneCmd);
|
||||
|
||||
// provision list/install/reprovision/forget
|
||||
const provisionCmd = program.command("provision");
|
||||
provisionCmd.description("Machine provisioning operations");
|
||||
// provision list/install/reprovision/forget
|
||||
const provisionCmd = program.command("provision");
|
||||
provisionCmd.description("Machine provisioning operations");
|
||||
|
||||
registerListCommand(provisionCmd);
|
||||
registerInstallCommand(provisionCmd);
|
||||
registerReprovisionCommand(provisionCmd);
|
||||
registerForgetCommand(provisionCmd);
|
||||
registerListCommand(provisionCmd);
|
||||
registerInstallCommand(provisionCmd);
|
||||
registerReprovisionCommand(provisionCmd);
|
||||
registerForgetCommand(provisionCmd);
|
||||
|
||||
program.parse();
|
||||
return program;
|
||||
}
|
||||
|
||||
// Run CLI when executed directly (not imported)
|
||||
const isDirectExecution =
|
||||
process.argv[1] !== undefined &&
|
||||
(process.argv[1].endsWith("/index.js") ||
|
||||
process.argv[1].endsWith("/index.ts") ||
|
||||
process.argv[1] === fileURLToPath(import.meta.url));
|
||||
|
||||
if (isDirectExecution) {
|
||||
createProgram().parse();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user