labctl: refuse to write a k3s config naming an IPv6 the node does not have
Some checks failed
Some checks failed
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:
@@ -109,6 +109,36 @@ kubelet-arg:
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
const content = isServerRole(ctx.config.role)
|
||||
|
||||
@@ -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<typeof mockCtx>[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.
|
||||
|
||||
Reference in New Issue
Block a user