// Form and inspect a real multi-node k3s cluster from an integration test. // // WHY THIS EXISTS // --------------- // Nothing in the repo decides which node bootstraps a cluster and which nodes // join it. The production generator has the right discriminator -- // generateServerConfig emits `cluster-init: true` when no server URL is set and // `server:`+`token:` when one is -- but the choice is an INPUT, and the only // caller that ever supplied it was a hand-run labsim shell script. // // So a test that wants three servers in one cluster has to sequence it. That // sequencing is here rather than in a test file because the rolling drill is // not the only thing that will want it: an etcd-recovery test (recoverEtcdMember // still has no caller), a dual-stack conversion and any HA upgrade test all // need exactly these primitives. // // Everything drives K3sModule, not shell. That is deliberate and is the same // rule labsim follows by shelling out to the compiled render-config binary: a // test that reimplements the install proves the test works, not the product. import { execSync } from "node:child_process"; import type { Role } from "../../../src/shared/src/types/state.js"; import { K3sModule } from "../../../src/modules/modules/k3s/src/k3s-module.js"; import type { ModuleContext } from "../../../src/modules/src/types.js"; import { log } from "./libvirt.js"; import { parseEtcdMembers, type EtcdMember } from "../../../src/modules/modules/k3s/src/operations/etcd-members.js"; import { sshExec } from "./ssh.js"; import type { Harness, ProvisionedVm } from "./provision-harness.js"; export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * A pre-shared cluster token, the way labsim does it. * * The alternative is scraping /var/lib/rancher/k3s/server/node-token off the * first server, and nothing in the repo does that. It is also the wrong shape * for a rolling migration: the first server gets wiped and rejoins, so the * token has to outlive it and be knowable before it exists. */ export const DRILL_TOKEN = "lab-drill-shared-token"; /** * k3s operations run unprefixed `mkdir -p /etc/rancher/k3s` and * `systemctl restart k3s`, so they need root. install.ks.ts provisions * `sshkey --username=root` and sets PermitRootLogin prohibit-password, so root * SSH works on every machine this harness builds. The harness's own `lab` user * stays for the sudo-based reads (readLvs, markers). */ export const MODULE_SSH_USER = "root"; /** * The k3s version the drill runs, pinned to what the fleet runs. * * get.k3s.io installs LATEST, and latest is currently two minor versions ahead * of production (v1.36.4 vs v1.34.6). Two things follow, and both matter: * * - a rehearsal on a different minor version is rehearsing a different * system, and Longhorn in particular is only qualified against a range of * Kubernetes versions; * - it is the same trap production would hit. A node reinstalled mid-migration * comes back on latest while its peers stay put, and Kubernetes supports * only ONE minor of skew between control-plane members. * * Override with LAB_K3S_VERSION to test an upgrade deliberately. */ export const PINNED_K3S_VERSION = process.env["LAB_K3S_VERSION"] ?? "v1.34.6+k3s1"; /** Build the ModuleContext K3sModule expects for one VM. */ export function moduleCtx( h: Harness, vm: ProvisionedVm, role: Role, config: Record = {}, ): ModuleContext { return { hostname: vm.name, ip: vm.ip, role, os: "fedora-43", arch: "x86_64", sshUser: MODULE_SSH_USER, sshKeyPath: h.sshKeyPath, config, }; } /** Run a command on a node as root, throwing with context on failure. */ export function nodeExec(h: Harness, vm: ProvisionedVm, cmd: string, timeoutMs = 120_000): string { const r = sshExec(vm.ip, MODULE_SSH_USER, cmd, { keyPath: h.sshKeyPath, timeout: timeoutMs }); if (r.exitCode !== 0) { throw new Error(`[${vm.name}] ${cmd} failed (${r.exitCode}): ${r.stderr.trim() || r.stdout.trim()}`); } return r.stdout; } /** Same, but returns the result instead of throwing. */ export function tryNodeExec(h: Harness, vm: ProvisionedVm, cmd: string, timeoutMs = 120_000) { return sshExec(vm.ip, MODULE_SSH_USER, cmd, { keyPath: h.sshKeyPath, timeout: timeoutMs }); } /** * The name Kubernetes knows this VM by. * * NOT the libvirt domain name. k3s registers a node under its FQDN, so a VM * called `smoke-a` appears as `smoke-a.pxe-test.local`. Passing the VM name to * `kubectl get node` silently finds nothing, and every wait built on it times * out instead of failing usefully. Read from the node itself, the same way the * production operations do (`hostname -f`), so the two can never disagree. */ const nodeNameCache = new Map(); export function k8sNodeName(h: Harness, vm: ProvisionedVm): string { const cached = nodeNameCache.get(vm.name); if (cached !== undefined) return cached; const name = nodeExec(h, vm, "hostname -f 2>/dev/null || hostname").trim(); if (name === "") throw new Error(`[${vm.name}] could not resolve node name`); nodeNameCache.set(vm.name, name); return name; } /** Forget cached names — a reinstalled machine re-registers. */ export function forgetNodeName(vm: ProvisionedVm): void { nodeNameCache.delete(vm.name); } /** kubectl against a server node. */ export function kubectlOn(h: Harness, server: ProvisionedVm, args: string, timeoutMs = 120_000): string { return nodeExec(h, server, `k3s kubectl ${args}`, timeoutMs); } export function kubectlJson(h: Harness, server: ProvisionedVm, args: string): T { return JSON.parse(kubectlOn(h, server, `${args} -o json`)) as T; } /** * Wait until a server's API answers /ping. * * Straight from labsim-k8s-etcd.sh, whose comment is the whole justification: * a joiner that starts before the first server has finished bootstrapping etcd * races it, and the failure looks like etcd instability rather than a startup * order problem. Probed from the HOST, so it does not depend on the joining * node being reachable yet. */ export async function waitForApiPing(vm: ProvisionedVm, timeoutMs = 450_000): Promise { const start = Date.now(); let lastErr = ""; while (Date.now() - start < timeoutMs) { try { const out = execSync( `curl -sk --max-time 5 https://${vm.ip}:6443/ping || true`, { encoding: "utf-8", timeout: 15_000 }, ).trim(); if (out === "pong") { log(`[${vm.name}] API answering after ${Math.round((Date.now() - start) / 1000)}s`); return; } lastErr = out; } catch (e) { lastErr = e instanceof Error ? e.message : String(e); } await sleep(5_000); } throw new Error(`[${vm.name}] API never answered /ping in ${timeoutMs / 1000}s (last: ${lastErr || "no response"})`); } /** Wait for a node to appear Ready in the cluster, as seen from `server`. */ export async function waitForNodeReady( h: Harness, server: ProvisionedVm, /** The VM to wait for; its Kubernetes name is resolved from the machine. */ target: ProvisionedVm, // Generous: a node is NotReady until its CNI is up, and on the cluster-init // server that means waiting for Cilium to install and pull its images. Five // minutes is enough on a warm host and not enough on a cold one, and the // cost of being wrong is a wasted multi-hour run. timeoutMs = 900_000, ): Promise { const nodeName = k8sNodeName(h, target); const start = Date.now(); let last = ""; while (Date.now() - start < timeoutMs) { const r = tryNodeExec(h, server, `k3s kubectl get node ${nodeName} -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'`); last = r.stdout.trim(); if (last === "True") { log(`[${nodeName}] Ready after ${Math.round((Date.now() - start) / 1000)}s`); return; } await sleep(5_000); } throw new Error(`[${nodeName}] never became Ready in ${timeoutMs / 1000}s (last status: ${last || "absent"})`); } /** * etcdctl invocation. * * NOT `k3s etcdctl` -- that is not a subcommand ("No help topic for 'etcdctl'", * verified on a live v1.34 node). The binary comes from the `etcd` rpm, which * our kickstart does NOT install, so ensureEtcdctl() has to put it there. * Cert paths verified against a real node. */ const ETCD_BASE = "/var/lib/rancher/k3s/server/tls/etcd"; const ETCDCTL = `ETCDCTL_API=3 etcdctl ` + `--cacert=${ETCD_BASE}/server-ca.crt ` + `--cert=${ETCD_BASE}/server-client.crt ` + `--key=${ETCD_BASE}/server-client.key ` + `--endpoints=https://127.0.0.1:2379`; /** Install etcdctl if the node does not have it. Idempotent, ~30s once. */ export function ensureEtcdctl(h: Harness, server: ProvisionedVm): void { const has = tryNodeExec(h, server, "command -v etcdctl"); if (has.exitCode === 0) return; log(`[${server.name}] installing etcdctl (the etcd rpm; kickstart does not include it)`); nodeExec(h, server, "dnf install -y etcd >/dev/null 2>&1 || dnf install -y etcd-ctl >/dev/null 2>&1", 300_000); nodeExec(h, server, "command -v etcdctl"); } /** * etcd membership, read with etcdctl against k3s's own certs. * * Read rather than inferred from `kubectl get nodes`: a node object can be * deleted while its etcd member survives, and that stale member is exactly the * failure a rolling reinstall creates. Counting nodes would not see it. */ export function etcdMembers(h: Harness, server: ProvisionedVm): EtcdMember[] { // NOT `-w json`. Two reasons, and the second is the dangerous one: // // 1. `member remove` requires the ID in HEX; the JSON writer emits decimal. // 2. etcd member IDs are uint64. JSON.parse turns them into JS numbers, // which silently lose precision above 2^53 -- the ID that comes back is // an APPROXIMATION of the real one. Passing that to `member remove` // fails with "value out of range" if you are lucky, and in principle // could match some other member if you are not. This happens at the one // moment it must not: the node is already stopped and about to be wiped. // // The default writer prints the ID as hex text, so it never becomes a // number at all: // 825852d3683bc406, started, worker2-...-5f7ad0c9, https://...:2380, ... return parseEtcdMembers(nodeExec(h, server, `${ETCDCTL} member list`)); } /** Are all etcd endpoints healthy? Answers the question quorum actually asks. */ export function etcdHealthy(h: Harness, server: ProvisionedVm): boolean { const r = tryNodeExec(h, server, `${ETCDCTL} endpoint health`); return r.exitCode === 0 && r.stdout.includes("is healthy"); } /** * Remove an etcd member by node name. * * Must happen BEFORE the node is wiped. k3s-install.ts removes the local * datastore when a server joins, so a reinstalled node rejoins as a NEW member * while the old record persists: 3 members becomes 4, quorum rises from 2 to 3, * and the cluster that tolerated one failure now tolerates none. */ export function removeEtcdMember(h: Harness, peer: ProvisionedVm, nodeName: string): boolean { const members = etcdMembers(h, peer); const target = members.find((m) => m.node === nodeName || m.name === nodeName); if (!target) { log(`[etcd] no member for ${nodeName} (have: ${members.map((m) => m.node).join(",")})`); return false; } nodeExec(h, peer, `${ETCDCTL} member remove ${target.id}`); log(`[etcd] removed member ${target.name} (${target.id})`); return true; } export interface ClusterSnapshot { nodes: { name: string; ready: boolean; roles: string }[]; readyCount: number; etcdMembers: number; etcdHealthy: boolean; } /** One read of cluster state, in the shape labsim's cmd_status prints. */ export function clusterSnapshot(h: Harness, server: ProvisionedVm): ClusterSnapshot { const raw = kubectlJson<{ items: { metadata: { name: string; labels?: Record }; status: { conditions?: { type: string; status: string }[] }; }[] }>(h, server, "get nodes"); const nodes = raw.items.map((n) => ({ name: n.metadata.name, ready: (n.status.conditions ?? []).some((c) => c.type === "Ready" && c.status === "True"), roles: Object.keys(n.metadata.labels ?? {}) .filter((k) => k.startsWith("node-role.kubernetes.io/")) .map((k) => k.replace("node-role.kubernetes.io/", "")) .sort().join(",") || "", })); let members = 0; let healthy = false; try { members = etcdMembers(h, server).length; healthy = etcdHealthy(h, server); } catch { /* an agent, or the API is down — report zero rather than throw */ } return { nodes, readyCount: nodes.filter((n) => n.ready).length, etcdMembers: members, etcdHealthy: healthy }; } /** * Print a snapshot. Called liberally during a drill: the VMs are destroyed at * the end, so this transcript is the only surviving record of what happened. */ export function printSnapshot(label: string, s: ClusterSnapshot): void { log(`[snapshot] ${label}: ${s.readyCount}/${s.nodes.length} Ready, ` + `etcd members=${s.etcdMembers} healthy=${s.etcdHealthy}`); for (const n of s.nodes) log(` ${n.ready ? "Ready " : "NotReady"} ${n.name} (${n.roles})`); } /** * Install k3s on one node via the production module. * * Pass no serverUrl to bootstrap the cluster (the token still seeds), or a * serverUrl to join an existing one. */ export async function installK3sNode(h: Harness, vm: ProvisionedVm, opts: { role: Role; serverUrl?: string; token?: string; }): Promise { const mod = new K3sModule(); const config: Record = { k3sToken: opts.token ?? DRILL_TOKEN, k3sVersion: PINNED_K3S_VERSION, ...(opts.serverUrl === undefined ? {} : { k3sServerUrl: opts.serverUrl }), }; log(`[${vm.name}] installing k3s (${opts.role}${opts.serverUrl ? ` -> ${opts.serverUrl}` : " — cluster-init"})`); const install = await mod.install(moduleCtx(h, vm, opts.role, config)); for (const line of install.output) log(` [${vm.name}] ${line}`); if (!install.success) { throw new Error(`[${vm.name}] k3s install failed: ${install.errors.join("; ")}`); } const configure = await mod.configure(moduleCtx(h, vm, opts.role, config)); for (const line of configure.output) log(` [${vm.name}] ${line}`); if (!configure.success) { throw new Error(`[${vm.name}] k3s configure failed: ${configure.errors.join("; ")}`); } } export function serverUrlOf(vm: ProvisionedVm): string { return `https://${vm.ip}:6443`; } /** * Form a cluster from a list of server VMs, strictly in order. * * The first bootstraps etcd and installs the CNI (k3s-module runs networking * only on the cluster-init node -- joiners get Cilium via its DaemonSet). The * rest join one at a time, each gated on the previous node being Ready. * * Serial on purpose. labsim gets away with starting joiners together because * its nodes are cloud-init VMs that poll /ping themselves; here the few extra * minutes buy determinism in the one place where etcd bootstrap genuinely * races. */ export async function formCluster(h: Harness, servers: ProvisionedVm[], opts: { role?: Role; token?: string; } = {}): Promise { if (servers.length === 0) throw new Error("formCluster: no servers"); const role = opts.role ?? "k8s-combo"; const token = opts.token ?? DRILL_TOKEN; const first = servers[0]!; await installK3sNode(h, first, { role, token }); await waitForApiPing(first); await waitForNodeReady(h, first, first); ensureEtcdctl(h, first); printSnapshot("after cluster-init", clusterSnapshot(h, first)); for (const vm of servers.slice(1)) { await waitForApiPing(first); await installK3sNode(h, vm, { role, token, serverUrl: serverUrlOf(first) }); await waitForNodeReady(h, first, vm); ensureEtcdctl(h, vm); printSnapshot(`after joining ${vm.name}`, clusterSnapshot(h, first)); } } /** Join an agent. Agents cannot start without a server URL and token. */ export async function joinAgent(h: Harness, vm: ProvisionedVm, opts: { server: ProvisionedVm; role?: Role; token?: string; }): Promise { await waitForApiPing(opts.server); await installK3sNode(h, vm, { role: opts.role ?? "k8s-worker", token: opts.token ?? DRILL_TOKEN, serverUrl: serverUrlOf(opts.server), }); await waitForNodeReady(h, opts.server, vm); } /** * A healthy server that is NOT the node being worked on. * * Needed because the first server is itself rolled during a migration: after * it is wiped it must rejoin against a survivor, so "the first node" cannot be * the join target and cannot be where cluster state is read from. */ export function pickJoinTarget( h: Harness, servers: ProvisionedVm[], exclude: ProvisionedVm, ): ProvisionedVm { for (const vm of servers) { if (vm.name === exclude.name) continue; const r = tryNodeExec(h, vm, "k3s kubectl get --raw /readyz"); if (r.exitCode === 0 && r.stdout.trim() === "ok") return vm; } throw new Error(`No healthy server other than ${exclude.name} — cluster has no quorum to join against`); } /** Every server reports /readyz ok. Asserted positively, not inferred. */ export function allServersReady(h: Harness, servers: ProvisionedVm[]): { ok: boolean; detail: string } { const results = servers.map((vm) => { const r = tryNodeExec(h, vm, "k3s kubectl get --raw /readyz"); return `${vm.name}=${r.exitCode === 0 ? r.stdout.trim() : `ERR(${r.exitCode})`}`; }); return { ok: results.every((r) => r.endsWith("=ok")), detail: results.join(" ") }; }