labctl: refuse to write a k3s config naming an IPv6 the node does not have
Some checks failed
CI/CD / lint (push) Failing after 9s
CI/CD / test (push) Failing after 9s
CI/CD / typecheck (push) Failing after 26s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped

Most of this estate is SSH-onboard, not PXE-provisioned, and that changes where
the dual-stack guarantee has to come from. os-install-research.md classifies the
two paths: Asahi cannot PXE at all, and the DGX Sparks (spark-2935 and
aitopatom-3a1c, both NVIDIA_DGX_Spark on NVIDIA's own OS) must never be
reinstalled -- see project_dgx_spark_kernel_recovery. Of the five nodes, at most
worker0 and worker2 ever run our install templates.

So for the majority the templates govern nothing, this module is the only
labctl touchpoint, and nothing upstream can promise the vendor OS took its
DHCPv6 lease. Writing node-ip for a missing address does not fail here; it fails
later when k3s will not start, and it reads as a bind error rather than as a
missing address. One ssh round trip turns that into a sentence naming the
address and telling you to check the kea reservation.

This is what makes "out of the box" true for heterogeneous hardware rather than
just for the nodes we image ourselves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMVzWZgiKW2wquf5z8S1yH
This commit is contained in:
Michal
2026-09-06 17:00:30 +01:00
parent a9ff182bd6
commit 914135c47d
2 changed files with 52 additions and 1 deletions

View File

@@ -109,6 +109,36 @@ kubelet-arg:
} }
export const writeK3sConfig: Operation = async (ctx): Promise<OperationResult> => { export const writeK3sConfig: Operation = async (ctx): Promise<OperationResult> => {
// 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)); await ctx.ssh.exec("mkdir -p /etc/rancher/k3s", sshOpts(ctx));
const content = isServerRole(ctx.config.role) const content = isServerRole(ctx.config.role)

View File

@@ -276,14 +276,19 @@ describe("writeK3sConfig", () => {
// already have on disk. If it is not, rolling this out rewrites every node's // 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. // 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<typeof mockCtx>[0]) => { const writtenBy = async (config: Parameters<typeof mockCtx>[0]) => {
const ctx = mockCtx(config); const ctx = mockCtx(config);
const hasV6 = !!(config as { ipv6?: string }).ipv6;
if (hasV6) ctx.ssh.exec.mockResolvedValueOnce(stdout("present"));
ctx.ssh.exec ctx.ssh.exec
.mockResolvedValueOnce(OK) .mockResolvedValueOnce(OK)
.mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__")) .mockResolvedValueOnce(stdout("__LABCTL_NOT_FOUND__"))
.mockResolvedValueOnce(OK); .mockResolvedValueOnce(OK);
await writeK3sConfig(ctx); 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 () => { it("emits no address-family lines at all when single-stack", async () => {
@@ -336,6 +341,22 @@ describe("writeK3sConfig", () => {
expect(out).not.toContain("service-cidr"); 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 () => { 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 // Guards the flag-day risk: supplying ranges alone must not start rewriting
// node identity on nodes that have no IPv6 yet. // node identity on nodes that have no IPv6 yet.