diff --git a/bastion/src/bastion/src/templates/install.ks.ts b/bastion/src/bastion/src/templates/install.ks.ts index 87229cf..e493320 100644 --- a/bastion/src/bastion/src/templates/install.ks.ts +++ b/bastion/src/bastion/src/templates/install.ks.ts @@ -134,7 +134,13 @@ lang ${locale} keyboard uk timezone ${timezone} --utc -network --bootproto=dhcp --activate --hostname=${fqdn} +# --ipv6=auto, not =dhcp: "auto" follows the router advertisement, so the RA's +# managed-flag is what steers the node to DHCPv6, and a VLAN with no DHCPv6 yet +# still installs instead of blocking on a lease that will never come. Failing an +# OS install because IPv6 was not ready would be a worse trade than a node that +# briefly has no v6. The address itself comes from a kea DHCPv6 reservation +# keyed on MAC -- the same source of truth as the v4 address. +network --bootproto=dhcp --ipv6=auto --activate --hostname=${fqdn} ${auth} ${userDirective} diff --git a/bastion/src/bastion/src/templates/ubuntu-autoinstall.ts b/bastion/src/bastion/src/templates/ubuntu-autoinstall.ts index 2594bab..b7f44f3 100644 --- a/bastion/src/bastion/src/templates/ubuntu-autoinstall.ts +++ b/bastion/src/bastion/src/templates/ubuntu-autoinstall.ts @@ -40,34 +40,39 @@ export function renderUbuntuAutoinstall(params: UbuntuAutoinstallParams): string // Build the LVM layout to match Fedora kickstart sizes const extraLvs: string[] = []; if (hasLonghorn) { - extraLvs.push(` - id: lv-longhorn - name: longhorn - type: lvm_partition - volgroup: vg0 - size: -1 - - id: fs-longhorn - type: format - volume: lv-longhorn - fstype: xfs - - id: mount-longhorn - type: mount - device: fs-longhorn - path: /var/lib/longhorn`); + // 6 spaces for the list item, 8 for its keys -- these are siblings of the + // lv-home/lv-srv entries in storage.config, which sit at 6. At 8 the + // rendered document is not valid YAML at all ("expected , but + // found '-'"), so an Ubuntu node with the longhorn role could never have + // installed. Ubuntu + longhorn is exactly the worker shape. + extraLvs.push(` - id: lv-longhorn + name: longhorn + type: lvm_partition + volgroup: vg0 + size: -1 + - id: fs-longhorn + type: format + volume: lv-longhorn + fstype: xfs + - id: mount-longhorn + type: mount + device: fs-longhorn + path: /var/lib/longhorn`); } if (hasRancher) { - extraLvs.push(` - id: lv-rancher - name: rancher - type: lvm_partition - volgroup: vg0 - size: 20G - - id: fs-rancher - type: format - volume: lv-rancher - fstype: xfs - - id: mount-rancher - type: mount - device: fs-rancher - path: /var/lib/rancher`); + extraLvs.push(` - id: lv-rancher + name: rancher + type: lvm_partition + volgroup: vg0 + size: 20G + - id: fs-rancher + type: format + volume: lv-rancher + fstype: xfs + - id: mount-rancher + type: mount + device: fs-rancher + path: /var/lib/rancher`); } const extraLvsBlock = extraLvs.length > 0 ? "\n" + extraLvs.join("\n") : ""; @@ -121,7 +126,20 @@ export function renderUbuntuAutoinstall(params: UbuntuAutoinstallParams): string `curtin in-target -- bash -c 'IP_ADDR=$(ip -4 addr show | awk "/inet / && !/127.0.0/ {split(\\$2,a,\\"/\\"); print a[1]; exit}"); curl -sf -X POST "http://${serverIp}:${httpPort}/api/progress" -H "Content-Type: application/json" -d "{\\"mac\\":\\"$(ip link show | awk "/ether/ && !/00:00:00:00/ {print \\$2; exit}")\\",\\"stage\\":\\"complete\\",\\"detail\\":\\"ready at $IP_ADDR\\"}" || true'`, ); - const lateCommandsYaml = lateCommands.map((c) => ` - "${c}"`).join("\n"); + // JSON.stringify, not `"${c}"`. JSON is a subset of YAML, so this produces a + // correctly escaped double-quoted scalar for free -- and the naive version + // was broken in two ways at once, for every role: + // + // * embedded double quotes ended the scalar early + // (`echo "tmpfs /tmp ..." >> /etc/fstab` -> "expected ") + // * the heredocs contain REAL newlines, and YAML folds newlines inside a + // double-quoted scalar into spaces -- so even where it parsed, the + // heredoc arrived at the target as one long line and wrote a file with + // no line breaks. + // + // JSON escaping turns the newlines into \n, which YAML unescapes back to + // real newlines on parse, so the heredoc survives intact. + const lateCommandsYaml = lateCommands.map((c) => ` - ${JSON.stringify(c)}`).join("\n"); return `#cloud-config autoinstall: @@ -139,6 +157,30 @@ autoinstall: allow-pw: false authorized-keys: ${sshKeysYaml} + # Both address families. Without dhcp6 the installer's default is IPv4-only, + # so a node provisioned into a dual-stack cluster comes up with no IPv6, k3s + # has no v6 node-ip to bind, and it joins as an IPv4-only member of a + # dual-stack cluster -- which surfaces later as pods on that node being + # unreachable over v6 while the node itself reads Ready. + # + # The address itself comes from a kea DHCPv6 reservation keyed on MAC, the + # same source of truth as the v4 address, so nothing here needs to know it. + # + # optional: true matters -- it lets the install proceed if the v6 lease is + # slow or the VLAN has no DHCPv6 yet, rather than blocking on a timeout. The + # node still needs the address before k3s starts, but that is a later step's + # problem and failing the OS install over it would be worse. + # (No backticks in this comment: it lives inside a TS template literal, and a + # backtick here ends the literal and breaks the build.) + network: + version: 2 + ethernets: + primary: + match: + name: "en*" + dhcp4: true + dhcp6: true + optional: true storage: config: - id: disk0 diff --git a/bastion/src/bastion/tests/ubuntu-autoinstall.test.ts b/bastion/src/bastion/tests/ubuntu-autoinstall.test.ts new file mode 100644 index 0000000..7fc1ed8 --- /dev/null +++ b/bastion/src/bastion/tests/ubuntu-autoinstall.test.ts @@ -0,0 +1,80 @@ +// The Ubuntu autoinstall document must be valid YAML for EVERY role. +// +// There was no test here, and the template shipped a document that did not +// parse: the longhorn/rancher LVM entries were indented 8 spaces while their +// siblings in storage.config sit at 6, giving "expected , but found +// '-'". Every role that gets a longhorn volume -- which is the worker shape -- +// rendered an uninstallable document. A `toContain` assertion would not have +// caught that; only parsing does. +// +// Parsed with python3's yaml rather than a new npm dependency, mirroring how +// kickstart.test.ts shells out to `ksvalidator`: the point is to check the +// artefact with a real parser, not to grow the dependency tree. + +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import { writeFileSync, unlinkSync } from "node:fs"; +import { renderUbuntuAutoinstall } from "../src/templates/ubuntu-autoinstall.js"; + +const base = { + hostname: "n6", + disk: "/dev/sda", + domain: "ad.itaz.eu", + ubuntuVersion: "24.04", + timezone: "Europe/London", + locale: "en_GB.UTF-8", + serverIp: "10.0.0.1", + httpPort: 8080, + sshKeys: ["ssh-ed25519 AAAAtest test@lab"], + adminUser: "root", +}; + +/** Parse with python3's yaml and return the document as JSON. */ +function parseYaml(text: string, label: string): Record { + const tmp = `/tmp/autoinstall-test-${label}.yaml`; + writeFileSync(tmp, text); + try { + const out = execFileSync( + "python3", + ["-c", "import sys,yaml,json; json.dump(yaml.safe_load(open(sys.argv[1])), sys.stdout)", tmp], + { encoding: "utf-8" }, + ); + return JSON.parse(out); + } catch (err: unknown) { + const msg = err instanceof Error ? (err as { stderr?: string }).stderr ?? err.message : String(err); + throw new Error(`autoinstall YAML did not parse for ${label}: ${msg}`); + } finally { + try { unlinkSync(tmp); } catch { /* ignore */ } + } +} + +describe("renderUbuntuAutoinstall", () => { + for (const role of ["vanilla", "worker", "infra"]) { + it(`renders parseable YAML for role=${role}`, () => { + const doc = parseYaml(renderUbuntuAutoinstall({ ...base, role }), role); + expect(doc.autoinstall).toBeDefined(); + expect(doc.autoinstall.version).toBe(1); + // storage.config must be a flat list; the indentation bug produced a + // nested map here, which is how it went unnoticed. + expect(Array.isArray(doc.autoinstall.storage.config)).toBe(true); + }); + } + + it("gives the longhorn role its volume as a sibling entry, not a nested map", () => { + const doc = parseYaml(renderUbuntuAutoinstall({ ...base, role: "worker" }), "longhorn"); + const ids = doc.autoinstall.storage.config.map((e: { id: string }) => e.id); + expect(ids).toContain("lv-longhorn"); + expect(ids).toContain("mount-longhorn"); + }); + + it("requests both address families on the primary NIC", () => { + const doc = parseYaml(renderUbuntuAutoinstall({ ...base, role: "worker" }), "net"); + const eth = doc.autoinstall.network.ethernets.primary; + expect(eth.dhcp4).toBe(true); + // Without this a node provisioned into a dual-stack cluster comes up with + // no IPv6 and joins as an IPv4-only member. + expect(eth.dhcp6).toBe(true); + // The install must not block waiting for a v6 lease that may never come. + expect(eth.optional).toBe(true); + }); +});