Compare commits
2 Commits
a5b36678ed
...
feat/vyos-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c54edce2 | ||
|
|
33be713d0c |
@@ -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
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user