Files
lab/bastion/src/modules/modules/k3s/tests/operations.test.ts
Michal 46b017d77e
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
feat: install logging, error trapping, PXE/ISO integration tests
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>
2026-03-26 22:26:33 +00:00

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");
});
});