diff --git a/bastion/src/modules/modules/k3s/src/operations/k3s-config.ts b/bastion/src/modules/modules/k3s/src/operations/k3s-config.ts index 3c5d3be..d5392b6 100644 --- a/bastion/src/modules/modules/k3s/src/operations/k3s-config.ts +++ b/bastion/src/modules/modules/k3s/src/operations/k3s-config.ts @@ -109,6 +109,36 @@ kubelet-arg: } export const writeK3sConfig: Operation = async (ctx): Promise => { + // Refuse to name an address the node does not have. + // + // Most of this estate is SSH-onboard, not PXE-provisioned: Asahi cannot PXE + // at all, and the DGX Sparks run NVIDIA's own OS and must never be + // reinstalled. For those nodes the install templates govern nothing and this + // module is the ONLY labctl touchpoint, so nothing upstream can guarantee the + // vendor OS actually took a DHCPv6 lease. + // + // Writing node-ip for a missing address does not fail here -- it fails later, + // when k3s will not start, with an error about binding rather than about + // addressing. Checking costs one ssh round trip and turns a confusing + // start-up failure into a sentence naming the address and the node. + if (ctx.config.ipv6) { + const probe = await ctx.ssh.exec( + `ip -6 -o addr show 2>/dev/null | grep -qF " ${ctx.config.ipv6}/" && echo present || true`, + sshOpts(ctx), + ); + if (!probe.stdout.includes("present")) { + return { + success: false, + changed: false, + message: `Node does not have IPv6 ${ctx.config.ipv6}`, + error: + `k3s config would set node-ip to ${ctx.config.ipv6}, but that address is not on any ` + + `interface. k3s resolves node-ip at start-up and would fail to bind. Check the node ` + + `took its DHCPv6 lease (a kea reservation keyed on its MAC) before retrying.`, + }; + } + } + await ctx.ssh.exec("mkdir -p /etc/rancher/k3s", sshOpts(ctx)); const content = isServerRole(ctx.config.role) diff --git a/bastion/src/modules/modules/k3s/tests/operations.test.ts b/bastion/src/modules/modules/k3s/tests/operations.test.ts index c5ea639..9c3ceba 100644 --- a/bastion/src/modules/modules/k3s/tests/operations.test.ts +++ b/bastion/src/modules/modules/k3s/tests/operations.test.ts @@ -276,14 +276,19 @@ describe("writeK3sConfig", () => { // already have on disk. If it is not, rolling this out rewrites every node's // config.yaml and restarts a healthy cluster to tell it what it already knew. + // With an ipv6 configured there is an extra ssh round trip up front -- the + // probe that checks the address is really on the node -- so the write lands + // one call later. const writtenBy = async (config: Parameters[0]) => { const ctx = mockCtx(config); + const hasV6 = !!(config as { ipv6?: string }).ipv6; + if (hasV6) ctx.ssh.exec.mockResolvedValueOnce(stdout("present")); ctx.ssh.exec .mockResolvedValueOnce(OK) .mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__")) .mockResolvedValueOnce(OK); await writeK3sConfig(ctx); - return ctx.ssh.exec.mock.calls[2]![0] as string; + return ctx.ssh.exec.mock.calls[hasV6 ? 3 : 2]![0] as string; }; it("emits no address-family lines at all when single-stack", async () => { @@ -336,6 +341,22 @@ describe("writeK3sConfig", () => { expect(out).not.toContain("service-cidr"); }); + it("refuses to write a config naming an IPv6 the node does not have", async () => { + // The SSH-onboard case: Asahi and the DGX Sparks run an OS labctl never + // installed, so nothing upstream guarantees a DHCPv6 lease was taken. + // Writing the config anyway defers the failure to k3s start-up, where it + // reads as a bind error rather than a missing address. + const ctx = mockCtx({ ip: "192.168.8.12", role: "worker", ipv6: "2001:470:187e:2::12" }); + ctx.ssh.exec.mockResolvedValueOnce(stdout("")); // probe: address absent + + const result = await writeK3sConfig(ctx); + expect(result.success).toBe(false); + expect(result.changed).toBe(false); + expect(result.error).toContain("2001:470:187e:2::12"); + // and it must not have written anything + expect(ctx.ssh.exec).toHaveBeenCalledTimes(1); + }); + it("does not write node-ip for a v4-only node even when CIDRs are given", async () => { // Guards the flag-day risk: supplying ranges alone must not start rewriting // node identity on nodes that have no IPv6 yet.