fix: PXE boot debugging — bisect root cause, syslog logging, serial console #3

Merged
michal merged 31 commits from wip/ks-debugging into main 2026-03-29 00:50:05 +00:00
40 changed files with 344 additions and 143 deletions
Showing only changes of commit 64533b2dcf - Show all commits

View File

@@ -1,34 +1,22 @@
{
"name": "lab-bastion",
"name": "lab",
"version": "0.1.0",
"private": true,
"description": "PXE bastion server for discover-first bare-metal provisioning",
"type": "module",
"bin": {
"bastion": "./dist/cli/index.js"
},
"main": "./dist/server/main.js",
"scripts": {
"build": "tsc",
"dev": "tsx src/cli/index.ts",
"start": "node dist/cli/index.js",
"build": "pnpm -r run build",
"test": "vitest",
"test:run": "vitest run",
"lint": "tsc --noEmit",
"clean": "rimraf dist"
"typecheck": "tsc --build",
"clean": "pnpm -r run clean && rimraf node_modules",
"lint": "eslint 'src/*/src/**/*.ts'"
},
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
},
"packageManager": "pnpm@9.15.0",
"dependencies": {
"@fastify/static": "^8.0.0",
"commander": "^13.0.0",
"execa": "^9.5.0",
"fastify": "^5.0.0",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"rimraf": "^6.0.0",

56
bastion/pnpm-lock.yaml generated
View File

@@ -7,22 +7,6 @@ settings:
importers:
.:
dependencies:
'@fastify/static':
specifier: ^8.0.0
version: 8.3.0
commander:
specifier: ^13.0.0
version: 13.1.0
execa:
specifier: ^9.5.0
version: 9.6.1
fastify:
specifier: ^5.0.0
version: 5.8.2
winston:
specifier: ^3.17.0
version: 3.19.0
devDependencies:
'@types/node':
specifier: ^22.10.0
@@ -40,6 +24,46 @@ importers:
specifier: ^3.0.0
version: 3.2.4(@types/node@22.19.15)(tsx@4.21.0)
src/bastion:
dependencies:
'@fastify/static':
specifier: ^8.0.0
version: 8.3.0
'@lab/shared':
specifier: workspace:*
version: link:../shared
execa:
specifier: ^9.5.0
version: 9.6.1
fastify:
specifier: ^5.0.0
version: 5.8.2
winston:
specifier: ^3.17.0
version: 3.19.0
devDependencies:
'@types/node':
specifier: ^22.10.0
version: 22.19.15
src/cli:
dependencies:
'@lab/bastion':
specifier: workspace:*
version: link:../bastion
'@lab/shared':
specifier: workspace:*
version: link:../shared
commander:
specifier: ^13.0.0
version: 13.1.0
devDependencies:
'@types/node':
specifier: ^22.10.0
version: 22.19.15
src/shared: {}
packages:
'@colors/colors@1.6.0':

View File

@@ -0,0 +1,2 @@
packages:
- "src/*"

View File

@@ -0,0 +1,31 @@
{
"name": "@lab/bastion",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/main.js",
"types": "./dist/main.d.ts",
"exports": {
".": {
"import": "./dist/main.js",
"types": "./dist/main.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"dev": "tsx src/main.ts",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@fastify/static": "^8.0.0",
"@lab/shared": "workspace:*",
"execa": "^9.5.0",
"fastify": "^5.0.0",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/node": "^22.10.0"
}
}

View File

@@ -1,31 +1,6 @@
// Configuration from environment variables with sensible defaults.
export interface BastionConfig {
fedoraVersion: string;
arch: string;
httpPort: number;
timezone: string;
locale: string;
bastionDir: string;
domain: string;
dhcpMode: "proxy" | "full";
dhcpRangeStart: string;
dhcpRangeEnd: string;
// Flags
skipDnsmasq?: boolean;
skipArtifacts?: boolean;
// Derived at runtime
iface: string;
serverIp: string;
network: string;
gateway: string;
sshKeys: string[];
adminUser: string;
fedoraMirror: string;
tftpDir: string;
httpDir: string;
stateFile: string;
}
import type { BastionConfig } from "@lab/shared";
export function loadConfig(overrides: Partial<BastionConfig> = {}): BastionConfig {
const fedoraVersion = overrides.fedoraVersion ?? process.env["FEDORA_VERSION"] ?? "43";

View File

@@ -3,12 +3,13 @@
import { mkdirSync, writeFileSync, existsSync, copyFileSync, symlinkSync } from "node:fs";
import { execSync } from "node:child_process";
import { loadConfig, type BastionConfig } from "./config.js";
import type { BastionConfig } from "@lab/shared";
import { loadConfig } from "./config.js";
import { populateNetworkConfig } from "./services/network.js";
import { createApp } from "./server.js";
import { startDnsmasq, stopDnsmasq, generateDnsmasqConf } from "./services/dnsmasq.js";
import { generateDiscoverKickstart } from "./services/kickstart-generator.js";
import { renderBootIpxe } from "../templates/boot.ipxe.js";
import { renderBootIpxe } from "./templates/boot.ipxe.js";
import { logger } from "./services/logger.js";
function copyIfMissing(src: string, dest: string, label: string): void {
@@ -179,8 +180,8 @@ function printBanner(config: BastionConfig): void {
console.log(" \x1b[33mIt will be inventoried and rebooted automatically.\x1b[0m");
console.log("");
console.log(" Commands (from another terminal):");
console.log(" \x1b[1mbastion list\x1b[0m -- show machines");
console.log(" \x1b[1mbastion install <mac> <hostname>\x1b[0m -- queue install");
console.log(" \x1b[1mlab list\x1b[0m -- show machines");
console.log(" \x1b[1mlab install <mac> <hostname>\x1b[0m -- queue install");
console.log("");
console.log(" Press \x1b[1mCtrl-C\x1b[0m to stop.");
console.log("");

View File

@@ -5,7 +5,8 @@
// /api/discover - receive hardware discovery reports from PXE-booted machines
import type { FastifyInstance } from "fastify";
import type { StateManager, HardwareInfo, InstalledInfo } from "../services/state.js";
import type { HardwareInfo, InstalledInfo } from "@lab/shared";
import type { StateManager } from "../services/state.js";
import { logger } from "../services/logger.js";
export function registerApiRoutes(

View File

@@ -5,13 +5,13 @@
// - unknown -> discovery mode (collect hardware, POST to bastion)
import type { FastifyInstance } from "fastify";
import type { BastionConfig } from "../config.js";
import type { BastionConfig } from "@lab/shared";
import type { StateManager } from "../services/state.js";
import {
renderDiscoverIpxe,
renderInstallIpxe,
renderLocalBootIpxe,
} from "../../templates/boot.ipxe.js";
} from "../templates/boot.ipxe.js";
import { logger } from "../services/logger.js";
export function registerDispatchRoutes(

View File

@@ -2,7 +2,7 @@
// Serves per-MAC install kickstart and the static discovery kickstart.
import type { FastifyInstance } from "fastify";
import type { BastionConfig } from "../config.js";
import type { BastionConfig } from "@lab/shared";
import type { StateManager } from "../services/state.js";
import { generateInstallKickstart, generateDiscoverKickstart } from "../services/kickstart-generator.js";

View File

@@ -3,7 +3,7 @@
import Fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { mkdirSync, existsSync } from "node:fs";
import type { BastionConfig } from "./config.js";
import type { BastionConfig } from "@lab/shared";
import { StateManager } from "./services/state.js";
import { logger } from "./services/logger.js";
import { registerDispatchRoutes } from "./routes/dispatch.js";

View File

@@ -4,8 +4,8 @@ import { writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { ResultPromise } from "execa";
import { execa } from "execa";
import type { BastionConfig } from "../config.js";
import { renderDnsmasqConf } from "../../templates/dnsmasq.conf.js";
import type { BastionConfig } from "@lab/shared";
import { renderDnsmasqConf } from "../templates/dnsmasq.conf.js";
import { logger } from "./logger.js";
type DnsmasqProcess = ResultPromise<{ stdout: "pipe"; stderr: "pipe" }>;

View File

@@ -1,9 +1,9 @@
// Generate kickstart content for discovery and install modes.
// Uses template literal functions -- no external template engine.
import type { BastionConfig } from "../config.js";
import { renderDiscoverKickstart } from "../../templates/discover.ks.js";
import { renderInstallKickstart, type InstallKickstartParams } from "../../templates/install.ks.js";
import type { BastionConfig } from "@lab/shared";
import { renderDiscoverKickstart } from "../templates/discover.ks.js";
import { renderInstallKickstart, type InstallKickstartParams } from "../templates/install.ks.js";
/**
* Generate a discovery kickstart that collects hardware info and POSTs to bastion.

View File

@@ -4,7 +4,7 @@ import { execSync } from "node:child_process";
import { readFileSync, existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { BastionConfig } from "../config.js";
import type { BastionConfig } from "@lab/shared";
import { logger } from "./logger.js";
/**

View File

@@ -2,45 +2,10 @@
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { BastionState } from "@lab/shared";
export interface HardwareInfo {
mac: string;
product: string;
board: string;
serial: string;
manufacturer: string;
cpu_model: string;
cpu_cores: number;
memory_gb: number;
arch: string;
disks: Array<{ name: string; size_gb: number; model: string }>;
nics: Array<{ name: string; mac: string; state: string }>;
first_seen: string;
last_seen: string;
}
export interface InstallConfig {
hostname: string;
disk: string;
role: "worker" | "infra";
queued_at: string;
progress?: string;
progress_at?: string;
progress_detail?: string;
}
export interface InstalledInfo {
hostname: string;
role: string;
ip: string;
installed_at: string;
}
export interface BastionState {
discovered: Record<string, HardwareInfo>;
install_queue: Record<string, InstallConfig>;
installed: Record<string, InstalledInfo>;
}
// Re-export types for consumers that import from this module
export type { HardwareInfo, InstallConfig, InstalledInfo, BastionState } from "@lab/shared";
const EMPTY_STATE: BastionState = {
discovered: {},

View File

@@ -2,7 +2,7 @@
// Supports proxy DHCP mode (alongside existing DHCP) and full DHCP mode.
// Handles UEFI HTTP Boot, iPXE chainloading, and PXE service directives.
import type { BastionConfig } from "../server/config.js";
import type { BastionConfig } from "@lab/shared";
export function renderDnsmasqConf(config: BastionConfig): string {
const {

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../shared" }
]
}

View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'bastion',
include: ['tests/**/*.test.ts'],
},
});

View File

@@ -0,0 +1,26 @@
{
"name": "@lab/cli",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"lab": "./dist/index.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"dev": "tsx src/index.ts",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@lab/bastion": "workspace:*",
"@lab/shared": "workspace:*",
"commander": "^13.0.0"
},
"devDependencies": {
"@types/node": "^22.10.0"
}
}

View File

@@ -2,7 +2,7 @@
// Merged view of all known machines with hardware + install info.
import type { Command } from "commander";
import type { BastionState } from "../../server/services/state.js";
import type { BastionState } from "@lab/shared";
const BOLD = "\x1b[1m";
const GREEN = "\x1b[0;32m";

View File

@@ -3,7 +3,7 @@
import { execSync } from "node:child_process";
import type { Command } from "commander";
import type { BastionState } from "../../server/services/state.js";
import type { BastionState } from "@lab/shared";
export function registerReprovisionCommand(program: Command): void {
program

View File

@@ -2,7 +2,7 @@
// Start the bastion server (HTTP + dnsmasq).
import type { Command } from "commander";
import { startBastion } from "../../server/main.js";
import { startBastion } from "@lab/bastion";
export function registerServeCommand(program: Command): void {
program

View File

@@ -3,6 +3,7 @@
// Commands: serve, install, list, reprovision
import { Command } from "commander";
import { APP_VERSION } from "@lab/shared";
import { registerServeCommand } from "./commands/serve.js";
import { registerInstallCommand } from "./commands/install.js";
import { registerListCommand } from "./commands/list.js";
@@ -11,9 +12,9 @@ import { registerReprovisionCommand } from "./commands/reprovision.js";
const program = new Command();
program
.name("bastion")
.name("lab")
.description("Lab PXE Bastion -- discover-first bare-metal provisioning")
.version("0.1.0");
.version(APP_VERSION);
registerServeCommand(program);
registerInstallCommand(program);

View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../shared" },
{ "path": "../bastion" }
]
}

View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'cli',
include: ['tests/**/*.test.ts'],
},
});

View File

@@ -0,0 +1,20 @@
{
"name": "@lab/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"test": "vitest",
"test:run": "vitest run"
}
}

View File

@@ -0,0 +1,4 @@
// Application-wide constants.
export const APP_NAME = "lab";
export const APP_VERSION = "0.1.0";

View File

@@ -0,0 +1,9 @@
export type {
HardwareInfo,
InstallConfig,
InstalledInfo,
BastionState,
BastionConfig,
} from "./types/index.js";
export { APP_NAME, APP_VERSION } from "./constants/index.js";

View File

@@ -0,0 +1,28 @@
// Configuration types for the bastion server.
export interface BastionConfig {
fedoraVersion: string;
arch: string;
httpPort: number;
timezone: string;
locale: string;
bastionDir: string;
domain: string;
dhcpMode: "proxy" | "full";
dhcpRangeStart: string;
dhcpRangeEnd: string;
// Flags
skipDnsmasq?: boolean | undefined;
skipArtifacts?: boolean | undefined;
// Derived at runtime
iface: string;
serverIp: string;
network: string;
gateway: string;
sshKeys: string[];
adminUser: string;
fedoraMirror: string;
tftpDir: string;
httpDir: string;
stateFile: string;
}

View File

@@ -0,0 +1,8 @@
export type {
HardwareInfo,
InstallConfig,
InstalledInfo,
BastionState,
} from "./state.js";
export type { BastionConfig } from "./config.js";

View File

@@ -0,0 +1,40 @@
// State types for discovered machines, install queue, and installed machines.
export interface HardwareInfo {
mac: string;
product: string;
board: string;
serial: string;
manufacturer: string;
cpu_model: string;
cpu_cores: number;
memory_gb: number;
arch: string;
disks: Array<{ name: string; size_gb: number; model: string }>;
nics: Array<{ name: string; mac: string; state: string }>;
first_seen: string;
last_seen: string;
}
export interface InstallConfig {
hostname: string;
disk: string;
role: "worker" | "infra";
queued_at: string;
progress?: string;
progress_at?: string;
progress_detail?: string;
}
export interface InstalledInfo {
hostname: string;
role: string;
ip: string;
installed_at: string;
}
export interface BastionState {
discovered: Record<string, HardwareInfo>;
install_queue: Record<string, InstallConfig>;
installed: Record<string, InstalledInfo>;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'shared',
include: ['tests/**/*.test.ts'],
},
});

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"incremental": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"resolveJsonModule": true
}
}

View File

@@ -1,27 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"incremental": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"resolveJsonModule": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
"files": [],
"references": [
{ "path": "src/shared" },
{ "path": "src/bastion" },
{ "path": "src/cli" }
]
}

15
bastion/vitest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/node_modules/**', '**/dist/**', '**/*.config.*'],
},
include: ['src/*/tests/**/*.test.ts'],
exclude: ['**/node_modules/**'],
testTimeout: 10000,
},
});