Some checks failed
CI/CD / lint (pull_request) Failing after 11s
CI/CD / test (pull_request) Failing after 9s
CI/CD / typecheck (pull_request) Failing after 22s
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
New `labctl provision debug <target>` command that PXE boots a machine into Fedora rescue mode (inst.rescue) for live debugging. Auto-clears after one boot so next reboot returns to normal. Adds debug state to BastionState, dispatch routing, API endpoints, labd command routing, and CLI with rescue workflow guide. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
143 lines
4.3 KiB
TypeScript
143 lines
4.3 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { StateManager } from "../src/services/state.js";
|
|
|
|
describe("StateManager", () => {
|
|
let testDir: string;
|
|
let stateFile: string;
|
|
let state: StateManager;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `bastion-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
stateFile = join(testDir, "state.json");
|
|
state = new StateManager(stateFile);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates empty state on first load", () => {
|
|
const loaded = state.load();
|
|
expect(loaded).toEqual({
|
|
discovered: {},
|
|
install_queue: {},
|
|
installed: {},
|
|
debug: {},
|
|
});
|
|
});
|
|
|
|
it("init creates the state file", () => {
|
|
expect(existsSync(stateFile)).toBe(false);
|
|
state.init();
|
|
expect(existsSync(stateFile)).toBe(true);
|
|
|
|
const content = JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
expect(content).toEqual({
|
|
discovered: {},
|
|
install_queue: {},
|
|
installed: {},
|
|
debug: {},
|
|
});
|
|
});
|
|
|
|
it("saves and loads state correctly", () => {
|
|
state.init();
|
|
|
|
state.update((s) => {
|
|
s.discovered["aa:bb:cc:dd:ee:ff"] = {
|
|
mac: "aa:bb:cc:dd:ee:ff",
|
|
product: "TestBox",
|
|
board: "TestBoard",
|
|
serial: "SN123",
|
|
manufacturer: "TestCorp",
|
|
cpu_model: "Test CPU",
|
|
cpu_cores: 8,
|
|
memory_gb: 32,
|
|
arch: "x86_64",
|
|
disks: [{ name: "sda", size_gb: 500, model: "TestDisk" }],
|
|
nics: [{ name: "eth0", mac: "aa:bb:cc:dd:ee:ff", state: "UP" }],
|
|
first_seen: "2025-01-01T00:00:00Z",
|
|
last_seen: "2025-01-01T00:00:00Z",
|
|
};
|
|
|
|
s.install_queue["11:22:33:44:55:66"] = {
|
|
hostname: "worker-1",
|
|
disk: "/dev/sda",
|
|
role: "worker",
|
|
queued_at: "2025-01-01T01:00:00Z",
|
|
};
|
|
});
|
|
|
|
// Load in a fresh StateManager to verify persistence
|
|
const state2 = new StateManager(stateFile);
|
|
const loaded = state2.load();
|
|
|
|
expect(loaded.discovered["aa:bb:cc:dd:ee:ff"]?.product).toBe("TestBox");
|
|
expect(loaded.discovered["aa:bb:cc:dd:ee:ff"]?.cpu_cores).toBe(8);
|
|
expect(loaded.install_queue["11:22:33:44:55:66"]?.hostname).toBe("worker-1");
|
|
expect(loaded.installed).toEqual({});
|
|
});
|
|
|
|
it("uses atomic writes (tmp file + rename)", () => {
|
|
state.init();
|
|
|
|
// After save, there should be no .tmp file left behind
|
|
state.update((s) => {
|
|
s.installed["aa:bb:cc:dd:ee:ff"] = {
|
|
hostname: "node1",
|
|
role: "worker",
|
|
ip: "10.0.0.1",
|
|
installed_at: "2025-01-01T00:00:00Z",
|
|
};
|
|
});
|
|
|
|
const tmpFile = `${stateFile}.tmp`;
|
|
expect(existsSync(tmpFile)).toBe(false);
|
|
expect(existsSync(stateFile)).toBe(true);
|
|
|
|
// Verify data was written correctly
|
|
const raw = readFileSync(stateFile, "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
expect(parsed.installed["aa:bb:cc:dd:ee:ff"].hostname).toBe("node1");
|
|
});
|
|
});
|
|
|
|
describe("PID file handling", () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `bastion-pid-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("handles stale PID file from previous run", () => {
|
|
const pidFile = join(testDir, "bastion.pid");
|
|
// Simulate a stale PID file with a dead process
|
|
writeFileSync(pidFile, "999999999");
|
|
// Should be readable
|
|
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
expect(pid).toBe(999999999);
|
|
});
|
|
|
|
it("handles corrupted PID file gracefully", () => {
|
|
const pidFile = join(testDir, "bastion.pid");
|
|
writeFileSync(pidFile, "not-a-number\n");
|
|
const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
|
|
expect(isNaN(pid)).toBe(true);
|
|
});
|
|
|
|
it("handles missing bastion directory", () => {
|
|
const missingDir = join(testDir, "nonexistent", "deep");
|
|
mkdirSync(missingDir, { recursive: true });
|
|
expect(existsSync(missingDir)).toBe(true);
|
|
});
|
|
});
|