Files
lab/bastion/tests/integration/partition-layout.test.ts

146 lines
6.1 KiB
TypeScript
Raw Normal View History

v2 partition schema: capability model, fixed sizing, and a proven rolling migration The fleet's disk layout was decided by literal role-name comparisons at 17 sites across 8 files. That is why `labcontroller` got no rancher LV (it matched neither "infra" nor "worker"), why labmaster carried an empty 728 GiB Longhorn LV, and why a 62 GiB container image tipped that node into DiskPressure and evicted bastion, labd, cockroach and openbao. Three generators each hardcoded their own sizes and disagreed: the kickstart said rancher=120G, ubuntu-autoinstall said 20G, asahi-firstboot said 20G under a comment asserting the sizes matched install.ks.ts. They did not. ROLE_REGISTRY already carried `k3s` and `parent` and nothing read them. This makes capabilities authoritative, moves every size into one shared module, and adds schema v2 (fixed 250G rancher / 300G longhorn, never --grow). Existing machines stay v1 forever: schema is detected from the on-disk VG tag, so a v1 box stays v1 even when v2 is requested. Proven on real VMs, not just unit tests: all four topologies installed and had their layouts read back off the machines; markers in /home, /srv, /var/lib/longhorn and /var/lib/rancher survived reinstall; and a 4-node cluster formed, took real data, and had all three servers rolled one at a time through evacuate / wipe / reinstall / rejoin with checksums verified before and after each roll. Drill: 10/10, evidence in migration/drill-evidence/. Production defects the drill found, none of which a unit test could reach: - sshOpts() capped every operation at 30s while drainNode asked kubectl for --timeout=15m, so the transport killed the drain after evicting most pods and returned a transcript that read like success. `labctl app k3s evacuate` could never have drained a real node. - generateAgentConfig put node-role.kubernetes.io/worker in the kubelet's node-label list, which NodeRestriction rejects, so no agent installed through K3sModule could start. systemd reported only "did not take the steps required by its unit configuration". - toOpContext built K3sConfig by enumerating three keys and silently dropped the rest, so the k3s version pin did nothing and --allow-single-replica was a no-op that claimed to override a data-loss guard. - The Longhorn disk annotation was mangled by shell quoting; with createDefaultDiskLabeledNodes the node then gets no disk at all, joins, goes Ready, and silently hosts no replica. - evacuate drained before evicting, which blocks on Longhorn's PDBs and would destroy a single-replica volume's only source. - etcd member ids were parsed from JSON, losing uint64 precision and emitting decimal where `member remove` needs hex -- at the moment a node is stopped and about to be wiped. - get.k3s.io installs latest, two minor versions ahead of the fleet, so a reinstalled node would rejoin on a newer k3s than its peers and roll an unplanned two-version control-plane upgrade. K3sConfig.k3sVersion now pins it. - `labctl app k3s install --role x` mapped anything that was not "worker" to "infra", so the runbook's own `--role k8s-combo` would have installed a different role with different disk expectations and no warning. Also: /var/lib/longhorn/replicas must never be deleted, only its contents, or every replica placed on the node dies on a non-recursive mkdir -- for ever, while the node reports Ready, Schedulable and healthy. etcd is now in the kickstart's package set for k3s server roles, because `k3s etcdctl` is not a command and a member that needs to recover its own membership has no internet. 542 unit tests / 39 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 22:33:59 +01:00
// Integration tests: the v2 disk layout, on real VMs.
//
// Unit tests prove the kickstart RENDERS the right layout. These prove anaconda
// ACTUALLY PRODUCES it -- which is a different claim, and the one that matters:
// the labmaster incident was caused by what landed on disk, not by what was
// written into a template.
//
// Every VM gets a 1 TB sparse disk, because "fits a 1 TB disk with headroom" is
// the constraint v2 was designed around. qcow2 is sparse, so a 1 TB disk costs
// ~10-15 GB of real space.
//
// Prerequisites: libvirtd, OVMF, ipxe-bootimgs-x86, sudo, and a Fedora mirror.
// Set FEDORA_MIRROR to a local mirror -- otherwise each install pulls the full
// package set over the internet and the matrix takes hours.
//
// Run one scenario at a time:
// sudo -E npx vitest run -c tests/integration/vitest.config.ts -t "vanilla"
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { v2Layout } from "../../src/shared/src/types/layout.js";
import type { Role } from "../../src/shared/src/types/state.js";
import {
startTestBastion, provisionVm, readLvs, readSchemaTag, destroyVms,
type Harness, type ProvisionedVm,
} from "./helpers/provision-harness.js";
import { destroyPxeNetwork } from "./helpers/pxe-network.js";
const HTTP_PORT = 8096;
const SSH_USER = "lab";
const DISK_GB = 1000; // the smallest disk we will ever buy
function findSshKey(): { pubKey: string; keyPath: string } {
const homes = [homedir()];
const sudoUser = process.env["SUDO_USER"];
if (sudoUser) homes.push(join("/home", sudoUser));
for (const home of homes) {
for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) {
const keyPath = join(home, ".ssh", name);
if (existsSync(keyPath) && existsSync(`${keyPath}.pub`)) {
return { pubKey: readFileSync(`${keyPath}.pub`, "utf-8").trim(), keyPath };
}
}
}
throw new Error("no SSH keypair found");
}
/** Assert the on-disk volumes match the shared v2 definition for this role. */
function expectV2Layout(h: Harness, vm: ProvisionedVm, role: Role): void {
const actual = readLvs(h, vm);
const expected = v2Layout(role);
expect(Object.keys(actual).sort(), `${vm.name} volume set`).toEqual(
expected.map((lv) => lv.name).sort(),
);
for (const spec of expected) {
// LVM rounds to extent boundaries (4 MiB), so allow a small delta.
expect(actual[spec.name], `${vm.name}/${spec.name} size`).toBeGreaterThanOrEqual(spec.sizeMib - 4);
expect(actual[spec.name], `${vm.name}/${spec.name} size`).toBeLessThanOrEqual(spec.sizeMib + 4);
}
expect(readSchemaTag(h, vm), `${vm.name} schema tag`).toContain("lab-schema-v2");
}
describe("v2 partition layout on real hardware", () => {
let h: Harness;
const created: string[] = [];
beforeAll(async () => {
const { pubKey, keyPath } = findSshKey();
h = await startTestBastion({
httpPort: HTTP_PORT, sshUser: SSH_USER, sshPubKey: pubKey,
sshKeyPath: keyPath, label: "partition",
});
}, 20 * 60_000);
afterAll(async () => {
destroyVms(...created);
try { await h?.close(); } catch { /* already closed */ }
try { destroyPxeNetwork(); } catch { /* already gone */ }
}, 5 * 60_000);
// --- Topology 1: vanilla -------------------------------------------------
it("vanilla: base volumes only, no rancher, no longhorn", async () => {
const name = "v2-vanilla";
created.push(name);
const vm = await provisionVm(h, { name, role: "vanilla", schemaVersion: 2, diskGb: DISK_GB });
expectV2Layout(h, vm, "vanilla");
const lvs = readLvs(h, vm);
expect(lvs["rancher"]).toBeUndefined();
expect(lvs["longhorn"]).toBeUndefined();
}, 60 * 60_000);
// --- Topology 2: standalone cluster --------------------------------------
it("standalone-cluster: single node with its own control plane and storage", async () => {
const name = "v2-standalone";
created.push(name);
const vm = await provisionVm(h, { name, role: "standalone-cluster", schemaVersion: 2, diskGb: DISK_GB, memory: 6144 });
expectV2Layout(h, vm, "standalone-cluster");
const lvs = readLvs(h, vm);
// A cluster of one still gets a Longhorn volume, so the `longhorn`
// StorageClass exists and manifests stay portable across clusters.
expect(lvs["longhorn"]).toBeDefined();
expect(lvs["rancher"]).toBeDefined();
}, 60 * 60_000);
// --- Topology 3: 3x combo (control plane AND worker on every node) -------
it("k8s-combo x3: every node is control plane and storage", async () => {
const names = ["v2-combo-1", "v2-combo-2", "v2-combo-3"];
created.push(...names);
const vms: ProvisionedVm[] = [];
for (const name of names) {
vms.push(await provisionVm(h, { name, role: "k8s-combo", schemaVersion: 2, diskGb: DISK_GB }));
}
for (const vm of vms) {
expectV2Layout(h, vm, "k8s-combo");
const lvs = readLvs(h, vm);
expect(lvs["longhorn"], `${vm.name} holds storage`).toBeDefined();
expect(lvs["rancher"], `${vm.name} has an image store`).toBeDefined();
}
}, 180 * 60_000);
// --- Topology 4: 3 controllers + 2 workers -------------------------------
it("3x k8s-controller + 2x k8s-worker: storage only on the workers", async () => {
const ctrls = ["v2-ctrl-1", "v2-ctrl-2", "v2-ctrl-3"];
const workers = ["v2-wrk-1", "v2-wrk-2"];
created.push(...ctrls, ...workers);
for (const name of ctrls) {
const vm = await provisionVm(h, { name, role: "k8s-controller", schemaVersion: 2, diskGb: DISK_GB });
expectV2Layout(h, vm, "k8s-controller");
// The distinguishing property of a controller: image store, no storage.
expect(readLvs(h, vm)["longhorn"], `${name} must not hold replicas`).toBeUndefined();
expect(readLvs(h, vm)["rancher"]).toBeDefined();
}
for (const name of workers) {
const vm = await provisionVm(h, { name, role: "k8s-worker", schemaVersion: 2, diskGb: DISK_GB });
expectV2Layout(h, vm, "k8s-worker");
expect(readLvs(h, vm)["longhorn"], `${name} must hold replicas`).toBeDefined();
}
}, 300 * 60_000);
});