2 Commits

Author SHA1 Message Date
Michal
72c54edce2 feat(k3s): enable swap and grow the rancher LV during host-prep
Some checks failed
CI/CD / lint (pull_request) Failing after 10s
CI/CD / test (pull_request) Failing after 10s
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
Replace the CIS-style disableSwap op with enableSwap: activate the
labvg-swap LV with an fstab entry (kubelet runs failSwapOn=false; zram
stays the fast tier, the LV is overflow before OOM kill). Add
growRancherLv: extend labvg/rancher to 120G when the VG has free space,
covering nodes installed before the kickstart sizing change and vanilla
nodes converted to k8s; skips with a clear message when the VG is full.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f6jyeeDqP4ufyeL3UER9w
2026-08-14 23:22:14 +01:00
Michal
33be713d0c feat(bastion): size the rancher LV at 120G for k8s roles in kickstart
The 20G /var/lib/rancher LV (k3s imageFs) idled at 85% used from
steady-state images alone; one ~5G image pull tripped imagefs eviction
and evicted unrelated pods (2026-08-14 DiskPressure incident). Create
the LV for both worker and infra roles at 120G — it must be sized here
because longhorn's --grow consumes all remaining VG space, making
post-install lvextend impossible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f6jyeeDqP4ufyeL3UER9w
2026-08-14 23:22:14 +01:00
8 changed files with 186 additions and 41 deletions

View File

@@ -40,6 +40,11 @@ export function renderInstallKickstart(params: InstallKickstartParams): string {
const now = new Date().toISOString(); const now = new Date().toISOString();
const hasLonghorn = role === "worker"; const hasLonghorn = role === "worker";
const hasRancher = role === "infra"; const hasRancher = role === "infra";
// k8s roles get a dedicated 120G image-store LV. 2026-08 incident: the old
// 20G LV idled at 85% used, so a single ~5G image pull tripped imagefs
// eviction. Must be sized here — longhorn's --grow consumes all remaining
// VG space, making post-install lvextend impossible on worker nodes.
const hasRancherLv = role === "infra" || role === "worker";
const isVanilla = role === "vanilla"; const isVanilla = role === "vanilla";
// -- Auth section -- // -- Auth section --
@@ -113,9 +118,9 @@ done
? `logvol /var/lib/longhorn --vgname=${vg} --name=longhorn --fstype=xfs --grow --size=1` ? `logvol /var/lib/longhorn --vgname=${vg} --name=longhorn --fstype=xfs --grow --size=1`
: ""; : "";
// -- Rancher LV for fresh install (infra role) -- // -- Rancher LV for fresh install (k8s roles: worker + infra) --
const rancherFreshLine = hasRancher const rancherFreshLine = hasRancherLv
? `logvol /var/lib/rancher --vgname=${vg} --name=rancher --fstype=xfs --size=20480` ? `logvol /var/lib/rancher --vgname=${vg} --name=rancher --fstype=xfs --size=122880`
: ""; : "";
return `# Lab Bastion -- Fedora ${fedoraVersion} server install return `# Lab Bastion -- Fedora ${fedoraVersion} server install

View File

@@ -96,9 +96,9 @@ describe("renderInstallKickstart", () => {
expect(ks).toContain("/api/progress"); expect(ks).toContain("/api/progress");
}); });
it("infra role has /var/lib/rancher partition", () => { it("infra role has 120G /var/lib/rancher partition", () => {
const ks = renderInstallKickstart(baseParams({ role: "infra" })); const ks = renderInstallKickstart(baseParams({ role: "infra" }));
expect(ks).toContain("logvol /var/lib/rancher --vgname=labvg --name=rancher --fstype=xfs --size=20480"); expect(ks).toContain("logvol /var/lib/rancher --vgname=labvg --name=rancher --fstype=xfs --size=122880");
}); });
it("infra role has k3s install", () => { it("infra role has k3s install", () => {
@@ -106,10 +106,14 @@ describe("renderInstallKickstart", () => {
expect(ks).toContain("curl -sfL https://get.k3s.io | INSTALL_K3S_SKIP_START=true sh -"); expect(ks).toContain("curl -sfL https://get.k3s.io | INSTALL_K3S_SKIP_START=true sh -");
}); });
it("worker role does NOT have /var/lib/rancher partition in fresh install", () => { it("worker role has 120G /var/lib/rancher partition (imageFs must be sized before longhorn --grow)", () => {
const ks = renderInstallKickstart(baseParams({ role: "worker" })); const ks = renderInstallKickstart(baseParams({ role: "worker" }));
// Worker should not have the fresh-install rancher partition line expect(ks).toContain("logvol /var/lib/rancher --vgname=labvg --name=rancher --fstype=xfs --size=122880");
expect(ks).not.toContain("logvol /var/lib/rancher --vgname=labvg --name=rancher --fstype=xfs --size=20480"); });
it("vanilla role does NOT have /var/lib/rancher partition in fresh install", () => {
const ks = renderInstallKickstart(baseParams({ role: "vanilla" }));
expect(ks).not.toContain("--name=rancher --fstype=xfs");
}); });
it("worker role does NOT have k3s install", () => { it("worker role does NOT have k3s install", () => {

View File

@@ -1,21 +1,23 @@
// Host preparation: kernel modules, sysctl, swap, firewall, SELinux. // Host preparation: kernel modules, sysctl, swap, storage, firewall, SELinux.
import type { OperationContext, OperationResult, OperationGroup } from "../types.js"; import type { OperationContext, OperationResult, OperationGroup } from "../types.js";
import { runSequential } from "../utils.js"; import { runSequential } from "../utils.js";
import { loadKernelModules } from "../operations/kernel-modules.js"; import { loadKernelModules } from "../operations/kernel-modules.js";
import { applyCisHardening } from "../operations/sysctl.js"; import { applyCisHardening } from "../operations/sysctl.js";
import { disableSwap } from "../operations/swap.js"; import { enableSwap } from "../operations/swap.js";
import { growRancherLv } from "../operations/rancher-storage.js";
import { disableFirewall } from "../operations/firewall.js"; import { disableFirewall } from "../operations/firewall.js";
import { setSelinuxPermissive } from "../operations/selinux.js"; import { setSelinuxPermissive } from "../operations/selinux.js";
import { enableIscsi } from "../operations/iscsi.js"; import { enableIscsi } from "../operations/iscsi.js";
export const hostPrepGroup: OperationGroup = { export const hostPrepGroup: OperationGroup = {
name: "host-prep", name: "host-prep",
description: "Prepare host for k3s: kernel modules, sysctl, swap, firewall, SELinux, iSCSI", description: "Prepare host for k3s: kernel modules, sysctl, swap, imageFs sizing, firewall, SELinux, iSCSI",
operations: [ operations: [
{ name: "Load kernel modules", fn: loadKernelModules }, { name: "Load kernel modules", fn: loadKernelModules },
{ name: "Apply CIS sysctl", fn: applyCisHardening }, { name: "Apply CIS sysctl", fn: applyCisHardening },
{ name: "Disable swap", fn: disableSwap }, { name: "Enable swap", fn: enableSwap },
{ name: "Grow rancher LV", fn: growRancherLv },
{ name: "Disable firewall", fn: disableFirewall }, { name: "Disable firewall", fn: disableFirewall },
{ name: "Set SELinux permissive", fn: setSelinuxPermissive }, { name: "Set SELinux permissive", fn: setSelinuxPermissive },
{ name: "Enable iSCSI", fn: enableIscsi }, { name: "Enable iSCSI", fn: enableIscsi },

View File

@@ -1,6 +1,7 @@
export { loadKernelModules } from "./kernel-modules.js"; export { loadKernelModules } from "./kernel-modules.js";
export { applyCisHardening } from "./sysctl.js"; export { applyCisHardening } from "./sysctl.js";
export { disableSwap } from "./swap.js"; export { enableSwap } from "./swap.js";
export { growRancherLv } from "./rancher-storage.js";
export { enableIscsi } from "./iscsi.js"; export { enableIscsi } from "./iscsi.js";
export { disableFirewall } from "./firewall.js"; export { disableFirewall } from "./firewall.js";
export { setSelinuxPermissive } from "./selinux.js"; export { setSelinuxPermissive } from "./selinux.js";

View File

@@ -0,0 +1,48 @@
// Grow the labvg/rancher LV (k3s image store / imageFs) to 120G.
// 2026-08 incident: the original 20G LV sat at 85% used from steady-state
// images alone, so one ~5G image pull tripped imagefs eviction and evicted
// unrelated pods. Fresh installs are sized at 120G by the kickstart; this op
// covers nodes installed before that change and vanilla nodes converted to
// k8s later. Never removes or shrinks anything — if the VG lacks free space
// (e.g. a longhorn --grow LV consumed it), it reports and moves on.
import type { Operation, OperationResult } from "../types.js";
import { sshOpts } from "../utils.js";
const RANCHER_LV = "labvg/rancher";
const TARGET_MIB = 122880; // 120G
export const growRancherLv: Operation = async (ctx): Promise<OperationResult> => {
const lv = await ctx.ssh.exec(
`lvs --noheadings --units m --nosuffix -o lv_size ${RANCHER_LV} 2>/dev/null || true`,
sshOpts(ctx),
);
const sizeMib = Number.parseFloat(lv.stdout.trim());
if (Number.isNaN(sizeMib)) {
return { success: true, changed: false, message: "No labvg/rancher LV — imageFs shares /var, skipping" };
}
if (sizeMib >= TARGET_MIB) {
return { success: true, changed: false, message: `rancher LV already ${Math.round(sizeMib / 1024)}G` };
}
const vg = await ctx.ssh.exec(`vgs --noheadings --units m --nosuffix -o vg_free labvg`, sshOpts(ctx));
const freeMib = Number.parseFloat(vg.stdout.trim());
const neededMib = TARGET_MIB - sizeMib;
if (Number.isNaN(freeMib) || freeMib < neededMib) {
return {
success: true,
changed: false,
message: `VG labvg has ${Math.floor((Number.isNaN(freeMib) ? 0 : freeMib) / 1024)}G free — ` +
`need ${Math.ceil(neededMib / 1024)}G to grow rancher LV to 120G (manual LV rebuild required)`,
};
}
await ctx.ssh.exec(`lvextend -L ${TARGET_MIB}m /dev/${RANCHER_LV}`, sshOpts(ctx));
await ctx.ssh.exec(`xfs_growfs /var/lib/rancher`, sshOpts(ctx));
return {
success: true,
changed: true,
message: `rancher LV grown ${Math.round(sizeMib / 1024)}G → 120G`,
};
};

View File

@@ -1,22 +1,40 @@
// Disable swap (CIS requirement for k3s). // Enable swap so memory pressure spills to disk instead of OOM-killing.
// kubelet runs with failSwapOn=false (k3s default); zram stays the fast tier,
// the labvg-swap LV is the overflow tier. Replaces the old CIS-style
// disableSwap op — a kernel OOM kill of a node daemon is worse than slow swap.
import type { Operation, OperationResult } from "../types.js"; import type { Operation, OperationResult } from "../types.js";
import { sshOpts } from "../utils.js"; import { sshOpts } from "../utils.js";
export const disableSwap: Operation = async (ctx): Promise<OperationResult> => { const SWAP_DEV = "/dev/mapper/labvg-swap";
const check = await ctx.ssh.exec("swapon --show --noheadings", sshOpts(ctx));
const active = check.stdout.trim().length > 0;
if (active) { export const enableSwap: Operation = async (ctx): Promise<OperationResult> => {
await ctx.ssh.exec("swapoff -a", sshOpts(ctx)); const lv = await ctx.ssh.exec(`test -b ${SWAP_DEV} && echo yes || echo no`, sshOpts(ctx));
if (lv.stdout.trim() !== "yes") {
return { success: true, changed: false, message: "No labvg-swap LV — skipping swap enable" };
} }
// Remove swap entries from fstab permanently const active = await ctx.ssh.exec(
await ctx.ssh.exec("sed -i '/\\sswap\\s/d' /etc/fstab", sshOpts(ctx)); `grep -q "^$(readlink -f ${SWAP_DEV}) " /proc/swaps && echo on || echo off`,
sshOpts(ctx),
);
const wasOff = active.stdout.trim() !== "on";
if (wasOff) {
// Format if the LV was never (or wrongly) initialised, then activate
await ctx.ssh.exec(`blkid ${SWAP_DEV} | grep -q 'TYPE="swap"' || mkswap ${SWAP_DEV}`, sshOpts(ctx));
await ctx.ssh.exec(`swapon ${SWAP_DEV}`, sshOpts(ctx));
}
// Persist across reboots (idempotent)
await ctx.ssh.exec(
`grep -q "labvg-swap" /etc/fstab || echo "${SWAP_DEV} none swap defaults 0 0" >> /etc/fstab`,
sshOpts(ctx),
);
return { return {
success: true, success: true,
changed: active, changed: wasOff,
message: active ? "Swap disabled" : "Swap already disabled", message: wasOff ? "LV swap enabled" : "LV swap already active",
}; };
}; };

View File

@@ -72,31 +72,97 @@ describe("applyCisHardening", () => {
// --- Swap --- // --- Swap ---
import { disableSwap } from "../src/operations/swap.js"; import { enableSwap } from "../src/operations/swap.js";
describe("disableSwap", () => { describe("enableSwap", () => {
it("disables active swap", async () => { it("activates LV swap when present but off", async () => {
const ctx = mockCtx(); const ctx = mockCtx();
ctx.ssh.exec ctx.ssh.exec
.mockResolvedValueOnce(stdout("/dev/sda2 partition 2G")) // swap active .mockResolvedValueOnce(stdout("yes")) // LV exists
.mockResolvedValueOnce(OK) // swapoff .mockResolvedValueOnce(stdout("off")) // not in /proc/swaps
.mockResolvedValueOnce(OK); // sed fstab .mockResolvedValueOnce(OK) // blkid || mkswap
.mockResolvedValueOnce(OK) // swapon
.mockResolvedValueOnce(OK); // fstab entry
const result = await disableSwap(ctx); const result = await enableSwap(ctx);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.changed).toBe(true); expect(result.changed).toBe(true);
expectCommand(ctx.ssh, "swapoff -a"); expectCommand(ctx.ssh, "swapon /dev/mapper/labvg-swap");
}); });
it("is idempotent when swap already off", async () => { it("is idempotent when LV swap already active", async () => {
const ctx = mockCtx(); const ctx = mockCtx();
ctx.ssh.exec ctx.ssh.exec
.mockResolvedValueOnce(stdout("")) // no swap .mockResolvedValueOnce(stdout("yes")) // LV exists
.mockResolvedValueOnce(OK); // sed fstab (always runs) .mockResolvedValueOnce(stdout("on")) // already in /proc/swaps
.mockResolvedValueOnce(OK); // fstab entry (always ensured)
const result = await disableSwap(ctx); const result = await enableSwap(ctx);
expect(result.changed).toBe(false); expect(result.changed).toBe(false);
expectNoCommand(ctx.ssh, "swapoff"); expectNoCommand(ctx.ssh, "swapon /dev/mapper/labvg-swap");
});
it("skips when no labvg-swap LV exists", async () => {
const ctx = mockCtx();
ctx.ssh.exec.mockResolvedValueOnce(stdout("no")); // LV missing
const result = await enableSwap(ctx);
expect(result.success).toBe(true);
expect(result.changed).toBe(false);
expectNoCommand(ctx.ssh, "swapon");
});
});
// --- Rancher LV (imageFs sizing) ---
import { growRancherLv } from "../src/operations/rancher-storage.js";
describe("growRancherLv", () => {
it("grows a 20G LV to 120G when the VG has space", async () => {
const ctx = mockCtx();
ctx.ssh.exec
.mockResolvedValueOnce(stdout(" 20480.00")) // lv_size
.mockResolvedValueOnce(stdout(" 747807.00")) // vg_free
.mockResolvedValueOnce(OK) // lvextend
.mockResolvedValueOnce(OK); // xfs_growfs
const result = await growRancherLv(ctx);
expect(result.success).toBe(true);
expect(result.changed).toBe(true);
expectCommand(ctx.ssh, "lvextend -L 122880m /dev/labvg/rancher");
expectCommand(ctx.ssh, "xfs_growfs /var/lib/rancher");
});
it("is idempotent when the LV is already 120G", async () => {
const ctx = mockCtx();
ctx.ssh.exec.mockResolvedValueOnce(stdout(" 122880.00")); // lv_size
const result = await growRancherLv(ctx);
expect(result.changed).toBe(false);
expectNoCommand(ctx.ssh, "lvextend");
});
it("reports without failing when the VG has no free space", async () => {
const ctx = mockCtx();
ctx.ssh.exec
.mockResolvedValueOnce(stdout(" 20480.00")) // lv_size
.mockResolvedValueOnce(stdout(" 0.00")); // vg_free
const result = await growRancherLv(ctx);
expect(result.success).toBe(true);
expect(result.changed).toBe(false);
expect(result.message).toContain("free");
expectNoCommand(ctx.ssh, "lvextend");
});
it("skips when there is no rancher LV", async () => {
const ctx = mockCtx();
ctx.ssh.exec.mockResolvedValueOnce(stdout("")); // lvs empty
const result = await growRancherLv(ctx);
expect(result.success).toBe(true);
expect(result.changed).toBe(false);
expectNoCommand(ctx.ssh, "lvextend");
}); });
}); });

View File

@@ -16,7 +16,7 @@ describe("smoke: full server install pipeline", () => {
const pipeline: NamedOperation[] = [ const pipeline: NamedOperation[] = [
{ name: "Kernel modules", fn: ops.loadKernelModules }, { name: "Kernel modules", fn: ops.loadKernelModules },
{ name: "Sysctl hardening", fn: ops.applyCisHardening }, { name: "Sysctl hardening", fn: ops.applyCisHardening },
{ name: "Disable swap", fn: ops.disableSwap }, { name: "Enable swap", fn: ops.enableSwap },
{ name: "Disable firewall", fn: ops.disableFirewall }, { name: "Disable firewall", fn: ops.disableFirewall },
{ name: "SELinux permissive", fn: ops.setSelinuxPermissive }, { name: "SELinux permissive", fn: ops.setSelinuxPermissive },
{ name: "Write k3s config", fn: ops.writeK3sConfig }, { name: "Write k3s config", fn: ops.writeK3sConfig },
@@ -73,7 +73,7 @@ describe("smoke: pipeline stops on failure", () => {
}; };
const results = await runSequential(ctx, [ const results = await runSequential(ctx, [
{ name: "OK op", fn: ops.disableSwap }, { name: "OK op", fn: ops.enableSwap },
{ name: "Failing op", fn: failingOp }, { name: "Failing op", fn: failingOp },
{ name: "Never called", fn: neverCalled }, { name: "Never called", fn: neverCalled },
]); ]);
@@ -98,11 +98,12 @@ describe("smoke: agent install rejects missing config", () => {
}); });
describe("smoke: all operations are exported", () => { describe("smoke: all operations are exported", () => {
it("exports all 15 operations", () => { it("exports all 16 operations", () => {
const exported = [ const exported = [
ops.loadKernelModules, ops.loadKernelModules,
ops.applyCisHardening, ops.applyCisHardening,
ops.disableSwap, ops.enableSwap,
ops.growRancherLv,
ops.disableFirewall, ops.disableFirewall,
ops.setSelinuxPermissive, ops.setSelinuxPermissive,
ops.writeK3sConfig, ops.writeK3sConfig,
@@ -117,7 +118,7 @@ describe("smoke: all operations are exported", () => {
ops.checkCertExpiry, ops.checkCertExpiry,
]; ];
expect(exported).toHaveLength(15); expect(exported).toHaveLength(16);
for (const op of exported) { for (const op of exported) {
expect(typeof op).toBe("function"); expect(typeof op).toBe("function");
} }