bastion: progress callbacks report the queue mac, not the first NIC
Some checks failed
CI/CD / lint (push) Successful in 1m18s
CI/CD / typecheck (push) Successful in 1m3s
CI/CD / test (push) Successful in 2m4s
CI/CD / build (push) Successful in 2m27s
CI/CD / publish-rpm (push) Failing after 29s
CI/CD / publish-deb (push) Successful in 25s

The kickstart/autoinstall progress helpers detected their own MAC at
runtime (first 'ether' line of ip link). On a multi-NIC board the first
NIC need not be the PXE NIC the bastion keyed the install under, so the
'complete' callback arrived under an unknown mac and was a silent no-op:
the install_queue entry survived, every reboot re-dispatched the
installer, and the machine reinstalled in an endless loop. Observed on
thebeast (2 onboard NICs) 2026-10-20; the single-NIC VM drill could not
have caught it.

The per-MAC /ks and /autoinstall routes already know the queue mac --
bake it into the generated script and keep runtime detection only as the
fallback when no mac is known.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DceLLgGmyS7zTGLaLNjQBd
This commit is contained in:
Michal
2026-10-20 01:27:05 +01:00
parent cd36825ad4
commit 5d5fc5cb7c
6 changed files with 65 additions and 3 deletions

View File

@@ -30,6 +30,9 @@ export function registerKickstartRoutes(
hostname: queueEntry?.hostname ?? "lab-node",
disk: queueEntry?.disk ?? "",
role: queueEntry?.role ?? "worker",
// Progress callbacks must report the mac this install is QUEUED under,
// not whatever NIC enumerates first on the installed system.
...(mac !== "" ? { mac } : {}),
...(queueEntry?.schemaVersion ? { schemaVersion: queueEntry.schemaVersion } : {}),
...(queueEntry?.dataDisks ? { dataDisks: queueEntry.dataDisks } : {}),
});
@@ -197,6 +200,7 @@ esac
hostname: queueEntry?.hostname ?? "lab-node",
disk: queueEntry?.disk ?? "",
role: queueEntry?.role ?? "worker",
...(mac !== "" ? { mac } : {}),
domain: config.domain,
ubuntuVersion: config.ubuntuVersion,
timezone: config.timezone,

View File

@@ -24,6 +24,8 @@ export function generateInstallKickstart(
hostname: string;
disk: string;
role: Role;
/** PXE MAC the install is keyed under; baked into progress callbacks. */
mac?: string;
schemaVersion?: 1 | 2;
dataDisks?: import("@lab/shared").DataDisks;
},
@@ -32,6 +34,7 @@ export function generateInstallKickstart(
hostname: params.hostname,
disk: params.disk,
role: params.role,
...(params.mac !== undefined && params.mac !== "" ? { mac: params.mac } : {}),
...(params.schemaVersion ? { schemaVersion: params.schemaVersion } : {}),
...(params.dataDisks ? { dataDisks: params.dataDisks } : {}),
domain: config.domain,

View File

@@ -12,6 +12,15 @@ export interface InstallKickstartParams {
hostname: string;
disk: string;
role: Role;
/**
* The PXE MAC the bastion keys this install under (install_queue /
* installed). When set, progress callbacks report THIS mac verbatim.
* Runtime detection (`ip link ... exit` = first NIC) is only a fallback:
* on a multi-NIC board the first NIC need not be the PXE NIC, and a
* completion reported under the wrong mac is a silent no-op — the queue
* entry survives and the machine reinstalls forever (thebeast 2026-10-20).
*/
mac?: string;
domain: string;
fedoraVersion: string;
timezone: string;
@@ -44,6 +53,7 @@ export function renderInstallKickstart(params: InstallKickstartParams): string {
hostname,
disk,
role,
mac,
domain,
fedoraVersion,
timezone,
@@ -55,6 +65,11 @@ export function renderInstallKickstart(params: InstallKickstartParams): string {
adminUser,
} = params;
// Shell line defining `mac` inside the bastion_progress helpers.
const macLine = mac !== undefined && mac !== ""
? `local mac="${mac}"`
: `local mac=$(ip link show | awk '/ether/ && !/00:00:00:00/ {print $2; exit}')`;
const fqdn = domain ? `${hostname}.${domain}` : hostname;
const vg = "labvg";
const now = new Date().toISOString();
@@ -335,7 +350,7 @@ set -x
# Progress callback helper
bastion_progress() {
local stage="$1" detail="\${2:-}"
local mac=$(ip link show | awk '/ether/ && !/00:00:00:00/ {print $2; exit}')
${macLine}
curl -sf -X POST "http://${serverIp}:${httpPort}/api/progress" \\
-H "Content-Type: application/json" \\
-d "{\\"mac\\":\\"$mac\\",\\"stage\\":\\"$stage\\",\\"detail\\":\\"$detail\\"}" 2>/dev/null || true
@@ -553,7 +568,7 @@ set -x
# Progress callback helper
bastion_progress() {
local stage="$1" detail="\${2:-}"
local mac=$(ip link show | awk '/ether/ && !/00:00:00:00/ {print $2; exit}')
${macLine}
curl -sf -X POST "http://${serverIp}:${httpPort}/api/progress" \\
-H "Content-Type: application/json" \\
-d "{\\"mac\\":\\"$mac\\",\\"stage\\":\\"$stage\\",\\"detail\\":\\"$detail\\"}" 2>/dev/null || true

View File

@@ -9,6 +9,8 @@ export interface UbuntuAutoinstallParams {
hostname: string;
disk: string;
role: Role;
/** PXE MAC the install is keyed under; baked into the progress callback. */
mac?: string;
/** Fresh-install layout. Defaults to 1 so existing callers are unchanged. */
schemaVersion?: 1 | 2;
domain: string;
@@ -26,6 +28,7 @@ export function renderUbuntuAutoinstall(params: UbuntuAutoinstallParams): string
hostname,
disk,
role,
mac,
domain,
timezone,
serverIp,
@@ -34,6 +37,12 @@ export function renderUbuntuAutoinstall(params: UbuntuAutoinstallParams): string
adminUser,
} = params;
// MAC reported by the completion callback: the queue key when known;
// first-NIC runtime detection only as fallback (wrong on multi-NIC boards).
const reportMac = mac !== undefined && mac !== ""
? mac
: `$(ip link show | awk "/ether/ && !/00:00:00:00/ {print \\$2; exit}")`;
const fqdn = domain ? `${hostname}.${domain}` : hostname;
const vg = "labvg";
// ---------------------------------------------------------------------------
@@ -175,7 +184,7 @@ export function renderUbuntuAutoinstall(params: UbuntuAutoinstallParams): string
// Progress callback (complete)
lateCommands.push(
`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'`,
`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\\":\\"${reportMac}\\",\\"stage\\":\\"complete\\",\\"detail\\":\\"ready at $IP_ADDR\\"}" || true'`,
);
// JSON.stringify, not `"${c}"`. JSON is a subset of YAML, so this produces a

View File

@@ -215,3 +215,21 @@ describe("renderInstallKickstart", () => {
expect(ks).not.toContain("ttyS0");
});
});
describe("progress callback mac", () => {
it("bakes the queue mac into every bastion_progress helper when given", () => {
const ks = renderInstallKickstart(baseParams({ mac: "34:97:f6:32:76:e9" }));
const baked = ks.match(/local mac="34:97:f6:32:76:e9"/g) ?? [];
expect(baked.length).toBeGreaterThanOrEqual(2); // %pre and %post helpers
// No runtime first-NIC detection left: on multi-NIC boards the first NIC
// need not be the PXE NIC, and a completion under the wrong mac is a
// silent no-op that reinstalls the machine forever.
expect(ks).not.toContain("local mac=$(ip link show");
});
it("falls back to runtime detection when no mac is known", () => {
const ks = renderInstallKickstart(baseParams());
expect(ks).toContain("local mac=$(ip link show");
expect(ks).not.toContain('local mac=""');
});
});

View File

@@ -182,3 +182,16 @@ describe("Ubuntu reinstall is destructive (known gap)", () => {
expect(cfg.find((c) => c["type"] === "disk")?.["wipe"]).toBe("superblock-recursive");
});
});
describe("progress callback mac", () => {
it("bakes the queue mac into the completion callback when given", () => {
const doc = renderUbuntuAutoinstall({ ...base, role: "worker", mac: "aa:bb:cc:dd:ee:ff" });
expect(doc).toContain("aa:bb:cc:dd:ee:ff");
expect(doc).not.toContain("ip link show");
});
it("falls back to runtime detection when no mac is known", () => {
const doc = renderUbuntuAutoinstall({ ...base, role: "worker" });
expect(doc).toContain("ip link show");
});
});