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
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:
85
bastion/src/shared/tests/errors.test.ts
Normal file
85
bastion/src/shared/tests/errors.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Tests for error hierarchy.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
LabError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
ValidationError,
|
||||
AgentNotConnectedError,
|
||||
isLabError,
|
||||
} from "../src/errors/index.js";
|
||||
|
||||
describe("LabError", () => {
|
||||
it("preserves stack trace", () => {
|
||||
const err = new LabError("test", "TEST", 500);
|
||||
expect(err.stack).toContain("errors.test");
|
||||
});
|
||||
|
||||
it("sets name to constructor name", () => {
|
||||
expect(new LabError("x", "X", 500).name).toBe("LabError");
|
||||
expect(new NotFoundError("Server", "w1").name).toBe("NotFoundError");
|
||||
});
|
||||
|
||||
it("toJSON returns structured error", () => {
|
||||
const err = new LabError("msg", "CODE", 418, { key: "val" });
|
||||
const json = err.toJSON();
|
||||
expect(json).toEqual({
|
||||
error: "msg",
|
||||
code: "CODE",
|
||||
statusCode: 418,
|
||||
details: { key: "val" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotFoundError", () => {
|
||||
it("has 404 status", () => {
|
||||
const err = new NotFoundError("Server", "worker-1");
|
||||
expect(err.statusCode).toBe(404);
|
||||
expect(err.code).toBe("NOT_FOUND");
|
||||
expect(err.message).toContain("Server");
|
||||
expect(err.message).toContain("worker-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PermissionDeniedError", () => {
|
||||
it("has 403 status", () => {
|
||||
const err = new PermissionDeniedError("exec", "server/w1", "no permission");
|
||||
expect(err.statusCode).toBe(403);
|
||||
expect(err.code).toBe("PERMISSION_DENIED");
|
||||
expect(err.message).toContain("exec");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ValidationError", () => {
|
||||
it("has 400 status", () => {
|
||||
const err = new ValidationError("bad input", "hostname");
|
||||
expect(err.statusCode).toBe(400);
|
||||
expect(err.code).toBe("VALIDATION_ERROR");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentNotConnectedError", () => {
|
||||
it("has 503 status", () => {
|
||||
const err = new AgentNotConnectedError("worker-1");
|
||||
expect(err.statusCode).toBe(503);
|
||||
expect(err.message).toContain("worker-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLabError", () => {
|
||||
it("returns true for LabError subclasses", () => {
|
||||
expect(isLabError(new NotFoundError("x", "y"))).toBe(true);
|
||||
expect(isLabError(new PermissionDeniedError("a", "b"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for plain Error", () => {
|
||||
expect(isLabError(new Error("nope"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-errors", () => {
|
||||
expect(isLabError("string")).toBe(false);
|
||||
expect(isLabError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
109
bastion/src/shared/tests/protocol.test.ts
Normal file
109
bastion/src/shared/tests/protocol.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user