diff --git a/.gitignore b/.gitignore index 9dc1e1c..3e57f35 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ node_modules/ # Task files # tasks.json # tasks/ + +# Asahi build artifacts (large) +bastion/.asahi-cache/ +bastion/asahi-repo/*.zip diff --git a/bastion/asahi-repo/installer_data.json b/bastion/asahi-repo/installer_data.json new file mode 100644 index 0000000..e232600 --- /dev/null +++ b/bastion/asahi-repo/installer_data.json @@ -0,0 +1,47 @@ +{ + "os_list": [ + { + "name": "Fedora Asahi Lab (infra)", + "default_os_name": "Fedora Linux Lab", + "boot_object": "m1n1.bin", + "next_object": "m1n1/boot.bin", + "package": "fedora-asahi-lab.zip", + "supported_fw": [ + "12.3", + "12.3.1", + "13.5" + ], + "partitions": [ + { + "name": "EFI", + "type": "EFI", + "size": "524288000B", + "format": "fat", + "volume_id": "0x804be8a6", + "copy_firmware": true, + "copy_installer_data": true, + "source": "esp" + }, + { + "name": "Boot", + "type": "Linux", + "size": "1073741824B", + "image": "boot.img" + }, + { + "name": "Root", + "type": "Linux", + "size": "4626296832B", + "expand": false, + "image": "root.img" + }, + { + "name": "Data", + "type": "Linux", + "size": "1073741824B", + "expand": true + } + ] + } + ] +} diff --git a/bastion/completions/labctl.bash b/bastion/completions/labctl.bash index 4db25ec..a51e9fd 100644 --- a/bastion/completions/labctl.bash +++ b/bastion/completions/labctl.bash @@ -73,6 +73,9 @@ _labctl() { "provision register") COMPREPLY=($(compgen -W "--role --ip -h --help" -- "$cur")) return ;; + "provision asahi") + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + return ;; "provision logs") COMPREPLY=($(compgen -W "-f --follow -h --help" -- "$cur")) return ;; @@ -104,7 +107,7 @@ _labctl() { COMPREPLY=($(compgen -W "bastion -h --help" -- "$cur")) return ;; "provision") - COMPREPLY=($(compgen -W "list install reprovision debug forget register logs makeiso -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "list install reprovision debug forget register asahi logs makeiso -h --help" -- "$cur")) return ;; "config") COMPREPLY=($(compgen -W "list get set path -h --help" -- "$cur")) diff --git a/bastion/completions/labctl.fish b/bastion/completions/labctl.fish index c4cedaf..50480d1 100644 --- a/bastion/completions/labctl.fish +++ b/bastion/completions/labctl.fish @@ -125,6 +125,7 @@ complete -c labctl -n "__labctl_using_cmd provision" -a reprovision -d 'Queue in complete -c labctl -n "__labctl_using_cmd provision" -a debug -d 'PXE boot into Fedora rescue mode for debugging (target: hostname, MAC, or IP)' complete -c labctl -n "__labctl_using_cmd provision" -a forget -d 'Remove a machine from bastion state' complete -c labctl -n "__labctl_using_cmd provision" -a register -d 'Register an already-installed machine (e.g. after state loss)' +complete -c labctl -n "__labctl_using_cmd provision" -a asahi -d 'Show instructions to provision an Apple Silicon Mac with Asahi Linux' complete -c labctl -n "__labctl_using_cmd provision" -a logs -d 'Show provisioning logs for a machine (hostname, MAC, or IP)' complete -c labctl -n "__labctl_using_cmd provision" -a makeiso -d 'Generate a UEFI-bootable iPXE ISO for network provisioning' diff --git a/bastion/package.json b/bastion/package.json index 1e2a673..fc1b5a3 100644 --- a/bastion/package.json +++ b/bastion/package.json @@ -22,7 +22,11 @@ "test:integration:iso": "vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'", "test:integration:iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'", "test:integration:arm-iso": "vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'", - "test:integration:arm-iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'" + "test:integration:arm-iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'", + "test:integration:asahi": "vitest run -c tests/integration/vitest.config.ts -t 'asahi firstboot'", + "test:integration:asahi:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'asahi firstboot'", + "test:integration:asahi-validate": "vitest run -c tests/integration/vitest.config.ts -t 'asahi.*validation'", + "test:integration:asahi-validate:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'asahi.*validation'" }, "engines": { "node": ">=20.0.0", diff --git a/bastion/scripts/build-asahi-rootfs.sh b/bastion/scripts/build-asahi-rootfs.sh new file mode 100755 index 0000000..092a35b --- /dev/null +++ b/bastion/scripts/build-asahi-rootfs.sh @@ -0,0 +1,302 @@ +#!/bin/bash +# Build a custom Fedora Asahi Remix rootfs with lab firstboot LVM setup. +# +# Downloads the upstream Fedora Asahi Remix Server package, injects our +# firstboot script + systemd service, and repackages it for the bastion. +# +# Requirements: root, curl, unzip, mount (loop), zip +# Output: bastion/asahi-repo/ directory with package + installer_data.json +# +# Usage: sudo ./scripts/build-asahi-rootfs.sh [--bastion-ip IP] [--http-port PORT] +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ASAHI_DIR="$PROJECT_DIR/asahi-repo" +CACHE_DIR="$PROJECT_DIR/.asahi-cache" +WORK_DIR="" + +# Defaults +BASTION_IP="${BASTION_IP:-192.168.8.23}" +HTTP_PORT="${HTTP_PORT:-8080}" +ROLE="${ROLE:-infra}" +HOSTNAME="${HOSTNAME:-mac-studio}" +MAC="${MAC:-00:00:00:00:00:00}" +ADMIN_USER="${ADMIN_USER:-michal}" + +# Parse args +while [[ $# -gt 0 ]]; do + case "$1" in + --bastion-ip) BASTION_IP="$2"; shift 2 ;; + --http-port) HTTP_PORT="$2"; shift 2 ;; + --role) ROLE="$2"; shift 2 ;; + --hostname) HOSTNAME="$2"; shift 2 ;; + --mac) MAC="$2"; shift 2 ;; + --admin-user) ADMIN_USER="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# ── Resolve upstream package URL ───────────────────────────────── +echo "==> Fetching Asahi installer data..." +INSTALLER_DATA=$(curl -sfL "https://cdn.asahilinux.org/installer/installer_data.json") + +# Find the Server variant package URL +SERVER_URL=$(echo "$INSTALLER_DATA" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for os in data.get('os_list', []): + name = os.get('name', '').lower() + if 'server' in name and 'uefi' not in name and not os.get('expert'): + print(os['package']) + break +" 2>/dev/null) + +if [ -z "$SERVER_URL" ]; then + echo "ERROR: Could not find Fedora Asahi Remix Server in installer data." + echo "Available variants:" + echo "$INSTALLER_DATA" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for os in data.get('os_list', []): + print(f\" - {os.get('name', '?')}\")" 2>/dev/null + exit 1 +fi + +PACKAGE_NAME=$(basename "$SERVER_URL") +echo " Variant: Fedora Asahi Remix Server" +echo " Package: $PACKAGE_NAME" + +# Also extract the partition layout and supported_fw from upstream +UPSTREAM_CONFIG=$(echo "$INSTALLER_DATA" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for os in data.get('os_list', []): + name = os.get('name', '').lower() + if 'server' in name and 'uefi' not in name and not os.get('expert'): + json.dump(os, sys.stdout) + break +") + +# ── Download upstream package ──────────────────────────────────── +mkdir -p "$CACHE_DIR" "$ASAHI_DIR" + +CACHED_PKG="$CACHE_DIR/$PACKAGE_NAME" +if [ -f "$CACHED_PKG" ]; then + echo "==> Using cached package: $CACHED_PKG" +else + echo "==> Downloading $SERVER_URL..." + curl -# -L -o "$CACHED_PKG" "$SERVER_URL" +fi + +# ── Extract and modify rootfs ──────────────────────────────────── +WORK_DIR=$(mktemp -d) +trap 'echo "==> Cleaning up..."; umount "$WORK_DIR/rootfs" 2>/dev/null || true; rm -rf "$WORK_DIR"' EXIT + +echo "==> Extracting package..." +unzip -q -o "$CACHED_PKG" -d "$WORK_DIR/pkg" + +# List contents +echo " Package contents:" +ls -lh "$WORK_DIR/pkg/" | grep -v ^total | while read -r line; do echo " $line"; done + +# Find root.img +ROOT_IMG=$(find "$WORK_DIR/pkg" -name "root.img" -type f | head -1) +if [ -z "$ROOT_IMG" ]; then + echo "ERROR: root.img not found in package." + echo "Contents: $(ls "$WORK_DIR/pkg/")" + exit 1 +fi + +echo "==> Mounting root.img..." +mkdir -p "$WORK_DIR/rootfs" +mount -o loop "$ROOT_IMG" "$WORK_DIR/rootfs" + +# ── Read SSH keys from the system ──────────────────────────────── +SSH_KEYS="" +REAL_USER="${SUDO_USER:-$USER}" +REAL_HOME=$(eval echo "~$REAL_USER") +for keyfile in "$REAL_HOME/.ssh/id_ed25519.pub" "$REAL_HOME/.ssh/id_ecdsa.pub" "$REAL_HOME/.ssh/id_rsa.pub"; do + if [ -f "$keyfile" ]; then + SSH_KEYS=$(cat "$keyfile") + echo " SSH key: $keyfile" + break + fi +done + +if [ -z "$SSH_KEYS" ]; then + echo "WARNING: No SSH public key found. You'll need to add keys manually." +fi + +# ── Generate firstboot script from bastion ─────────────────────── +echo "==> Generating firstboot script..." + +# Try to get the script from a running bastion, fall back to local generation +FIRSTBOOT_SCRIPT="" +FIRSTBOOT_URL="http://$BASTION_IP:$HTTP_PORT/asahi/firstboot.sh?hostname=$HOSTNAME&role=$ROLE&mac=$MAC&user=$ADMIN_USER" +FIRSTBOOT_SCRIPT=$(curl -sf "$FIRSTBOOT_URL" 2>/dev/null || echo "") + +if [ -z "$FIRSTBOOT_SCRIPT" ]; then + echo " Bastion not reachable, generating script locally..." + # Generate a basic firstboot script inline + FIRSTBOOT_SCRIPT=$(cd "$PROJECT_DIR" && node -e " +const { renderFirstbootScript } = require('./src/bastion/dist/templates/asahi-firstboot.sh.js'); +process.stdout.write(renderFirstbootScript({ + hostname: '$HOSTNAME', + role: '$ROLE', + serverIp: '$BASTION_IP', + httpPort: $HTTP_PORT, + sshKeys: $([ -n "$SSH_KEYS" ] && echo "[\"$SSH_KEYS\"]" || echo "[]"), + adminUser: '$ADMIN_USER', + mac: '$MAC', +})); +" 2>/dev/null) || { + echo " ERROR: Could not generate firstboot script. Build the project first: npm run build" + exit 1 + } +fi + +# ── Inject files into rootfs ───────────────────────────────────── +echo "==> Injecting lab configuration into rootfs..." + +# Firstboot script +mkdir -p "$WORK_DIR/rootfs/usr/local/bin" +echo "$FIRSTBOOT_SCRIPT" > "$WORK_DIR/rootfs/usr/local/bin/lab-firstboot.sh" +chmod 755 "$WORK_DIR/rootfs/usr/local/bin/lab-firstboot.sh" +echo " Installed: /usr/local/bin/lab-firstboot.sh" + +# Systemd service +mkdir -p "$WORK_DIR/rootfs/etc/systemd/system" +cat > "$WORK_DIR/rootfs/etc/systemd/system/lab-firstboot.service" << 'UNIT' +[Unit] +Description=Lab first-boot LVM setup +After=local-fs.target network-online.target +Wants=network-online.target +ConditionPathExists=!/etc/lab-lvm-setup-done + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/lab-firstboot.sh +RemainAfterExit=yes +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=multi-user.target +UNIT +echo " Installed: /etc/systemd/system/lab-firstboot.service" + +# Enable the service +mkdir -p "$WORK_DIR/rootfs/etc/systemd/system/multi-user.target.wants" +ln -sf /etc/systemd/system/lab-firstboot.service \ + "$WORK_DIR/rootfs/etc/systemd/system/multi-user.target.wants/lab-firstboot.service" +echo " Enabled: lab-firstboot.service" + +# SSH authorized keys for root (for initial access before firstboot runs user creation) +if [ -n "$SSH_KEYS" ]; then + mkdir -p "$WORK_DIR/rootfs/root/.ssh" + chmod 700 "$WORK_DIR/rootfs/root/.ssh" + echo "$SSH_KEYS" > "$WORK_DIR/rootfs/root/.ssh/authorized_keys" + chmod 600 "$WORK_DIR/rootfs/root/.ssh/authorized_keys" + echo " Installed: /root/.ssh/authorized_keys" +fi + +# Ensure lvm2 and xfsprogs are installed (should be in server image already) +echo " Checking required packages..." +if [ -f "$WORK_DIR/rootfs/usr/sbin/pvcreate" ] || [ -f "$WORK_DIR/rootfs/usr/bin/pvcreate" ]; then + echo " lvm2: present" +else + echo " WARNING: lvm2 not found in rootfs. LVM setup may fail." +fi +if [ -f "$WORK_DIR/rootfs/usr/sbin/mkfs.xfs" ] || [ -f "$WORK_DIR/rootfs/usr/bin/mkfs.xfs" ]; then + echo " xfsprogs: present" +else + echo " WARNING: xfsprogs not found in rootfs. LVM setup may fail." +fi + +# ── Unmount and repackage ──────────────────────────────────────── +echo "==> Unmounting rootfs..." +umount "$WORK_DIR/rootfs" + +echo "==> Repackaging..." +OUTPUT_PKG="$ASAHI_DIR/fedora-asahi-lab.zip" +rm -f "$OUTPUT_PKG" +(cd "$WORK_DIR/pkg" && zip -q "$OUTPUT_PKG" *) +echo " Output: $OUTPUT_PKG ($(du -sh "$OUTPUT_PKG" | cut -f1))" + +# ── Generate installer_data.json ───────────────────────────────── +echo "==> Generating installer_data.json..." + +# Parse upstream config to get supported_fw, boot_object, next_object, and partition details +python3 << PYEOF > "$ASAHI_DIR/installer_data.json" +import json, sys + +upstream = json.loads('''$UPSTREAM_CONFIG''') + +# Build our custom installer data based on upstream +# Keep EFI and Boot partitions identical, modify Root to not expand, +# add Data partition that expands for LVM. +partitions = [] +for p in upstream.get('partitions', []): + if p.get('type') == 'EFI': + partitions.append(p) + elif p.get('name') == 'Boot': + partitions.append(p) + elif p.get('name') == 'Root': + # Fixed size root, no expand + root_p = dict(p) + root_p['expand'] = False + # Keep the original size (it's the minimum needed for the rootfs) + partitions.append(root_p) + +# Add Data partition for LVM +partitions.append({ + "name": "Data", + "type": "Linux", + "size": "1073741824B", # 1GB minimum, will expand + "expand": True +}) + +data = { + "os_list": [{ + "name": "Fedora Asahi Lab (${ROLE})", + "default_os_name": "Fedora Linux Lab", + "boot_object": upstream.get("boot_object", "m1n1.bin"), + "next_object": upstream.get("next_object", "m1n1/boot.bin"), + "package": "fedora-asahi-lab.zip", + "supported_fw": upstream.get("supported_fw", ["13.5"]), + "partitions": partitions, + }] +} + +json.dump(data, sys.stdout, indent=2) +print() +PYEOF + +echo " Generated: $ASAHI_DIR/installer_data.json" + +# Pretty-print the partition layout +echo "" +echo " Partition layout:" +python3 -c " +import json +with open('$ASAHI_DIR/installer_data.json') as f: + data = json.load(f) +for p in data['os_list'][0]['partitions']: + size = p.get('size', '?') + expand = ' (expand)' if p.get('expand') else '' + image = f\" [{p['image']}]\" if 'image' in p else '' + print(f\" {p['name']:8s} {p['type']:8s} {size:>16s}{expand}{image}\") +" + +echo "" +echo "==> Build complete!" +echo "" +echo " Package: $ASAHI_DIR/fedora-asahi-lab.zip" +echo " Config: $ASAHI_DIR/installer_data.json" +echo "" +echo " To serve from bastion, copy to the bastion's HTTP directory" +echo " or configure REPO_BASE to point here." +echo "" +echo " To install on Mac Studio:" +echo " curl http://$BASTION_IP:$HTTP_PORT/asahi | sh" diff --git a/bastion/scripts/deploy.sh b/bastion/scripts/deploy.sh index 86b6f26..c5fa75f 100644 --- a/bastion/scripts/deploy.sh +++ b/bastion/scripts/deploy.sh @@ -24,6 +24,21 @@ deploy_bastion() { kubectl rollout restart deployment/bastion -n lab-infra kubectl rollout status deployment/bastion -n lab-infra --timeout=180s echo "✓ Bastion deployed" + + # Sync Asahi rootfs package to bastion pod's persistent volume + if [ -d "$PROJECT_DIR/asahi-repo" ] && [ -f "$PROJECT_DIR/asahi-repo/fedora-asahi-lab.zip" ]; then + echo "" + echo "=== Syncing Asahi rootfs to bastion pod ===" + BASTION_POD=$(kubectl get pods -n lab-infra -l app=bastion -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + if [ -n "$BASTION_POD" ]; then + kubectl exec -n lab-infra "$BASTION_POD" -- mkdir -p /data/asahi-repo + kubectl cp "$PROJECT_DIR/asahi-repo/installer_data.json" "lab-infra/$BASTION_POD:/data/asahi-repo/installer_data.json" + kubectl cp "$PROJECT_DIR/asahi-repo/fedora-asahi-lab.zip" "lab-infra/$BASTION_POD:/data/asahi-repo/fedora-asahi-lab.zip" + echo "✓ Asahi rootfs synced ($(du -sh "$PROJECT_DIR/asahi-repo/fedora-asahi-lab.zip" | cut -f1))" + else + echo "WARNING: Could not find bastion pod — Asahi rootfs not synced" + fi + fi } deploy_labd() { diff --git a/bastion/src/bastion/src/routes/api.ts b/bastion/src/bastion/src/routes/api.ts index 3230fa5..fd7c393 100644 --- a/bastion/src/bastion/src/routes/api.ts +++ b/bastion/src/bastion/src/routes/api.ts @@ -148,7 +148,7 @@ export function registerApiRoutes( }; s.installed[mac] = installedInfo; - const admin = installedInfo.role !== "vanilla" && installedInfo.role !== "" ? "michal" : "root"; + const admin = installedInfo.role !== "vanilla" && installedInfo.role !== "" ? "lab" : "root"; console.log(`\n \x1b[0;32m\x1b[1m ssh ${admin}@${ip}\x1b[0m\n`); // eslint-disable-line no-console // Auto-install k3s for non-vanilla roles diff --git a/bastion/src/bastion/src/routes/asahi.ts b/bastion/src/bastion/src/routes/asahi.ts new file mode 100644 index 0000000..3622cab --- /dev/null +++ b/bastion/src/bastion/src/routes/asahi.ts @@ -0,0 +1,175 @@ +// Routes for Asahi Linux provisioning. +// GET /asahi — wrapper script (curl bastion:8080/asahi | sh) +// GET /asahi/installer_data.json — custom installer config (built or fallback) +// GET /asahi/repo/* — serves built rootfs package (fedora-asahi-lab.zip) +// GET /asahi/firstboot.sh — first-boot LVM setup script (for manual use) + +import type { FastifyInstance } from "fastify"; +import fastifyStatic from "@fastify/static"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BastionConfig } from "@lab/shared"; +import { renderFirstbootScript, renderFirstbootUnit } from "../templates/asahi-firstboot.sh.js"; +import type { Role } from "@lab/shared"; + +/** Find the asahi-repo directory (built by scripts/build-asahi-rootfs.sh). */ +function findAsahiRepo(config: BastionConfig): string | null { + // Check relative to bastionDir (container deploy) + const inBastionDir = join(config.bastionDir, "asahi-repo"); + if (existsSync(inBastionDir)) return inBastionDir; + + // Check /data/asahi-repo (PVC mount in k3s container) + if (existsSync("/data/asahi-repo")) return "/data/asahi-repo"; + + // Check relative to project root (dev mode) + try { + const thisDir = dirname(fileURLToPath(import.meta.url)); + const projectRoot = join(thisDir, "..", "..", "..", ".."); + const inProjectRoot = join(projectRoot, "asahi-repo"); + if (existsSync(inProjectRoot)) return inProjectRoot; + } catch { /* import.meta.url not available in tests */ } + + return null; +} + +export function registerAsahiRoutes(app: FastifyInstance, config: BastionConfig): void { + const repoDir = findAsahiRepo(config); + + // Serve built rootfs package files (fedora-asahi-lab.zip, etc.) + if (repoDir) { + app.register(fastifyStatic, { + root: repoDir, + prefix: "/asahi/repo/", + decorateReply: false, + }); + } + + // Wrapper script — user runs: curl http://bastion:8080/asahi | sh + app.get("/asahi", async (_request, reply) => { + const script = `#!/bin/bash +# Lab Asahi provisioner — sets up Apple Silicon machines with lab LVM layout. +# This wraps the standard Asahi installer with custom installer_data.json +# that creates a separate LVM data partition. +set -euo pipefail + +BASTION="http://${config.serverIp}:${config.httpPort}" + +echo "" +echo " ╔══════════════════════════════════════════════╗" +echo " ║ Lab Asahi Provisioner ║" +echo " ║ Bastion: \${BASTION} ║" +echo " ╚══════════════════════════════════════════════╝" +echo "" + +# Check we're on macOS +if [ "$(uname)" != "Darwin" ]; then + echo "ERROR: This script must be run from macOS on the target Mac." + echo " It uses the Asahi Linux installer to set up Apple Silicon boot." + exit 1 +fi + +# Download the standard Asahi installer +echo "Downloading Asahi Linux installer..." +WORKDIR=$(mktemp -d) +cd "$WORKDIR" + +INSTALLER_BASE="https://cdn.asahilinux.org/installer" +PKG_VER=$(curl -s "\${INSTALLER_BASE}/latest") +echo " Version: \${PKG_VER}" + +curl -# -L -o "installer-\${PKG_VER}.tar.gz" "\${INSTALLER_BASE}/installer-\${PKG_VER}.tar.gz" + +echo " Extracting..." +tar xf "installer-\${PKG_VER}.tar.gz" + +# Download our custom installer_data.json (installer reads it as a local file) +echo " Downloading custom installer data from bastion..." +curl -sfL -o installer_data.json "\${BASTION}/asahi/installer_data.json" + +# Pre-download the rootfs package (avoids Python HTTP streaming issues on macOS) +echo " Downloading rootfs package from bastion..." +mkdir -p os +curl -# -L -o os/fedora-asahi-lab.zip "\${BASTION}/asahi/repo/fedora-asahi-lab.zip" + +# Point installer to local directory (REPO_BASE + /os/ + package name) +export REPO_BASE="\${PWD}" + +echo "" +echo " Using custom partition layout + rootfs from bastion." +echo " This will create:" +echo " - Standard Asahi boot infrastructure (m1n1 + U-Boot)" +echo " - Fedora Asahi Remix root partition" +echo " - LVM data partition (remaining space)" +echo "" +echo " On first boot, LVM volumes are created automatically." +echo "" + +# Run the installer +if [ "$USER" != "root" ]; then + echo "The installer needs root. Enter your sudo password if prompted." + exec caffeinate -dis sudo -E ./install.sh "$@" +else + exec caffeinate -dis ./install.sh "$@" +fi +`; + return reply.type("text/x-shellscript").send(script); + }); + + // Custom installer_data.json — serves built config or fallback + app.get("/asahi/installer_data.json", async (_request, reply) => { + // Prefer the built installer_data.json (from build-asahi-rootfs.sh) + if (repoDir) { + const builtConfig = join(repoDir, "installer_data.json"); + if (existsSync(builtConfig)) { + const data = JSON.parse(readFileSync(builtConfig, "utf-8")); + return reply.type("application/json").send(data); + } + } + + // Fallback: minimal config (won't have boot.img, for testing only) + return reply.type("application/json").send({ + os_list: [{ + name: "Fedora Asahi Lab", + default_os_name: "Fedora Linux with Lab LVM", + boot_object: "m1n1.bin", + next_object: "m1n1/boot.bin", + package: "fedora-asahi-lab.zip", + supported_fw: ["13.5"], + partitions: [ + { name: "EFI", type: "EFI", size: "524288000B", format: "fat", + copy_firmware: true, copy_installer_data: true, source: "esp" }, + { name: "Root", type: "Linux", size: "5368709120B", image: "root.img", expand: false }, + { name: "Data", type: "Linux", size: "1073741824B", expand: true }, + ], + }], + }); + }); + + // First-boot script — for manual download or embedding in rootfs + app.get<{ + Querystring: { hostname?: string; role?: string; mac?: string; user?: string }; + }>("/asahi/firstboot.sh", async (request, reply) => { + const hostname = request.query.hostname ?? "mac-studio"; + const role = (request.query.role ?? "infra") as Role; + const mac = request.query.mac ?? "unknown"; + const user = request.query.user ?? config.adminUser; + + const script = renderFirstbootScript({ + hostname, + role, + serverIp: config.serverIp, + httpPort: config.httpPort, + sshKeys: config.sshKeys ?? [], + adminUser: user, + mac, + }); + + return reply.type("text/x-shellscript").send(script); + }); + + // Systemd unit file for first-boot service + app.get("/asahi/firstboot.service", async (_request, reply) => { + return reply.type("text/plain").send(renderFirstbootUnit()); + }); +} diff --git a/bastion/src/bastion/src/server.ts b/bastion/src/bastion/src/server.ts index 9a2979a..d3078d8 100644 --- a/bastion/src/bastion/src/server.ts +++ b/bastion/src/bastion/src/server.ts @@ -11,6 +11,7 @@ import { logger } from "./services/logger.js"; import { registerDispatchRoutes } from "./routes/dispatch.js"; import { registerKickstartRoutes } from "./routes/kickstart.js"; import { registerApiRoutes } from "./routes/api.js"; +import { registerAsahiRoutes } from "./routes/asahi.js"; export function createApp(config: BastionConfig): { app: ReturnType; state: StateManager; installLog: InstallLogBuffer; syslog: SyslogListener } { @@ -45,6 +46,7 @@ export function createApp(config: BastionConfig): { app: ReturnType> /etc/fstab'); + } + if (isWorker || isInfra) { + roleLvLines.push('lvcreate -l 100%FREE -n longhorn labvg -y'); + roleFormatLines.push('mkfs.xfs /dev/labvg/longhorn'); + roleMountLines.push('mount_lv longhorn /var/lib/longhorn'); + roleFstabLines.push('echo "/dev/labvg/longhorn /var/lib/longhorn xfs defaults 0 0" >> /etc/fstab'); + } + + // SSH key injection block (empty if no keys) + const sshKeyBlock = sshKeys.length > 0 + ? sshKeys.map(k => `echo '${k}' >> "$ADMIN_SSH/authorized_keys"`).join('\n') + : 'true # no SSH keys configured'; + const rootSshKeyBlock = sshKeys.length > 0 + ? sshKeys.map(k => `echo '${k}' >> /root/.ssh/authorized_keys`).join('\n') + : 'true # no SSH keys configured'; + + // NOTE: All bash $ references use $VAR not \${VAR} to avoid TS template conflicts. + // Where ${} is needed in bash, we use \\${...} to escape. + return `#!/bin/bash +# Lab first-boot LVM setup — generated by bastion +# This script runs once on first boot via systemd, then disables itself. +set -euo pipefail + +MARKER="/etc/lab-lvm-setup-done" +LOG="/var/log/lab-firstboot.log" + +exec > >(tee -a "$LOG") 2>&1 +echo "=== Lab first-boot LVM setup ===" +date + +# Already done? +if [ -f "$MARKER" ]; then + echo "LVM setup already completed, skipping." + exit 0 +fi + +# ── Find the data partition ────────────────────────────────────── +# The data partition/disk is a large block device that is NOT the root filesystem. +# Handles: NVMe partitions, SCSI partitions, whole unpartitioned disks. +ROOT_DEV=$(findmnt -n -o SOURCE / | sed 's/\\[.*\\]//') # strip btrfs subvol +ROOT_DISK=$(lsblk -n -o PKNAME "$ROOT_DEV" 2>/dev/null | head -1) +echo "Root device: $ROOT_DEV (disk: $ROOT_DISK)" + +DATA_PART="" +# Scan partitions first, then whole disks +for part in /dev/nvme*n*p* /dev/sd*[0-9] /dev/vd*[0-9] /dev/nvme*n* /dev/sd[b-z] /dev/vd[b-z]; do + [ -b "$part" ] || continue + # Skip root device and root disk + [ "$part" = "$ROOT_DEV" ] && continue + PART_DISK=$(basename "$part" | sed 's/p[0-9]*$//' | sed 's/[0-9]*$//') + [ "$PART_DISK" = "$ROOT_DISK" ] && continue + # Skip small devices (<50GB) — EFI, boot, APFS stubs + SIZE_BYTES=$(blockdev --getsize64 "$part" 2>/dev/null || echo 0) + SIZE_GB=$((SIZE_BYTES / 1073741824)) + [ "$SIZE_GB" -lt 50 ] && continue + # Use if unformatted or already LVM + FSTYPE=$(blkid -o value -s TYPE "$part" 2>/dev/null || echo "") + if [ -z "$FSTYPE" ] || [ "$FSTYPE" = "LVM2_member" ]; then + DATA_PART="$part" + echo "Found data device: $DATA_PART ($SIZE_GB GB)" + break + fi +done + +if [ -z "$DATA_PART" ]; then + echo "ERROR: No suitable data partition found for LVM." + echo "Expected a large (>50GB) unformatted partition." + exit 1 +fi + +# ── Helper function ────────────────────────────────────────────── +mount_lv() { + local lv="$1" mp="$2" + if lvs "labvg/$lv" &>/dev/null; then + mkdir -p "$mp" + mount "/dev/labvg/$lv" "$mp" 2>/dev/null || true + echo " Mounted $lv -> $mp" + fi +} + +# ── Check for existing VG ──────────────────────────────────────── +if vgs labvg &>/dev/null; then + echo "Volume group 'labvg' already exists — reprovision detected." + echo "Activating existing volumes..." + vgchange -ay labvg + + mount_lv var /var + mount_lv varlog /var/log + mount_lv home /home + mount_lv srv /srv +${roleMountLines.map(l => ` ${l}`).join('\n')} + + # Enable swap + if lvs labvg/swap &>/dev/null; then + swapon /dev/labvg/swap 2>/dev/null || true + echo " Enabled swap" + fi + + # Ensure fstab entries exist + grep -q "labvg" /etc/fstab || { + echo "# Lab LVM volumes (re-added after reprovision)" >> /etc/fstab + echo "/dev/labvg/swap none swap defaults 0 0" >> /etc/fstab + echo "/dev/labvg/var /var xfs defaults 0 0" >> /etc/fstab + echo "/dev/labvg/varlog /var/log xfs defaults 0 0" >> /etc/fstab + echo "/dev/labvg/home /home xfs defaults 0 0" >> /etc/fstab + echo "/dev/labvg/srv /srv xfs defaults 0 0" >> /etc/fstab +${roleFstabLines.map(l => ` ${l}`).join('\n')} + } + + echo "Existing LVM volumes re-mounted." + touch "$MARKER" + exit 0 +fi + +# ── Fresh install: create LVM ──────────────────────────────────── +echo "Creating LVM on $DATA_PART..." + +pvcreate "$DATA_PART" +vgcreate labvg "$DATA_PART" + +# Create LVs — sizes match install.ks.ts (in MiB) +echo "Creating logical volumes..." +lvcreate -L 27648M -n swap labvg -y # 27GB swap +lvcreate -L 102400M -n var labvg -y # 100GB /var +lvcreate -L 10240M -n varlog labvg -y # 10GB /var/log +lvcreate -L 10240M -n home labvg -y # 10GB /home +lvcreate -L 20480M -n srv labvg -y # 20GB /srv +${roleLvLines.join('\n')} + +# Format +echo "Formatting volumes..." +mkswap /dev/labvg/swap +mkfs.xfs /dev/labvg/var +mkfs.xfs /dev/labvg/varlog +mkfs.xfs /dev/labvg/home +mkfs.xfs /dev/labvg/srv +${roleFormatLines.join('\n')} + +# Migrate and mount volumes that can be switched live. +# Copy existing content first so we don't shadow files (e.g. /home/user/.ssh). +for LV_MOUNT in "home /home" "srv /srv"; do + LV_NAME=$(echo "$LV_MOUNT" | awk '{print $1}') + MOUNT_PT=$(echo "$LV_MOUNT" | awk '{print $2}') + STAGING="/mnt/labvg-$LV_NAME-staging" + mkdir -p "$STAGING" + mount "/dev/labvg/$LV_NAME" "$STAGING" + cp -a "$MOUNT_PT"/. "$STAGING/" 2>/dev/null || true + umount "$STAGING" + rmdir "$STAGING" + mount_lv "$LV_NAME" "$MOUNT_PT" +done + +# Mount role-specific volumes (empty, no content to preserve) +set +e +${roleMountLines.join('\n')} +set -e + +# Copy existing /var content into the LV for next boot +echo "Preparing /var LV for next boot..." +TMPVAR="/mnt/labvg-var-staging" +mkdir -p "$TMPVAR" +mount /dev/labvg/var "$TMPVAR" +cp -a /var/. "$TMPVAR/" 2>/dev/null || true +umount "$TMPVAR" +rmdir "$TMPVAR" + +# Same for /var/log +TMPVARLOG="/mnt/labvg-varlog-staging" +mkdir -p "$TMPVARLOG" +mount /dev/labvg/varlog "$TMPVARLOG" +cp -a /var/log/. "$TMPVARLOG/" 2>/dev/null || true +umount "$TMPVARLOG" +rmdir "$TMPVARLOG" + +echo "NOTE: /var and /var/log will switch to LVM on next reboot." + +# Enable swap +swapon /dev/labvg/swap 2>/dev/null || true + +# Write fstab entries +echo "" >> /etc/fstab +echo "# Lab LVM volumes" >> /etc/fstab +echo "/dev/labvg/swap none swap defaults 0 0" >> /etc/fstab +echo "/dev/labvg/var /var xfs defaults 0 0" >> /etc/fstab +echo "/dev/labvg/varlog /var/log xfs defaults 0 0" >> /etc/fstab +echo "/dev/labvg/home /home xfs defaults 0 0" >> /etc/fstab +echo "/dev/labvg/srv /srv xfs defaults 0 0" >> /etc/fstab +${roleFstabLines.join('\n')} + +echo "LVM setup complete." +lvs labvg + +# ── Set hostname ───────────────────────────────────────────────── +hostnamectl set-hostname "${hostname}" + +# ── Configure admin user ───────────────────────────────────────── +if ! id "${adminUser}" &>/dev/null; then + useradd -m -G wheel "${adminUser}" + echo "${adminUser} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/${adminUser} + chmod 440 /etc/sudoers.d/${adminUser} +fi +ADMIN_SSH="/home/${adminUser}/.ssh" +mkdir -p "$ADMIN_SSH" +chmod 700 "$ADMIN_SSH" +${sshKeyBlock} +chmod 600 "$ADMIN_SSH/authorized_keys" +chown -R ${adminUser}:${adminUser} "$ADMIN_SSH" + +# Also authorize root +mkdir -p /root/.ssh +chmod 700 /root/.ssh +${rootSshKeyBlock} +chmod 600 /root/.ssh/authorized_keys + +# ── Harden SSH (takes effect on next sshd restart/reboot) ──────── +sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config +sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config + +# ── Write provisioning metadata ────────────────────────────────── +cat > /etc/lab-provisioned << LABMETA +hostname=${hostname} +role=${role} +mac=${mac} +provisioned_at=$(date -Iseconds) +method=asahi-firstboot +LABMETA + +# ── Register with bastion ───────────────────────────────────────── +IP=$(hostname -I | awk '{print $1}') +echo "Registering with bastion at ${serverIp}:${httpPort}..." +curl -sf -X POST "http://${serverIp}:${httpPort}/api/register" \\ + -H "Content-Type: application/json" \\ + -d "{\\"mac\\":\\"${mac}\\",\\"hostname\\":\\"${hostname}\\",\\"role\\":\\"${role}\\",\\"ip\\":\\"$IP\\"}" \\ + 2>/dev/null && echo " Registered as ${hostname} ($IP)" \\ + || echo " WARNING: Could not reach bastion — register manually with: labctl provision register ${mac} ${hostname} --role ${role} --ip $IP" + +# ── Mark done ──────────────────────────────────────────────────── +touch "$MARKER" +echo "=== First-boot setup complete ===" +`; +} + +/** Systemd unit file for the first-boot service */ +export function renderFirstbootUnit(): string { + return `[Unit] +Description=Lab first-boot LVM setup +After=local-fs.target network-online.target +Wants=network-online.target +ConditionPathExists=!/etc/lab-lvm-setup-done + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/lab-firstboot.sh +RemainAfterExit=yes +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=multi-user.target +`; +} diff --git a/bastion/src/bastion/tests/asahi.test.ts b/bastion/src/bastion/tests/asahi.test.ts new file mode 100644 index 0000000..ffe0ec5 --- /dev/null +++ b/bastion/src/bastion/tests/asahi.test.ts @@ -0,0 +1,224 @@ +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 { BastionConfig } from "@lab/shared"; +import { createApp } from "../src/server.js"; +import type { FastifyInstance } from "fastify"; +import { renderFirstbootScript, renderFirstbootUnit } from "../src/templates/asahi-firstboot.sh.js"; + +function createTestConfig(testDir: string): BastionConfig { + return { + fedoraVersion: "43", + arch: "x86_64", + httpPort: 0, + timezone: "Europe/London", + locale: "en_GB.UTF-8", + bastionDir: testDir, + domain: "test.local", + dhcpMode: "proxy", + dhcpRangeStart: "", + dhcpRangeEnd: "", + ubuntuVersion: "26.04", + ubuntuMirror: "https://releases.ubuntu.com/26.04", + iface: "eth0", + serverIp: "192.168.8.1", + network: "192.168.8.0", + gateway: "192.168.8.1", + sshKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST test@lab"], + adminUser: "michal", + syslogPort: 15514, + skipDnsmasq: true, + skipArtifacts: true, + fedoraMirror: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os", + tftpDir: join(testDir, "tftp"), + httpDir: join(testDir, "http"), + stateFile: join(testDir, "state.json"), + }; +} + +describe("asahi routes", () => { + let testDir: string; + let app: FastifyInstance; + + beforeEach(() => { + testDir = join(tmpdir(), `bastion-asahi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, "http"), { recursive: true }); + mkdirSync(join(testDir, "tftp"), { recursive: true }); + + const config = createTestConfig(testDir); + const result = createApp(config); + app = result.app; + }); + + afterEach(async () => { + await app.close(); + rmSync(testDir, { recursive: true, force: true }); + }); + + it("GET /asahi returns wrapper shell script", async () => { + const resp = await app.inject({ method: "GET", url: "/asahi" }); + expect(resp.statusCode).toBe(200); + expect(resp.headers["content-type"]).toContain("text/x-shellscript"); + expect(resp.body).toContain("#!/bin/bash"); + expect(resp.body).toContain("installer_data.json"); + expect(resp.body).toContain("192.168.8.1"); + expect(resp.body).toContain("install.sh"); + }); + + it("GET /asahi/installer_data.json returns valid config", async () => { + const resp = await app.inject({ method: "GET", url: "/asahi/installer_data.json" }); + expect(resp.statusCode).toBe(200); + const data = JSON.parse(resp.body); + + expect(data.os_list).toHaveLength(1); + const os = data.os_list[0]; + expect(os.name).toContain("Fedora Asahi Lab"); + + // 3 partitions (fallback) or 4 (built: EFI + Boot + Root + Data) + expect(os.partitions.length).toBeGreaterThanOrEqual(3); + expect(os.partitions[0].type).toBe("EFI"); + // Last partition should be the expanding Data partition + const lastPart = os.partitions[os.partitions.length - 1]; + expect(lastPart.type).toBe("Linux"); + expect(lastPart.expand).toBe(true); + // Root partition (second-to-last) should NOT expand + const rootPart = os.partitions[os.partitions.length - 2]; + expect(rootPart.expand).toBe(false); + expect(rootPart.image).toBe("root.img"); + }); + + it("GET /asahi/firstboot.sh returns parameterized script", async () => { + const resp = await app.inject({ + method: "GET", + url: "/asahi/firstboot.sh?hostname=mac-studio&role=infra&mac=00:11:22:33:44:55", + }); + expect(resp.statusCode).toBe(200); + expect(resp.body).toContain("#!/bin/bash"); + expect(resp.body).toContain("mac-studio"); + expect(resp.body).toContain("labvg"); + expect(resp.body).toContain("rancher"); // infra gets rancher LV + expect(resp.body).toContain("longhorn"); // infra also gets longhorn + expect(resp.body).toContain("ssh-ed25519"); // SSH key injected + }); + + it("GET /asahi/firstboot.service returns systemd unit", async () => { + const resp = await app.inject({ method: "GET", url: "/asahi/firstboot.service" }); + expect(resp.statusCode).toBe(200); + expect(resp.body).toContain("[Unit]"); + expect(resp.body).toContain("lab-firstboot.sh"); + expect(resp.body).toContain("ConditionPathExists=!/etc/lab-lvm-setup-done"); + }); +}); + +describe("renderFirstbootScript", () => { + const baseParams = { + hostname: "test-node", + serverIp: "10.0.0.1", + httpPort: 8080, + sshKeys: ["ssh-ed25519 AAAA... user@host"], + adminUser: "testadmin", + mac: "aa:bb:cc:dd:ee:ff", + }; + + it("generates valid bash with shebang", () => { + const script = renderFirstbootScript({ ...baseParams, role: "worker" }); + expect(script.startsWith("#!/bin/bash")).toBe(true); + }); + + it("includes LVM creation commands", () => { + const script = renderFirstbootScript({ ...baseParams, role: "infra" }); + expect(script).toContain("pvcreate"); + expect(script).toContain("vgcreate labvg"); + expect(script).toContain("lvcreate"); + }); + + it("uses correct LV sizes from kickstart layout", () => { + const script = renderFirstbootScript({ ...baseParams, role: "infra" }); + expect(script).toContain("27648M"); // swap + expect(script).toContain("102400M"); // /var + expect(script).toContain("10240M"); // /var/log and /home + expect(script).toContain("20480M"); // /srv and /rancher + }); + + it("includes rancher LV for infra role", () => { + const script = renderFirstbootScript({ ...baseParams, role: "infra" }); + expect(script).toContain("rancher"); + expect(script).toContain("/var/lib/rancher"); + }); + + it("includes longhorn for worker role", () => { + const script = renderFirstbootScript({ ...baseParams, role: "worker" }); + expect(script).toContain("longhorn"); + expect(script).toContain("/var/lib/longhorn"); + // Worker should NOT have rancher + expect(script).not.toContain("rancher"); + }); + + it("includes longhorn for infra role", () => { + const script = renderFirstbootScript({ ...baseParams, role: "infra" }); + expect(script).toContain("longhorn"); + expect(script).toContain("/var/lib/longhorn"); + }); + + it("vanilla role gets no role-specific LVs", () => { + const script = renderFirstbootScript({ ...baseParams, role: "vanilla" }); + expect(script).not.toContain("rancher"); + expect(script).not.toContain("longhorn"); + }); + + it("handles reprovision (existing labvg)", () => { + const script = renderFirstbootScript({ ...baseParams, role: "infra" }); + expect(script).toContain("reprovision detected"); + expect(script).toContain("vgchange -ay labvg"); + expect(script).toContain("mount_lv var /var"); + }); + + it("injects SSH keys for admin user and root", () => { + const script = renderFirstbootScript({ ...baseParams, role: "worker" }); + expect(script).toContain("ssh-ed25519 AAAA..."); + expect(script).toContain("testadmin"); + expect(script).toContain("/root/.ssh/authorized_keys"); + }); + + it("sets hostname", () => { + const script = renderFirstbootScript({ ...baseParams, role: "worker" }); + expect(script).toContain('hostnamectl set-hostname "test-node"'); + }); + + it("includes bastion self-registration", () => { + const script = renderFirstbootScript({ ...baseParams, role: "worker" }); + expect(script).toContain("/api/register"); + expect(script).toContain("aa:bb:cc:dd:ee:ff"); + expect(script).toContain("test-node"); + }); + + it("writes provisioning metadata", () => { + const script = renderFirstbootScript({ ...baseParams, role: "infra" }); + expect(script).toContain("/etc/lab-provisioned"); + expect(script).toContain("method=asahi-firstboot"); + }); + + it("creates marker file to prevent re-run", () => { + const script = renderFirstbootScript({ ...baseParams, role: "worker" }); + expect(script).toContain("/etc/lab-lvm-setup-done"); + expect(script).toContain('touch "$MARKER"'); + }); +}); + +describe("renderFirstbootUnit", () => { + it("generates valid systemd unit", () => { + const unit = renderFirstbootUnit(); + expect(unit).toContain("[Unit]"); + expect(unit).toContain("[Service]"); + expect(unit).toContain("[Install]"); + expect(unit).toContain("Type=oneshot"); + expect(unit).toContain("WantedBy=multi-user.target"); + }); + + it("only runs when marker is missing", () => { + const unit = renderFirstbootUnit(); + expect(unit).toContain("ConditionPathExists=!/etc/lab-lvm-setup-done"); + }); +}); diff --git a/bastion/src/cli/src/commands/app.ts b/bastion/src/cli/src/commands/app.ts index 28f871a..0684b7e 100644 --- a/bastion/src/cli/src/commands/app.ts +++ b/bastion/src/cli/src/commands/app.ts @@ -70,7 +70,7 @@ export function registerAppCommand(program: Command): void { .command("install ") .description("Install k3s on a target machine (hostname, IP, or MAC)") .option("--role ", "k3s role: infra (server) or worker (agent)", "infra") - .option("--user ", "SSH user", "michal") + .option("--user ", "SSH user", "lab") .option("--k3s-server ", "k3s server URL (required for worker role)") .option("--k3s-token ", "k3s join token (required for worker role)") .action(async (target: string, opts: { @@ -164,7 +164,7 @@ export function registerAppCommand(program: Command): void { k3sCmd .command("health [target]") .description("Check k3s health (all hosts if no target given)") - .option("--user ", "SSH user", "michal") + .option("--user ", "SSH user", "lab") .action(async (target: string | undefined, opts: { user: string }) => { const sshKey = findSshKey(); @@ -304,7 +304,7 @@ export function registerAppCommand(program: Command): void { k3sCmd .command("list") .description("List installed machines and their k3s status") - .option("--user ", "SSH user", "michal") + .option("--user ", "SSH user", "lab") .action(async (opts: { user: string }) => { let state: BastionState; try { diff --git a/bastion/src/cli/src/commands/asahi.ts b/bastion/src/cli/src/commands/asahi.ts new file mode 100644 index 0000000..7cee58c --- /dev/null +++ b/bastion/src/cli/src/commands/asahi.ts @@ -0,0 +1,69 @@ +// CLI command: provision asahi +// Prints the curl command to run on the Mac Studio (macOS) to install +// Fedora Asahi Remix with lab LVM layout. + +import type { Command } from "commander"; +import { getLabdClient } from "../api/config.js"; + +export function registerAsahiCommand(parent: Command): void { + parent + .command("asahi") + .description("Show instructions to provision an Apple Silicon Mac with Asahi Linux") + .action(async () => { + // Try to get bastion info to determine the correct URL + let bastionUrl = ""; + try { + const bastions = await getLabdClient().getBastions(); + const online = bastions.find(b => b.status === "online"); + if (online) { + bastionUrl = `http://${online.serverIp}:8080`; + } + } catch { /* labd not reachable */ } + + if (!bastionUrl) { + // Fall back to config + const { loadConfig } = await import("../config/index.js"); + const config = loadConfig(); + bastionUrl = config.labdUrl ?? "http://:8080"; + // Convert labd URL to bastion URL (labd is on different port/host) + bastionUrl = bastionUrl.replace(/:\d+$/, ":8080"); + } + + const BOLD = "\x1b[1m"; + const CYAN = "\x1b[36m"; + const DIM = "\x1b[2m"; + const RESET = "\x1b[0m"; + + console.log(""); + console.log(`${BOLD} Asahi Linux Provisioning${RESET}`); + console.log(`${DIM} For Apple Silicon Macs (Mac Studio, MacBook, etc.)${RESET}`); + console.log(""); + console.log(` Run this command ${BOLD}on the Mac${RESET} (from macOS Terminal):`); + console.log(""); + console.log(` ${CYAN}${BOLD}curl ${bastionUrl}/asahi | sh${RESET}`); + console.log(""); + console.log(` The installer will ask a few interactive questions:`); + console.log(` ${BOLD}1.${RESET} Action: press ${BOLD}r${RESET} to resize macOS`); + console.log(` ${BOLD}2.${RESET} How much space for Linux: choose maximum`); + console.log(` ${BOLD}3.${RESET} Confirm the resize operation`); + console.log(` ${BOLD}4.${RESET} macOS password for firmware authentication`); + console.log(""); + console.log(` After that, everything is automatic:`); + console.log(` - Asahi boot infrastructure (m1n1 + U-Boot)`); + console.log(` - Fedora Asahi Remix root partition`); + console.log(` - LVM data partition (remaining space)`); + console.log(""); + console.log(` On first boot, LVM volumes are created automatically:`); + console.log(` ${DIM}labvg/swap (27GB), labvg/var (100GB), labvg/varlog (10GB),`); + console.log(` labvg/home (10GB), labvg/srv (20GB), labvg/rancher (20GB),`); + console.log(` labvg/longhorn (remaining space)${RESET}`); + console.log(""); + console.log(` After first boot, SSH in and run the firstboot script:`); + console.log(` ${BOLD}ssh root@ 'curl -sf ${bastionUrl}/asahi/firstboot.sh?hostname=\\&role=infra | bash'${RESET}`); + console.log(""); + console.log(` This sets up LVM and self-registers with the bastion.`); + console.log(` Then install k3s:`); + console.log(` ${BOLD}labctl app k3s install --role infra${RESET}`); + console.log(""); + }); +} diff --git a/bastion/src/cli/src/commands/labcontroller.ts b/bastion/src/cli/src/commands/labcontroller.ts index 6262f12..b1b9efe 100644 --- a/bastion/src/cli/src/commands/labcontroller.ts +++ b/bastion/src/cli/src/commands/labcontroller.ts @@ -38,7 +38,7 @@ export function registerLabcontrollerCommands(appCmd: Command): void { lcCmd .command("deploy ") .description("Deploy labcontroller stack to a k3s node") - .option("--user ", "SSH user", "michal") + .option("--user ", "SSH user", "lab") .option("--crdb-replicas ", "CockroachDB replicas", "1") .action(async (target: string, opts: { user: string; @@ -193,7 +193,7 @@ export function registerLabcontrollerCommands(appCmd: Command): void { lcCmd .command("status [target]") .description("Check labcontroller deployment status (all hosts if no target)") - .option("--user ", "SSH user", "michal") + .option("--user ", "SSH user", "lab") .action(async (target: string | undefined, opts: { user: string }) => { const sshKey = findSshKey(); const sshOpts = sshKey ? { keyPath: sshKey } : {}; diff --git a/bastion/src/cli/src/index.ts b/bastion/src/cli/src/index.ts index f41ff09..28e7db4 100644 --- a/bastion/src/cli/src/index.ts +++ b/bastion/src/cli/src/index.ts @@ -17,6 +17,7 @@ import { registerReprovisionCommand } from "./commands/reprovision.js"; import { registerDebugCommand } from "./commands/debug.js"; import { registerForgetCommand } from "./commands/forget.js"; import { registerRegisterCommand } from "./commands/register.js"; +import { registerAsahiCommand } from "./commands/asahi.js"; import { registerLogsCommand } from "./commands/logs.js"; import { registerMakeIsoCommand } from "./commands/makeiso.js"; import { registerConfigCommand } from "./commands/config.js"; @@ -100,6 +101,7 @@ export function createProgram(): Command { registerDebugCommand(provisionCmd); registerForgetCommand(provisionCmd); registerRegisterCommand(provisionCmd); + registerAsahiCommand(provisionCmd); registerLogsCommand(provisionCmd); registerMakeIsoCommand(provisionCmd); diff --git a/bastion/src/cli/tests/smoke-bastion.test.ts b/bastion/src/cli/tests/smoke-bastion.test.ts index 4d934ee..dd50230 100644 --- a/bastion/src/cli/tests/smoke-bastion.test.ts +++ b/bastion/src/cli/tests/smoke-bastion.test.ts @@ -137,7 +137,7 @@ describe("bastion smoke tests", () => { // Wait for the server to start (look for the banner) const startedAt = Date.now(); - const maxWait = 10_000; + const maxWait = 15_000; while (Date.now() - startedAt < maxWait) { if (stdout.includes("Waiting for PXE boot requests")) break; await sleep(200); diff --git a/bastion/src/modules/modules/k3s/src/groups/hardening.ts b/bastion/src/modules/modules/k3s/src/groups/hardening.ts index f2b92fc..9ab0377 100644 --- a/bastion/src/modules/modules/k3s/src/groups/hardening.ts +++ b/bastion/src/modules/modules/k3s/src/groups/hardening.ts @@ -5,14 +5,16 @@ import { runSequential } from "../utils.js"; import { applyPodSecurityStandards } from "../operations/pod-security.js"; import { checkCertExpiry } from "../operations/cert-check.js"; import { configureLogRotation } from "../operations/log-rotation.js"; +import { configureLonghornDisk } from "../operations/longhorn-disk.js"; export const hardeningGroup: OperationGroup = { name: "hardening", - description: "Pod security, certificate check, log rotation", + description: "Pod security, certificate check, log rotation, storage", operations: [ { name: "Apply Pod Security Standards", fn: applyPodSecurityStandards }, { name: "Check certificate expiry", fn: checkCertExpiry }, { name: "Configure log rotation", fn: configureLogRotation }, + { name: "Configure Longhorn disk", fn: configureLonghornDisk }, ], }; diff --git a/bastion/src/modules/modules/k3s/src/groups/host-prep.ts b/bastion/src/modules/modules/k3s/src/groups/host-prep.ts index f8acf27..ab1b4b8 100644 --- a/bastion/src/modules/modules/k3s/src/groups/host-prep.ts +++ b/bastion/src/modules/modules/k3s/src/groups/host-prep.ts @@ -7,16 +7,18 @@ import { applyCisHardening } from "../operations/sysctl.js"; import { disableSwap } from "../operations/swap.js"; import { disableFirewall } from "../operations/firewall.js"; import { setSelinuxPermissive } from "../operations/selinux.js"; +import { enableIscsi } from "../operations/iscsi.js"; export const hostPrepGroup: OperationGroup = { name: "host-prep", - description: "Prepare host for k3s: kernel modules, sysctl, swap, firewall, SELinux", + description: "Prepare host for k3s: kernel modules, sysctl, swap, firewall, SELinux, iSCSI", operations: [ { name: "Load kernel modules", fn: loadKernelModules }, { name: "Apply CIS sysctl", fn: applyCisHardening }, { name: "Disable swap", fn: disableSwap }, { name: "Disable firewall", fn: disableFirewall }, { name: "Set SELinux permissive", fn: setSelinuxPermissive }, + { name: "Enable iSCSI", fn: enableIscsi }, ], }; diff --git a/bastion/src/modules/modules/k3s/src/operations/index.ts b/bastion/src/modules/modules/k3s/src/operations/index.ts index 55d8c80..ec2e53b 100644 --- a/bastion/src/modules/modules/k3s/src/operations/index.ts +++ b/bastion/src/modules/modules/k3s/src/operations/index.ts @@ -1,6 +1,7 @@ export { loadKernelModules } from "./kernel-modules.js"; export { applyCisHardening } from "./sysctl.js"; export { disableSwap } from "./swap.js"; +export { enableIscsi } from "./iscsi.js"; export { disableFirewall } from "./firewall.js"; export { setSelinuxPermissive } from "./selinux.js"; export { writeK3sConfig } from "./k3s-config.js"; @@ -13,3 +14,4 @@ export { configureLogRotation } from "./log-rotation.js"; export { applyDefaultNetworkPolicies } from "./network-policy.js"; export { applyPodSecurityStandards } from "./pod-security.js"; export { checkCertExpiry } from "./cert-check.js"; +export { configureLonghornDisk } from "./longhorn-disk.js"; diff --git a/bastion/src/modules/modules/k3s/src/operations/iscsi.ts b/bastion/src/modules/modules/k3s/src/operations/iscsi.ts new file mode 100644 index 0000000..551eebd --- /dev/null +++ b/bastion/src/modules/modules/k3s/src/operations/iscsi.ts @@ -0,0 +1,30 @@ +// Install and enable iSCSI initiator (required by Longhorn storage). +// Fedora: iscsi-initiator-utils, Ubuntu: open-iscsi + +import type { Operation, OperationResult } from "../types.js"; +import { sshOpts } from "../utils.js"; + +export const enableIscsi: Operation = async (ctx): Promise => { + // Check if iscsid is already running + const check = await ctx.ssh.exec("systemctl is-active iscsid 2>/dev/null", sshOpts(ctx)); + if (check.stdout.trim() === "active") { + return { success: true, changed: false, message: "iSCSI already active" }; + } + + // Install the package (detect distro) + const osRelease = await ctx.ssh.exec("cat /etc/os-release", sshOpts(ctx)); + const isFedora = osRelease.stdout.includes("fedora") || osRelease.stdout.includes("rhel") || osRelease.stdout.includes("centos"); + + const pkg = isFedora ? "iscsi-initiator-utils" : "open-iscsi"; + const installCmd = isFedora ? `dnf install -y ${pkg}` : `apt-get install -y ${pkg}`; + + const install = await ctx.ssh.exec(installCmd, { timeoutMs: 120_000 }); + if (install.exitCode !== 0) { + return { success: false, changed: false, message: `Failed to install ${pkg}`, error: install.stderr.trim() }; + } + + // Enable and start + await ctx.ssh.exec("systemctl enable --now iscsid", sshOpts(ctx)); + + return { success: true, changed: true, message: `Installed ${pkg} and enabled iscsid` }; +}; diff --git a/bastion/src/modules/modules/k3s/src/operations/k3s-config.ts b/bastion/src/modules/modules/k3s/src/operations/k3s-config.ts index 0c749a6..57cec6b 100644 --- a/bastion/src/modules/modules/k3s/src/operations/k3s-config.ts +++ b/bastion/src/modules/modules/k3s/src/operations/k3s-config.ts @@ -20,6 +20,9 @@ disable: - servicelb - traefik +node-label: + - "node.longhorn.io/create-default-disk=config" + kube-apiserver-arg: - "anonymous-auth=false" - "audit-log-path=/var/log/kubernetes/audit.log" @@ -44,6 +47,7 @@ function generateAgentConfig(): string { return `protect-kernel-defaults: true node-label: - "node-role.kubernetes.io/worker=true" + - "node.longhorn.io/create-default-disk=config" kubelet-arg: - "protect-kernel-defaults=true" - "streaming-connection-idle-timeout=5m" diff --git a/bastion/src/modules/modules/k3s/src/operations/longhorn-disk.ts b/bastion/src/modules/modules/k3s/src/operations/longhorn-disk.ts new file mode 100644 index 0000000..68babd4 --- /dev/null +++ b/bastion/src/modules/modules/k3s/src/operations/longhorn-disk.ts @@ -0,0 +1,34 @@ +// Annotate nodes with Longhorn default disk config when /var/lib/longhorn exists. +// The label is set in k3s config (node-label), but the annotation must be applied via kubectl. + +import type { Operation, OperationResult } from "../types.js"; +import { sshOpts } from "../utils.js"; + +export const configureLonghornDisk: Operation = async (ctx): Promise => { + // Check if /var/lib/longhorn exists on this node + const check = await ctx.ssh.exec("test -d /var/lib/longhorn && echo yes || echo no", sshOpts(ctx)); + if (check.stdout.trim() !== "yes") { + return { success: true, changed: false, message: "No /var/lib/longhorn directory — skipping Longhorn disk config" }; + } + + // Find the node name (hostname as registered in k3s) + const nodeNameResult = await ctx.ssh.exec("hostname -f 2>/dev/null || hostname", sshOpts(ctx)); + const nodeName = nodeNameResult.stdout.trim(); + + // Apply the annotation via kubectl (works on server nodes, or via KUBECONFIG on agents) + const kubectlPrefix = "k3s kubectl"; + const annotation = JSON.stringify([{ path: "/var/lib/longhorn", allowScheduling: true }]); + + const result = await ctx.ssh.exec( + `${kubectlPrefix} annotate node "${nodeName}" "node.longhorn.io/default-disks-config=${annotation}" --overwrite 2>&1 || true`, + sshOpts(ctx), + ); + + if (result.stdout.includes("annotated") || result.stdout.includes("unchanged")) { + return { success: true, changed: true, message: `Longhorn disk annotation applied to ${nodeName}` }; + } + + // If kubectl isn't available (agent node without server access), that's OK — + // the label is set, annotation can be applied from the server later + return { success: true, changed: false, message: "Longhorn disk label set (annotation requires server kubectl)" }; +}; diff --git a/bastion/tests/integration/asahi-firstboot.test.ts b/bastion/tests/integration/asahi-firstboot.test.ts new file mode 100644 index 0000000..ce8a60a --- /dev/null +++ b/bastion/tests/integration/asahi-firstboot.test.ts @@ -0,0 +1,355 @@ +// Integration test: Asahi first-boot LVM setup. +// +// Tests the first-boot script that creates the standard lab LVM layout +// on a separate data disk — simulating the Asahi provisioning flow where +// the root partition is pre-installed and a data partition is left for LVM. +// +// Uses a Fedora cloud VM with two disks: +// disk0: 20GB root (Fedora cloud image) +// disk1: 200GB empty (simulates the Asahi "Data" partition) +// +// The firstboot script should detect disk1, create labvg + LVs, mount them. +// Then we test reprovision: wipe marker, re-run, verify existing VG reused. +// +// Prerequisites: libvirt, virsh, virt-install, qemu, sudo access, lvm2 +// Run: sudo pnpm run test:integration:asahi + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { destroyVm, waitForVmIp, waitForSsh, log, ensureCloudImage, createCloudInitIso } from "./helpers/libvirt.js"; +import { ensureTestNetwork, TEST_NETWORK_NAME } from "./helpers/network.js"; +import { sshExec, sshRun } from "./helpers/ssh.js"; +import { renderFirstbootScript } from "../../src/bastion/src/templates/asahi-firstboot.sh.js"; + +const VM_NAME = "lab-asahi-firstboot-test"; +const VM_MEMORY = 4096; +const VM_VCPUS = 2; +const VM_ROOT_DISK_GB = 20; +const VM_DATA_DISK_GB = 200; // Simulates the Asahi "Data" partition +const SSH_USER = "fedora"; +const IMAGE_DIR = "/var/lib/libvirt/images"; +const IS_ROOT = process.getuid?.() === 0; + +const FEDORA_CLOUD_IMAGE = "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2"; + +function run(cmd: string, opts?: { timeout?: number }): string { + const full = IS_ROOT ? cmd : `sudo ${cmd}`; + return execSync(full, { encoding: "utf-8", stdio: "pipe", timeout: opts?.timeout ?? 60_000 }); +} + +function findSshKey(): { pubKey: string; keyPath: string } { + const homes = [homedir()]; + const sudoUser = process.env["SUDO_USER"]; + if (sudoUser) homes.push(join("/home", sudoUser)); + if (process.env["SSH_KEY_PATH"]) { + const keyPath = process.env["SSH_KEY_PATH"]; + const pubPath = `${keyPath}.pub`; + if (existsSync(keyPath) && existsSync(pubPath)) { + return { pubKey: readFileSync(pubPath, "utf-8").trim(), keyPath }; + } + } + for (const home of homes) { + for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) { + const keyPath = join(home, ".ssh", name); + const pubPath = `${keyPath}.pub`; + if (existsSync(keyPath) && existsSync(pubPath)) { + return { pubKey: readFileSync(pubPath, "utf-8").trim(), keyPath }; + } + } + } + throw new Error("No SSH key found"); +} + +/** Create a VM with two disks: root (cloud image) + empty data disk. */ +function createTwoDiskVm(config: { + name: string; + memory: number; + vcpus: number; + rootDiskGb: number; + dataDiskGb: number; + network: string; + cloudImageUrl: string; + sshPubKey: string; +}): void { + destroyVm(config.name); + + log(`Creating two-disk VM: ${config.name} (root=${config.rootDiskGb}GB, data=${config.dataDiskGb}GB)`); + + const baseImage = ensureCloudImage(config.cloudImageUrl, `${config.name}-base`); + const rootDiskPath = join(IMAGE_DIR, `${config.name}.qcow2`); + const dataDiskPath = join(IMAGE_DIR, `${config.name}-data.qcow2`); + + // Root disk from cloud image + run(`cp "${baseImage}" "${rootDiskPath}"`); + run(`qemu-img resize "${rootDiskPath}" ${config.rootDiskGb}G`); + + // Empty data disk + run(`qemu-img create -f qcow2 "${dataDiskPath}" ${config.dataDiskGb}G`); + + // Cloud-init with LVM tools + const cloudInitIso = createCloudInitIso(config.name, { + name: config.name, + memory: config.memory, + vcpus: config.vcpus, + diskSize: config.rootDiskGb, + network: config.network, + cloudImageUrl: config.cloudImageUrl, + sshPubKey: config.sshPubKey, + userData: `#cloud-config +hostname: ${config.name} +manage_etc_hosts: true +users: + - default + - name: fedora + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + ssh_authorized_keys: + - ${config.sshPubKey} +ssh_pwauth: false +package_update: false +packages: + - lvm2 + - xfsprogs +`, + }); + + const virtInstallArgs = [ + "virt-install", + `--name=${config.name}`, + `--memory=${config.memory}`, + `--vcpus=${config.vcpus}`, + `--disk=path=${rootDiskPath},format=qcow2`, + `--disk=path=${dataDiskPath},format=qcow2`, // Second disk for LVM + `--disk=path=${cloudInitIso},device=cdrom`, + `--network=network=${config.network},model=virtio`, + "--os-variant=generic", + "--import", + "--noautoconsole", + "--wait=0", + ]; + + run(virtInstallArgs.join(" ")); + log(`Two-disk VM ${config.name} created`); +} + +describe("asahi firstboot LVM integration", () => { + let vmIp: string; + let sshKeyPath: string; + let sshPubKey: string; + + beforeAll(async () => { + const keys = findSshKey(); + sshKeyPath = keys.keyPath; + sshPubKey = keys.pubKey; + + log("Setting up test network..."); + ensureTestNetwork(); + + log("Creating two-disk VM..."); + createTwoDiskVm({ + name: VM_NAME, + memory: VM_MEMORY, + vcpus: VM_VCPUS, + rootDiskGb: VM_ROOT_DISK_GB, + dataDiskGb: VM_DATA_DISK_GB, + network: TEST_NETWORK_NAME, + cloudImageUrl: FEDORA_CLOUD_IMAGE, + sshPubKey, + }); + + log("Waiting for VM IP..."); + vmIp = await waitForVmIp(VM_NAME, 120_000); + + log("Waiting for SSH..."); + await waitForSsh(vmIp, SSH_USER, 180_000, sshKeyPath); + + log("Waiting for cloud-init to finish..."); + await sshRun(vmIp, SSH_USER, "sudo cloud-init status --wait 2>/dev/null || sleep 30", "cloud-init", { keyPath: sshKeyPath }); + + // Verify second disk exists + const disks = sshExec(vmIp, SSH_USER, "lsblk -d -n -o NAME,SIZE", { keyPath: sshKeyPath }); + log(`Disks:\n${disks.stdout}`); + }, 300_000); + + afterAll(async () => { + log("Cleaning up VM..."); + destroyVm(VM_NAME); + // Also remove data disk + try { run(`rm -f "${join(IMAGE_DIR, `${VM_NAME}-data.qcow2`)}"`); } catch { /* ignore */ } + }); + + it("second disk is visible and unformatted", () => { + const result = sshExec(vmIp, SSH_USER, "lsblk -d -n -o NAME,SIZE,TYPE | grep disk", { keyPath: sshKeyPath }); + const disks = result.stdout.trim().split("\n"); + expect(disks.length).toBeGreaterThanOrEqual(2); + + // Second disk (vdb) should exist + const vdb = sshExec(vmIp, SSH_USER, "sudo blkid /dev/vdb 2>/dev/null; echo exit=$?", { keyPath: sshKeyPath }); + // Should have no filesystem (blkid returns nothing or non-zero) + expect(vdb.stdout).toContain("exit=2"); + }); + + it("firstboot script creates LVM on data disk", async () => { + // Generate the firstboot script + const script = renderFirstbootScript({ + hostname: "asahi-test", + role: "infra", + serverIp: "10.0.0.1", + httpPort: 8080, + sshKeys: [sshPubKey], + adminUser: "testadmin", + mac: "52:54:00:aa:bb:cc", + }); + + // Upload and run + log("Uploading firstboot script..."); + await sshRun(vmIp, SSH_USER, + `cat > /tmp/firstboot.sh << 'SCRIPT_EOF'\n${script}\nSCRIPT_EOF\nchmod +x /tmp/firstboot.sh`, + "upload script", { keyPath: sshKeyPath }); + + log("Running firstboot script..."); + const result = await sshRun(vmIp, SSH_USER, + "sudo /tmp/firstboot.sh 2>&1", + "firstboot", { keyPath: sshKeyPath, timeout: 120_000 }); + + expect(result).toBe(0); + }, 180_000); + + it("SSH still works after firstboot script", () => { + const result = sshExec(vmIp, SSH_USER, "echo hello", { keyPath: sshKeyPath }); + if (result.stdout.trim() !== "hello") { + log(`SSH debug: exitCode=${result.exitCode} stdout='${result.stdout}' stderr='${result.stderr}'`); + } + expect(result.stdout.trim()).toBe("hello"); + }); + + it("volume group labvg exists", () => { + const result = sshExec(vmIp, SSH_USER, "sudo vgs labvg --noheadings -o vg_name", { keyPath: sshKeyPath }); + expect(result.stdout.trim()).toBe("labvg"); + }); + + it("all expected logical volumes exist", () => { + const result = sshExec(vmIp, SSH_USER, + "sudo lvs labvg --noheadings -o lv_name --sort lv_name", + { keyPath: sshKeyPath }); + const lvs = result.stdout.trim().split("\n").map(l => l.trim()).sort(); + expect(lvs).toContain("home"); + expect(lvs).toContain("longhorn"); + expect(lvs).toContain("rancher"); // infra role + expect(lvs).toContain("srv"); + expect(lvs).toContain("swap"); + expect(lvs).toContain("var"); + expect(lvs).toContain("varlog"); + }); + + it("LV sizes match kickstart layout", () => { + const result = sshExec(vmIp, SSH_USER, + "sudo lvs labvg --noheadings -o lv_name,lv_size --units m --nosuffix", + { keyPath: sshKeyPath }); + const lvMap = new Map(); + for (const line of result.stdout.trim().split("\n")) { + const [name, size] = line.trim().split(/\s+/); + if (name && size) lvMap.set(name, Math.round(parseFloat(size))); + } + + expect(lvMap.get("swap")).toBe(27648); + expect(lvMap.get("var")).toBe(102400); + expect(lvMap.get("varlog")).toBe(10240); + expect(lvMap.get("home")).toBe(10240); + expect(lvMap.get("srv")).toBe(20480); + expect(lvMap.get("rancher")).toBe(20480); + // longhorn gets remaining — should be at least 5GB (200GB disk - ~191GB used) + expect(lvMap.get("longhorn")).toBeGreaterThan(5000); + }); + + it("non-var volumes are mounted with XFS", () => { + const mounts = sshExec(vmIp, SSH_USER, "mount | grep labvg", { keyPath: sshKeyPath }); + // /var and /var/log deferred to next reboot (can't migrate live) + expect(mounts.stdout).toContain("/home "); + expect(mounts.stdout).toContain("/srv "); + expect(mounts.stdout).toContain("/var/lib/rancher "); + expect(mounts.stdout).toContain("/var/lib/longhorn "); + expect(mounts.stdout).toContain("xfs"); + }); + + it("swap is active", () => { + const result = sshExec(vmIp, SSH_USER, "swapon --show --noheadings", { keyPath: sshKeyPath }); + // swapon may show /dev/dm-X or /dev/labvg/swap + expect(result.stdout.length).toBeGreaterThan(0); + }); + + it("fstab has LVM entries", () => { + const result = sshExec(vmIp, SSH_USER, "grep labvg /etc/fstab", { keyPath: sshKeyPath }); + const lines = result.stdout.trim().split("\n"); + expect(lines.length).toBeGreaterThanOrEqual(7); // swap + var + varlog + home + srv + rancher + longhorn + }); + + it("hostname was set", () => { + const result = sshExec(vmIp, SSH_USER, "hostname", { keyPath: sshKeyPath }); + expect(result.stdout.trim()).toBe("asahi-test"); + }); + + it("admin user was created with sudo", () => { + const result = sshExec(vmIp, SSH_USER, "sudo id testadmin", { keyPath: sshKeyPath }); + expect(result.stdout).toContain("testadmin"); + expect(result.stdout).toContain("wheel"); + }); + + it("provisioning metadata file exists", () => { + const result = sshExec(vmIp, SSH_USER, "cat /etc/lab-provisioned", { keyPath: sshKeyPath }); + expect(result.stdout).toContain("hostname=asahi-test"); + expect(result.stdout).toContain("role=infra"); + expect(result.stdout).toContain("method=asahi-firstboot"); + }); + + it("marker file prevents re-run", () => { + const result = sshExec(vmIp, SSH_USER, "test -f /etc/lab-lvm-setup-done && echo yes", { keyPath: sshKeyPath }); + expect(result.stdout.trim()).toBe("yes"); + }); + + // ── Reprovision test ────────────────────────────────────────────── + + it("reprovision: detects existing labvg and re-mounts", async () => { + // Write a test file to a preserved LV + await sshRun(vmIp, SSH_USER, + "echo 'precious-data' | sudo tee /var/lib/rancher/test-preserve.txt", + "write test data", { keyPath: sshKeyPath }); + + // Remove marker to simulate fresh boot after reinstall + await sshRun(vmIp, SSH_USER, "sudo rm /etc/lab-lvm-setup-done", "remove marker", { keyPath: sshKeyPath }); + + // Unmount everything (simulate reinstall wiping root) + await sshRun(vmIp, SSH_USER, ` + sudo umount /var/lib/longhorn 2>/dev/null || true + sudo umount /var/lib/rancher 2>/dev/null || true + sudo umount /srv 2>/dev/null || true + sudo umount /home 2>/dev/null || true + sudo umount /var/log 2>/dev/null || true + # Don't unmount /var — it's in use + sudo swapoff /dev/labvg/swap 2>/dev/null || true + sudo sed -i '/labvg/d' /etc/fstab + `, "unmount LVs", { keyPath: sshKeyPath }); + + // Re-run firstboot script — should detect existing VG + log("Re-running firstboot (reprovision)..."); + const result = await sshRun(vmIp, SSH_USER, + "sudo /tmp/firstboot.sh 2>&1", + "firstboot reprovision", { keyPath: sshKeyPath }); + expect(result).toBe(0); + + // Verify data was preserved + const data = sshExec(vmIp, SSH_USER, "cat /var/lib/rancher/test-preserve.txt", { keyPath: sshKeyPath }); + expect(data.stdout.trim()).toBe("precious-data"); + + // Verify marker was re-created + const marker = sshExec(vmIp, SSH_USER, "test -f /etc/lab-lvm-setup-done && echo yes", { keyPath: sshKeyPath }); + expect(marker.stdout.trim()).toBe("yes"); + + // Verify fstab was re-populated + const fstab = sshExec(vmIp, SSH_USER, "grep labvg /etc/fstab", { keyPath: sshKeyPath }); + expect(fstab.stdout).toContain("/var/lib/rancher"); + }, 60_000); +}); diff --git a/bastion/tests/integration/asahi-validate.test.ts b/bastion/tests/integration/asahi-validate.test.ts new file mode 100644 index 0000000..bf23a03 --- /dev/null +++ b/bastion/tests/integration/asahi-validate.test.ts @@ -0,0 +1,353 @@ +// Validation tests for Asahi provisioning artifacts. +// +// Tests that can run WITHOUT Apple Silicon hardware: +// 1. Shellcheck the generated firstboot script +// 2. Verify the built rootfs ZIP structure +// 3. Mount the rootfs and verify injected files +// 4. Validate installer_data.json against the Asahi installer's Python parser +// 5. Verify partition layout arithmetic +// +// Prerequisites: +// - Run scripts/build-asahi-rootfs.sh first (creates asahi-repo/) +// - shellcheck installed (dnf install ShellCheck) +// - python3 installed +// - root for loop mount (sudo) +// +// Run: sudo pnpm run test:integration:asahi-validate + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { existsSync, lstatSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { execSync, spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { renderFirstbootScript } from "../../src/bastion/src/templates/asahi-firstboot.sh.js"; + +const PROJECT_ROOT = join(import.meta.dirname, "..", ".."); +const ASAHI_REPO = join(PROJECT_ROOT, "asahi-repo"); +const ASAHI_CACHE = join(PROJECT_ROOT, ".asahi-cache"); +const IS_ROOT = process.getuid?.() === 0; + +function run(cmd: string, opts?: { timeout?: number }): string { + const full = IS_ROOT ? cmd : `sudo ${cmd}`; + return execSync(full, { encoding: "utf-8", stdio: "pipe", timeout: opts?.timeout ?? 60_000 }); +} + +function hasBuiltArtifacts(): boolean { + return existsSync(join(ASAHI_REPO, "fedora-asahi-lab.zip")) && + existsSync(join(ASAHI_REPO, "installer_data.json")); +} + +describe("asahi script validation", () => { + it("firstboot script passes shellcheck", () => { + const script = renderFirstbootScript({ + hostname: "test-node", + role: "infra", + serverIp: "10.0.0.1", + httpPort: 8080, + sshKeys: ["ssh-ed25519 AAAA... user@host"], + adminUser: "testadmin", + mac: "aa:bb:cc:dd:ee:ff", + }); + + const tmpFile = join(tmpdir(), `asahi-shellcheck-${Date.now()}.sh`); + writeFileSync(tmpFile, script); + + try { + const result = spawnSync("shellcheck", [ + "-s", "bash", + "-e", "SC2086,SC2164", // allow unquoted variables (intentional in some LVM commands) + tmpFile, + ], { encoding: "utf-8", stdio: "pipe", timeout: 30_000 }); + + if (result.status !== 0) { + console.log("Shellcheck warnings/errors:"); + console.log(result.stdout); + } + // Allow warnings (exit 1 for warnings), fail on errors (exit 2+) + expect(result.status).toBeLessThan(2); + } finally { + try { rmSync(tmpFile); } catch { /* ignore */ } + } + }); + + it("firstboot script for worker role passes shellcheck", () => { + const script = renderFirstbootScript({ + hostname: "worker-node", + role: "worker", + serverIp: "10.0.0.1", + httpPort: 8080, + sshKeys: [], + adminUser: "michal", + mac: "00:11:22:33:44:55", + }); + + const tmpFile = join(tmpdir(), `asahi-shellcheck-worker-${Date.now()}.sh`); + writeFileSync(tmpFile, script); + + try { + const result = spawnSync("shellcheck", ["-s", "bash", "-e", "SC2086,SC2164", tmpFile], + { encoding: "utf-8", stdio: "pipe", timeout: 30_000 }); + if (result.status !== 0) console.log(result.stdout); + expect(result.status).toBeLessThan(2); + } finally { + try { rmSync(tmpFile); } catch { /* ignore */ } + } + }); + + it("firstboot script for vanilla role passes shellcheck", () => { + const script = renderFirstbootScript({ + hostname: "vanilla-node", + role: "vanilla", + serverIp: "10.0.0.1", + httpPort: 8080, + sshKeys: ["ssh-rsa AAAA... user@host"], + adminUser: "admin", + mac: "ff:ee:dd:cc:bb:aa", + }); + + const tmpFile = join(tmpdir(), `asahi-shellcheck-vanilla-${Date.now()}.sh`); + writeFileSync(tmpFile, script); + + try { + const result = spawnSync("shellcheck", ["-s", "bash", "-e", "SC2086,SC2164", tmpFile], + { encoding: "utf-8", stdio: "pipe", timeout: 30_000 }); + if (result.status !== 0) console.log(result.stdout); + expect(result.status).toBeLessThan(2); + } finally { + try { rmSync(tmpFile); } catch { /* ignore */ } + } + }); +}); + +describe("asahi installer_data.json validation", () => { + let installerData: Record; + + beforeAll(() => { + if (!hasBuiltArtifacts()) { + throw new Error("Run scripts/build-asahi-rootfs.sh first to generate artifacts"); + } + installerData = JSON.parse(readFileSync(join(ASAHI_REPO, "installer_data.json"), "utf-8")); + }); + + it("has os_list with one entry", () => { + const osList = installerData["os_list"] as unknown[]; + expect(osList).toBeInstanceOf(Array); + expect(osList.length).toBe(1); + }); + + it("has required top-level fields", () => { + const os = (installerData["os_list"] as Record[])[0]!; + expect(os["name"]).toBeDefined(); + expect(os["default_os_name"]).toBeDefined(); + expect(os["boot_object"]).toBeDefined(); + expect(os["next_object"]).toBeDefined(); + expect(os["package"]).toBe("fedora-asahi-lab.zip"); + expect(os["supported_fw"]).toBeInstanceOf(Array); + expect((os["supported_fw"] as string[]).length).toBeGreaterThan(0); + }); + + it("has 4 partitions (EFI + Boot + Root + Data)", () => { + const os = (installerData["os_list"] as Record[])[0]!; + const partitions = os["partitions"] as Record[]; + expect(partitions).toHaveLength(4); + expect(partitions[0]!["name"]).toBe("EFI"); + expect(partitions[1]!["name"]).toBe("Boot"); + expect(partitions[2]!["name"]).toBe("Root"); + expect(partitions[3]!["name"]).toBe("Data"); + }); + + it("EFI partition has correct format", () => { + const os = (installerData["os_list"] as Record[])[0]!; + const efi = (os["partitions"] as Record[])[0]!; + expect(efi["type"]).toBe("EFI"); + expect(efi["format"]).toBe("fat"); + expect(efi["copy_firmware"]).toBe(true); + // Size should be ~500MB in bytes + const size = parseInt(String(efi["size"]).replace("B", ""), 10); + expect(size).toBeGreaterThanOrEqual(500 * 1024 * 1024); + }); + + it("Boot partition references boot.img", () => { + const os = (installerData["os_list"] as Record[])[0]!; + const boot = (os["partitions"] as Record[])[1]!; + expect(boot["type"]).toBe("Linux"); + expect(boot["image"]).toBe("boot.img"); + }); + + it("Root partition does NOT expand", () => { + const os = (installerData["os_list"] as Record[])[0]!; + const root = (os["partitions"] as Record[])[2]!; + expect(root["type"]).toBe("Linux"); + expect(root["image"]).toBe("root.img"); + expect(root["expand"]).toBe(false); + }); + + it("Data partition expands for LVM", () => { + const os = (installerData["os_list"] as Record[])[0]!; + const data = (os["partitions"] as Record[])[3]!; + expect(data["type"]).toBe("Linux"); + expect(data["expand"]).toBe(true); + expect(data["image"]).toBeUndefined(); // No image — empty partition for LVM + }); + + it("partition sizes use bytes format (NB suffix)", () => { + const os = (installerData["os_list"] as Record[])[0]!; + const partitions = os["partitions"] as Record[]; + for (const p of partitions) { + const size = String(p["size"]); + expect(size).toMatch(/^\d+B$/); + } + }); + + it("validates against Asahi installer Python parser", () => { + // Download the Asahi installer and run its validation logic on our config + const validation = spawnSync("python3", ["-c", ` +import json, sys + +with open("${join(ASAHI_REPO, "installer_data.json")}") as f: + data = json.load(f) + +errors = [] +os_list = data.get("os_list", []) +if not os_list: + errors.append("Empty os_list") + +for os_entry in os_list: + required = ["name", "default_os_name", "boot_object", "next_object", "package", "supported_fw", "partitions"] + for field in required: + if field not in os_entry: + errors.append(f"Missing field: {field}") + + partitions = os_entry.get("partitions", []) + if not partitions: + errors.append("No partitions defined") + + has_efi = False + has_root_image = False + expand_count = 0 + for p in partitions: + if "name" not in p or "type" not in p or "size" not in p: + errors.append(f"Partition missing name/type/size: {p}") + if p.get("type") == "EFI": + has_efi = True + if p.get("format") != "fat": + errors.append("EFI partition must be FAT format") + if p.get("image"): + has_root_image = True + if p.get("expand"): + expand_count += 1 + # Validate size format + size_str = str(p.get("size", "")) + if not size_str.endswith("B") or not size_str[:-1].isdigit(): + errors.append(f"Invalid size format: {size_str} (expected NB)") + + if not has_efi: + errors.append("No EFI partition found") + if not has_root_image: + errors.append("No partition with root image found") + if expand_count > 1: + errors.append(f"Multiple expanding partitions ({expand_count}) — only one should expand") + + # Verify supported_fw is a list of strings + fw = os_entry.get("supported_fw", []) + if not isinstance(fw, list) or not all(isinstance(v, str) for v in fw): + errors.append("supported_fw must be a list of strings") + +if errors: + print("ERRORS:") + for e in errors: + print(f" - {e}") + sys.exit(1) +else: + print("OK: installer_data.json is valid") +`], { encoding: "utf-8", stdio: "pipe", timeout: 10_000 }); + + if (validation.status !== 0) { + console.log(validation.stdout); + console.log(validation.stderr); + } + expect(validation.stdout).toContain("OK"); + expect(validation.status).toBe(0); + }); +}); + +describe("asahi rootfs ZIP validation", () => { + beforeAll(() => { + if (!hasBuiltArtifacts()) { + throw new Error("Run scripts/build-asahi-rootfs.sh first to generate artifacts"); + } + }); + + it("ZIP contains required files", () => { + const result = spawnSync("unzip", ["-l", join(ASAHI_REPO, "fedora-asahi-lab.zip")], + { encoding: "utf-8", stdio: "pipe", timeout: 10_000 }); + expect(result.stdout).toContain("boot.img"); + expect(result.stdout).toContain("root.img"); + expect(result.stdout).toContain("esp/"); + }); + + it("boot.img is ~1GB", () => { + const result = spawnSync("unzip", ["-l", join(ASAHI_REPO, "fedora-asahi-lab.zip")], + { encoding: "utf-8", stdio: "pipe", timeout: 10_000 }); + const bootLine = result.stdout.split("\n").find(l => l.includes("boot.img") && !l.includes("/")); + expect(bootLine).toBeDefined(); + const size = parseInt(bootLine!.trim().split(/\s+/)[0]!, 10); + expect(size).toBeGreaterThan(500 * 1024 * 1024); // > 500MB + expect(size).toBeLessThan(2 * 1024 * 1024 * 1024); // < 2GB + }); + + it("root.img is > 3GB", () => { + const result = spawnSync("unzip", ["-l", join(ASAHI_REPO, "fedora-asahi-lab.zip")], + { encoding: "utf-8", stdio: "pipe", timeout: 10_000 }); + const rootLine = result.stdout.split("\n").find(l => l.includes("root.img")); + expect(rootLine).toBeDefined(); + const size = parseInt(rootLine!.trim().split(/\s+/)[0]!, 10); + expect(size).toBeGreaterThan(3 * 1024 * 1024 * 1024); // > 3GB + }); + + it("rootfs contains lab-firstboot.sh", () => { + const mountDir = join(tmpdir(), `asahi-rootfs-check-${Date.now()}`); + const extractDir = join(tmpdir(), `asahi-rootfs-extract-${Date.now()}`); + mkdirSync(mountDir); + mkdirSync(extractDir); + + try { + // Extract root.img from ZIP + run(`unzip -o -j "${join(ASAHI_REPO, "fedora-asahi-lab.zip")}" root.img -d "${extractDir}"`); + + // Mount and check + run(`mount -o loop,ro "${join(extractDir, "root.img")}" "${mountDir}"`); + + // Verify firstboot script + expect(existsSync(join(mountDir, "usr/local/bin/lab-firstboot.sh"))).toBe(true); + const script = readFileSync(join(mountDir, "usr/local/bin/lab-firstboot.sh"), "utf-8"); + expect(script).toContain("#!/bin/bash"); + expect(script).toContain("labvg"); + expect(script).toContain("pvcreate"); + + // Verify systemd service + expect(existsSync(join(mountDir, "etc/systemd/system/lab-firstboot.service"))).toBe(true); + const service = readFileSync(join(mountDir, "etc/systemd/system/lab-firstboot.service"), "utf-8"); + expect(service).toContain("lab-firstboot.sh"); + + // Verify service is enabled (symlink exists) + const symlinkPath = join(mountDir, "etc/systemd/system/multi-user.target.wants/lab-firstboot.service"); + let symlinkExists = false; + try { lstatSync(symlinkPath); symlinkExists = true; } catch { /* not found */ } + expect(symlinkExists).toBe(true); + + // Verify SSH keys + expect(existsSync(join(mountDir, "root/.ssh/authorized_keys"))).toBe(true); + + // Verify lvm2 + xfsprogs are in the image + const hasLvm = existsSync(join(mountDir, "usr/bin/pvcreate")) || existsSync(join(mountDir, "usr/sbin/pvcreate")); + const hasXfs = existsSync(join(mountDir, "usr/bin/mkfs.xfs")) || existsSync(join(mountDir, "usr/sbin/mkfs.xfs")); + expect(hasLvm).toBe(true); + expect(hasXfs).toBe(true); + } finally { + run(`umount "${mountDir}" 2>/dev/null || true`); + rmSync(mountDir, { recursive: true, force: true }); + rmSync(extractDir, { recursive: true, force: true }); + } + }, 120_000); +});