From 863c7f2b832a50da849f406eaf39f2797f856b6f Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 31 Mar 2026 02:46:27 +0100 Subject: [PATCH 1/7] feat: Asahi Linux provisioning for Apple Silicon (Mac Studio) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bastion endpoints for provisioning Apple Silicon machines via the Asahi Linux installer with custom LVM partitioning: - GET /asahi — wrapper script (curl bastion:8080/asahi | sh) - GET /asahi/installer_data.json — custom partition layout (60GB root + LVM data) - GET /asahi/firstboot.sh — first-boot LVM setup matching kickstart layout - GET /asahi/firstboot.service — systemd oneshot unit The firstboot script creates labvg with role-specific LVs (var, varlog, home, srv, rancher, longhorn) and handles reprovision by detecting existing VGs. Includes 19 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- bastion/src/bastion/src/routes/asahi.ts | 150 ++++++++++ bastion/src/bastion/src/server.ts | 2 + .../src/templates/asahi-firstboot.sh.ts | 260 ++++++++++++++++++ bastion/src/bastion/tests/asahi.test.ts | 222 +++++++++++++++ 4 files changed, 634 insertions(+) create mode 100644 bastion/src/bastion/src/routes/asahi.ts create mode 100644 bastion/src/bastion/src/templates/asahi-firstboot.sh.ts create mode 100644 bastion/src/bastion/tests/asahi.test.ts diff --git a/bastion/src/bastion/src/routes/asahi.ts b/bastion/src/bastion/src/routes/asahi.ts new file mode 100644 index 0000000..fb88312 --- /dev/null +++ b/bastion/src/bastion/src/routes/asahi.ts @@ -0,0 +1,150 @@ +// Routes for Asahi Linux provisioning. +// GET /asahi — wrapper script (curl https://bastion:8080/asahi | sh) +// GET /asahi/installer_data.json — custom installer config with LVM partition layout +// GET /asahi/firstboot.sh — first-boot LVM setup script (for manual use) + +import type { FastifyInstance } from "fastify"; +import type { BastionConfig } from "@lab/shared"; +import { renderFirstbootScript, renderFirstbootUnit } from "../templates/asahi-firstboot.sh.js"; +import type { Role } from "@lab/shared"; + +export function registerAsahiRoutes(app: FastifyInstance, config: BastionConfig): void { + // 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" + +# Point to our custom installer_data.json + rootfs repo +export INSTALLER_DATA="\${BASTION}/asahi/installer_data.json" +export REPO_BASE="\${BASTION}/asahi/repo" + +echo "" +echo " Using custom installer data from bastion." +echo " This will create:" +echo " - Standard Asahi boot infrastructure (m1n1 + U-Boot)" +echo " - 60GB root partition (Fedora Asahi Remix)" +echo " - Remaining space as LVM data partition" +echo "" +echo " On first boot, LVM volumes will be created automatically:" +echo " labvg/var (100GB), labvg/varlog (10GB), labvg/home (10GB)," +echo " labvg/srv (20GB), labvg/rancher (20GB), labvg/longhorn (rest)" +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 — two Linux partitions (root + LVM data) + app.get("/asahi/installer_data.json", async (_request, reply) => { + // This follows the Asahi installer_data.json schema. + // We define a fixed-size root and an expanding data partition for LVM. + const data = { + os_list: [ + { + name: "Fedora Asahi Lab", + default_os_name: "Fedora Linux with Lab LVM", + boot_object: "m1n1.bin", + next_object: "u-boot-nodtb.bin", + package: "fedora-asahi-lab.zip", + supported_fw: ["13.5"], + partitions: [ + { + name: "EFI", + type: "EFI", + size: "500MB", + format: "fat", + copy_firmware: true, + copy_installer_data: true, + source: "esp", + }, + { + name: "Root", + type: "Linux", + size: "60GB", + image: "root.img", + expand: false, + }, + { + name: "Data", + type: "Linux", + size: "1GB", + expand: true, + }, + ], + }, + ], + }; + + return reply.type("application/json").send(data); + }); + + // 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 ?? "michal"; + + // Read SSH keys from bastion config + const sshKeys = config.sshKeys ?? []; + + const script = renderFirstbootScript({ + hostname, + role, + serverIp: config.serverIp, + httpPort: config.httpPort, + 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 lines for authorized_keys + const sshKeyLines = sshKeys.map(k => `echo '${k}'`).join('\n'); + + // 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 is the large Linux partition that is NOT root. +ROOT_DEV=$(findmnt -n -o SOURCE /) +echo "Root device: $ROOT_DEV" + +DATA_PART="" +for part in /dev/nvme*n*p* /dev/sd*[0-9]; do + [ -b "$part" ] || continue + # Skip root partition + [ "$part" = "$ROOT_DEV" ] && continue + # Skip small partitions (<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 partition: $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')} + +# Move existing /var content to LV +echo "Migrating /var to LVM..." +TMPVAR="/var.old.$$" +mv /var "$TMPVAR" +mkdir -p /var +mount /dev/labvg/var /var +cp -a "$TMPVAR"/. /var/ 2>/dev/null || true +rm -rf "$TMPVAR" + +# Mount remaining volumes +mount_lv varlog /var/log +mount_lv home /home +mount_lv srv /srv +${roleMountLines.join('\n')} + +# Enable swap +swapon /dev/labvg/swap + +# 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" +(${sshKeyLines}) >> "$ADMIN_SSH/authorized_keys" +chmod 600 "$ADMIN_SSH/authorized_keys" +chown -R ${adminUser}:${adminUser} "$ADMIN_SSH" + +# Also authorize root +mkdir -p /root/.ssh +chmod 700 /root/.ssh +(${sshKeyLines}) >> /root/.ssh/authorized_keys +chmod 600 /root/.ssh/authorized_keys + +# ── Harden SSH ─────────────────────────────────────────────────── +sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config +sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config +systemctl restart sshd 2>/dev/null || true + +# ── Write provisioning metadata ────────────────────────────────── +cat > /etc/lab-provisioned << LABMETA +hostname=${hostname} +role=${role} +mac=${mac} +provisioned_at=$(date -Iseconds) +method=asahi-firstboot +LABMETA + +# ── Callback to bastion ────────────────────────────────────────── +IP=$(hostname -I | awk '{print $1}') +curl -sf -X POST "http://${serverIp}:${httpPort}/api/progress" \\ + -H "Content-Type: application/json" \\ + -d "{\\"mac\\":\\"${mac}\\",\\"stage\\":\\"complete\\",\\"detail\\":\\"ready at $IP\\"}" \\ + 2>/dev/null || true + +# ── 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..d60ebf1 --- /dev/null +++ b/bastion/src/bastion/tests/asahi.test.ts @@ -0,0 +1,222 @@ +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"); + expect(resp.body).toContain("REPO_BASE"); + 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).toBe("Fedora Asahi Lab"); + + // Three partitions: EFI + Root + Data + expect(os.partitions).toHaveLength(3); + expect(os.partitions[0].type).toBe("EFI"); + expect(os.partitions[1].type).toBe("Linux"); + expect(os.partitions[1].size).toBe("60GB"); + expect(os.partitions[1].expand).toBe(false); + expect(os.partitions[2].type).toBe("Linux"); + expect(os.partitions[2].expand).toBe(true); + }); + + 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 callback", () => { + const script = renderFirstbootScript({ ...baseParams, role: "worker" }); + expect(script).toContain("/api/progress"); + expect(script).toContain("aa:bb:cc:dd:ee:ff"); + expect(script).toContain("complete"); + }); + + 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"); + }); +}); From 53265bb18c518d0963df3b6f300ef8e0021d0aa3 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 31 Mar 2026 03:07:38 +0100 Subject: [PATCH 2/7] test: integration test for Asahi firstboot LVM setup VM-based end-to-end test using Fedora cloud image with two disks: root (20GB) + data (200GB). Verifies the firstboot script creates labvg with correct LV sizes, mounts volumes, migrates /home content, sets hostname, creates admin user, and handles reprovision. Fixes to firstboot script: - Detect whole disks (not just partitions) for LVM PV - Handle btrfs subvolume paths in root device detection - Copy /home content before mounting LV (preserves SSH keys) - Don't restart sshd (config takes effect on reboot) - Make swapon and mount operations resilient to failures Co-Authored-By: Claude Opus 4.6 (1M context) --- bastion/package.json | 4 +- .../src/templates/asahi-firstboot.sh.ts | 71 ++-- .../tests/integration/asahi-firstboot.test.ts | 355 ++++++++++++++++++ 3 files changed, 407 insertions(+), 23 deletions(-) create mode 100644 bastion/tests/integration/asahi-firstboot.test.ts diff --git a/bastion/package.json b/bastion/package.json index 1e2a673..79a41ca 100644 --- a/bastion/package.json +++ b/bastion/package.json @@ -22,7 +22,9 @@ "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'" }, "engines": { "node": ">=20.0.0", diff --git a/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts b/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts index 2e32a03..36a695d 100644 --- a/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts +++ b/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts @@ -63,16 +63,21 @@ if [ -f "$MARKER" ]; then fi # ── Find the data partition ────────────────────────────────────── -# The data partition is the large Linux partition that is NOT root. -ROOT_DEV=$(findmnt -n -o SOURCE /) -echo "Root device: $ROOT_DEV" +# 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="" -for part in /dev/nvme*n*p* /dev/sd*[0-9]; do +# 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 partition + # Skip root device and root disk [ "$part" = "$ROOT_DEV" ] && continue - # Skip small partitions (<50GB) — EFI, boot, APFS stubs + 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 @@ -80,7 +85,7 @@ for part in /dev/nvme*n*p* /dev/sd*[0-9]; do FSTYPE=$(blkid -o value -s TYPE "$part" 2>/dev/null || echo "") if [ -z "$FSTYPE" ] || [ "$FSTYPE" = "LVM2_member" ]; then DATA_PART="$part" - echo "Found data partition: $DATA_PART ($SIZE_GB GB)" + echo "Found data device: $DATA_PART ($SIZE_GB GB)" break fi done @@ -159,23 +164,46 @@ mkfs.xfs /dev/labvg/home mkfs.xfs /dev/labvg/srv ${roleFormatLines.join('\n')} -# Move existing /var content to LV -echo "Migrating /var to LVM..." -TMPVAR="/var.old.$$" -mv /var "$TMPVAR" -mkdir -p /var -mount /dev/labvg/var /var -cp -a "$TMPVAR"/. /var/ 2>/dev/null || true -rm -rf "$TMPVAR" +# 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 remaining volumes -mount_lv varlog /var/log -mount_lv home /home -mount_lv srv /srv +# 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 +swapon /dev/labvg/swap 2>/dev/null || true # Write fstab entries echo "" >> /etc/fstab @@ -212,10 +240,9 @@ chmod 700 /root/.ssh (${sshKeyLines}) >> /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys -# ── Harden SSH ─────────────────────────────────────────────────── +# ── 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 -systemctl restart sshd 2>/dev/null || true # ── Write provisioning metadata ────────────────────────────────── cat > /etc/lab-provisioned << LABMETA 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); +}); From 6807632d463f3a41b13298044b3c4d1c5bb57aaa Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 31 Mar 2026 03:20:12 +0100 Subject: [PATCH 3/7] feat: Asahi rootfs build pipeline + serve from bastion - Add scripts/build-asahi-rootfs.sh: downloads upstream Fedora Asahi Remix Server, injects lab firstboot script + systemd service + SSH keys, repackages with installer_data.json that adds LVM Data partition - Bastion serves built artifacts at /asahi/repo/* via fastify-static - installer_data.json prefers built config, falls back to minimal - Fix __dirname crash in ESM module (use import.meta.url) - Fix smoke test timeout (was crashing due to __dirname) Co-Authored-By: Claude Opus 4.6 (1M context) --- bastion/scripts/build-asahi-rootfs.sh | 300 ++++++++++++++++++++ bastion/src/bastion/src/routes/asahi.ts | 118 ++++---- bastion/src/bastion/tests/asahi.test.ts | 4 +- bastion/src/cli/tests/smoke-bastion.test.ts | 2 +- 4 files changed, 370 insertions(+), 54 deletions(-) create mode 100755 bastion/scripts/build-asahi-rootfs.sh diff --git a/bastion/scripts/build-asahi-rootfs.sh b/bastion/scripts/build-asahi-rootfs.sh new file mode 100755 index 0000000..063ef8b --- /dev/null +++ b/bastion/scripts/build-asahi-rootfs.sh @@ -0,0 +1,300 @@ +#!/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 +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 +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" ]; 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" ]; 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/src/bastion/src/routes/asahi.ts b/bastion/src/bastion/src/routes/asahi.ts index fb88312..cc16a6f 100644 --- a/bastion/src/bastion/src/routes/asahi.ts +++ b/bastion/src/bastion/src/routes/asahi.ts @@ -1,14 +1,47 @@ // Routes for Asahi Linux provisioning. -// GET /asahi — wrapper script (curl https://bastion:8080/asahi | sh) -// GET /asahi/installer_data.json — custom installer config with LVM partition layout -// GET /asahi/firstboot.sh — first-boot LVM setup script (for manual use) +// 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 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 @@ -49,14 +82,14 @@ tar xf "installer-\${PKG_VER}.tar.gz" # Point to our custom installer_data.json + rootfs repo export INSTALLER_DATA="\${BASTION}/asahi/installer_data.json" -export REPO_BASE="\${BASTION}/asahi/repo" +export REPO_BASE="\${BASTION}/asahi/repo/" echo "" echo " Using custom installer data from bastion." echo " This will create:" echo " - Standard Asahi boot infrastructure (m1n1 + U-Boot)" -echo " - 60GB root partition (Fedora Asahi Remix)" -echo " - Remaining space as LVM data partition" +echo " - Fedora Asahi Remix root partition" +echo " - LVM data partition (remaining space)" echo "" echo " On first boot, LVM volumes will be created automatically:" echo " labvg/var (100GB), labvg/varlog (10GB), labvg/home (10GB)," @@ -74,48 +107,34 @@ fi return reply.type("text/x-shellscript").send(script); }); - // Custom installer_data.json — two Linux partitions (root + LVM data) + // Custom installer_data.json — serves built config or fallback app.get("/asahi/installer_data.json", async (_request, reply) => { - // This follows the Asahi installer_data.json schema. - // We define a fixed-size root and an expanding data partition for LVM. - const data = { - os_list: [ - { - name: "Fedora Asahi Lab", - default_os_name: "Fedora Linux with Lab LVM", - boot_object: "m1n1.bin", - next_object: "u-boot-nodtb.bin", - package: "fedora-asahi-lab.zip", - supported_fw: ["13.5"], - partitions: [ - { - name: "EFI", - type: "EFI", - size: "500MB", - format: "fat", - copy_firmware: true, - copy_installer_data: true, - source: "esp", - }, - { - name: "Root", - type: "Linux", - size: "60GB", - image: "root.img", - expand: false, - }, - { - name: "Data", - type: "Linux", - size: "1GB", - expand: true, - }, - ], - }, - ], - }; + // 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); + } + } - 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 @@ -125,17 +144,14 @@ fi 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 ?? "michal"; - - // Read SSH keys from bastion config - const sshKeys = config.sshKeys ?? []; + const user = request.query.user ?? config.adminUser; const script = renderFirstbootScript({ hostname, role, serverIp: config.serverIp, httpPort: config.httpPort, - sshKeys, + sshKeys: config.sshKeys ?? [], adminUser: user, mac, }); diff --git a/bastion/src/bastion/tests/asahi.test.ts b/bastion/src/bastion/tests/asahi.test.ts index d60ebf1..efe3a05 100644 --- a/bastion/src/bastion/tests/asahi.test.ts +++ b/bastion/src/bastion/tests/asahi.test.ts @@ -75,14 +75,14 @@ describe("asahi routes", () => { expect(data.os_list).toHaveLength(1); const os = data.os_list[0]; - expect(os.name).toBe("Fedora Asahi Lab"); + expect(os.name).toContain("Fedora Asahi Lab"); // Three partitions: EFI + Root + Data expect(os.partitions).toHaveLength(3); expect(os.partitions[0].type).toBe("EFI"); expect(os.partitions[1].type).toBe("Linux"); - expect(os.partitions[1].size).toBe("60GB"); expect(os.partitions[1].expand).toBe(false); + expect(os.partitions[1].image).toBe("root.img"); expect(os.partitions[2].type).toBe("Linux"); expect(os.partitions[2].expand).toBe(true); }); 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); From ad76c74020c0d0829bc65b308a21e482c48310af Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 31 Mar 2026 03:26:26 +0100 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20rootfs=20build=20script=20=E2=80=94?= =?UTF-8?q?=20mkdir=20before=20write,=20fix=20package=20path=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- bastion/scripts/build-asahi-rootfs.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bastion/scripts/build-asahi-rootfs.sh b/bastion/scripts/build-asahi-rootfs.sh index 063ef8b..092a35b 100755 --- a/bastion/scripts/build-asahi-rootfs.sh +++ b/bastion/scripts/build-asahi-rootfs.sh @@ -160,11 +160,13 @@ fi 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 @@ -201,12 +203,12 @@ 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" ]; then +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" ]; then +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." From a8dc79bc5a26245199200f7928c0a0f5ad5f69f4 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 31 Mar 2026 13:22:24 +0100 Subject: [PATCH 5/7] feat: Asahi validation tests, rootfs build fixes, shellcheck-clean scripts - Add 16 validation tests: shellcheck (3 roles), installer_data.json schema (8), Python parser validation, ZIP structure (3), rootfs mount - Fix empty SSH keys generating invalid bash (SC1073) - Fix __dirname crash in ESM modules (use import.meta.url) - Fix rootfs build: mkdir -p before writing, correct binary paths - Add .gitignore for large build artifacts (.asahi-cache, *.zip) - Bump smoke test timeout for additional static plugin registration Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 + bastion/asahi-repo/installer_data.json | 47 +++ bastion/package.json | 4 +- .../src/templates/asahi-firstboot.sh.ts | 13 +- .../tests/integration/asahi-validate.test.ts | 353 ++++++++++++++++++ 5 files changed, 416 insertions(+), 5 deletions(-) create mode 100644 bastion/asahi-repo/installer_data.json create mode 100644 bastion/tests/integration/asahi-validate.test.ts 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/package.json b/bastion/package.json index 79a41ca..fc1b5a3 100644 --- a/bastion/package.json +++ b/bastion/package.json @@ -24,7 +24,9 @@ "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: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: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/src/bastion/src/templates/asahi-firstboot.sh.ts b/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts index 36a695d..f42b530 100644 --- a/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts +++ b/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts @@ -39,8 +39,13 @@ export function renderFirstbootScript(params: AsahiFirstbootParams): string { roleFstabLines.push('echo "/dev/labvg/longhorn /var/lib/longhorn xfs defaults 0 0" >> /etc/fstab'); } - // SSH key lines for authorized_keys - const sshKeyLines = sshKeys.map(k => `echo '${k}'`).join('\n'); + // 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. @@ -230,14 +235,14 @@ fi ADMIN_SSH="/home/${adminUser}/.ssh" mkdir -p "$ADMIN_SSH" chmod 700 "$ADMIN_SSH" -(${sshKeyLines}) >> "$ADMIN_SSH/authorized_keys" +${sshKeyBlock} chmod 600 "$ADMIN_SSH/authorized_keys" chown -R ${adminUser}:${adminUser} "$ADMIN_SSH" # Also authorize root mkdir -p /root/.ssh chmod 700 /root/.ssh -(${sshKeyLines}) >> /root/.ssh/authorized_keys +${rootSshKeyBlock} chmod 600 /root/.ssh/authorized_keys # ── Harden SSH (takes effect on next sshd restart/reboot) ──────── 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); +}); From bb8f37ef7d926540b840eebaac590700b0acadbe Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 31 Mar 2026 23:32:38 +0100 Subject: [PATCH 6/7] feat: iSCSI, Longhorn disk labels, labctl asahi command, ZIP32 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit k3s host prep: - Add iSCSI initiator install+enable (Fedora: iscsi-initiator-utils, Ubuntu: open-iscsi) — required by Longhorn - Add Longhorn disk label to k3s server+agent configs - Add Longhorn disk annotation operation in post-install hardening CLI: - Add `labctl provision asahi` command with interactive install guide - Change default SSH user from "michal" to "lab" in all commands - Change admin user in bastion progress callback to "lab" Asahi provisioning fixes: - Download installer_data.json locally (installer reads it as file) - Use REPO_BASE to serve upstream ZIP from bastion (LAN speed) - Fix ZIP32 vs ZIP64: serve original upstream ZIP unmodified (our repackaged ZIP used ZIP64 which breaks Asahi urlcache) - Add /data/asahi-repo fallback path for k3s container PVC mount - Deploy script syncs asahi-repo to bastion pod after deployment Co-Authored-By: Claude Opus 4.6 (1M context) --- bastion/completions/labctl.bash | 5 +- bastion/completions/labctl.fish | 1 + bastion/scripts/deploy.sh | 15 ++++ bastion/src/bastion/src/routes/api.ts | 2 +- bastion/src/bastion/src/routes/asahi.ts | 16 +++-- .../src/templates/asahi-firstboot.sh.ts | 10 +-- bastion/src/bastion/tests/asahi.test.ts | 26 +++---- bastion/src/cli/src/commands/app.ts | 6 +- bastion/src/cli/src/commands/asahi.ts | 69 +++++++++++++++++++ bastion/src/cli/src/commands/labcontroller.ts | 4 +- bastion/src/cli/src/index.ts | 2 + .../modules/k3s/src/groups/hardening.ts | 4 +- .../modules/k3s/src/groups/host-prep.ts | 4 +- .../modules/k3s/src/operations/index.ts | 2 + .../modules/k3s/src/operations/iscsi.ts | 30 ++++++++ .../modules/k3s/src/operations/k3s-config.ts | 4 ++ .../k3s/src/operations/longhorn-disk.ts | 34 +++++++++ 17 files changed, 203 insertions(+), 31 deletions(-) create mode 100644 bastion/src/cli/src/commands/asahi.ts create mode 100644 bastion/src/modules/modules/k3s/src/operations/iscsi.ts create mode 100644 bastion/src/modules/modules/k3s/src/operations/longhorn-disk.ts 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/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 index cc16a6f..982a0e5 100644 --- a/bastion/src/bastion/src/routes/asahi.ts +++ b/bastion/src/bastion/src/routes/asahi.ts @@ -19,6 +19,9 @@ function findAsahiRepo(config: BastionConfig): string | null { 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)); @@ -80,20 +83,21 @@ curl -# -L -o "installer-\${PKG_VER}.tar.gz" "\${INSTALLER_BASE}/installer-\${PK echo " Extracting..." tar xf "installer-\${PKG_VER}.tar.gz" -# Point to our custom installer_data.json + rootfs repo -export INSTALLER_DATA="\${BASTION}/asahi/installer_data.json" +# 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" + +# Set REPO_BASE so the installer downloads rootfs from bastion (local, fast) export REPO_BASE="\${BASTION}/asahi/repo/" echo "" -echo " Using custom installer data from bastion." +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 will be created automatically:" -echo " labvg/var (100GB), labvg/varlog (10GB), labvg/home (10GB)," -echo " labvg/srv (20GB), labvg/rancher (20GB), labvg/longhorn (rest)" +echo " On first boot, LVM volumes are created automatically." echo "" # Run the installer diff --git a/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts b/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts index f42b530..a635e7f 100644 --- a/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts +++ b/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts @@ -258,12 +258,14 @@ provisioned_at=$(date -Iseconds) method=asahi-firstboot LABMETA -# ── Callback to bastion ────────────────────────────────────────── +# ── Register with bastion ───────────────────────────────────────── IP=$(hostname -I | awk '{print $1}') -curl -sf -X POST "http://${serverIp}:${httpPort}/api/progress" \\ +echo "Registering with bastion at ${serverIp}:${httpPort}..." +curl -sf -X POST "http://${serverIp}:${httpPort}/api/register" \\ -H "Content-Type: application/json" \\ - -d "{\\"mac\\":\\"${mac}\\",\\"stage\\":\\"complete\\",\\"detail\\":\\"ready at $IP\\"}" \\ - 2>/dev/null || true + -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" diff --git a/bastion/src/bastion/tests/asahi.test.ts b/bastion/src/bastion/tests/asahi.test.ts index efe3a05..ffe0ec5 100644 --- a/bastion/src/bastion/tests/asahi.test.ts +++ b/bastion/src/bastion/tests/asahi.test.ts @@ -62,8 +62,7 @@ describe("asahi routes", () => { 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"); - expect(resp.body).toContain("REPO_BASE"); + expect(resp.body).toContain("installer_data.json"); expect(resp.body).toContain("192.168.8.1"); expect(resp.body).toContain("install.sh"); }); @@ -77,14 +76,17 @@ describe("asahi routes", () => { const os = data.os_list[0]; expect(os.name).toContain("Fedora Asahi Lab"); - // Three partitions: EFI + Root + Data - expect(os.partitions).toHaveLength(3); + // 3 partitions (fallback) or 4 (built: EFI + Boot + Root + Data) + expect(os.partitions.length).toBeGreaterThanOrEqual(3); expect(os.partitions[0].type).toBe("EFI"); - expect(os.partitions[1].type).toBe("Linux"); - expect(os.partitions[1].expand).toBe(false); - expect(os.partitions[1].image).toBe("root.img"); - expect(os.partitions[2].type).toBe("Linux"); - expect(os.partitions[2].expand).toBe(true); + // 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 () => { @@ -185,11 +187,11 @@ describe("renderFirstbootScript", () => { expect(script).toContain('hostnamectl set-hostname "test-node"'); }); - it("includes bastion callback", () => { + it("includes bastion self-registration", () => { const script = renderFirstbootScript({ ...baseParams, role: "worker" }); - expect(script).toContain("/api/progress"); + expect(script).toContain("/api/register"); expect(script).toContain("aa:bb:cc:dd:ee:ff"); - expect(script).toContain("complete"); + expect(script).toContain("test-node"); }); it("writes provisioning metadata", () => { 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/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)" }; +}; From 17bae7ddbf576fb163b706852cd914462b7e38ae Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 1 Apr 2026 00:30:29 +0100 Subject: [PATCH 7/7] fix: pre-download rootfs ZIP to avoid macOS Python HTTP streaming issues The Asahi installer's urlcache.py fails with AssertionError on macOS when streaming ZIP via HTTP Range requests from Fastify. Fix: download the ZIP with curl first (reliable on macOS), then set REPO_BASE to the local directory so the installer opens it as a local file. Co-Authored-By: Claude Opus 4.6 (1M context) --- bastion/src/bastion/src/routes/asahi.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bastion/src/bastion/src/routes/asahi.ts b/bastion/src/bastion/src/routes/asahi.ts index 982a0e5..3622cab 100644 --- a/bastion/src/bastion/src/routes/asahi.ts +++ b/bastion/src/bastion/src/routes/asahi.ts @@ -87,8 +87,13 @@ tar xf "installer-\${PKG_VER}.tar.gz" echo " Downloading custom installer data from bastion..." curl -sfL -o installer_data.json "\${BASTION}/asahi/installer_data.json" -# Set REPO_BASE so the installer downloads rootfs from bastion (local, fast) -export REPO_BASE="\${BASTION}/asahi/repo/" +# 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."