feat: install logging, error trapping, PXE/ISO integration tests
Some checks failed
CI/CD / lint (pull_request) Failing after 13s
CI/CD / test (pull_request) Failing after 10s
CI/CD / typecheck (pull_request) Failing after 36s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped

Kickstart installs on real hardware failed silently — no error reporting,
only 3 progress callbacks, zero log streaming. This overhaul makes every
install fully observable.

Kickstart improvements:
- Error trapping in %pre and %post (trap ERR sends failure details to bastion)
- 12+ granular progress stages (was 3): SSH, hostname, k3s prep, EFI boot, metadata
- Background log streamer: tails %post output and batch-sends to /api/log
- bastion_log() function for explicit log lines from kickstart scripts

Bastion API:
- POST /api/log — receives raw log lines from kickstart (single or batch)
- InstallLogBuffer — per-MAC ring buffer (2000 lines) + file persistence
- GET /api/logs/:mac — now returns log_lines + log_total alongside stages
- SSE /api/logs/:mac/follow — uses named events (event: stage vs event: log)
- Progress events forwarded to labd via bastion-progress WebSocket message
- Post-provision k3s logs routed through progressBus (was console-only)

dnsmasq fixes found during VM testing:
- HTTP Boot filename: ipxe-real.efi → ipxe.efi (leftover from old 2-stage approach)
- pxe-service directives: only in proxy mode (breaks OVMF PXE in full mode)
- PXEClient vendor class echo for UEFI firmware compatibility

Integration tests:
- PXE boot test: blank UEFI VM → dnsmasq → HTTP Boot → iPXE → bastion → install
- ISO boot test: blank VM boots from bastion-generated ISO → same flow
- Shared helpers: pxe-network (no DHCP, nftables fix), pxe-vm (UEFI + ISO boot)
- test-provision.sh: runs both PXE + ISO tests with prerequisite checks
- 250GB sparse QCOW2 disk (LVM layout needs ~204GB)

201 unit tests passing (11 new).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-26 22:26:33 +00:00
parent ffc4a782d2
commit 46b017d77e
189 changed files with 16241 additions and 432 deletions

View File

@@ -0,0 +1,109 @@
// Tests for WebSocket protocol types, type guards, and parsing.
import { describe, it, expect } from "vitest";
import {
isAgentMessage,
isServerMessage,
parseAgentMessage,
parseServerMessage,
generateRequestId,
} from "../src/protocol/index.js";
describe("protocol type guards", () => {
it("isAgentMessage accepts valid heartbeat", () => {
expect(
isAgentMessage({
type: "heartbeat",
hostname: "worker-1",
uptime: 100,
version: "0.1.0",
memUsage: 1024,
cpuUsage: 0.5,
}),
).toBe(true);
});
it("isAgentMessage accepts valid exec-exit", () => {
expect(
isAgentMessage({ type: "exec-exit", requestId: "abc", exitCode: 0 }),
).toBe(true);
});
it("isAgentMessage rejects unknown type", () => {
expect(isAgentMessage({ type: "unknown-type" })).toBe(false);
});
it("isAgentMessage rejects non-object", () => {
expect(isAgentMessage("heartbeat")).toBe(false);
expect(isAgentMessage(null)).toBe(false);
expect(isAgentMessage(42)).toBe(false);
});
it("isServerMessage accepts valid exec", () => {
expect(
isServerMessage({
type: "exec",
requestId: "abc",
command: "ls",
args: ["-la"],
timeout: 30000,
tty: false,
}),
).toBe(true);
});
it("isServerMessage accepts server-shutdown", () => {
expect(
isServerMessage({ type: "server-shutdown", reconnectAfter: 5000 }),
).toBe(true);
});
it("isServerMessage rejects agent message types", () => {
expect(isServerMessage({ type: "heartbeat" })).toBe(false);
});
});
describe("parseAgentMessage", () => {
it("parses valid JSON", () => {
const msg = parseAgentMessage(
JSON.stringify({ type: "heartbeat", hostname: "w1", uptime: 1, version: "0.1.0", memUsage: 0, cpuUsage: 0 }),
);
expect(msg.type).toBe("heartbeat");
});
it("throws on invalid JSON", () => {
expect(() => parseAgentMessage("not json")).toThrow();
});
it("throws on invalid message type", () => {
expect(() => parseAgentMessage(JSON.stringify({ type: "bogus" }))).toThrow(
"Invalid agent message",
);
});
});
describe("parseServerMessage", () => {
it("parses valid JSON", () => {
const msg = parseServerMessage(
JSON.stringify({ type: "heartbeat-ack", serverTime: "2026-01-01T00:00:00Z" }),
);
expect(msg.type).toBe("heartbeat-ack");
});
it("throws on agent message type", () => {
expect(() =>
parseServerMessage(JSON.stringify({ type: "heartbeat" })),
).toThrow("Invalid server message");
});
});
describe("generateRequestId", () => {
it("returns a string", () => {
expect(typeof generateRequestId()).toBe("string");
});
it("returns unique IDs", () => {
const ids = new Set(Array.from({ length: 100 }, () => generateRequestId()));
expect(ids.size).toBe(100);
});
});