dedicated data disks: whole-disk longhorn/rancher volumes, by stable by-id path
Some checks failed
CI/CD / typecheck (pull_request) Failing after 13s
CI/CD / test (pull_request) Failing after 10s
CI/CD / lint (pull_request) Failing after 24s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped

A machine with spare NVMe bays (thebeast: 1TB OS + 2TB + 2TB) wants the
opposite trade from the fixed 250G/300G LVs that exist to FIT a 1TB disk:
give /var/lib/longhorn and /var/lib/rancher a whole disk each and keep the
OS VG small. Until now every generator was single-disk
(ignoredisk --only-use=$DISK, one VG) and had no way to say that.

New in the shared layout (v2 only):
- DataDisks { longhorn?, rancher? }: /dev/disk/by-id paths ONLY, because
  nvme0n1/nvme1n1 enumeration is not stable across boots on multi-NVMe
  hosts and a swapped kernel name formats the wrong disk.
- v2Layout() drops the LV whose mount a dedicated disk supplies.
- validateDataDisks() shared by HTTP API, websocket handler and CLI, so
  all three reject with the same words: by-id enforced, capability-gated
  (no longhorn disk on a control-plane-only node, no rancher disk without
  k3s), no double-booking one disk.

Fedora kickstart (the only generator extended; dataDisks on ubuntu/vyos
are rejected, not dropped):
- %pre resolves each by-id path to tonight's kernel name; a missing disk
  fails the install loudly instead of continuing single-disk.
- Fresh disk: ONE xfs partition spanning it, labelled lab-longhorn /
  lab-rancher. --grow is correct here -- consuming the disk is the point,
  unlike LVs competing inside a shared VG.
- The label is on-disk truth, exactly like the lab-schema-v2 VG tag:
  a reprovision rediscovers labelled disks via blkid with NO flags and
  mounts them --noformat; a fresh install refuses to wipe or shadow a
  labelled disk it was not told about; a label on the wrong disk, or a
  data LV coexisting with a dedicated disk, aborts rather than guesses.
- --disk now also accepts a by-id path (resolved in %pre), and auto-detect
  can never pick a data disk as the OS disk.

Plumbing: InstallConfig.dataDisks, POST /api/install validation,
labctl provision install --longhorn-disk/--rancher-disk, labd forwarding.
The labd command-install path also silently DROPPED schemaVersion (an
explicit --schema-version 2 on a legacy role became v1 on the far side);
it now travels with dataDisks in the protocol message.

migration/THEBEAST-K8S-WORKER.md is the concrete runbook: disk map by
serial, data still to copy off the degraded (zero-redundancy) rpool,
VLAN move, the exact labctl command, and the k8s0 join with CI/CD taint.

569 tests pass (34 new), tsc --build clean, no new lint errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6HNx6cyr4J4CiMGCy1wkF
This commit is contained in:
Michal
2026-10-14 18:58:37 +01:00
parent 84836cb8f2
commit 66182c8cb8
17 changed files with 741 additions and 33 deletions

View File

@@ -40,8 +40,14 @@ _labctl() {
"app labcontroller status")
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
return ;;
"app k3s evacuate")
COMPREPLY=($(compgen -W "--user --allow-single-replica -h --help" -- "$cur"))
return ;;
"app k3s admit")
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
return ;;
"app k3s install")
COMPREPLY=($(compgen -W "--role --user --k3s-server --k3s-token -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "--role --user --k3s-server --k3s-token --k3s-version -h --help" -- "$cur"))
return ;;
"app k3s health")
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
@@ -59,7 +65,7 @@ _labctl() {
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
return ;;
"provision install")
COMPREPLY=($(compgen -W "--role --os --disk --vyos-mgmt --vyos-mgmt-address --vyos-bond --vyos-bond-address --vyos-bond-vrrp --vlan-vip --vyos-vrrp-priority --vyos-mgmt-vlan --vlan --vyos-password --vyos-hwid --vyos-fresh-config -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "--role --os --disk --schema-version --longhorn-disk --rancher-disk --vyos-mgmt --vyos-mgmt-address --vyos-bond --vyos-bond-address --vyos-bond-vrrp --vlan-vip --vyos-vrrp-priority --vyos-mgmt-vlan --vlan --vyos-password --vyos-hwid --vyos-fresh-config --vyos-bundle --vyos-api-key --vyos-api-listen -h --help" -- "$cur"))
return ;;
"provision reprovision")
COMPREPLY=($(compgen -W "--role --os --disk --user -h --help" -- "$cur"))
@@ -101,7 +107,7 @@ _labctl() {
COMPREPLY=($(compgen -W "deploy status -h --help" -- "$cur"))
return ;;
"app k3s")
COMPREPLY=($(compgen -W "install health list kubeconfig -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "evacuate admit install health list kubeconfig -h --help" -- "$cur"))
return ;;
"version")
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))

View File

@@ -131,9 +131,12 @@ complete -c labctl -n "__labctl_using_cmd provision" -a makeiso -d 'Generate a U
complete -c labctl -n "__labctl_using_cmd provision" -a recheck -d 'Refresh hardware info for all installed machines via SSH'
# provision install options
complete -c labctl -n "__labctl_in_cmd provision install" -l role -d 'Machine role (see below)' -xa 'vanilla worker infra labcontroller'
complete -c labctl -n "__labctl_in_cmd provision install" -l role -d 'Machine role (see below)' -xa 'vanilla worker infra labcontroller standalone-cluster k8s-controller k8s-worker k8s-combo'
complete -c labctl -n "__labctl_in_cmd provision install" -l os -d 'Operating system' -xa 'fedora-43 ubuntu-26.04 vyos-rolling'
complete -c labctl -n "__labctl_in_cmd provision install" -l disk -d 'Target disk device (auto-detect if omitted)' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l disk -d 'Target OS disk: kernel name or stable /dev/disk/by-id/ path (auto-detect if omitted)' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l schema-version -d 'Disk layout for a FRESH install (default 1). Ignored on reprovision: the layout already on the disk wins.' -xa '1 2'
complete -c labctl -n "__labctl_in_cmd provision install" -l longhorn-disk -d 'Dedicated whole disk for /var/lib/longhorn, as a stable /dev/disk/by-id/ path (fedora, schema v2 only)' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l rancher-disk -d 'Dedicated whole disk for /var/lib/rancher, as a stable /dev/disk/by-id/ path (fedora, schema v2 only)' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-mgmt -d 'VyOS: untagged interface the machine PXE boots from (default eth0)' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-mgmt-address -d 'VyOS: CIDR for the management interface, or \'dhcp\' (default dhcp)' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-bond -d 'VyOS: comma-separated LACP bond members (must exclude the PXE NIC)' -x
@@ -146,9 +149,12 @@ complete -c labctl -n "__labctl_in_cmd provision install" -l vlan -d 'VyOS: tagg
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-password -d 'VyOS: password for the \'vyos\' user' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-hwid -d 'VyOS: pin an interface name to a MAC via hw-id (repeatable)' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-fresh-config -d 'VyOS: on reinstall, overwrite the preserved config with the generated one'
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-bundle -d 'VyOS: apply a Pulumi-rendered bundle verbatim (kubernetes-deployment/infra/vyos/vyos-bundle.json). Replaces the derived --vyos-bond/--vlan/... config; secret values are left unset for `pulumi up`.' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-api-key -d 'VyOS: enable the HTTP API with this key so Pulumi can manage the router from first boot' -x
complete -c labctl -n "__labctl_in_cmd provision install" -l vyos-api-listen -d 'VyOS: address the HTTP API binds to (default: the static management address). Required when management is DHCP; the API is never bound to all interfaces.' -x
# provision reprovision options
complete -c labctl -n "__labctl_in_cmd provision reprovision" -l role -d 'Machine role (see below)' -xa 'vanilla worker infra labcontroller'
complete -c labctl -n "__labctl_in_cmd provision reprovision" -l role -d 'Machine role (see below)' -xa 'vanilla worker infra labcontroller standalone-cluster k8s-controller k8s-worker k8s-combo'
complete -c labctl -n "__labctl_in_cmd provision reprovision" -l os -d 'Operating system' -xa 'fedora-43 ubuntu-26.04 vyos-rolling'
complete -c labctl -n "__labctl_in_cmd provision reprovision" -l disk -d 'Target disk device (auto-detect if omitted)' -x
complete -c labctl -n "__labctl_in_cmd provision reprovision" -l user -d 'SSH user for the reboot (default: vyos for VyOS machines, else current user)' -x
@@ -157,7 +163,7 @@ complete -c labctl -n "__labctl_in_cmd provision reprovision" -l user -d 'SSH us
complete -c labctl -n "__labctl_in_cmd provision debug" -l pxe-boot -d 'Boot installed system via PXE (kernel+initrd from network, root from NVMe)'
# provision register options
complete -c labctl -n "__labctl_in_cmd provision register" -l role -d 'Machine role' -xa 'vanilla worker infra labcontroller'
complete -c labctl -n "__labctl_in_cmd provision register" -l role -d 'Machine role' -xa 'vanilla worker infra labcontroller standalone-cluster k8s-controller k8s-worker k8s-combo'
complete -c labctl -n "__labctl_in_cmd provision register" -l ip -d 'Machine IP address' -x
# provision logs options
@@ -200,16 +206,26 @@ complete -c labctl -n "__labctl_in_cmd app labcontroller deploy" -l crdb-replica
complete -c labctl -n "__labctl_in_cmd app labcontroller status" -l user -d 'SSH user' -x
# app k3s subcommands
complete -c labctl -n "__labctl_using_cmd app k3s" -a evacuate -d 'Cordon, evict Longhorn replicas and drain so a node can be safely wiped'
complete -c labctl -n "__labctl_using_cmd app k3s" -a admit -d 'Return an evacuated node to service: uncordon, clear eviction, restore its Longhorn disk'
complete -c labctl -n "__labctl_using_cmd app k3s" -a install -d 'Install k3s on a target machine (hostname, IP, or MAC)'
complete -c labctl -n "__labctl_using_cmd app k3s" -a health -d 'Check k3s health (all hosts if no target given)'
complete -c labctl -n "__labctl_using_cmd app k3s" -a list -d 'List installed machines and their k3s status'
complete -c labctl -n "__labctl_using_cmd app k3s" -a kubeconfig -d 'Fetch kubeconfig from a target and merge into ~/.kube/config'
# app k3s evacuate options
complete -c labctl -n "__labctl_in_cmd app k3s evacuate" -l user -d 'SSH user' -x
complete -c labctl -n "__labctl_in_cmd app k3s evacuate" -l allow-single-replica -d 'permit evacuating volumes that have only one replica (rebuilds from the only copy; there is no backup target)'
# app k3s admit options
complete -c labctl -n "__labctl_in_cmd app k3s admit" -l user -d 'SSH user' -x
# app k3s install options
complete -c labctl -n "__labctl_in_cmd app k3s install" -l role -d 'k3s role: infra (server) or worker (agent)' -x
complete -c labctl -n "__labctl_in_cmd app k3s install" -l role -d 'k3s role: infra, labcontroller, standalone-cluster, k8s-controller, k8s-combo (servers) or worker, k8s-worker (agents)' -x
complete -c labctl -n "__labctl_in_cmd app k3s install" -l user -d 'SSH user' -x
complete -c labctl -n "__labctl_in_cmd app k3s install" -l k3s-server -d 'k3s server URL (required for worker role)' -x
complete -c labctl -n "__labctl_in_cmd app k3s install" -l k3s-token -d 'k3s join token (required for worker role)' -x
complete -c labctl -n "__labctl_in_cmd app k3s install" -l k3s-server -d 'k3s server URL (required for agent roles, and to JOIN as a server)' -x
complete -c labctl -n "__labctl_in_cmd app k3s install" -l k3s-token -d 'k3s join token; on a first server it SEEDS the cluster token' -x
complete -c labctl -n "__labctl_in_cmd app k3s install" -l k3s-version -d 'exact k3s version, e.g. v1.34.6+k3s1. Unset installs LATEST, which on a rejoining node can be several minor versions ahead of the cluster' -x
# app k3s health options
complete -c labctl -n "__labctl_in_cmd app k3s health" -l user -d 'SSH user' -x

View File

@@ -4,6 +4,7 @@
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync, symlinkSync, unlinkSync } from "node:fs";
import { execSync } from "node:child_process";
import type { BastionConfig } from "@lab/shared";
import { isRole, validateDataDisks } from "@lab/shared";
import { loadConfig } from "./config.js";
import { populateNetworkConfig } from "./services/network.js";
import { createApp } from "./server.js";
@@ -384,6 +385,22 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
// Wire up command handlers so labd can send install/forget/role commands
labdConn.onCommand("command-install", async (msg) => {
if (msg.type !== "command-install") throw new Error("unexpected");
// Same guard as POST /api/install: disks named by an operator must
// never silently degrade into LVs because they arrived over the socket.
const hasDataDisks = msg.dataDisks !== undefined
&& Object.values(msg.dataDisks).some((d) => d !== undefined);
if (hasDataDisks) {
if ((msg.os ?? "fedora-43") !== "fedora-43") {
return { status: "error", error: `dataDisks are only supported for fedora installs (got os '${msg.os}')` };
}
if (!isRole(msg.role)) {
return { status: "error", error: `invalid role: '${msg.role}'` };
}
const problem = validateDataDisks(msg.role, msg.dataDisks!);
if (problem !== null) {
return { status: "error", error: `invalid dataDisks: ${problem}` };
}
}
state.update((s) => {
s.install_queue[msg.mac] = {
hostname: msg.hostname,
@@ -391,6 +408,8 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
role: msg.role as import("@lab/shared").Role,
os: msg.os as import("@lab/shared").OsId,
queued_at: new Date().toISOString(),
...(msg.schemaVersion ? { schemaVersion: msg.schemaVersion } : {}),
...(hasDataDisks ? { dataDisks: msg.dataDisks } : {}),
...(msg.vyos ? { vyos: msg.vyos } : {}),
};
});

View File

@@ -14,7 +14,7 @@ import { progressBus } from "../services/progress-events.js";
import type { ProgressEvent } from "../services/progress-events.js";
import type { InstallLogBuffer } from "../services/install-log.js";
import type { SyslogListener } from "../services/syslog-listener.js";
import { isRole, requiresSchemaV2 } from "@lab/shared";
import { isRole, requiresSchemaV2, validateDataDisks, type DataDisks } from "@lab/shared";
/**
* Seconds after dispatch with zero progress before a machine is called stalled.
@@ -45,9 +45,10 @@ export function registerApiRoutes(
os?: string;
vyos?: VyosInstallSpec;
schemaVersion?: number;
dataDisks?: DataDisks;
};
}>("/api/install", async (request, reply) => {
const { mac: rawMac, hostname, disk, role, os, vyos, schemaVersion } = request.body ?? {};
const { mac: rawMac, hostname, disk, role, os, vyos, schemaVersion, dataDisks } = request.body ?? {};
const mac = (rawMac ?? "").toLowerCase().replace(/-/g, ":");
if (mac === "") {
@@ -79,6 +80,36 @@ export function registerApiRoutes(
return reply.status(400).send({ error: `invalid os: '${osId}'. Supported: ${SUPPORTED_OS.join(", ")}` });
}
// Dedicated data disks are rendered by the kickstart generator only, and
// only in the v2 layout. Rejected here rather than silently dropped at
// render time -- an operator who named disks must not get LVs instead.
const hasDataDisks = dataDisks !== undefined
&& Object.values(dataDisks).some((d) => d !== undefined);
if (hasDataDisks) {
if (osId !== "fedora-43") {
return reply.status(400).send({
error: `dataDisks are only supported for fedora installs (got os '${osId}')`,
});
}
if (schemaVersion === 1) {
return reply.status(400).send({
error: "dataDisks require schemaVersion 2 -- the v1 layout is frozen and single-disk",
});
}
if (!isRole(validRole)) {
return reply.status(400).send({ error: `invalid role: '${validRole}'` });
}
if (!requiresSchemaV2(validRole) && schemaVersion === undefined) {
return reply.status(400).send({
error: `role '${validRole}' defaults to the v1 layout -- pass schemaVersion 2 to use dataDisks`,
});
}
const problem = validateDataDisks(validRole, dataDisks as DataDisks);
if (problem !== null) {
return reply.status(400).send({ error: `invalid dataDisks: ${problem}` });
}
}
state.update((s) => {
s.install_queue[mac] = {
hostname: hostname ?? "lab-node",
@@ -87,6 +118,7 @@ export function registerApiRoutes(
os: osId,
queued_at: new Date().toISOString(),
...(schemaVersion ? { schemaVersion: schemaVersion as 1 | 2 } : {}),
...(hasDataDisks ? { dataDisks } : {}),
...(vyos ? { vyos } : {}),
};
});

View File

@@ -31,6 +31,7 @@ export function registerKickstartRoutes(
disk: queueEntry?.disk ?? "",
role: queueEntry?.role ?? "worker",
...(queueEntry?.schemaVersion ? { schemaVersion: queueEntry.schemaVersion } : {}),
...(queueEntry?.dataDisks ? { dataDisks: queueEntry.dataDisks } : {}),
});
return reply.type("text/plain").send(ks);

View File

@@ -25,6 +25,7 @@ export function generateInstallKickstart(
disk: string;
role: Role;
schemaVersion?: 1 | 2;
dataDisks?: import("@lab/shared").DataDisks;
},
): string {
const ksParams: InstallKickstartParams = {
@@ -32,6 +33,7 @@ export function generateInstallKickstart(
disk: params.disk,
role: params.role,
...(params.schemaVersion ? { schemaVersion: params.schemaVersion } : {}),
...(params.dataDisks ? { dataDisks: params.dataDisks } : {}),
domain: config.domain,
fedoraVersion: config.fedoraVersion,
timezone: config.timezone,

View File

@@ -2,8 +2,11 @@
// Full Fedora server install with LVM partitioning, %pre for reprovision detection,
// packages, and %post with SSH keys, user creation, k3s prereqs, progress callbacks.
import type { Role } from "@lab/shared";
import { v2Layout, V2_BOOT, requiresSchemaV2, effectiveSchemaVersion, isK3sServer, type LvSpec } from "@lab/shared";
import type { Role, DataDisks, DataDiskName } from "@lab/shared";
import {
v2Layout, V2_BOOT, requiresSchemaV2, effectiveSchemaVersion, isK3sServer,
validateDataDisks, DATA_DISK_LABELS, type LvSpec,
} from "@lab/shared";
export interface InstallKickstartParams {
hostname: string;
@@ -27,6 +30,13 @@ export interface InstallKickstartParams {
* never silently change its layout -- that is how data gets destroyed.
*/
schemaVersion?: 1 | 2;
/**
* Dedicated whole-disk data volumes, by stable /dev/disk/by-id path (v2
* only). The matching LV is not created; the disk gets one labelled xfs
* partition instead. On a reprovision %pre rediscovers these by label, so a
* reinstall preserves them whether or not the queue entry names them.
*/
dataDisks?: DataDisks;
}
export function renderInstallKickstart(params: InstallKickstartParams): string {
@@ -72,8 +82,24 @@ export function renderInstallKickstart(params: InstallKickstartParams): string {
`Pass schemaVersion: 2 (labctl provision install --schema-version 2).`,
);
}
const dataDisks = params.dataDisks ?? {};
const hasDataDisks = Object.values(dataDisks).some((d) => d !== undefined);
if (hasDataDisks) {
// The v1 branches are frozen and know nothing about dedicated disks, so a
// v1 render with them would silently create BOTH the LV and the disk.
if (schemaVersion !== 2) {
throw new Error(
`dedicated data disks require the v2 layout - pass schemaVersion: 2 ` +
`(labctl provision install --schema-version 2)`,
);
}
const problem = validateDataDisks(role, dataDisks);
if (problem !== null) throw new Error(problem);
}
const v2RootMib = v2Layout(role).find((lv: LvSpec) => lv.name === "root")?.sizeMib ?? 32768;
const v2 = v2Layout(role);
const v2 = v2Layout(role, { dataDisks });
const v2LogvolLines = v2
.map((lv: LvSpec) =>
lv.fs === "swap"
@@ -149,14 +175,29 @@ chmod 440 /etc/sudoers.d/${adminUser}`;
}
// -- Disk detection --
// An explicit disk may be a stable /dev/disk/by-id path: kernel names are
// not stable across boots on multi-NVMe hosts, so a pinned install must be
// able to name the disk by serial. Anaconda directives want the kernel name,
// so %pre resolves the symlink at install time.
const diskLine = disk
? `DISK="${disk}"`
? disk.startsWith("/dev/")
? `# OS disk pinned by stable path -- resolve to the kernel name anaconda wants
DISK=""
for _wait in $(seq 1 10); do
_os_target=$(readlink -f "${disk}" 2>/dev/null || true)
if [ -n "$_os_target" ] && [ -b "$_os_target" ]; then DISK=$(basename "$_os_target"); break; fi
sleep 1
done
[ -z "$DISK" ] && { bastion_progress "error" "OS disk not found: ${disk}"; echo "ERROR: OS disk not found: ${disk}"; exit 1; }`
: `DISK="${disk}"`
: `DISK=""
# Wait up to 10s for NVMe/SCSI disks to appear (they init async in initrd)
for _wait in $(seq 1 10); do
for d in /dev/nvme0n1 /dev/nvme1n1 /dev/sda /dev/sdb /dev/vda; do
[ -b "$d" ] || continue
_bname=$(basename "$d")
# Never auto-pick a dedicated data disk as the OS disk
case "$_bname" in "$LONGHORN_DISK"|"$RANCHER_DISK") continue ;; esac
# Skip removable disks (USB, CD-ROM, JetKVM virtual media)
[ -f "/sys/block/$_bname/removable" ] && [ "$(cat /sys/block/$_bname/removable)" = "1" ] && continue
# Skip USB-attached disks (JetKVM virtual media shows as SCSI over USB)
@@ -172,6 +213,80 @@ for _wait in $(seq 1 10); do
done
[ -z "$DISK" ] && { echo "ERROR: no disk found"; exit 1; }`;
// -- Dedicated data disks: %pre resolution --
// Resolved BEFORE the OS disk so auto-detect can refuse to pick one of them.
const dataDiskResolveBlocks = (Object.keys(DATA_DISK_LABELS) as DataDiskName[])
.map((name) => {
const device = dataDisks[name];
const VAR = name.toUpperCase();
if (device === undefined) return `${VAR}_DISK=""`;
return `# ${name} gets a whole disk of its own, pinned by stable path
${VAR}_DISK_ID="${device}"
${VAR}_DISK=""
for _wait in $(seq 1 10); do
_dd_target=$(readlink -f "$${VAR}_DISK_ID" 2>/dev/null || true)
if [ -n "$_dd_target" ] && [ -b "$_dd_target" ]; then ${VAR}_DISK=$(basename "$_dd_target"); break; fi
sleep 1
done
[ -z "$${VAR}_DISK" ] && { bastion_progress "error" "${name} disk not found: $${VAR}_DISK_ID"; echo "ERROR: ${name} disk not found: $${VAR}_DISK_ID"; exit 1; }`;
})
.join("\n");
// -- Dedicated data disks: discovery and preservation decisions --
// A labelled data partition is on-disk truth, exactly like the lab-schema-v2
// LVM tag: it is preserved whether or not this install names the disk, and it
// is never silently wiped or shadowed. blkid finds it wherever tonight's NVMe
// enumeration put it.
const dataDiskDecisionBlocks = (Object.keys(DATA_DISK_LABELS) as DataDiskName[])
.map((name) => {
const VAR = name.toUpperCase();
const label = DATA_DISK_LABELS[name];
const mount = name === "longhorn" ? "/var/lib/longhorn" : "/var/lib/rancher";
return `${VAR}_PART=$(blkid -t LABEL=${label} -o device 2>/dev/null | head -1)
${VAR}_REUSE=no
if [ -n "$${VAR}_PART" ]; then
_dd_parent=$(lsblk -ndo pkname "$${VAR}_PART" 2>/dev/null)
if [ -n "$${VAR}_DISK" ] && [ "$_dd_parent" != "$${VAR}_DISK" ]; then
bastion_progress "error" "${label} label is on $_dd_parent, not on the configured ${name} disk $${VAR}_DISK"
echo "ERROR: ${label} label found on $_dd_parent but the ${name} disk is configured as $${VAR}_DISK -- refusing to guess"
exit 1
fi
if [ "$REPROVISION" = "no" ] && [ -z "$${VAR}_DISK" ]; then
bastion_progress "error" "found ${label} data on $_dd_parent but this install configures no ${name} disk"
echo "ERROR: ${label} labelled data exists on $_dd_parent. Refusing to wipe or shadow it -- queue the install with a ${name} data disk, or wipe the label first"
exit 1
fi
${VAR}_DISK="$_dd_parent"
${VAR}_REUSE=yes
fi
if [ "$PRESERVE_${VAR}" = "yes" ] && [ -n "$${VAR}_DISK" ]; then
bastion_progress "error" "both a ${name} LV and a dedicated ${name} disk exist"
echo "ERROR: VG $VG has a ${name} LV AND a dedicated ${name} disk is present -- two things cannot mount ${mount}. Migrate the LV off first"
exit 1
fi`;
})
.join("\n\n");
// -- Dedicated data disks: partition directives --
// Appended to whichever /tmp/part.ks the branches above wrote. A reused
// partition is mounted --noformat; a fresh disk gets ONE partition spanning
// it. --grow is fine HERE: the whole point of a dedicated disk is to consume
// it, unlike LVs competing inside a shared VG.
const dataDiskPartBlocks = (Object.keys(DATA_DISK_LABELS) as DataDiskName[])
.map((name) => {
const VAR = name.toUpperCase();
const label = DATA_DISK_LABELS[name];
const mount = name === "longhorn" ? "/var/lib/longhorn" : "/var/lib/rancher";
return `if [ -n "$${VAR}_DISK" ]; then
if [ "$${VAR}_REUSE" = "yes" ]; then
echo "part ${mount} --onpart=$${VAR}_PART --noformat" >> /tmp/part.ks
else
echo "part ${mount} --fstype=xfs --ondisk=$${VAR}_DISK --grow --size=1 --label=${label}" >> /tmp/part.ks
fi
fi`;
})
.join("\n");
// -- Longhorn LV for fresh install --
const longhornFreshLine = hasLonghorn
? `logvol /var/lib/longhorn --vgname=${vg} --name=longhorn --fstype=xfs --grow --size=1`
@@ -229,6 +344,9 @@ bastion_progress() {
bastion_progress "partitioning" "detecting disk"
VG="${vg}"
${dataDiskResolveBlocks}
${diskLine}
REPROVISION=no
@@ -265,6 +383,19 @@ else
bastion_progress "partitioning" "fresh install on $DISK"
fi
${dataDiskDecisionBlocks}
# Every disk anaconda may touch; everything else stays invisible to it.
KS_DISKS="$DISK"
[ -n "$LONGHORN_DISK" ] && KS_DISKS="$KS_DISKS,$LONGHORN_DISK"
[ -n "$RANCHER_DISK" ] && [ "$RANCHER_DISK" != "$LONGHORN_DISK" ] && KS_DISKS="$KS_DISKS,$RANCHER_DISK"
# Fresh installs wipe the OS disk plus any data disk being formatted for the
# first time -- NEVER one carrying a lab-* label (those are reused --noformat).
CLEARPART_DRIVES="$DISK"
[ -n "$LONGHORN_DISK" ] && [ "$LONGHORN_REUSE" = "no" ] && CLEARPART_DRIVES="$CLEARPART_DRIVES,$LONGHORN_DISK"
[ -n "$RANCHER_DISK" ] && [ "$RANCHER_REUSE" = "no" ] && [ "$RANCHER_DISK" != "$LONGHORN_DISK" ] && CLEARPART_DRIVES="$CLEARPART_DRIVES,$RANCHER_DISK"
if [ "$REPROVISION" = "yes" ]; then
# Find existing boot partitions by type
EFI_PART=$(blkid -t TYPE=vfat -o device /dev/\${DISK}* 2>/dev/null | head -1)
@@ -275,7 +406,7 @@ if [ "$REPROVISION" = "yes" ]; then
# Build partition config reusing existing PV/VG
cat > /tmp/part.ks << PARTEOF
ignoredisk --only-use=$DISK
ignoredisk --only-use=$KS_DISKS
clearpart --none
part /boot/efi --onpart=$EFI_PART --fstype=efi
part /boot --onpart=$BOOT_PART --fstype=ext4
@@ -320,8 +451,8 @@ PARTEOF
else
# Fresh install
${schemaVersion === 2 ? ` cat > /tmp/part.ks << PARTEOF
ignoredisk --only-use=$DISK
clearpart --all --initlabel --drives=$DISK
ignoredisk --only-use=$KS_DISKS
clearpart --all --initlabel --drives=$CLEARPART_DRIVES
part /boot/efi --fstype=efi --size=${V2_BOOT.efiMib} --ondisk=$DISK
part /boot --fstype=ext4 --size=${V2_BOOT.bootMib} --ondisk=$DISK
part pv.01 --size=1 --grow --ondisk=$DISK
@@ -349,6 +480,8 @@ ${rancherFreshLine}
PARTEOF`}
fi
${dataDiskPartBlocks}
echo "=== Generated partition config ==="
cat /tmp/part.ks
echo "==================================="

View File

@@ -0,0 +1,274 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { FastifyInstance } from "fastify";
import { renderInstallKickstart } from "../src/templates/install.ks.js";
import type { InstallKickstartParams } from "../src/templates/install.ks.js";
import { createApp } from "../src/server.js";
import { validateDataDisks, v2Layout, DATA_DISK_LABELS, type BastionConfig, type DataDisks } from "@lab/shared";
/**
* Dedicated whole-disk data volumes (thebeast: 1 TB OS + 2 TB longhorn +
* 2 TB rancher). The layout contract these tests pin down:
*
* - naming a data disk DROPS the matching LV -- never both.
* - disks are named by /dev/disk/by-id only; kernel names are rejected,
* because nvme enumeration order is not stable across boots.
* - a lab-longhorn / lab-rancher labelled partition is on-disk truth, like
* the lab-schema-v2 VG tag: reused --noformat, never wiped, never
* silently shadowed.
*/
const BY_ID = "/dev/disk/by-id";
const LONGHORN_ID = `${BY_ID}/nvme-Sabrent_Rocket_4.0_2TB_7F600711179D89261778`;
const RANCHER_ID = `${BY_ID}/nvme-Sabrent_Rocket_4.0_2TB_7FD4070C1EDA92197301`;
const OS_ID = `${BY_ID}/nvme-Samsung_SSD_970_EVO_Plus_1TB_S4EWNJ0N138234M`;
function baseParams(over: Partial<InstallKickstartParams> = {}): InstallKickstartParams {
return {
hostname: "thebeast", disk: "", role: "k8s-worker", domain: "ad.itaz.eu",
fedoraVersion: "43", timezone: "Europe/Warsaw", locale: "en_US.UTF-8",
serverIp: "192.168.8.11", httpPort: 8080, syslogPort: 5514,
sshKeys: ["ssh-ed25519 AAAA test"], adminUser: "michal",
dataDisks: { longhorn: LONGHORN_ID, rancher: RANCHER_ID },
...over,
};
}
/** The fresh-install part.ks heredoc (the one that creates the PV). */
function freshBlock(ks: string): string {
const blocks = [...ks.matchAll(/cat >>?\s*\/tmp\/part\.ks\s*<<\s*PARTEOF\n([\s\S]*?)\nPARTEOF/g)]
.map((m) => m[1]!);
const fresh = blocks.find((b) => b.includes("part pv.01"));
if (!fresh) throw new Error("no fresh-install part.ks block found");
return fresh;
}
describe("validateDataDisks", () => {
const ok: DataDisks = { longhorn: LONGHORN_ID, rancher: RANCHER_ID };
it("accepts a sane spec for a role that hosts longhorn", () => {
expect(validateDataDisks("k8s-worker", ok)).toBeNull();
expect(validateDataDisks("k8s-combo", ok)).toBeNull();
expect(validateDataDisks("worker", ok)).toBeNull();
});
it("rejects kernel names -- they are not stable across boots", () => {
expect(validateDataDisks("k8s-worker", { longhorn: "nvme1n1" })).toMatch(/by-id/);
expect(validateDataDisks("k8s-worker", { rancher: "/dev/nvme2n1" })).toMatch(/by-id/);
});
it("rejects a longhorn disk for a role that holds no replicas", () => {
expect(validateDataDisks("k8s-controller", { longhorn: LONGHORN_ID })).toMatch(/no Longhorn replicas/);
expect(validateDataDisks("vanilla", { longhorn: LONGHORN_ID })).toMatch(/no Longhorn replicas/);
});
it("rejects a rancher disk for a role that runs no k3s", () => {
expect(validateDataDisks("vanilla", { rancher: RANCHER_ID })).toMatch(/no k3s/);
});
it("a control-plane-only node may still take a rancher disk", () => {
expect(validateDataDisks("k8s-controller", { rancher: RANCHER_ID })).toBeNull();
});
it("rejects the same disk doing both jobs", () => {
expect(validateDataDisks("k8s-worker", { longhorn: LONGHORN_ID, rancher: LONGHORN_ID }))
.toMatch(/same disk/);
});
});
describe("v2Layout with dedicated disks", () => {
it("drops exactly the LV a dedicated disk replaces", () => {
const names = (d: DataDisks) => v2Layout("k8s-worker", { dataDisks: d }).map((l) => l.name);
expect(names({})).toContain("longhorn");
expect(names({ longhorn: LONGHORN_ID })).not.toContain("longhorn");
expect(names({ longhorn: LONGHORN_ID })).toContain("rancher");
expect(names({ longhorn: LONGHORN_ID, rancher: RANCHER_ID })).not.toContain("rancher");
// base volumes untouched
expect(names({ longhorn: LONGHORN_ID, rancher: RANCHER_ID }))
.toEqual(expect.arrayContaining(["swap", "root", "var", "varlog", "home", "srv"]));
});
});
describe("renderInstallKickstart with data disks", () => {
it("refuses the v1 layout -- it is frozen and single-disk", () => {
expect(() => renderInstallKickstart(baseParams({ role: "worker", schemaVersion: 1 })))
.toThrow(/v2 layout/);
// a legacy role with no explicit schema defaults to v1 -> same refusal
expect(() => renderInstallKickstart(baseParams({ role: "worker" })))
.toThrow(/v2 layout/);
});
it("refuses a kernel-name data disk", () => {
expect(() => renderInstallKickstart(baseParams({ dataDisks: { longhorn: "nvme1n1" } })))
.toThrow(/by-id/);
});
it("creates no longhorn/rancher LV when both mounts come from dedicated disks", () => {
const fresh = freshBlock(renderInstallKickstart(baseParams()));
expect(fresh).not.toContain("--name=longhorn");
expect(fresh).not.toContain("--name=rancher");
// base LVs still there
expect(fresh).toContain("--name=root");
});
it("anaconda only sees the named disks, and only wipes unlabelled ones", () => {
const ks = renderInstallKickstart(baseParams());
const fresh = freshBlock(ks);
expect(fresh).toContain("ignoredisk --only-use=$KS_DISKS");
expect(fresh).toContain("clearpart --all --initlabel --drives=$CLEARPART_DRIVES");
// a disk whose partition carries our label is excluded from clearpart
expect(ks).toContain('[ "$LONGHORN_REUSE" = "no" ] && CLEARPART_DRIVES=');
});
it("%pre resolves each by-id path and fails loudly when a disk is missing", () => {
const ks = renderInstallKickstart(baseParams());
expect(ks).toContain(`LONGHORN_DISK_ID="${LONGHORN_ID}"`);
expect(ks).toContain(`RANCHER_DISK_ID="${RANCHER_ID}"`);
expect(ks).toContain('echo "ERROR: longhorn disk not found: $LONGHORN_DISK_ID"; exit 1');
});
it("a labelled partition is reused --noformat; a fresh disk gets one labelled partition", () => {
const ks = renderInstallKickstart(baseParams());
expect(ks).toContain("part /var/lib/longhorn --onpart=$LONGHORN_PART --noformat");
expect(ks).toContain(
`part /var/lib/longhorn --fstype=xfs --ondisk=$LONGHORN_DISK --grow --size=1 --label=${DATA_DISK_LABELS.longhorn}`,
);
expect(ks).toContain("part /var/lib/rancher --onpart=$RANCHER_PART --noformat");
expect(ks).toContain(
`part /var/lib/rancher --fstype=xfs --ondisk=$RANCHER_DISK --grow --size=1 --label=${DATA_DISK_LABELS.rancher}`,
);
});
it("refuses to run when a data LV and a dedicated disk both exist", () => {
const ks = renderInstallKickstart(baseParams());
expect(ks).toContain('[ "$PRESERVE_LONGHORN" = "yes" ] && [ -n "$LONGHORN_DISK" ]');
expect(ks).toContain('[ "$PRESERVE_RANCHER" = "yes" ] && [ -n "$RANCHER_DISK" ]');
});
it("a fresh install never wipes or shadows a labelled disk it was not told about", () => {
// Rendered even WITHOUT dataDisks: the label is on-disk truth and the
// check protects against a queue entry that forgot the disks.
const ks = renderInstallKickstart(baseParams({ role: "k8s-worker", dataDisks: {} }));
expect(ks).toContain(`blkid -t LABEL=${DATA_DISK_LABELS.longhorn}`);
expect(ks).toContain("Refusing to wipe or shadow it");
});
it("an OS disk given as a by-id path is resolved in %pre", () => {
const ks = renderInstallKickstart(baseParams({ disk: OS_ID }));
expect(ks).toContain(`readlink -f "${OS_ID}"`);
expect(ks).not.toContain(`DISK="${OS_ID}"`); // never fed to anaconda unresolved
});
it("auto-detect never picks a data disk as the OS disk", () => {
const ks = renderInstallKickstart(baseParams({ disk: "" }));
expect(ks).toContain('case "$_bname" in "$LONGHORN_DISK"|"$RANCHER_DISK") continue ;; esac');
});
it("a plain kernel-name OS disk still renders exactly as before", () => {
const ks = renderInstallKickstart(baseParams({ disk: "vda", dataDisks: {} }));
expect(ks).toContain('DISK="vda"');
});
it("the %pre block still survives the /ks-auto extractor", () => {
const ks = renderInstallKickstart(baseParams());
const blocks = [...ks.matchAll(/%pre[^\n]*\n([\s\S]*?)%end/g)];
expect(blocks.length).toBeGreaterThan(0);
expect(blocks[0]![1]).toContain("/tmp/part.ks");
});
});
describe("dataDisks plumbing: /api/install -> /ks", () => {
let app: FastifyInstance;
let testDir: string;
function testConfig(dir: string): BastionConfig {
return {
fedoraVersion: "43", arch: "x86_64", httpPort: 0,
timezone: "Europe/London", locale: "en_GB.UTF-8",
bastionDir: dir, domain: "test.local", dhcpMode: "proxy",
dhcpRangeStart: "", dhcpRangeEnd: "",
ubuntuVersion: "26.04", ubuntuMirror: "https://releases.ubuntu.com/26.04",
vyosIsoUrl: "https://downloads.vyos.io/rolling/current/generic/vyos-rolling-latest.iso",
vyosDefaultPassword: "vyos", iface: "eth0",
serverIp: "10.0.0.1", network: "10.0.0.0", gateway: "10.0.0.1",
sshKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST test@test"],
adminUser: "testadmin", syslogPort: 15514,
skipDnsmasq: true, skipArtifacts: true,
fedoraMirror: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os",
tftpDir: join(dir, "tftp"), httpDir: join(dir, "http"), stateFile: join(dir, "state.json"),
};
}
beforeEach(async () => {
testDir = join(tmpdir(), `datadisks-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(testDir, { recursive: true });
const built = await createApp(testConfig(testDir));
app = built.app;
await app.ready();
});
afterEach(async () => {
await app.close();
rmSync(testDir, { recursive: true, force: true });
});
const queue = (body: Record<string, unknown>) =>
app.inject({ method: "POST", url: "/api/install", payload: { mac: "aa:bb:cc:dd:ee:03", hostname: "thebeast", ...body } });
const ks = async () => (await app.inject({ method: "GET", url: "/ks?mac=aa:bb:cc:dd:ee:03" })).body;
it("named disks reach the rendered kickstart, and their LVs are gone", async () => {
const res = await queue({ role: "k8s-worker", dataDisks: { longhorn: LONGHORN_ID, rancher: RANCHER_ID } });
expect(res.statusCode).toBe(200);
const body = await ks();
expect(body).toContain(LONGHORN_ID);
expect(body).toContain(RANCHER_ID);
expect(freshBlock(body)).not.toContain("--name=longhorn");
expect(freshBlock(body)).not.toContain("--name=rancher");
});
it("rejects dataDisks on a non-fedora install instead of silently dropping them", async () => {
const res = await queue({ role: "k8s-worker", os: "ubuntu-26.04", dataDisks: { longhorn: LONGHORN_ID } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/fedora/);
});
it("rejects dataDisks with an explicit v1 schema", async () => {
const res = await queue({ role: "worker", schemaVersion: 1, dataDisks: { longhorn: LONGHORN_ID } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/schemaVersion 2/);
});
it("rejects dataDisks for a legacy role that would default to v1", async () => {
const res = await queue({ role: "worker", dataDisks: { longhorn: LONGHORN_ID } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/schemaVersion 2/);
});
it("legacy role + explicit v2 + dataDisks is accepted", async () => {
const res = await queue({ role: "worker", schemaVersion: 2, dataDisks: { longhorn: LONGHORN_ID } });
expect(res.statusCode).toBe(200);
expect(await ks()).toContain(LONGHORN_ID);
});
it("rejects a kernel-name disk with the shared message", async () => {
const res = await queue({ role: "k8s-worker", dataDisks: { longhorn: "nvme1n1" } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/by-id/);
});
it("rejects a capability mismatch (longhorn disk on a control-plane-only role)", async () => {
const res = await queue({ role: "k8s-controller", dataDisks: { longhorn: LONGHORN_ID } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/no Longhorn replicas/);
});
it("an install without dataDisks is untouched by all of this", async () => {
expect((await queue({ role: "k8s-worker" })).statusCode).toBe(200);
const body = await ks();
expect(freshBlock(body)).toContain("--name=longhorn --fstype=xfs --size=307200");
expect(freshBlock(body)).toContain("--name=rancher --fstype=xfs --size=256000");
});
});

View File

@@ -91,6 +91,7 @@ export class LabdClient {
async installMachine(opts: {
mac: string; hostname: string; disk?: string; role?: string; os?: string;
schemaVersion?: 1 | 2;
dataDisks?: import("@lab/shared").DataDisks;
vyos?: import("@lab/shared").VyosInstallSpec;
}): Promise<{ status: string; data?: unknown; error?: string }> {
return this.request("POST", "/api/machines/install", { body: opts });

View File

@@ -3,8 +3,8 @@
import { readFileSync } from "node:fs";
import { Command, Option, InvalidArgumentError } from "commander";
import { isValidOsId, SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY } from "@lab/shared";
import type { VyosBundle, VyosInstallSpec, VyosVlanSpec } from "@lab/shared";
import { isValidOsId, SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isRole, requiresSchemaV2, validateDataDisks } from "@lab/shared";
import type { DataDisks, VyosBundle, VyosInstallSpec, VyosVlanSpec } from "@lab/shared";
import { getLabdClient } from "../api/config.js";
/**
@@ -106,8 +106,16 @@ export function registerInstallCommand(parent: Command): void {
.addHelpText("after", roleTable())
.addOption(new Option("--role <role>", "Machine role (see below)").choices([...SUPPORTED_ROLES]).default("worker"))
.addOption(new Option("--os <os>", "Operating system").choices([...SUPPORTED_OS]).default("fedora-43"))
.option("--disk <device>", "Target disk device (auto-detect if omitted)")
.option("--disk <device>", "Target OS disk: kernel name or stable /dev/disk/by-id/ path (auto-detect if omitted)")
.addOption(new Option("--schema-version <n>", "Disk layout for a FRESH install (default 1). Ignored on reprovision: the layout already on the disk wins.").choices(["1", "2"]))
.option(
"--longhorn-disk <by-id>",
"Dedicated whole disk for /var/lib/longhorn, as a stable /dev/disk/by-id/ path (fedora, schema v2 only)",
)
.option(
"--rancher-disk <by-id>",
"Dedicated whole disk for /var/lib/rancher, as a stable /dev/disk/by-id/ path (fedora, schema v2 only)",
)
.option("--vyos-mgmt <iface>", "VyOS: untagged interface the machine PXE boots from (default eth0)")
.option("--vyos-mgmt-address <addr>", "VyOS: CIDR for the management interface, or 'dhcp' (default dhcp)")
.option("--vyos-bond <ifaces>", "VyOS: comma-separated LACP bond members (must exclude the PXE NIC)")
@@ -140,6 +148,8 @@ export function registerInstallCommand(parent: Command): void {
/** commander gives us the string form of the --schema-version choice. */
schemaVersion?: string;
disk?: string;
longhornDisk?: string;
rancherDisk?: string;
vyosMgmt?: string;
vyosMgmtAddress?: string;
vyosBond?: string;
@@ -166,6 +176,32 @@ export function registerInstallCommand(parent: Command): void {
process.exit(1);
}
// Dedicated data disks -- validated here for fast feedback, and again on
// the bastion, which is the enforcement point.
const dataDisks: DataDisks = {
...(opts.longhornDisk !== undefined && opts.longhornDisk !== ""
? { longhorn: opts.longhornDisk } : {}),
...(opts.rancherDisk !== undefined && opts.rancherDisk !== ""
? { rancher: opts.rancherDisk } : {}),
};
const hasDataDisks = Object.keys(dataDisks).length > 0;
if (hasDataDisks) {
if (opts.os !== "fedora-43") {
console.error(`--longhorn-disk/--rancher-disk only work with --os fedora-43 (got --os ${opts.os})`);
process.exit(1);
}
if (opts.schemaVersion === "1"
|| (opts.schemaVersion === undefined && isRole(opts.role) && !requiresSchemaV2(opts.role))) {
console.error("--longhorn-disk/--rancher-disk need the v2 layout -- add --schema-version 2");
process.exit(1);
}
const problem = isRole(opts.role) ? validateDataDisks(opts.role, dataDisks) : null;
if (problem !== null) {
console.error(`Invalid data disks: ${problem}`);
process.exit(1);
}
}
const bondMembers = opts.vyosBond !== undefined && opts.vyosBond !== ""
? opts.vyosBond.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
: [];
@@ -257,6 +293,7 @@ export function registerInstallCommand(parent: Command): void {
role: opts.role,
os: opts.os,
...(opts.disk ? { disk: opts.disk } : {}),
...(hasDataDisks ? { dataDisks } : {}),
...(hasVyosOptions ? { vyos } : {}),
});

View File

@@ -164,9 +164,9 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
// Queue install — route to correct bastion by MAC
app.post<{
Body: { mac?: string; hostname?: string; disk?: string; role?: string; os?: string; vyos?: VyosInstallSpec };
Body: { mac?: string; hostname?: string; disk?: string; role?: string; os?: string; schemaVersion?: 1 | 2; dataDisks?: import("@lab/shared").DataDisks; vyos?: VyosInstallSpec };
}>("/api/machines/install", async (request, reply) => {
const { mac, hostname, disk, role, os, vyos } = request.body ?? {};
const { mac, hostname, disk, role, os, schemaVersion, dataDisks, vyos } = request.body ?? {};
if (!mac || !hostname) {
return reply.code(400).send({ error: "mac and hostname are required" });
}
@@ -184,6 +184,8 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
const result = await sendCommand(all[0]!.bastionId, {
type: "command-install",
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
...(schemaVersion ? { schemaVersion } : {}),
...(dataDisks ? { dataDisks } : {}),
...(vyos ? { vyos } : {}),
});
return reply.code(result.status === "ok" ? 200 : 500).send(result);
@@ -198,6 +200,8 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
const result = await sendCommand(bastion.bastionId, {
type: "command-install",
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
...(schemaVersion ? { schemaVersion } : {}),
...(dataDisks ? { dataDisks } : {}),
...(vyos ? { vyos } : {}),
});
return reply.code(result.status === "ok" ? 200 : 500).send(result);

View File

@@ -51,5 +51,6 @@ export {
} from "./errors/index.js";
export { v2Layout, v2AllocatedMib, V2_BOOT, V2_RANCHER_MIB, V2_LONGHORN_MIB,
SCHEMA_VERSIONS, CONTAINERD_BIND_FSTAB, LEGACY_ROLES, requiresSchemaV2, effectiveSchemaVersion } from "./types/index.js";
export type { LvSpec, SchemaVersion } from "./types/index.js";
SCHEMA_VERSIONS, CONTAINERD_BIND_FSTAB, LEGACY_ROLES, requiresSchemaV2, effectiveSchemaVersion,
validateDataDisks, DATA_DISK_LABELS } from "./types/index.js";
export type { LvSpec, SchemaVersion, DataDisks, DataDiskName } from "./types/index.js";

View File

@@ -109,7 +109,7 @@ export type BastionMessage =
export type LabdBastionMessage =
| { type: "bastion-enrolled"; bastionId: string }
| { type: "bastion-heartbeat-ack"; serverTime: string }
| { type: "command-install"; requestId: string; mac: string; hostname: string; disk?: string; role: string; os: string; vyos?: VyosInstallSpec }
| { type: "command-install"; requestId: string; mac: string; hostname: string; disk?: string; role: string; os: string; schemaVersion?: 1 | 2; dataDisks?: import("../types/layout.js").DataDisks; vyos?: VyosInstallSpec }
| { type: "command-forget"; requestId: string; mac: string }
| { type: "command-role-update"; requestId: string; mac: string; role: string }
| { type: "command-debug"; requestId: string; mac: string; pxeBoot?: boolean }

View File

@@ -20,5 +20,6 @@ export type { RoleInfo, Lifecycle, K3sMode } from "./state.js";
export type { BastionConfig } from "./config.js";
export { v2Layout, v2AllocatedMib, V2_BOOT, V2_RANCHER_MIB, V2_LONGHORN_MIB,
SCHEMA_VERSIONS, CONTAINERD_BIND_FSTAB, LEGACY_ROLES, requiresSchemaV2, effectiveSchemaVersion } from "./layout.js";
export type { LvSpec, SchemaVersion } from "./layout.js";
SCHEMA_VERSIONS, CONTAINERD_BIND_FSTAB, LEGACY_ROLES, requiresSchemaV2, effectiveSchemaVersion,
validateDataDisks, DATA_DISK_LABELS } from "./layout.js";
export type { LvSpec, SchemaVersion, DataDisks, DataDiskName } from "./layout.js";

View File

@@ -94,13 +94,80 @@ const V2_BASE: readonly LvSpec[] = [
export const V2_RANCHER_MIB = 256000; // 250 GiB
export const V2_LONGHORN_MIB = 307200; // 300 GiB
/**
* Dedicated whole-disk data volumes (v2 only).
*
* The fixed 250G/300G LVs exist to FIT a 1 TB OS disk. A machine with spare
* NVMe bays (thebeast: 1 TB OS + 2 TB + 2 TB) wants the opposite trade: give
* each data mount a whole disk and keep the OS VG small. Each value is a
* stable /dev/disk/by-id/ path -- NEVER a kernel name, because nvme0n1/nvme1n1
* enumeration order is not stable across boots on multi-NVMe hosts, and a
* swapped name here formats the wrong disk.
*
* The disk gets one partition spanning it, xfs, labelled (DATA_DISK_LABELS)
* so a reprovision can recognise and preserve it the same way the LVs are
* recognised by name inside the VG. The corresponding LV is simply not
* created -- v2Layout() drops it when a dedicated disk supplies that mount.
*/
export interface DataDisks {
/** Whole disk for /var/lib/longhorn (replica store). */
longhorn?: string;
/** Whole disk for /var/lib/rancher (image store + local-path PVCs). */
rancher?: string;
}
/**
* Filesystem labels that mark a dedicated data disk's partition. They are the
* reprovision contract: %pre finds these by label and mounts them --noformat,
* exactly as it finds LVs by name. Changing a label orphans every disk
* already carrying the old one.
*/
export const DATA_DISK_LABELS = {
longhorn: "lab-longhorn",
rancher: "lab-rancher",
} as const;
export type DataDiskName = keyof typeof DATA_DISK_LABELS;
const BY_ID_PREFIX = "/dev/disk/by-id/";
/**
* Why a DataDisks spec is unusable for this role, or null when it is fine.
* Shared by the HTTP API, the websocket command handler and the CLI so all
* three reject with the same words instead of three drifting checks.
*/
export function validateDataDisks(role: Role, disks: DataDisks): string | null {
const info = roleInfo(role);
for (const name of Object.keys(DATA_DISK_LABELS) as DataDiskName[]) {
const device = disks[name];
if (device === undefined) continue;
if (!device.startsWith(BY_ID_PREFIX)) {
return `${name} disk must be a stable ${BY_ID_PREFIX} path (got '${device}'). ` +
`Kernel names like nvme0n1 change across boots on multi-NVMe hosts.`;
}
}
if (disks.rancher !== undefined && info.k3sMode === null) {
return `role '${role}' runs no k3s -- a dedicated rancher disk makes no sense for it`;
}
if (disks.longhorn !== undefined && (info.k3sMode === null || info.controlPlaneOnly)) {
return `role '${role}' holds no Longhorn replicas -- a dedicated longhorn disk makes no sense for it`;
}
if (disks.longhorn !== undefined && disks.longhorn === disks.rancher) {
return `longhorn and rancher point at the same disk (${disks.longhorn}) -- they need one each`;
}
return null;
}
/**
* The v2 volumes for a role, gated by CAPABILITY rather than role name.
*
* `omitRoot` is for Asahi, where the vendor installer already created a btrfs
* root partition outside our VG and we must not make a second one.
*
* `dataDisks` drops the longhorn/rancher LV whose mount a dedicated disk
* supplies -- two things mounting /var/lib/longhorn is an invalid layout.
*/
export function v2Layout(role: Role, opts: { omitRoot?: boolean } = {}): LvSpec[] {
export function v2Layout(role: Role, opts: { omitRoot?: boolean; dataDisks?: DataDisks } = {}): LvSpec[] {
const info = roleInfo(role);
const lvs: LvSpec[] = V2_BASE.filter(
(lv) => !(opts.omitRoot && lv.name === "root"),
@@ -109,12 +176,12 @@ export function v2Layout(role: Role, opts: { omitRoot?: boolean } = {}): LvSpec[
// Anything running k3s needs a dedicated image store. This is the check that
// `role === "infra" || role === "worker"` kept getting wrong: labcontroller
// runs k3s and was silently excluded.
if (info.k3sMode !== null) {
if (info.k3sMode !== null && opts.dataDisks?.rancher === undefined) {
lvs.push({ name: "rancher", mount: "/var/lib/rancher", sizeMib: V2_RANCHER_MIB, fs: "xfs" });
}
// Fixed, never --grow. A control-plane-only node holds no replicas.
if (info.k3sMode !== null && !info.controlPlaneOnly) {
if (info.k3sMode !== null && !info.controlPlaneOnly && opts.dataDisks?.longhorn === undefined) {
lvs.push({ name: "longhorn", mount: "/var/lib/longhorn", sizeMib: V2_LONGHORN_MIB, fs: "xfs" });
}
@@ -122,7 +189,7 @@ export function v2Layout(role: Role, opts: { omitRoot?: boolean } = {}): LvSpec[
}
/** Total MiB a role's v2 layout consumes inside the VG. */
export function v2AllocatedMib(role: Role, opts: { omitRoot?: boolean } = {}): number {
export function v2AllocatedMib(role: Role, opts: { omitRoot?: boolean; dataDisks?: DataDisks } = {}): number {
return v2Layout(role, opts).reduce((n, lv) => n + lv.sizeMib, 0);
}

View File

@@ -356,6 +356,13 @@ export interface InstallConfig {
* disk instead, because reinstalling a machine must never change its layout.
*/
schemaVersion?: 1 | 2;
/**
* Dedicated whole-disk data volumes (v2 + fedora only), by stable
* /dev/disk/by-id path. See DataDisks in layout.ts. On a reprovision these
* are optional: %pre rediscovers the disks by filesystem label and preserves
* them whether or not the queue entry names them.
*/
dataDisks?: import("./layout.js").DataDisks;
queued_at: string;
/**
* When dispatch last served this machine an install boot script. Progress

View File

@@ -0,0 +1,107 @@
# thebeast → prod k8s worker — conversion runbook
Converting the old desktop **thebeast** (currently Fedora/ZFS desktop at
`10.0.1.22`) into a `k8s-worker` node of the **prod cluster (k8s0)** via the
PXE bastion, using the dedicated-data-disk layout (`--longhorn-disk` /
`--rancher-disk`, introduced with this runbook).
Decisions already taken (2026-10-14): Windows disk is sacrificed (unused for
years, no backup wanted) · cluster = prod k8s0 · OS = Fedora `k8s-worker`
(thebeast is mainly an AMD-GPU machine: 2× AMD + 1× RTX 3080 Ti, no unified
memory) · the node will carry a dedicated taint and also serve CI/CD.
## Disk map — ALWAYS by-id, never nvme0n1
Kernel NVMe names are **not stable across boots on this machine** (observed).
Every command below uses `/dev/disk/by-id`.
| target | disk | by-id |
|---|---|---|
| OS (wipes Windows) | Samsung 970 EVO Plus 1TB | `nvme-Samsung_SSD_970_EVO_Plus_1TB_S4EWNJ0N138234M` |
| /var/lib/longhorn | Sabrent Rocket 4.0 2TB | `nvme-Sabrent_Rocket_4.0_2TB_7F600711179D89261778` |
| /var/lib/rancher | Sabrent Rocket 4.0 2TB | `nvme-Sabrent_Rocket_4.0_2TB_7FD4070C1EDA92197301` |
| DISCONNECT after final shutdown | Samsung 970 EVO Plus 2TB `S4J4NF0NC00809J` | sits on its own motherboard add-on card, PCIe x4, model discontinued |
NICs: `enp69s0` `34:97:f6:32:76:e9` (current uplink — this MAC is what the
bastion will see), `enp70s0` `34:97:f6:31:b3:99` (down).
## 0. Before the final shutdown — data
**rpool is a 4-wide raidz1 already DEGRADED** (member
`S4J4NF0R102776W` removed since 2026-09-14). There is **zero redundancy
left**: one more failure loses the pool, and a disk pulled out of it is NOT
importable alone. Finish the copies first, then never boot the desktop again.
Already safe (verified 2026-10-14): `~/developer` (all 113 repos) + the
credential/dotfile set → bazzite (Oct 12 rsync, 1 file of drift).
Still to copy before shutdown (≈27 GB + small dirs):
- `~/keyssecrets` (57K) and `~/dicesecrets` (5.8M) — **explicitly flagged
"not copied" in the Oct 12 audit**
- `~/Videos/canaries` (24G drone footage), `~/Pictures` (479M),
`~/Videos/{Webcam,subscribescreen,Screencasts,music}` (~370M),
`~/Documents` (6.8M), `~/opt/vaultbackup` (3.1M, OpenBao backups),
`~/migration-backup` (1.1G, archival), `~/backups/sogo-final-2026-09-13`
- Verify the Syncthing replicas on synology0-nas for `~/Logseq`,
`~/DICESharedBoostNote`, `~/Boostnote`, `~/MangaReader_backup` — thebeast's
syncthing shows 0 connected peers, so the NAS copy is **unverified**
- Final re-sync of `~/.claude` + atuin (activity postdates the Oct 12 copy)
## 1. Physical + network
1. Final desktop shutdown.
2. Disconnect the Samsung 2TB `S4J4NF0NC00809J` (the add-on card).
3. Move thebeast's switch port to the `192.168.8.0/24` VLAN (same as k8s0 and
labmaster). The bastion's proxy-DHCP (`dhcp-range=192.168.8.0,proxy`)
coexists with the real DHCP there — no DHCP changes needed.
4. Make sure network boot is enabled/first in firmware (UEFI PXE, IPv4).
## 2. Discovery
PXE boot. Unknown MAC → discovery image → the machine self-reports:
```
labctl provision list # thebeast appears under discovered (MAC 34:97:f6:32:76:e9)
```
## 3. Install
```
labctl provision install 34:97:f6:32:76:e9 thebeast \
--role k8s-worker \
--disk /dev/disk/by-id/nvme-Samsung_SSD_970_EVO_Plus_1TB_S4EWNJ0N138234M \
--longhorn-disk /dev/disk/by-id/nvme-Sabrent_Rocket_4.0_2TB_7F600711179D89261778 \
--rancher-disk /dev/disk/by-id/nvme-Sabrent_Rocket_4.0_2TB_7FD4070C1EDA92197301
```
(`k8s-worker` is v2-only, so no `--schema-version` needed.)
What the layout does: OS disk gets EFI + /boot + `labvg` with the v2 base LVs
only — **no** longhorn/rancher LVs. Each 2 TB disk gets ONE xfs partition
spanning it, labelled `lab-longhorn` / `lab-rancher`, mounted at
`/var/lib/longhorn` / `/var/lib/rancher`. The ZFS signatures on those disks
are wiped by the install — which is the point of section 0.
Reinstall semantics from then on: the labels are on-disk truth. A reprovision
rediscovers both data disks by label (no flags needed) and mounts them
`--noformat`; a fresh install refuses to wipe or shadow a labelled disk it was
not told about.
## 4. Join k8s0
As on every k8s-worker, k3s is installed with `INSTALL_K3S_SKIP_START`; the
join is deliberate (see `/root/README` on the node):
```
curl -sfL https://get.k3s.io | K3S_URL=https://<k8s0-server>:6443 K3S_TOKEN=<token> sh -
```
Add the agreed CI/CD taint/labels at join time (k3s agent flags), e.g.:
```
--node-taint dedicated=cicd:NoSchedule --node-label itaz.eu/cicd=true
```
(exact taint TBD with the Pulumi model; GPU bits — ROCm for the 2 AMD cards,
nvidia for the 3080 Ti — are cluster-side follow-ups in
thelab-kubernetes-pulumi, not part of the install.)