Compare commits
11 Commits
feat/v2-pa
...
feat/dedic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fe71eba63 | ||
|
|
745f9100c8 | ||
|
|
8e00898c6f | ||
|
|
16659236f2 | ||
|
|
b26733015f | ||
|
|
ddd80ce2ea | ||
|
|
88848f04ac | ||
|
|
9bbaf2a385 | ||
|
|
b15eabe191 | ||
|
|
ee171a5f53 | ||
|
|
66182c8cb8 |
@@ -28,6 +28,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
package_json_file: bastion/package.json
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -47,6 +49,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
package_json_file: bastion/package.json
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -54,6 +58,9 @@ jobs:
|
|||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Prisma client (labd typecheck needs it)
|
||||||
|
run: pnpm -C src/labd run db:generate
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
|
|
||||||
@@ -66,6 +73,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
package_json_file: bastion/package.json
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -73,6 +82,15 @@ jobs:
|
|||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Prisma client (labd typecheck needs it)
|
||||||
|
run: pnpm -C src/labd run db:generate
|
||||||
|
|
||||||
|
- name: Install test validators (ubuntu-autoinstall parses YAML via python, kickstart.test runs ksvalidator, the bastion smoke start runs `ip route`)
|
||||||
|
# pykickstart is not in the runner image's apt sources -- pip it is.
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq python3-yaml python3-pip iproute2
|
||||||
|
sudo pip3 install --quiet --break-system-packages pykickstart
|
||||||
|
|
||||||
- name: Build (needed by completions check)
|
- name: Build (needed by completions check)
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
@@ -91,6 +109,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
package_json_file: bastion/package.json
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
@@ -99,6 +119,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Prisma client (labd typecheck needs it)
|
||||||
|
run: pnpm -C src/labd run db:generate
|
||||||
|
|
||||||
- name: Build all packages
|
- name: Build all packages
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
@@ -123,13 +146,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Package x86_64 RPM + DEB
|
- name: Package x86_64 RPM + DEB
|
||||||
run: |
|
run: |
|
||||||
sed -e 's|^arch:.*|arch: amd64|' -e 's|src: ./dist/lab$|src: ./dist/lab-x86_64|' nfpm.yaml > /tmp/nfpm-x86_64.yaml
|
sed -e 's|^arch:.*|arch: amd64|' -e 's|src: ./dist/labctl$|src: ./dist/lab-x86_64|' nfpm.yaml > /tmp/nfpm-x86_64.yaml
|
||||||
nfpm pkg --config /tmp/nfpm-x86_64.yaml --packager rpm --target dist/
|
nfpm pkg --config /tmp/nfpm-x86_64.yaml --packager rpm --target dist/
|
||||||
nfpm pkg --config /tmp/nfpm-x86_64.yaml --packager deb --target dist/
|
nfpm pkg --config /tmp/nfpm-x86_64.yaml --packager deb --target dist/
|
||||||
|
|
||||||
- name: Package arm64 RPM + DEB
|
- name: Package arm64 RPM + DEB
|
||||||
run: |
|
run: |
|
||||||
sed -e 's|^arch:.*|arch: arm64|' -e 's|src: ./dist/lab$|src: ./dist/lab-arm64|' nfpm.yaml > /tmp/nfpm-arm64.yaml
|
sed -e 's|^arch:.*|arch: arm64|' -e 's|src: ./dist/labctl$|src: ./dist/lab-arm64|' nfpm.yaml > /tmp/nfpm-arm64.yaml
|
||||||
nfpm pkg --config /tmp/nfpm-arm64.yaml --packager rpm --target dist/
|
nfpm pkg --config /tmp/nfpm-arm64.yaml --packager rpm --target dist/
|
||||||
nfpm pkg --config /tmp/nfpm-arm64.yaml --packager deb --target dist/
|
nfpm pkg --config /tmp/nfpm-arm64.yaml --packager deb --target dist/
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ _labctl() {
|
|||||||
"app labcontroller status")
|
"app labcontroller status")
|
||||||
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
|
||||||
return ;;
|
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")
|
"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 ;;
|
return ;;
|
||||||
"app k3s health")
|
"app k3s health")
|
||||||
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--user -h --help" -- "$cur"))
|
||||||
@@ -59,7 +65,7 @@ _labctl() {
|
|||||||
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
"provision install")
|
"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 ;;
|
return ;;
|
||||||
"provision reprovision")
|
"provision reprovision")
|
||||||
COMPREPLY=($(compgen -W "--role --os --disk --user -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--role --os --disk --user -h --help" -- "$cur"))
|
||||||
@@ -101,7 +107,7 @@ _labctl() {
|
|||||||
COMPREPLY=($(compgen -W "deploy status -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "deploy status -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
"app k3s")
|
"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 ;;
|
return ;;
|
||||||
"version")
|
"version")
|
||||||
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||||
|
|||||||
@@ -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'
|
complete -c labctl -n "__labctl_using_cmd provision" -a recheck -d 'Refresh hardware info for all installed machines via SSH'
|
||||||
|
|
||||||
# provision install options
|
# 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 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 -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-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
|
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-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-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-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
|
# 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 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 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
|
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)'
|
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
|
# 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
|
complete -c labctl -n "__labctl_in_cmd provision register" -l ip -d 'Machine IP address' -x
|
||||||
|
|
||||||
# provision logs options
|
# 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
|
complete -c labctl -n "__labctl_in_cmd app labcontroller status" -l user -d 'SSH user' -x
|
||||||
|
|
||||||
# app k3s subcommands
|
# 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 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 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 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'
|
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
|
# 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 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-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 (required for worker role)' -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
|
# app k3s health options
|
||||||
complete -c labctl -n "__labctl_in_cmd app k3s health" -l user -d 'SSH user' -x
|
complete -c labctl -n "__labctl_in_cmd app k3s health" -l user -d 'SSH user' -x
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync, symlinkSync, unlinkSync } from "node:fs";
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync, symlinkSync, unlinkSync } from "node:fs";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import type { BastionConfig } from "@lab/shared";
|
import type { BastionConfig } from "@lab/shared";
|
||||||
|
import { isRole, validateDataDisks } from "@lab/shared";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { populateNetworkConfig } from "./services/network.js";
|
import { populateNetworkConfig } from "./services/network.js";
|
||||||
import { createApp } from "./server.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
|
// Wire up command handlers so labd can send install/forget/role commands
|
||||||
labdConn.onCommand("command-install", async (msg) => {
|
labdConn.onCommand("command-install", async (msg) => {
|
||||||
if (msg.type !== "command-install") throw new Error("unexpected");
|
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) => {
|
state.update((s) => {
|
||||||
s.install_queue[msg.mac] = {
|
s.install_queue[msg.mac] = {
|
||||||
hostname: msg.hostname,
|
hostname: msg.hostname,
|
||||||
@@ -391,6 +408,8 @@ export async function startBastion(overrides: Partial<BastionConfig> = {}): Prom
|
|||||||
role: msg.role as import("@lab/shared").Role,
|
role: msg.role as import("@lab/shared").Role,
|
||||||
os: msg.os as import("@lab/shared").OsId,
|
os: msg.os as import("@lab/shared").OsId,
|
||||||
queued_at: new Date().toISOString(),
|
queued_at: new Date().toISOString(),
|
||||||
|
...(msg.schemaVersion ? { schemaVersion: msg.schemaVersion } : {}),
|
||||||
|
...(hasDataDisks ? { dataDisks: msg.dataDisks } : {}),
|
||||||
...(msg.vyos ? { vyos: msg.vyos } : {}),
|
...(msg.vyos ? { vyos: msg.vyos } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { progressBus } from "../services/progress-events.js";
|
|||||||
import type { ProgressEvent } from "../services/progress-events.js";
|
import type { ProgressEvent } from "../services/progress-events.js";
|
||||||
import type { InstallLogBuffer } from "../services/install-log.js";
|
import type { InstallLogBuffer } from "../services/install-log.js";
|
||||||
import type { SyslogListener } from "../services/syslog-listener.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.
|
* Seconds after dispatch with zero progress before a machine is called stalled.
|
||||||
@@ -45,9 +45,10 @@ export function registerApiRoutes(
|
|||||||
os?: string;
|
os?: string;
|
||||||
vyos?: VyosInstallSpec;
|
vyos?: VyosInstallSpec;
|
||||||
schemaVersion?: number;
|
schemaVersion?: number;
|
||||||
|
dataDisks?: DataDisks;
|
||||||
};
|
};
|
||||||
}>("/api/install", async (request, reply) => {
|
}>("/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, ":");
|
const mac = (rawMac ?? "").toLowerCase().replace(/-/g, ":");
|
||||||
|
|
||||||
if (mac === "") {
|
if (mac === "") {
|
||||||
@@ -79,6 +80,36 @@ export function registerApiRoutes(
|
|||||||
return reply.status(400).send({ error: `invalid os: '${osId}'. Supported: ${SUPPORTED_OS.join(", ")}` });
|
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) => {
|
state.update((s) => {
|
||||||
s.install_queue[mac] = {
|
s.install_queue[mac] = {
|
||||||
hostname: hostname ?? "lab-node",
|
hostname: hostname ?? "lab-node",
|
||||||
@@ -87,6 +118,7 @@ export function registerApiRoutes(
|
|||||||
os: osId,
|
os: osId,
|
||||||
queued_at: new Date().toISOString(),
|
queued_at: new Date().toISOString(),
|
||||||
...(schemaVersion ? { schemaVersion: schemaVersion as 1 | 2 } : {}),
|
...(schemaVersion ? { schemaVersion: schemaVersion as 1 | 2 } : {}),
|
||||||
|
...(hasDataDisks ? { dataDisks } : {}),
|
||||||
...(vyos ? { vyos } : {}),
|
...(vyos ? { vyos } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function registerKickstartRoutes(
|
|||||||
disk: queueEntry?.disk ?? "",
|
disk: queueEntry?.disk ?? "",
|
||||||
role: queueEntry?.role ?? "worker",
|
role: queueEntry?.role ?? "worker",
|
||||||
...(queueEntry?.schemaVersion ? { schemaVersion: queueEntry.schemaVersion } : {}),
|
...(queueEntry?.schemaVersion ? { schemaVersion: queueEntry.schemaVersion } : {}),
|
||||||
|
...(queueEntry?.dataDisks ? { dataDisks: queueEntry.dataDisks } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.type("text/plain").send(ks);
|
return reply.type("text/plain").send(ks);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function generateInstallKickstart(
|
|||||||
disk: string;
|
disk: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
schemaVersion?: 1 | 2;
|
schemaVersion?: 1 | 2;
|
||||||
|
dataDisks?: import("@lab/shared").DataDisks;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
const ksParams: InstallKickstartParams = {
|
const ksParams: InstallKickstartParams = {
|
||||||
@@ -32,6 +33,7 @@ export function generateInstallKickstart(
|
|||||||
disk: params.disk,
|
disk: params.disk,
|
||||||
role: params.role,
|
role: params.role,
|
||||||
...(params.schemaVersion ? { schemaVersion: params.schemaVersion } : {}),
|
...(params.schemaVersion ? { schemaVersion: params.schemaVersion } : {}),
|
||||||
|
...(params.dataDisks ? { dataDisks: params.dataDisks } : {}),
|
||||||
domain: config.domain,
|
domain: config.domain,
|
||||||
fedoraVersion: config.fedoraVersion,
|
fedoraVersion: config.fedoraVersion,
|
||||||
timezone: config.timezone,
|
timezone: config.timezone,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
// Full Fedora server install with LVM partitioning, %pre for reprovision detection,
|
// Full Fedora server install with LVM partitioning, %pre for reprovision detection,
|
||||||
// packages, and %post with SSH keys, user creation, k3s prereqs, progress callbacks.
|
// packages, and %post with SSH keys, user creation, k3s prereqs, progress callbacks.
|
||||||
|
|
||||||
import type { Role } from "@lab/shared";
|
import type { Role, DataDisks, DataDiskName } from "@lab/shared";
|
||||||
import { v2Layout, V2_BOOT, requiresSchemaV2, effectiveSchemaVersion, isK3sServer, type LvSpec } from "@lab/shared";
|
import {
|
||||||
|
v2Layout, V2_BOOT, requiresSchemaV2, effectiveSchemaVersion, isK3sServer,
|
||||||
|
validateDataDisks, DATA_DISK_LABELS, type LvSpec,
|
||||||
|
} from "@lab/shared";
|
||||||
|
|
||||||
export interface InstallKickstartParams {
|
export interface InstallKickstartParams {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
@@ -27,6 +30,13 @@ export interface InstallKickstartParams {
|
|||||||
* never silently change its layout -- that is how data gets destroyed.
|
* never silently change its layout -- that is how data gets destroyed.
|
||||||
*/
|
*/
|
||||||
schemaVersion?: 1 | 2;
|
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 {
|
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).`,
|
`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 v2RootMib = v2Layout(role).find((lv: LvSpec) => lv.name === "root")?.sizeMib ?? 32768;
|
||||||
const v2 = v2Layout(role);
|
const v2 = v2Layout(role, { dataDisks });
|
||||||
const v2LogvolLines = v2
|
const v2LogvolLines = v2
|
||||||
.map((lv: LvSpec) =>
|
.map((lv: LvSpec) =>
|
||||||
lv.fs === "swap"
|
lv.fs === "swap"
|
||||||
@@ -149,14 +175,29 @@ chmod 440 /etc/sudoers.d/${adminUser}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- Disk detection --
|
// -- 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
|
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=""
|
: `DISK=""
|
||||||
# Wait up to 10s for NVMe/SCSI disks to appear (they init async in initrd)
|
# Wait up to 10s for NVMe/SCSI disks to appear (they init async in initrd)
|
||||||
for _wait in $(seq 1 10); do
|
for _wait in $(seq 1 10); do
|
||||||
for d in /dev/nvme0n1 /dev/nvme1n1 /dev/sda /dev/sdb /dev/vda; do
|
for d in /dev/nvme0n1 /dev/nvme1n1 /dev/sda /dev/sdb /dev/vda; do
|
||||||
[ -b "$d" ] || continue
|
[ -b "$d" ] || continue
|
||||||
_bname=$(basename "$d")
|
_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)
|
# Skip removable disks (USB, CD-ROM, JetKVM virtual media)
|
||||||
[ -f "/sys/block/$_bname/removable" ] && [ "$(cat /sys/block/$_bname/removable)" = "1" ] && continue
|
[ -f "/sys/block/$_bname/removable" ] && [ "$(cat /sys/block/$_bname/removable)" = "1" ] && continue
|
||||||
# Skip USB-attached disks (JetKVM virtual media shows as SCSI over USB)
|
# Skip USB-attached disks (JetKVM virtual media shows as SCSI over USB)
|
||||||
@@ -172,6 +213,80 @@ for _wait in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
[ -z "$DISK" ] && { echo "ERROR: no disk found"; exit 1; }`;
|
[ -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 --
|
// -- Longhorn LV for fresh install --
|
||||||
const longhornFreshLine = hasLonghorn
|
const longhornFreshLine = hasLonghorn
|
||||||
? `logvol /var/lib/longhorn --vgname=${vg} --name=longhorn --fstype=xfs --grow --size=1`
|
? `logvol /var/lib/longhorn --vgname=${vg} --name=longhorn --fstype=xfs --grow --size=1`
|
||||||
@@ -229,6 +344,9 @@ bastion_progress() {
|
|||||||
bastion_progress "partitioning" "detecting disk"
|
bastion_progress "partitioning" "detecting disk"
|
||||||
|
|
||||||
VG="${vg}"
|
VG="${vg}"
|
||||||
|
|
||||||
|
${dataDiskResolveBlocks}
|
||||||
|
|
||||||
${diskLine}
|
${diskLine}
|
||||||
|
|
||||||
REPROVISION=no
|
REPROVISION=no
|
||||||
@@ -265,6 +383,19 @@ else
|
|||||||
bastion_progress "partitioning" "fresh install on $DISK"
|
bastion_progress "partitioning" "fresh install on $DISK"
|
||||||
fi
|
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
|
if [ "$REPROVISION" = "yes" ]; then
|
||||||
# Find existing boot partitions by type
|
# Find existing boot partitions by type
|
||||||
EFI_PART=$(blkid -t TYPE=vfat -o device /dev/\${DISK}* 2>/dev/null | head -1)
|
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
|
# Build partition config reusing existing PV/VG
|
||||||
cat > /tmp/part.ks << PARTEOF
|
cat > /tmp/part.ks << PARTEOF
|
||||||
ignoredisk --only-use=$DISK
|
ignoredisk --only-use=$KS_DISKS
|
||||||
clearpart --none
|
clearpart --none
|
||||||
part /boot/efi --onpart=$EFI_PART --fstype=efi
|
part /boot/efi --onpart=$EFI_PART --fstype=efi
|
||||||
part /boot --onpart=$BOOT_PART --fstype=ext4
|
part /boot --onpart=$BOOT_PART --fstype=ext4
|
||||||
@@ -320,8 +451,8 @@ PARTEOF
|
|||||||
else
|
else
|
||||||
# Fresh install
|
# Fresh install
|
||||||
${schemaVersion === 2 ? ` cat > /tmp/part.ks << PARTEOF
|
${schemaVersion === 2 ? ` cat > /tmp/part.ks << PARTEOF
|
||||||
ignoredisk --only-use=$DISK
|
ignoredisk --only-use=$KS_DISKS
|
||||||
clearpart --all --initlabel --drives=$DISK
|
clearpart --all --initlabel --drives=$CLEARPART_DRIVES
|
||||||
part /boot/efi --fstype=efi --size=${V2_BOOT.efiMib} --ondisk=$DISK
|
part /boot/efi --fstype=efi --size=${V2_BOOT.efiMib} --ondisk=$DISK
|
||||||
part /boot --fstype=ext4 --size=${V2_BOOT.bootMib} --ondisk=$DISK
|
part /boot --fstype=ext4 --size=${V2_BOOT.bootMib} --ondisk=$DISK
|
||||||
part pv.01 --size=1 --grow --ondisk=$DISK
|
part pv.01 --size=1 --grow --ondisk=$DISK
|
||||||
@@ -349,6 +480,8 @@ ${rancherFreshLine}
|
|||||||
PARTEOF`}
|
PARTEOF`}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
${dataDiskPartBlocks}
|
||||||
|
|
||||||
echo "=== Generated partition config ==="
|
echo "=== Generated partition config ==="
|
||||||
cat /tmp/part.ks
|
cat /tmp/part.ks
|
||||||
echo "==================================="
|
echo "==================================="
|
||||||
|
|||||||
274
bastion/src/bastion/tests/data-disks.test.ts
Normal file
274
bastion/src/bastion/tests/data-disks.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,6 +91,7 @@ export class LabdClient {
|
|||||||
async installMachine(opts: {
|
async installMachine(opts: {
|
||||||
mac: string; hostname: string; disk?: string; role?: string; os?: string;
|
mac: string; hostname: string; disk?: string; role?: string; os?: string;
|
||||||
schemaVersion?: 1 | 2;
|
schemaVersion?: 1 | 2;
|
||||||
|
dataDisks?: import("@lab/shared").DataDisks;
|
||||||
vyos?: import("@lab/shared").VyosInstallSpec;
|
vyos?: import("@lab/shared").VyosInstallSpec;
|
||||||
}): Promise<{ status: string; data?: unknown; error?: string }> {
|
}): Promise<{ status: string; data?: unknown; error?: string }> {
|
||||||
return this.request("POST", "/api/machines/install", { body: opts });
|
return this.request("POST", "/api/machines/install", { body: opts });
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { Command, Option, InvalidArgumentError } from "commander";
|
import { Command, Option, InvalidArgumentError } from "commander";
|
||||||
import { isValidOsId, SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY } from "@lab/shared";
|
import { isValidOsId, SUPPORTED_OS, SUPPORTED_ROLES, ROLE_REGISTRY, isRole, requiresSchemaV2, validateDataDisks } from "@lab/shared";
|
||||||
import type { VyosBundle, VyosInstallSpec, VyosVlanSpec } from "@lab/shared";
|
import type { DataDisks, VyosBundle, VyosInstallSpec, VyosVlanSpec } from "@lab/shared";
|
||||||
import { getLabdClient } from "../api/config.js";
|
import { getLabdClient } from "../api/config.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,8 +106,16 @@ export function registerInstallCommand(parent: Command): void {
|
|||||||
.addHelpText("after", roleTable())
|
.addHelpText("after", roleTable())
|
||||||
.addOption(new Option("--role <role>", "Machine role (see below)").choices([...SUPPORTED_ROLES]).default("worker"))
|
.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"))
|
.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"]))
|
.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 <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-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)")
|
.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. */
|
/** commander gives us the string form of the --schema-version choice. */
|
||||||
schemaVersion?: string;
|
schemaVersion?: string;
|
||||||
disk?: string;
|
disk?: string;
|
||||||
|
longhornDisk?: string;
|
||||||
|
rancherDisk?: string;
|
||||||
vyosMgmt?: string;
|
vyosMgmt?: string;
|
||||||
vyosMgmtAddress?: string;
|
vyosMgmtAddress?: string;
|
||||||
vyosBond?: string;
|
vyosBond?: string;
|
||||||
@@ -166,6 +176,32 @@ export function registerInstallCommand(parent: Command): void {
|
|||||||
process.exit(1);
|
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 !== ""
|
const bondMembers = opts.vyosBond !== undefined && opts.vyosBond !== ""
|
||||||
? opts.vyosBond.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
|
? 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,
|
role: opts.role,
|
||||||
os: opts.os,
|
os: opts.os,
|
||||||
...(opts.disk ? { disk: opts.disk } : {}),
|
...(opts.disk ? { disk: opts.disk } : {}),
|
||||||
|
...(hasDataDisks ? { dataDisks } : {}),
|
||||||
...(hasVyosOptions ? { vyos } : {}),
|
...(hasVyosOptions ? { vyos } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ describe("bastion smoke tests", () => {
|
|||||||
await sleep(200);
|
await sleep(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(stdout).toContain("Waiting for PXE boot requests");
|
// On failure, show what the process actually said -- an empty stdout with
|
||||||
|
// a hidden stderr is undiagnosable from a CI log.
|
||||||
|
expect(stdout, `bastion never printed its banner; stderr was:\n${stderr}`)
|
||||||
|
.toContain("Waiting for PXE boot requests");
|
||||||
expect(stdout).toContain("HTTP server listening");
|
expect(stdout).toContain("HTTP server listening");
|
||||||
|
|
||||||
// Verify the process is still alive after startup
|
// Verify the process is still alive after startup
|
||||||
|
|||||||
@@ -164,9 +164,9 @@ export function registerBastionRoutes(app: FastifyInstance, db: DbClient): void
|
|||||||
|
|
||||||
// Queue install — route to correct bastion by MAC
|
// Queue install — route to correct bastion by MAC
|
||||||
app.post<{
|
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) => {
|
}>("/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) {
|
if (!mac || !hostname) {
|
||||||
return reply.code(400).send({ error: "mac and hostname are required" });
|
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, {
|
const result = await sendCommand(all[0]!.bastionId, {
|
||||||
type: "command-install",
|
type: "command-install",
|
||||||
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
|
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
|
||||||
|
...(schemaVersion ? { schemaVersion } : {}),
|
||||||
|
...(dataDisks ? { dataDisks } : {}),
|
||||||
...(vyos ? { vyos } : {}),
|
...(vyos ? { vyos } : {}),
|
||||||
});
|
});
|
||||||
return reply.code(result.status === "ok" ? 200 : 500).send(result);
|
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, {
|
const result = await sendCommand(bastion.bastionId, {
|
||||||
type: "command-install",
|
type: "command-install",
|
||||||
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
|
mac, hostname, disk: disk ?? "", role: role ?? "infra", os: os ?? "fedora-43",
|
||||||
|
...(schemaVersion ? { schemaVersion } : {}),
|
||||||
|
...(dataDisks ? { dataDisks } : {}),
|
||||||
...(vyos ? { vyos } : {}),
|
...(vyos ? { vyos } : {}),
|
||||||
});
|
});
|
||||||
return reply.code(result.status === "ok" ? 200 : 500).send(result);
|
return reply.code(result.status === "ok" ? 200 : 500).send(result);
|
||||||
|
|||||||
@@ -51,5 +51,6 @@ export {
|
|||||||
} from "./errors/index.js";
|
} from "./errors/index.js";
|
||||||
|
|
||||||
export { v2Layout, v2AllocatedMib, V2_BOOT, V2_RANCHER_MIB, V2_LONGHORN_MIB,
|
export { v2Layout, v2AllocatedMib, V2_BOOT, V2_RANCHER_MIB, V2_LONGHORN_MIB,
|
||||||
SCHEMA_VERSIONS, CONTAINERD_BIND_FSTAB, LEGACY_ROLES, requiresSchemaV2, effectiveSchemaVersion } from "./types/index.js";
|
SCHEMA_VERSIONS, CONTAINERD_BIND_FSTAB, LEGACY_ROLES, requiresSchemaV2, effectiveSchemaVersion,
|
||||||
export type { LvSpec, SchemaVersion } from "./types/index.js";
|
validateDataDisks, DATA_DISK_LABELS } from "./types/index.js";
|
||||||
|
export type { LvSpec, SchemaVersion, DataDisks, DataDiskName } from "./types/index.js";
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export type BastionMessage =
|
|||||||
export type LabdBastionMessage =
|
export type LabdBastionMessage =
|
||||||
| { type: "bastion-enrolled"; bastionId: string }
|
| { type: "bastion-enrolled"; bastionId: string }
|
||||||
| { type: "bastion-heartbeat-ack"; serverTime: 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-forget"; requestId: string; mac: string }
|
||||||
| { type: "command-role-update"; requestId: string; mac: string; role: string }
|
| { type: "command-role-update"; requestId: string; mac: string; role: string }
|
||||||
| { type: "command-debug"; requestId: string; mac: string; pxeBoot?: boolean }
|
| { type: "command-debug"; requestId: string; mac: string; pxeBoot?: boolean }
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ export type { RoleInfo, Lifecycle, K3sMode } from "./state.js";
|
|||||||
export type { BastionConfig } from "./config.js";
|
export type { BastionConfig } from "./config.js";
|
||||||
|
|
||||||
export { v2Layout, v2AllocatedMib, V2_BOOT, V2_RANCHER_MIB, V2_LONGHORN_MIB,
|
export { v2Layout, v2AllocatedMib, V2_BOOT, V2_RANCHER_MIB, V2_LONGHORN_MIB,
|
||||||
SCHEMA_VERSIONS, CONTAINERD_BIND_FSTAB, LEGACY_ROLES, requiresSchemaV2, effectiveSchemaVersion } from "./layout.js";
|
SCHEMA_VERSIONS, CONTAINERD_BIND_FSTAB, LEGACY_ROLES, requiresSchemaV2, effectiveSchemaVersion,
|
||||||
export type { LvSpec, SchemaVersion } from "./layout.js";
|
validateDataDisks, DATA_DISK_LABELS } from "./layout.js";
|
||||||
|
export type { LvSpec, SchemaVersion, DataDisks, DataDiskName } from "./layout.js";
|
||||||
|
|||||||
@@ -94,13 +94,80 @@ const V2_BASE: readonly LvSpec[] = [
|
|||||||
export const V2_RANCHER_MIB = 256000; // 250 GiB
|
export const V2_RANCHER_MIB = 256000; // 250 GiB
|
||||||
export const V2_LONGHORN_MIB = 307200; // 300 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.
|
* 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
|
* `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.
|
* 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 info = roleInfo(role);
|
||||||
const lvs: LvSpec[] = V2_BASE.filter(
|
const lvs: LvSpec[] = V2_BASE.filter(
|
||||||
(lv) => !(opts.omitRoot && lv.name === "root"),
|
(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
|
// Anything running k3s needs a dedicated image store. This is the check that
|
||||||
// `role === "infra" || role === "worker"` kept getting wrong: labcontroller
|
// `role === "infra" || role === "worker"` kept getting wrong: labcontroller
|
||||||
// runs k3s and was silently excluded.
|
// 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" });
|
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.
|
// 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" });
|
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. */
|
/** 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);
|
return v2Layout(role, opts).reduce((n, lv) => n + lv.sizeMib, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -356,6 +356,13 @@ export interface InstallConfig {
|
|||||||
* disk instead, because reinstalling a machine must never change its layout.
|
* disk instead, because reinstalling a machine must never change its layout.
|
||||||
*/
|
*/
|
||||||
schemaVersion?: 1 | 2;
|
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;
|
queued_at: string;
|
||||||
/**
|
/**
|
||||||
* When dispatch last served this machine an install boot script. Progress
|
* When dispatch last served this machine an install boot script. Progress
|
||||||
|
|||||||
173
bastion/tests/integration/data-disks.test.ts
Normal file
173
bastion/tests/integration/data-disks.test.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// Integration test: dedicated data disks, on a real VM.
|
||||||
|
//
|
||||||
|
// The unit tests prove the kickstart RENDERS the right %pre and part lines.
|
||||||
|
// This proves anaconda ACTUALLY PRODUCES the layout thebeast will get: OS on
|
||||||
|
// one disk, /var/lib/longhorn and /var/lib/rancher each a whole-disk labelled
|
||||||
|
// xfs partition on their own disks -- and that a reprovision with NO flags
|
||||||
|
// rediscovers both by label and remounts them without reformatting.
|
||||||
|
//
|
||||||
|
// The VM mirrors thebeast's topology (1 OS disk + 2 data disks addressed by
|
||||||
|
// /dev/disk/by-id/ serial); virtio serials stand in for NVMe serials, which is
|
||||||
|
// exactly the aliasing the feature is specified over (any by-id path).
|
||||||
|
//
|
||||||
|
// Run (on a libvirt host):
|
||||||
|
// sudo -E npx vitest run -c tests/integration/vitest.config.ts tests/integration/data-disks.test.ts
|
||||||
|
//
|
||||||
|
// Set FEDORA_MIRROR to a local mirror or each install pulls packages from the
|
||||||
|
// internet (~12-15 min per install either way; two installs in this file).
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { v2Layout, DATA_DISK_LABELS } from "../../src/shared/src/types/layout.js";
|
||||||
|
import {
|
||||||
|
startTestBastion, provisionVm, reprovisionVm, readLvs, readSchemaTag,
|
||||||
|
writeMarkers, readMarkers, destroyVms, virtioById,
|
||||||
|
type Harness, type ProvisionedVm,
|
||||||
|
} from "./helpers/provision-harness.js";
|
||||||
|
import { destroyPxeNetwork } from "./helpers/pxe-network.js";
|
||||||
|
import { sshExec } from "./helpers/ssh.js";
|
||||||
|
|
||||||
|
const HTTP_PORT = 8094;
|
||||||
|
const SSH_USER = "lab";
|
||||||
|
const DISK_GB = 1000; // same sparse OS disk as every other layout test
|
||||||
|
|
||||||
|
// Short virtio serials (virtio-blk truncates at 20 chars), echoing thebeast's
|
||||||
|
// real disk serial fragments so the drill log reads like the runbook.
|
||||||
|
const LH = { serial: "LHDRILL7F600711", sizeGb: 20 };
|
||||||
|
const RN = { serial: "RNDRILL7FD4070C", sizeGb: 20 };
|
||||||
|
|
||||||
|
const PRESERVED = ["/home", "/srv", "/var/lib/longhorn", "/var/lib/rancher"];
|
||||||
|
|
||||||
|
function findSshKey(): { pubKey: string; keyPath: string } {
|
||||||
|
// A passphrase-protected default key fails SILENTLY here: BatchMode ssh
|
||||||
|
// never offers it without an agent, the server logs only "Connection closed
|
||||||
|
// [preauth]", and the test reads as "installed machine never came up" when
|
||||||
|
// the machine was fine. LAB_TEST_SSH_KEY names a dedicated passphrase-less
|
||||||
|
// keypair and sidesteps the whole class.
|
||||||
|
const envKey = process.env["LAB_TEST_SSH_KEY"];
|
||||||
|
if (envKey) {
|
||||||
|
if (!existsSync(envKey) || !existsSync(`${envKey}.pub`)) {
|
||||||
|
throw new Error(`LAB_TEST_SSH_KEY=${envKey}: key or .pub missing`);
|
||||||
|
}
|
||||||
|
return { pubKey: readFileSync(`${envKey}.pub`, "utf-8").trim(), keyPath: envKey };
|
||||||
|
}
|
||||||
|
const homes = [homedir()];
|
||||||
|
const sudoUser = process.env["SUDO_USER"];
|
||||||
|
if (sudoUser) homes.push(join("/home", sudoUser));
|
||||||
|
for (const home of homes) {
|
||||||
|
for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) {
|
||||||
|
const keyPath = join(home, ".ssh", name);
|
||||||
|
if (existsSync(keyPath) && existsSync(`${keyPath}.pub`)) {
|
||||||
|
return { pubKey: readFileSync(`${keyPath}.pub`, "utf-8").trim(), keyPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("no SSH keypair found");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a command on the VM and insist it succeeds. */
|
||||||
|
function ssh(h: Harness, vm: ProvisionedVm, cmd: string): string {
|
||||||
|
const r = sshExec(vm.ip, h.sshUser, cmd, { keyPath: h.sshKeyPath });
|
||||||
|
if (r.exitCode !== 0) throw new Error(`[${vm.name}] \`${cmd}\` failed: ${r.stderr || r.stdout}`);
|
||||||
|
return r.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What actually backs a mount point, and whether it is the whole-disk labelled
|
||||||
|
* partition the feature promises.
|
||||||
|
*/
|
||||||
|
function inspectMount(h: Harness, vm: ProvisionedVm, mount: string, serial: string) {
|
||||||
|
const source = ssh(h, vm, `findmnt -no SOURCE ${mount}`);
|
||||||
|
const fstype = ssh(h, vm, `findmnt -no FSTYPE ${mount}`);
|
||||||
|
const label = ssh(h, vm, `sudo blkid -s LABEL -o value ${source}`);
|
||||||
|
const uuid = ssh(h, vm, `sudo blkid -s UUID -o value ${source}`);
|
||||||
|
// The partition must live on the disk with OUR serial, not wherever the
|
||||||
|
// kernel happened to enumerate something.
|
||||||
|
const disk = ssh(h, vm, `readlink -f ${virtioById(serial)}`);
|
||||||
|
const parent = "/dev/" + ssh(h, vm, `lsblk -ndo pkname ${source}`);
|
||||||
|
return { source, fstype, label, uuid, disk, parent };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dedicated data disks on real hardware", () => {
|
||||||
|
let h: Harness;
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { pubKey, keyPath } = findSshKey();
|
||||||
|
h = await startTestBastion({
|
||||||
|
httpPort: HTTP_PORT, sshUser: SSH_USER, sshPubKey: pubKey,
|
||||||
|
sshKeyPath: keyPath, label: "datadisks",
|
||||||
|
});
|
||||||
|
}, 20 * 60_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// KEEP_VMS=1 leaves the VM behind for a post-mortem (virsh console,
|
||||||
|
// screenshot, mounting the qcow2). Without it a failed boot is destroyed
|
||||||
|
// along with every clue about why it failed.
|
||||||
|
if (!process.env["KEEP_VMS"]) destroyVms(...created);
|
||||||
|
try { await h?.close(); } catch { /* closed */ }
|
||||||
|
try { destroyPxeNetwork(); } catch { /* gone */ }
|
||||||
|
}, 5 * 60_000);
|
||||||
|
|
||||||
|
it("k8s-worker with longhorn+rancher disks: install, then flagless reprovision rediscovers both", async () => {
|
||||||
|
const name = "dd-worker";
|
||||||
|
created.push(name);
|
||||||
|
const dataDisks = { longhorn: virtioById(LH.serial), rancher: virtioById(RN.serial) };
|
||||||
|
|
||||||
|
// --- Fresh install, thebeast-style -----------------------------------
|
||||||
|
let vm = await provisionVm(h, {
|
||||||
|
name, role: "k8s-worker", schemaVersion: 2, diskGb: DISK_GB,
|
||||||
|
dataDisks: { longhorn: LH, rancher: RN },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The VG must hold exactly the v2 set MINUS the LVs the disks replace.
|
||||||
|
const lvs = readLvs(h, vm);
|
||||||
|
const expected = v2Layout("k8s-worker", { dataDisks }).map((lv) => lv.name).sort();
|
||||||
|
expect(Object.keys(lvs).sort(), "volume set").toEqual(expected);
|
||||||
|
expect(lvs["longhorn"]).toBeUndefined();
|
||||||
|
expect(lvs["rancher"]).toBeUndefined();
|
||||||
|
expect(readSchemaTag(h, vm)).toContain("lab-schema-v2");
|
||||||
|
|
||||||
|
// Each mount is a whole-disk xfs partition, labelled, on the RIGHT disk.
|
||||||
|
const lhBefore = inspectMount(h, vm, "/var/lib/longhorn", LH.serial);
|
||||||
|
expect(lhBefore.fstype).toBe("xfs");
|
||||||
|
expect(lhBefore.label).toBe(DATA_DISK_LABELS.longhorn);
|
||||||
|
expect(lhBefore.parent, "/var/lib/longhorn sits on the longhorn disk").toBe(lhBefore.disk);
|
||||||
|
|
||||||
|
const rnBefore = inspectMount(h, vm, "/var/lib/rancher", RN.serial);
|
||||||
|
expect(rnBefore.fstype).toBe("xfs");
|
||||||
|
expect(rnBefore.label).toBe(DATA_DISK_LABELS.rancher);
|
||||||
|
expect(rnBefore.parent, "/var/lib/rancher sits on the rancher disk").toBe(rnBefore.disk);
|
||||||
|
|
||||||
|
const token = writeMarkers(h, vm, PRESERVED);
|
||||||
|
|
||||||
|
// --- Reprovision with NO dataDisks flags ------------------------------
|
||||||
|
// This is the claim that matters for day 2: the labels on disk are the
|
||||||
|
// truth, and a plain `labctl provision reprovision` must find and keep
|
||||||
|
// them. Passing the flags again here would hide a regression in exactly
|
||||||
|
// that rediscovery.
|
||||||
|
vm = await reprovisionVm(h, vm, "k8s-worker");
|
||||||
|
|
||||||
|
const markers = readMarkers(h, vm, PRESERVED);
|
||||||
|
for (const mount of PRESERVED) {
|
||||||
|
expect(markers[mount], `${mount} must survive a reinstall`).toBe(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still LV-less for longhorn/rancher, still v2.
|
||||||
|
const lvsAfter = readLvs(h, vm);
|
||||||
|
expect(Object.keys(lvsAfter).sort()).toEqual(expected);
|
||||||
|
expect(readSchemaTag(h, vm)).toContain("lab-schema-v2");
|
||||||
|
|
||||||
|
// Same partitions, same filesystems. An unchanged UUID is the proof that
|
||||||
|
// --noformat really happened -- a remade xfs with the same label would
|
||||||
|
// pass every other check here.
|
||||||
|
const lhAfter = inspectMount(h, vm, "/var/lib/longhorn", LH.serial);
|
||||||
|
expect(lhAfter.uuid, "longhorn fs was NOT reformatted").toBe(lhBefore.uuid);
|
||||||
|
expect(lhAfter.parent).toBe(lhAfter.disk);
|
||||||
|
const rnAfter = inspectMount(h, vm, "/var/lib/rancher", RN.serial);
|
||||||
|
expect(rnAfter.uuid, "rancher fs was NOT reformatted").toBe(rnBefore.uuid);
|
||||||
|
expect(rnAfter.parent).toBe(rnAfter.disk);
|
||||||
|
}, 150 * 60_000);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import type { BastionConfig } from "../../../src/shared/src/types/config.js";
|
|||||||
import type { Role } from "../../../src/shared/src/types/state.js";
|
import type { Role } from "../../../src/shared/src/types/state.js";
|
||||||
import { log, waitForSsh } from "./libvirt.js";
|
import { log, waitForSsh } from "./libvirt.js";
|
||||||
import { ensurePxeNetwork, deleteNftablesRejectRules, PXE_NETWORK_NAME, PXE_BRIDGE, PXE_GATEWAY, PXE_SUBNET } from "./pxe-network.js";
|
import { ensurePxeNetwork, deleteNftablesRejectRules, PXE_NETWORK_NAME, PXE_BRIDGE, PXE_GATEWAY, PXE_SUBNET } from "./pxe-network.js";
|
||||||
import { createPxeVm, destroyPxeVm, getVmMac, rebootPxeVm } from "./pxe-vm.js";
|
import { createPxeVm, destroyPxeVm, getVmMac, rebootPxeVm, readSerialLog } from "./pxe-vm.js";
|
||||||
import { sshExec } from "./ssh.js";
|
import { sshExec } from "./ssh.js";
|
||||||
|
|
||||||
export const DISCOVERY_TIMEOUT_MS = 6 * 60_000;
|
export const DISCOVERY_TIMEOUT_MS = 6 * 60_000;
|
||||||
@@ -295,7 +295,23 @@ async function waitForInstallStart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProvisionedVm { name: string; mac: string; ip: string }
|
export interface ProvisionedVm { name: string; mac: string; ip: string; serialPort: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "SSH never came up" on its own is undiagnosable once afterAll has destroyed
|
||||||
|
* the VM -- the machine may be sitting in emergency mode with the exact error
|
||||||
|
* on its serial console. Capture that console into the failure.
|
||||||
|
*/
|
||||||
|
async function sshFailurePostMortem(name: string, serialPort: number, cause: unknown): Promise<never> {
|
||||||
|
let state = "unknown";
|
||||||
|
try { state = execSync(`sudo virsh domstate ${name}`, { encoding: "utf-8" }).trim(); } catch { /* gone */ }
|
||||||
|
let serial = "(no serial output)";
|
||||||
|
try { serial = await readSerialLog(serialPort, { lastLines: 60 }); } catch { /* no console */ }
|
||||||
|
throw new Error(
|
||||||
|
`[${name}] ${cause instanceof Error ? cause.message : String(cause)}\n` +
|
||||||
|
`domstate: ${state}\n--- serial console (last lines) ---\n${serial}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serial ports are a global resource on the host, so hand out a distinct one
|
* Serial ports are a global resource on the host, so hand out a distinct one
|
||||||
@@ -304,6 +320,15 @@ export interface ProvisionedVm { name: string; mac: string; ip: string }
|
|||||||
*/
|
*/
|
||||||
let nextSerialPort = 4600;
|
let nextSerialPort = 4600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stable path a virtio data disk gets inside the guest. This is what the
|
||||||
|
* install request must carry: the feature refuses anything outside
|
||||||
|
* /dev/disk/by-id/, exactly as it does for real NVMe serials.
|
||||||
|
*/
|
||||||
|
export function virtioById(serial: string): string {
|
||||||
|
return `/dev/disk/by-id/virtio-${serial}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a blank VM, let it discover, queue the install, wait for SSH. */
|
/** Create a blank VM, let it discover, queue the install, wait for SSH. */
|
||||||
export async function provisionVm(h: Harness, spec: {
|
export async function provisionVm(h: Harness, spec: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -312,16 +337,26 @@ export async function provisionVm(h: Harness, spec: {
|
|||||||
diskGb: number;
|
diskGb: number;
|
||||||
memory?: number;
|
memory?: number;
|
||||||
vcpus?: number;
|
vcpus?: number;
|
||||||
|
/**
|
||||||
|
* Dedicated data disks: attached to the VM with the given virtio serial AND
|
||||||
|
* passed to /api/install as /dev/disk/by-id/virtio-<serial>. Mirrors
|
||||||
|
* thebeast's layout (OS disk + separate longhorn + rancher disks).
|
||||||
|
*/
|
||||||
|
dataDisks?: { longhorn?: { serial: string; sizeGb: number }; rancher?: { serial: string; sizeGb: number } };
|
||||||
}): Promise<ProvisionedVm> {
|
}): Promise<ProvisionedVm> {
|
||||||
const base = `http://${PXE_GATEWAY}:${h.httpPort}`;
|
const base = `http://${PXE_GATEWAY}:${h.httpPort}`;
|
||||||
log(`[${spec.name}] creating VM (${spec.diskGb}GB disk, role=${spec.role}, schema=v${spec.schemaVersion ?? 1})`);
|
const vmDataDisks = [spec.dataDisks?.longhorn, spec.dataDisks?.rancher]
|
||||||
|
.filter((d): d is { serial: string; sizeGb: number } => d !== undefined);
|
||||||
|
log(`[${spec.name}] creating VM (${spec.diskGb}GB disk + ${vmDataDisks.length} data disk(s), role=${spec.role}, schema=v${spec.schemaVersion ?? 1})`);
|
||||||
|
const serialPort = nextSerialPort++;
|
||||||
createPxeVm({
|
createPxeVm({
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
memory: spec.memory ?? 4096,
|
memory: spec.memory ?? 4096,
|
||||||
vcpus: spec.vcpus ?? 4,
|
vcpus: spec.vcpus ?? 4,
|
||||||
diskSize: spec.diskGb,
|
diskSize: spec.diskGb,
|
||||||
network: PXE_NETWORK_NAME,
|
network: PXE_NETWORK_NAME,
|
||||||
serialPort: nextSerialPort++,
|
serialPort,
|
||||||
|
...(vmDataDisks.length > 0 ? { dataDisks: vmDataDisks } : {}),
|
||||||
});
|
});
|
||||||
deleteNftablesRejectRules();
|
deleteNftablesRejectRules();
|
||||||
|
|
||||||
@@ -339,6 +374,12 @@ export async function provisionVm(h: Harness, spec: {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mac, hostname: spec.name, disk: "", role: spec.role,
|
mac, hostname: spec.name, disk: "", role: spec.role,
|
||||||
...(spec.schemaVersion ? { schemaVersion: spec.schemaVersion } : {}),
|
...(spec.schemaVersion ? { schemaVersion: spec.schemaVersion } : {}),
|
||||||
|
...(spec.dataDisks ? {
|
||||||
|
dataDisks: {
|
||||||
|
...(spec.dataDisks.longhorn ? { longhorn: virtioById(spec.dataDisks.longhorn.serial) } : {}),
|
||||||
|
...(spec.dataDisks.rancher ? { rancher: virtioById(spec.dataDisks.rancher.serial) } : {}),
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`[${spec.name}] install queue rejected: ${res.status} ${await res.text()}`);
|
if (!res.ok) throw new Error(`[${spec.name}] install queue rejected: ${res.status} ${await res.text()}`);
|
||||||
@@ -376,9 +417,13 @@ export async function provisionVm(h: Harness, spec: {
|
|||||||
deleteNftablesRejectRules();
|
deleteNftablesRejectRules();
|
||||||
|
|
||||||
log(`[${spec.name}] waiting for SSH at ${ip}...`);
|
log(`[${spec.name}] waiting for SSH at ${ip}...`);
|
||||||
await waitForSsh(ip, h.sshUser, SSH_TIMEOUT_MS, h.sshKeyPath);
|
try {
|
||||||
|
await waitForSsh(ip, h.sshUser, SSH_TIMEOUT_MS, h.sshKeyPath);
|
||||||
|
} catch (e) {
|
||||||
|
await sshFailurePostMortem(spec.name, serialPort, e);
|
||||||
|
}
|
||||||
log(`[${spec.name}] up at ${ip}`);
|
log(`[${spec.name}] up at ${ip}`);
|
||||||
return { name: spec.name, mac, ip };
|
return { name: spec.name, mac, ip, serialPort };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -498,7 +543,11 @@ export async function reprovisionVm(
|
|||||||
} catch { /* already running */ }
|
} catch { /* already running */ }
|
||||||
deleteNftablesRejectRules();
|
deleteNftablesRejectRules();
|
||||||
|
|
||||||
await waitForSsh(ip, h.sshUser, SSH_TIMEOUT_MS, h.sshKeyPath);
|
try {
|
||||||
|
await waitForSsh(ip, h.sshUser, SSH_TIMEOUT_MS, h.sshKeyPath);
|
||||||
|
} catch (e) {
|
||||||
|
await sshFailurePostMortem(vm.name, vm.serialPort, e);
|
||||||
|
}
|
||||||
log(`[${vm.name}] back up at ${ip} after reinstall`);
|
log(`[${vm.name}] back up at ${ip} after reinstall`);
|
||||||
return { ...vm, ip };
|
return { ...vm, ip };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ export interface PxeVmConfig {
|
|||||||
*/
|
*/
|
||||||
decoyNics?: number;
|
decoyNics?: number;
|
||||||
decoyNetwork?: string;
|
decoyNetwork?: string;
|
||||||
|
/**
|
||||||
|
* Extra blank data disks, attached after the OS disk. Each gets a fixed
|
||||||
|
* virtio serial, so inside the guest it has a stable
|
||||||
|
* /dev/disk/by-id/virtio-<serial> path -- the moral equivalent of the
|
||||||
|
* nvme-..._<serial> links the dedicated-data-disk feature requires on real
|
||||||
|
* hardware. virtio-blk truncates serials at 20 characters, so keep them
|
||||||
|
* short or the by-id link quietly won't match.
|
||||||
|
*/
|
||||||
|
dataDisks?: { serial: string; sizeGb: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a blank UEFI VM that PXE boots from the network. */
|
/** Create a blank UEFI VM that PXE boots from the network. */
|
||||||
@@ -60,6 +69,14 @@ export function createPxeVm(config: PxeVmConfig): void {
|
|||||||
const diskPath = join(IMAGE_DIR, `${config.name}.qcow2`);
|
const diskPath = join(IMAGE_DIR, `${config.name}.qcow2`);
|
||||||
run(`qemu-img create -f qcow2 "${diskPath}" ${config.diskSize}G`);
|
run(`qemu-img create -f qcow2 "${diskPath}" ${config.diskSize}G`);
|
||||||
|
|
||||||
|
const dataDiskArgs: string[] = [];
|
||||||
|
for (const dd of config.dataDisks ?? []) {
|
||||||
|
if (dd.serial.length > 20) throw new Error(`virtio serial '${dd.serial}' exceeds 20 chars -- the guest by-id link would be truncated`);
|
||||||
|
const p = join(IMAGE_DIR, `${config.name}-${dd.serial}.qcow2`);
|
||||||
|
run(`qemu-img create -f qcow2 "${p}" ${dd.sizeGb}G`);
|
||||||
|
dataDiskArgs.push(`--disk=path=${p},format=qcow2,bus=virtio,serial=${dd.serial}`);
|
||||||
|
}
|
||||||
|
|
||||||
// UEFI firmware paths (Fedora)
|
// UEFI firmware paths (Fedora)
|
||||||
if (arch === "aarch64") {
|
if (arch === "aarch64") {
|
||||||
const aavmf = "/usr/share/edk2/aarch64/QEMU_EFI.fd";
|
const aavmf = "/usr/share/edk2/aarch64/QEMU_EFI.fd";
|
||||||
@@ -79,6 +96,7 @@ export function createPxeVm(config: PxeVmConfig): void {
|
|||||||
`--memory=${config.memory}`,
|
`--memory=${config.memory}`,
|
||||||
`--vcpus=${config.vcpus}`,
|
`--vcpus=${config.vcpus}`,
|
||||||
`--disk=path=${diskPath},format=qcow2,bus=virtio`,
|
`--disk=path=${diskPath},format=qcow2,bus=virtio`,
|
||||||
|
...dataDiskArgs,
|
||||||
// Decoys first so they enumerate ahead of the PXE NIC. They are up and
|
// Decoys first so they enumerate ahead of the PXE NIC. They are up and
|
||||||
// carry a lease, but have no route to the bastion.
|
// carry a lease, but have no route to the bastion.
|
||||||
...Array.from({ length: config.decoyNics ?? 0 }, () =>
|
...Array.from({ length: config.decoyNics ?? 0 }, () =>
|
||||||
|
|||||||
113
migration/THEBEAST-K8S-WORKER.md
Normal file
113
migration/THEBEAST-K8S-WORKER.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
**VM-drilled 2026-10-14** (on thebeast itself, evidence:
|
||||||
|
`drill-evidence/data-disks-2026-10-14.txt`): fresh install lands the exact
|
||||||
|
layout below, and a flagless reprovision rediscovers both data disks by label
|
||||||
|
and remounts them without reformatting (UUIDs unchanged, marker files intact).
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
Copied to bazzite and rsync-verified 2026-10-14: `~/keyssecrets`,
|
||||||
|
`~/dicesecrets`, `~/Videos/canaries` (24G), `~/Pictures`, `~/Documents`.
|
||||||
|
`~/opt/vaultbackup` and `~/migration-backup` declared done/not-needed (user,
|
||||||
|
2026-10-14).
|
||||||
|
|
||||||
|
Still open before shutdown:
|
||||||
|
- `~/Videos/{Webcam,subscribescreen,Screencasts,music}` (~370M) and
|
||||||
|
`~/backups/sogo-final-2026-09-13` — small, not yet copied
|
||||||
|
- 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.)
|
||||||
40
migration/drill-evidence/data-disks-2026-10-14.txt
Normal file
40
migration/drill-evidence/data-disks-2026-10-14.txt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
Dedicated data disks drill — fresh install + flagless reprovision, real VM
|
||||||
|
run finished: 2026-10-14T23:30+01:00 host: thebeast
|
||||||
|
test: bastion/tests/integration/data-disks.test.ts (1 passed, 1164s)
|
||||||
|
topology: 1 VM, role k8s-worker schema v2, 1000G OS disk + 2x 20G data disks
|
||||||
|
addressed as /dev/disk/by-id/virtio-LHDRILL7F600711 (longhorn) and
|
||||||
|
/dev/disk/by-id/virtio-RNDRILL7FD4070C (rancher) — the same by-id contract
|
||||||
|
thebeast's NVMe serials use.
|
||||||
|
|
||||||
|
Proven:
|
||||||
|
- fresh install: labvg = home/root/srv/swap/var/varlog ONLY (no longhorn,
|
||||||
|
no rancher LV); /var/lib/longhorn and /var/lib/rancher are whole-disk xfs
|
||||||
|
partitions labelled lab-longhorn / lab-rancher, each verified to sit on
|
||||||
|
the disk with the matching serial; lab-schema-v2 tag present; fstab by UUID.
|
||||||
|
- reprovision with NO dataDisks flags: both disks rediscovered by label,
|
||||||
|
remounted --noformat (filesystem UUIDs identical before/after), marker
|
||||||
|
files intact in /home /srv /var/lib/longhorn /var/lib/rancher, LV set and
|
||||||
|
schema tag unchanged.
|
||||||
|
|
||||||
|
=== checkpoints ===
|
||||||
|
[22:10:40] [dd-worker] creating VM (1000GB disk + 2 data disk(s), role=k8s-worker, schema=v2)
|
||||||
|
[22:13:02] [dd-worker] queueing install...
|
||||||
|
[22:15:25] [dd-worker] installing (up to 60 min)...
|
||||||
|
[22:21:24] [dd-worker] up at 192.168.251.194
|
||||||
|
[22:21:24] [dd-worker] on-disk LVs: home=8G root=32G srv=16G swap=16G var=48G varlog=16G
|
||||||
|
[22:21:27] [dd-worker] wrote marker preserve-1792016486955-iu1rih into /home, /srv, /var/lib/longhorn, /var/lib/rancher
|
||||||
|
[22:21:27] [dd-worker] reprovisioning (role=k8s-worker, schema: whatever is on disk)...
|
||||||
|
[22:29:58] [dd-worker] back up at 192.168.251.194 after reinstall
|
||||||
|
[22:29:59] [dd-worker] markers after reinstall: all four mounts carry preserve-1792016486955-iu1rih
|
||||||
|
[22:29:59] [dd-worker] on-disk LVs: home=8G root=32G srv=16G swap=16G var=48G varlog=16G
|
||||||
|
✓ k8s-worker with longhorn+rancher disks: install, then flagless reprovision rediscovers both (1161s)
|
||||||
|
|
||||||
|
Run notes (cost of learning, so nobody pays it twice):
|
||||||
|
- the drill machine's bastion imports resolve @lab/shared to dist/ -- run
|
||||||
|
`pnpm build` after switching branches or the API answers with the OLD role
|
||||||
|
registry ("invalid role: 'k8s-worker'").
|
||||||
|
- a passphrase-protected default ~/.ssh/id_rsa fails as "SSH not available"
|
||||||
|
with a perfectly healthy machine (BatchMode never offers it without an
|
||||||
|
agent). Use LAB_TEST_SSH_KEY with a dedicated passphrase-less key.
|
||||||
|
- run: cd bastion && LAB_TEST_SSH_KEY=~/.ssh/lab-integration_ed25519 \
|
||||||
|
sudo -E npx vitest run -c tests/integration/vitest.config.ts tests/integration/data-disks.test.ts
|
||||||
Reference in New Issue
Block a user