From 6807632d463f3a41b13298044b3c4d1c5bb57aaa Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 31 Mar 2026 03:20:12 +0100 Subject: [PATCH] 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);