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:
Michal
2026-03-17 21:51:01 +00:00
parent 520af41a52
commit ed1df8a77c
22 changed files with 1885 additions and 75 deletions

View File

@@ -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 */ }

View File

@@ -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" });
}

View File

@@ -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
});

View File

@@ -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)`);
}

View File

@@ -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;
}

View File

@@ -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 ?? "-";

View File

@@ -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',
];

View File

@@ -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();
}