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>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
// Unit tests for k3s operations.
|
|
// Each operation is tested for: correctness, idempotency, and error handling.
|
|
|
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { mockCtx, OK, FAIL, stdout, expectCommand, expectNoCommand } from "./helpers.js";
|
|
|
|
// --- Kernel Modules ---
|
|
|
|
import { loadKernelModules } from "../src/operations/kernel-modules.js";
|
|
|
|
describe("loadKernelModules", () => {
|
|
it("loads missing modules and writes config", async () => {
|
|
const ctx = mockCtx();
|
|
// lsmod checks: br_netfilter missing, overlay loaded, ip_conntrack missing
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(FAIL) // br_netfilter not loaded
|
|
.mockResolvedValueOnce(OK) // modprobe br_netfilter
|
|
.mockResolvedValueOnce(OK) // overlay loaded
|
|
.mockResolvedValueOnce(FAIL) // ip_conntrack not loaded
|
|
.mockResolvedValueOnce(OK) // modprobe ip_conntrack
|
|
.mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__")) // cat config file
|
|
.mockResolvedValueOnce(OK); // write config
|
|
|
|
const result = await loadKernelModules(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.details).toContain("Loaded: br_netfilter");
|
|
expect(result.details).toContain("Already loaded: overlay");
|
|
});
|
|
|
|
it("is idempotent when all modules loaded and config exists", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(OK) // br_netfilter loaded
|
|
.mockResolvedValueOnce(OK) // overlay loaded
|
|
.mockResolvedValueOnce(OK) // ip_conntrack loaded
|
|
.mockResolvedValueOnce(stdout("br_netfilter\noverlay\nip_conntrack")); // config exists with correct content
|
|
|
|
const result = await loadKernelModules(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(result.changed).toBe(false);
|
|
});
|
|
});
|
|
|
|
// --- Sysctl ---
|
|
|
|
import { applyCisHardening } from "../src/operations/sysctl.js";
|
|
|
|
describe("applyCisHardening", () => {
|
|
it("writes config and applies sysctl", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__")) // file not found
|
|
.mockResolvedValueOnce(OK) // write file
|
|
.mockResolvedValueOnce(OK); // sysctl --system
|
|
|
|
const result = await applyCisHardening(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expectCommand(ctx.ssh, "sysctl --system");
|
|
});
|
|
|
|
it("skips sysctl when config unchanged", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec.mockResolvedValueOnce(stdout("# k3s CIS hardening\nnet.bridge.bridge-nf-call-iptables = 1\nnet.bridge.bridge-nf-call-ip6tables = 1\nnet.ipv4.ip_forward = 1\nvm.panic_on_oom = 0\nvm.overcommit_memory = 1\nkernel.panic = 10\nkernel.panic_on_oops = 1\n# inotify limits for large clusters\nfs.inotify.max_user_instances = 524288\nfs.inotify.max_user_watches = 524288"));
|
|
|
|
const result = await applyCisHardening(ctx);
|
|
expect(result.changed).toBe(false);
|
|
expectNoCommand(ctx.ssh, "sysctl --system");
|
|
});
|
|
});
|
|
|
|
// --- Swap ---
|
|
|
|
import { disableSwap } from "../src/operations/swap.js";
|
|
|
|
describe("disableSwap", () => {
|
|
it("disables active swap", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("/dev/sda2 partition 2G")) // swap active
|
|
.mockResolvedValueOnce(OK) // swapoff
|
|
.mockResolvedValueOnce(OK); // sed fstab
|
|
|
|
const result = await disableSwap(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expectCommand(ctx.ssh, "swapoff -a");
|
|
});
|
|
|
|
it("is idempotent when swap already off", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("")) // no swap
|
|
.mockResolvedValueOnce(OK); // sed fstab (always runs)
|
|
|
|
const result = await disableSwap(ctx);
|
|
expect(result.changed).toBe(false);
|
|
expectNoCommand(ctx.ssh, "swapoff");
|
|
});
|
|
});
|
|
|
|
// --- Firewall ---
|
|
|
|
import { disableFirewall } from "../src/operations/firewall.js";
|
|
|
|
describe("disableFirewall", () => {
|
|
it("disables active firewalld", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("active")) // firewalld active
|
|
.mockResolvedValueOnce(OK) // disable
|
|
.mockResolvedValueOnce(OK) // mask
|
|
.mockResolvedValueOnce(FAIL); // ufw not active
|
|
|
|
const result = await disableFirewall(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.details).toContain("Disabled and masked: firewalld");
|
|
});
|
|
|
|
it("is idempotent when nothing active", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(FAIL) // firewalld not active
|
|
.mockResolvedValueOnce(stdout("masked")) // already masked
|
|
.mockResolvedValueOnce(FAIL) // ufw not active
|
|
.mockResolvedValueOnce(FAIL); // ufw not enabled
|
|
|
|
const result = await disableFirewall(ctx);
|
|
expect(result.changed).toBe(false);
|
|
});
|
|
});
|
|
|
|
// --- SELinux ---
|
|
|
|
import { setSelinuxPermissive } from "../src/operations/selinux.js";
|
|
|
|
describe("setSelinuxPermissive", () => {
|
|
it("sets enforcing to permissive", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("Enforcing")) // current mode
|
|
.mockResolvedValueOnce(OK) // setenforce 0
|
|
.mockResolvedValueOnce(OK); // sed config
|
|
|
|
const result = await setSelinuxPermissive(ctx);
|
|
expect(result.changed).toBe(true);
|
|
});
|
|
|
|
it("skips when already permissive", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec.mockResolvedValueOnce(stdout("Permissive"));
|
|
|
|
const result = await setSelinuxPermissive(ctx);
|
|
expect(result.changed).toBe(false);
|
|
});
|
|
|
|
it("skips when disabled", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec.mockResolvedValueOnce(stdout("Disabled"));
|
|
|
|
const result = await setSelinuxPermissive(ctx);
|
|
expect(result.changed).toBe(false);
|
|
});
|
|
});
|
|
|
|
// --- K3s Config ---
|
|
|
|
import { writeK3sConfig } from "../src/operations/k3s-config.js";
|
|
|
|
describe("writeK3sConfig", () => {
|
|
it("writes server config with TLS SANs", async () => {
|
|
const ctx = mockCtx({ hostname: "node1.lab", ip: "10.0.1.1", role: "infra" });
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(OK) // mkdir
|
|
.mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__")) // cat existing
|
|
.mockResolvedValueOnce(OK); // write
|
|
|
|
const result = await writeK3sConfig(ctx);
|
|
expect(result.changed).toBe(true);
|
|
|
|
// Verify the written content includes TLS SANs
|
|
const writeCall = ctx.ssh.exec.mock.calls[2]![0] as string;
|
|
expect(writeCall).toContain("node1.lab");
|
|
expect(writeCall).toContain("10.0.1.1");
|
|
expect(writeCall).toContain("secrets-encryption: true");
|
|
expect(writeCall).toContain("flannel-backend: none");
|
|
});
|
|
|
|
it("writes minimal agent config", async () => {
|
|
const ctx = mockCtx({ role: "worker" });
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(OK) // mkdir
|
|
.mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__"))
|
|
.mockResolvedValueOnce(OK);
|
|
|
|
const result = await writeK3sConfig(ctx);
|
|
expect(result.changed).toBe(true);
|
|
|
|
const writeCall = ctx.ssh.exec.mock.calls[2]![0] as string;
|
|
expect(writeCall).toContain("protect-kernel-defaults: true");
|
|
expect(writeCall).not.toContain("secrets-encryption");
|
|
});
|
|
});
|
|
|
|
// --- CNI Cleanup ---
|
|
|
|
import { cleanupStaleCni } from "../src/operations/cni-cleanup.js";
|
|
|
|
describe("cleanupStaleCni", () => {
|
|
it("stops k3s and removes stale interfaces", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("active")) // k3s is active
|
|
.mockResolvedValueOnce(OK) // systemctl stop k3s
|
|
.mockResolvedValueOnce(OK) // flannel.1 exists
|
|
.mockResolvedValueOnce(OK) // delete flannel.1
|
|
.mockResolvedValueOnce(FAIL) // cilium_vxlan not found
|
|
.mockResolvedValueOnce(FAIL) // cilium_host not found
|
|
.mockResolvedValueOnce(FAIL) // cilium_net not found
|
|
.mockResolvedValueOnce(stdout("")) // no vxlans
|
|
.mockResolvedValueOnce(OK); // rm -rf cni
|
|
|
|
const result = await cleanupStaleCni(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.details).toContain("Stopped k3s service");
|
|
expect(result.details).toContain("Removed interface: flannel.1");
|
|
});
|
|
|
|
it("is idempotent when nothing to clean", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(FAIL) // k3s not active
|
|
.mockResolvedValueOnce(FAIL) // flannel.1 not found
|
|
.mockResolvedValueOnce(FAIL) // cilium_vxlan
|
|
.mockResolvedValueOnce(FAIL) // cilium_host
|
|
.mockResolvedValueOnce(FAIL) // cilium_net
|
|
.mockResolvedValueOnce(stdout("")) // no vxlans
|
|
.mockResolvedValueOnce(OK); // rm -rf (always runs)
|
|
|
|
const result = await cleanupStaleCni(ctx);
|
|
expect(result.changed).toBe(false);
|
|
});
|
|
});
|
|
|
|
// --- K3s Install ---
|
|
|
|
import { installK3sBinary } from "../src/operations/k3s-install.js";
|
|
|
|
describe("installK3sBinary", () => {
|
|
it("installs k3s server", async () => {
|
|
const ctx = mockCtx({ role: "infra" });
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(FAIL) // k3s not installed
|
|
.mockResolvedValueOnce(OK) // curl install
|
|
.mockResolvedValueOnce(OK) // restart
|
|
.mockResolvedValueOnce(OK); // kubectl get nodes
|
|
|
|
const result = await installK3sBinary(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
});
|
|
|
|
it("fails agent without server URL", async () => {
|
|
const ctx = mockCtx({ role: "worker" });
|
|
ctx.ssh.exec.mockResolvedValueOnce(FAIL); // not installed
|
|
|
|
const result = await installK3sBinary(ctx);
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("Missing agent");
|
|
});
|
|
|
|
it("installs k3s agent with URL and token", async () => {
|
|
const ctx = mockCtx({ role: "worker", k3sServerUrl: "https://10.0.0.1:6443", k3sToken: "secret" });
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(FAIL) // not installed
|
|
.mockResolvedValueOnce(OK) // curl install
|
|
.mockResolvedValueOnce(OK); // restart
|
|
|
|
const result = await installK3sBinary(ctx);
|
|
expect(result.success).toBe(true);
|
|
expectCommand(ctx.ssh, "K3S_URL=");
|
|
expectCommand(ctx.ssh, "K3S_TOKEN=");
|
|
});
|
|
});
|
|
|
|
// --- DNS Fix ---
|
|
|
|
import { fixCoreDnsUpstream } from "../src/operations/dns-fix.js";
|
|
|
|
describe("fixCoreDnsUpstream", () => {
|
|
it("detects upstream DNS and writes resolv.conf", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("192.168.8.1")) // resolvectl
|
|
.mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__")) // cat existing
|
|
.mockResolvedValueOnce(OK) // write resolv.conf
|
|
.mockResolvedValueOnce(stdout("active")) // k3s active
|
|
.mockResolvedValueOnce(OK) // restart
|
|
.mockResolvedValueOnce(OK); // kubectl get nodes
|
|
|
|
const result = await fixCoreDnsUpstream(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(result.changed).toBe(true);
|
|
expect(result.message).toContain("192.168.8.1");
|
|
});
|
|
|
|
it("falls back to /run/systemd/resolve/resolv.conf", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("")) // resolvectl empty
|
|
.mockResolvedValueOnce(stdout("10.0.0.1")) // fallback resolv.conf
|
|
.mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__"))
|
|
.mockResolvedValueOnce(OK)
|
|
.mockResolvedValueOnce(stdout("active"))
|
|
.mockResolvedValueOnce(OK)
|
|
.mockResolvedValueOnce(OK);
|
|
|
|
const result = await fixCoreDnsUpstream(ctx);
|
|
expect(result.changed).toBe(true);
|
|
});
|
|
|
|
it("skips when upstream cannot be detected", async () => {
|
|
const ctx = mockCtx();
|
|
ctx.ssh.exec
|
|
.mockResolvedValueOnce(stdout("")) // resolvectl empty
|
|
.mockResolvedValueOnce(stdout("127.0.0.53")); // fallback is still stub
|
|
|
|
const result = await fixCoreDnsUpstream(ctx);
|
|
expect(result.changed).toBe(false);
|
|
});
|
|
});
|
|
|
|
// --- Pod Security ---
|
|
|
|
import { applyPodSecurityStandards } from "../src/operations/pod-security.js";
|
|
|
|
describe("applyPodSecurityStandards", () => {
|
|
it("applies all three labels", async () => {
|
|
const ctx = mockCtx();
|
|
const result = await applyPodSecurityStandards(ctx);
|
|
expect(result.success).toBe(true);
|
|
expect(ctx.ssh.exec).toHaveBeenCalledTimes(3);
|
|
expectCommand(ctx.ssh, "pod-security.kubernetes.io/enforce=restricted");
|
|
expectCommand(ctx.ssh, "pod-security.kubernetes.io/warn=restricted");
|
|
expectCommand(ctx.ssh, "pod-security.kubernetes.io/audit=restricted");
|
|
});
|
|
});
|