feat: Asahi Linux provisioning for Apple Silicon #10
4
.gitignore
vendored
4
.gitignore
vendored
@@ -27,3 +27,7 @@ node_modules/
|
||||
# Task files
|
||||
# tasks.json
|
||||
# tasks/
|
||||
|
||||
# Asahi build artifacts (large)
|
||||
bastion/.asahi-cache/
|
||||
bastion/asahi-repo/*.zip
|
||||
|
||||
47
bastion/asahi-repo/installer_data.json
Normal file
47
bastion/asahi-repo/installer_data.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
"test:integration:iso": "vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'",
|
||||
"test:integration:iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'",
|
||||
"test:integration:arm-iso": "vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'",
|
||||
"test:integration:arm-iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'"
|
||||
"test:integration:arm-iso:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'ARM ISO'",
|
||||
"test:integration:asahi": "vitest run -c tests/integration/vitest.config.ts -t 'asahi firstboot'",
|
||||
"test:integration:asahi:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'asahi firstboot'",
|
||||
"test:integration:asahi-validate": "vitest run -c tests/integration/vitest.config.ts -t 'asahi.*validation'",
|
||||
"test:integration:asahi-validate:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'asahi.*validation'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
|
||||
302
bastion/scripts/build-asahi-rootfs.sh
Executable file
302
bastion/scripts/build-asahi-rootfs.sh
Executable file
@@ -0,0 +1,302 @@
|
||||
#!/bin/bash
|
||||
# Build a custom Fedora Asahi Remix rootfs with lab firstboot LVM setup.
|
||||
#
|
||||
# Downloads the upstream Fedora Asahi Remix Server package, injects our
|
||||
# firstboot script + systemd service, and repackages it for the bastion.
|
||||
#
|
||||
# Requirements: root, curl, unzip, mount (loop), zip
|
||||
# Output: bastion/asahi-repo/ directory with package + installer_data.json
|
||||
#
|
||||
# Usage: sudo ./scripts/build-asahi-rootfs.sh [--bastion-ip IP] [--http-port PORT]
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ASAHI_DIR="$PROJECT_DIR/asahi-repo"
|
||||
CACHE_DIR="$PROJECT_DIR/.asahi-cache"
|
||||
WORK_DIR=""
|
||||
|
||||
# Defaults
|
||||
BASTION_IP="${BASTION_IP:-192.168.8.23}"
|
||||
HTTP_PORT="${HTTP_PORT:-8080}"
|
||||
ROLE="${ROLE:-infra}"
|
||||
HOSTNAME="${HOSTNAME:-mac-studio}"
|
||||
MAC="${MAC:-00:00:00:00:00:00}"
|
||||
ADMIN_USER="${ADMIN_USER:-michal}"
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--bastion-ip) BASTION_IP="$2"; shift 2 ;;
|
||||
--http-port) HTTP_PORT="$2"; shift 2 ;;
|
||||
--role) ROLE="$2"; shift 2 ;;
|
||||
--hostname) HOSTNAME="$2"; shift 2 ;;
|
||||
--mac) MAC="$2"; shift 2 ;;
|
||||
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Resolve upstream package URL ─────────────────────────────────
|
||||
echo "==> Fetching Asahi installer data..."
|
||||
INSTALLER_DATA=$(curl -sfL "https://cdn.asahilinux.org/installer/installer_data.json")
|
||||
|
||||
# Find the Server variant package URL
|
||||
SERVER_URL=$(echo "$INSTALLER_DATA" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for os in data.get('os_list', []):
|
||||
name = os.get('name', '').lower()
|
||||
if 'server' in name and 'uefi' not in name and not os.get('expert'):
|
||||
print(os['package'])
|
||||
break
|
||||
" 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVER_URL" ]; then
|
||||
echo "ERROR: Could not find Fedora Asahi Remix Server in installer data."
|
||||
echo "Available variants:"
|
||||
echo "$INSTALLER_DATA" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for os in data.get('os_list', []):
|
||||
print(f\" - {os.get('name', '?')}\")" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PACKAGE_NAME=$(basename "$SERVER_URL")
|
||||
echo " Variant: Fedora Asahi Remix Server"
|
||||
echo " Package: $PACKAGE_NAME"
|
||||
|
||||
# Also extract the partition layout and supported_fw from upstream
|
||||
UPSTREAM_CONFIG=$(echo "$INSTALLER_DATA" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
for os in data.get('os_list', []):
|
||||
name = os.get('name', '').lower()
|
||||
if 'server' in name and 'uefi' not in name and not os.get('expert'):
|
||||
json.dump(os, sys.stdout)
|
||||
break
|
||||
")
|
||||
|
||||
# ── Download upstream package ────────────────────────────────────
|
||||
mkdir -p "$CACHE_DIR" "$ASAHI_DIR"
|
||||
|
||||
CACHED_PKG="$CACHE_DIR/$PACKAGE_NAME"
|
||||
if [ -f "$CACHED_PKG" ]; then
|
||||
echo "==> Using cached package: $CACHED_PKG"
|
||||
else
|
||||
echo "==> Downloading $SERVER_URL..."
|
||||
curl -# -L -o "$CACHED_PKG" "$SERVER_URL"
|
||||
fi
|
||||
|
||||
# ── Extract and modify rootfs ────────────────────────────────────
|
||||
WORK_DIR=$(mktemp -d)
|
||||
trap 'echo "==> Cleaning up..."; umount "$WORK_DIR/rootfs" 2>/dev/null || true; rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
echo "==> Extracting package..."
|
||||
unzip -q -o "$CACHED_PKG" -d "$WORK_DIR/pkg"
|
||||
|
||||
# List contents
|
||||
echo " Package contents:"
|
||||
ls -lh "$WORK_DIR/pkg/" | grep -v ^total | while read -r line; do echo " $line"; done
|
||||
|
||||
# Find root.img
|
||||
ROOT_IMG=$(find "$WORK_DIR/pkg" -name "root.img" -type f | head -1)
|
||||
if [ -z "$ROOT_IMG" ]; then
|
||||
echo "ERROR: root.img not found in package."
|
||||
echo "Contents: $(ls "$WORK_DIR/pkg/")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Mounting root.img..."
|
||||
mkdir -p "$WORK_DIR/rootfs"
|
||||
mount -o loop "$ROOT_IMG" "$WORK_DIR/rootfs"
|
||||
|
||||
# ── Read SSH keys from the system ────────────────────────────────
|
||||
SSH_KEYS=""
|
||||
REAL_USER="${SUDO_USER:-$USER}"
|
||||
REAL_HOME=$(eval echo "~$REAL_USER")
|
||||
for keyfile in "$REAL_HOME/.ssh/id_ed25519.pub" "$REAL_HOME/.ssh/id_ecdsa.pub" "$REAL_HOME/.ssh/id_rsa.pub"; do
|
||||
if [ -f "$keyfile" ]; then
|
||||
SSH_KEYS=$(cat "$keyfile")
|
||||
echo " SSH key: $keyfile"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$SSH_KEYS" ]; then
|
||||
echo "WARNING: No SSH public key found. You'll need to add keys manually."
|
||||
fi
|
||||
|
||||
# ── Generate firstboot script from bastion ───────────────────────
|
||||
echo "==> Generating firstboot script..."
|
||||
|
||||
# Try to get the script from a running bastion, fall back to local generation
|
||||
FIRSTBOOT_SCRIPT=""
|
||||
FIRSTBOOT_URL="http://$BASTION_IP:$HTTP_PORT/asahi/firstboot.sh?hostname=$HOSTNAME&role=$ROLE&mac=$MAC&user=$ADMIN_USER"
|
||||
FIRSTBOOT_SCRIPT=$(curl -sf "$FIRSTBOOT_URL" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$FIRSTBOOT_SCRIPT" ]; then
|
||||
echo " Bastion not reachable, generating script locally..."
|
||||
# Generate a basic firstboot script inline
|
||||
FIRSTBOOT_SCRIPT=$(cd "$PROJECT_DIR" && node -e "
|
||||
const { renderFirstbootScript } = require('./src/bastion/dist/templates/asahi-firstboot.sh.js');
|
||||
process.stdout.write(renderFirstbootScript({
|
||||
hostname: '$HOSTNAME',
|
||||
role: '$ROLE',
|
||||
serverIp: '$BASTION_IP',
|
||||
httpPort: $HTTP_PORT,
|
||||
sshKeys: $([ -n "$SSH_KEYS" ] && echo "[\"$SSH_KEYS\"]" || echo "[]"),
|
||||
adminUser: '$ADMIN_USER',
|
||||
mac: '$MAC',
|
||||
}));
|
||||
" 2>/dev/null) || {
|
||||
echo " ERROR: Could not generate firstboot script. Build the project first: npm run build"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# ── Inject files into rootfs ─────────────────────────────────────
|
||||
echo "==> Injecting lab configuration into rootfs..."
|
||||
|
||||
# Firstboot script
|
||||
mkdir -p "$WORK_DIR/rootfs/usr/local/bin"
|
||||
echo "$FIRSTBOOT_SCRIPT" > "$WORK_DIR/rootfs/usr/local/bin/lab-firstboot.sh"
|
||||
chmod 755 "$WORK_DIR/rootfs/usr/local/bin/lab-firstboot.sh"
|
||||
echo " Installed: /usr/local/bin/lab-firstboot.sh"
|
||||
|
||||
# Systemd service
|
||||
mkdir -p "$WORK_DIR/rootfs/etc/systemd/system"
|
||||
cat > "$WORK_DIR/rootfs/etc/systemd/system/lab-firstboot.service" << 'UNIT'
|
||||
[Unit]
|
||||
Description=Lab first-boot LVM setup
|
||||
After=local-fs.target network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=!/etc/lab-lvm-setup-done
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/lab-firstboot.sh
|
||||
RemainAfterExit=yes
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
echo " Installed: /etc/systemd/system/lab-firstboot.service"
|
||||
|
||||
# Enable the service
|
||||
mkdir -p "$WORK_DIR/rootfs/etc/systemd/system/multi-user.target.wants"
|
||||
ln -sf /etc/systemd/system/lab-firstboot.service \
|
||||
"$WORK_DIR/rootfs/etc/systemd/system/multi-user.target.wants/lab-firstboot.service"
|
||||
echo " Enabled: lab-firstboot.service"
|
||||
|
||||
# SSH authorized keys for root (for initial access before firstboot runs user creation)
|
||||
if [ -n "$SSH_KEYS" ]; then
|
||||
mkdir -p "$WORK_DIR/rootfs/root/.ssh"
|
||||
chmod 700 "$WORK_DIR/rootfs/root/.ssh"
|
||||
echo "$SSH_KEYS" > "$WORK_DIR/rootfs/root/.ssh/authorized_keys"
|
||||
chmod 600 "$WORK_DIR/rootfs/root/.ssh/authorized_keys"
|
||||
echo " Installed: /root/.ssh/authorized_keys"
|
||||
fi
|
||||
|
||||
# Ensure lvm2 and xfsprogs are installed (should be in server image already)
|
||||
echo " Checking required packages..."
|
||||
if [ -f "$WORK_DIR/rootfs/usr/sbin/pvcreate" ] || [ -f "$WORK_DIR/rootfs/usr/bin/pvcreate" ]; then
|
||||
echo " lvm2: present"
|
||||
else
|
||||
echo " WARNING: lvm2 not found in rootfs. LVM setup may fail."
|
||||
fi
|
||||
if [ -f "$WORK_DIR/rootfs/usr/sbin/mkfs.xfs" ] || [ -f "$WORK_DIR/rootfs/usr/bin/mkfs.xfs" ]; then
|
||||
echo " xfsprogs: present"
|
||||
else
|
||||
echo " WARNING: xfsprogs not found in rootfs. LVM setup may fail."
|
||||
fi
|
||||
|
||||
# ── Unmount and repackage ────────────────────────────────────────
|
||||
echo "==> Unmounting rootfs..."
|
||||
umount "$WORK_DIR/rootfs"
|
||||
|
||||
echo "==> Repackaging..."
|
||||
OUTPUT_PKG="$ASAHI_DIR/fedora-asahi-lab.zip"
|
||||
rm -f "$OUTPUT_PKG"
|
||||
(cd "$WORK_DIR/pkg" && zip -q "$OUTPUT_PKG" *)
|
||||
echo " Output: $OUTPUT_PKG ($(du -sh "$OUTPUT_PKG" | cut -f1))"
|
||||
|
||||
# ── Generate installer_data.json ─────────────────────────────────
|
||||
echo "==> Generating installer_data.json..."
|
||||
|
||||
# Parse upstream config to get supported_fw, boot_object, next_object, and partition details
|
||||
python3 << PYEOF > "$ASAHI_DIR/installer_data.json"
|
||||
import json, sys
|
||||
|
||||
upstream = json.loads('''$UPSTREAM_CONFIG''')
|
||||
|
||||
# Build our custom installer data based on upstream
|
||||
# Keep EFI and Boot partitions identical, modify Root to not expand,
|
||||
# add Data partition that expands for LVM.
|
||||
partitions = []
|
||||
for p in upstream.get('partitions', []):
|
||||
if p.get('type') == 'EFI':
|
||||
partitions.append(p)
|
||||
elif p.get('name') == 'Boot':
|
||||
partitions.append(p)
|
||||
elif p.get('name') == 'Root':
|
||||
# Fixed size root, no expand
|
||||
root_p = dict(p)
|
||||
root_p['expand'] = False
|
||||
# Keep the original size (it's the minimum needed for the rootfs)
|
||||
partitions.append(root_p)
|
||||
|
||||
# Add Data partition for LVM
|
||||
partitions.append({
|
||||
"name": "Data",
|
||||
"type": "Linux",
|
||||
"size": "1073741824B", # 1GB minimum, will expand
|
||||
"expand": True
|
||||
})
|
||||
|
||||
data = {
|
||||
"os_list": [{
|
||||
"name": "Fedora Asahi Lab (${ROLE})",
|
||||
"default_os_name": "Fedora Linux Lab",
|
||||
"boot_object": upstream.get("boot_object", "m1n1.bin"),
|
||||
"next_object": upstream.get("next_object", "m1n1/boot.bin"),
|
||||
"package": "fedora-asahi-lab.zip",
|
||||
"supported_fw": upstream.get("supported_fw", ["13.5"]),
|
||||
"partitions": partitions,
|
||||
}]
|
||||
}
|
||||
|
||||
json.dump(data, sys.stdout, indent=2)
|
||||
print()
|
||||
PYEOF
|
||||
|
||||
echo " Generated: $ASAHI_DIR/installer_data.json"
|
||||
|
||||
# Pretty-print the partition layout
|
||||
echo ""
|
||||
echo " Partition layout:"
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$ASAHI_DIR/installer_data.json') as f:
|
||||
data = json.load(f)
|
||||
for p in data['os_list'][0]['partitions']:
|
||||
size = p.get('size', '?')
|
||||
expand = ' (expand)' if p.get('expand') else ''
|
||||
image = f\" [{p['image']}]\" if 'image' in p else ''
|
||||
print(f\" {p['name']:8s} {p['type']:8s} {size:>16s}{expand}{image}\")
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "==> Build complete!"
|
||||
echo ""
|
||||
echo " Package: $ASAHI_DIR/fedora-asahi-lab.zip"
|
||||
echo " Config: $ASAHI_DIR/installer_data.json"
|
||||
echo ""
|
||||
echo " To serve from bastion, copy to the bastion's HTTP directory"
|
||||
echo " or configure REPO_BASE to point here."
|
||||
echo ""
|
||||
echo " To install on Mac Studio:"
|
||||
echo " curl http://$BASTION_IP:$HTTP_PORT/asahi | sh"
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
175
bastion/src/bastion/src/routes/asahi.ts
Normal file
175
bastion/src/bastion/src/routes/asahi.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Routes for Asahi Linux provisioning.
|
||||
// GET /asahi — wrapper script (curl bastion:8080/asahi | sh)
|
||||
// GET /asahi/installer_data.json — custom installer config (built or fallback)
|
||||
// GET /asahi/repo/* — serves built rootfs package (fedora-asahi-lab.zip)
|
||||
// GET /asahi/firstboot.sh — first-boot LVM setup script (for manual use)
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { BastionConfig } from "@lab/shared";
|
||||
import { renderFirstbootScript, renderFirstbootUnit } from "../templates/asahi-firstboot.sh.js";
|
||||
import type { Role } from "@lab/shared";
|
||||
|
||||
/** Find the asahi-repo directory (built by scripts/build-asahi-rootfs.sh). */
|
||||
function findAsahiRepo(config: BastionConfig): string | null {
|
||||
// Check relative to bastionDir (container deploy)
|
||||
const inBastionDir = join(config.bastionDir, "asahi-repo");
|
||||
if (existsSync(inBastionDir)) return inBastionDir;
|
||||
|
||||
// Check /data/asahi-repo (PVC mount in k3s container)
|
||||
if (existsSync("/data/asahi-repo")) return "/data/asahi-repo";
|
||||
|
||||
// Check relative to project root (dev mode)
|
||||
try {
|
||||
const thisDir = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = join(thisDir, "..", "..", "..", "..");
|
||||
const inProjectRoot = join(projectRoot, "asahi-repo");
|
||||
if (existsSync(inProjectRoot)) return inProjectRoot;
|
||||
} catch { /* import.meta.url not available in tests */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerAsahiRoutes(app: FastifyInstance, config: BastionConfig): void {
|
||||
const repoDir = findAsahiRepo(config);
|
||||
|
||||
// Serve built rootfs package files (fedora-asahi-lab.zip, etc.)
|
||||
if (repoDir) {
|
||||
app.register(fastifyStatic, {
|
||||
root: repoDir,
|
||||
prefix: "/asahi/repo/",
|
||||
decorateReply: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Wrapper script — user runs: curl http://bastion:8080/asahi | sh
|
||||
app.get("/asahi", async (_request, reply) => {
|
||||
const script = `#!/bin/bash
|
||||
# Lab Asahi provisioner — sets up Apple Silicon machines with lab LVM layout.
|
||||
# This wraps the standard Asahi installer with custom installer_data.json
|
||||
# that creates a separate LVM data partition.
|
||||
set -euo pipefail
|
||||
|
||||
BASTION="http://${config.serverIp}:${config.httpPort}"
|
||||
|
||||
echo ""
|
||||
echo " ╔══════════════════════════════════════════════╗"
|
||||
echo " ║ Lab Asahi Provisioner ║"
|
||||
echo " ║ Bastion: \${BASTION} ║"
|
||||
echo " ╚══════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Check we're on macOS
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
echo "ERROR: This script must be run from macOS on the target Mac."
|
||||
echo " It uses the Asahi Linux installer to set up Apple Silicon boot."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download the standard Asahi installer
|
||||
echo "Downloading Asahi Linux installer..."
|
||||
WORKDIR=$(mktemp -d)
|
||||
cd "$WORKDIR"
|
||||
|
||||
INSTALLER_BASE="https://cdn.asahilinux.org/installer"
|
||||
PKG_VER=$(curl -s "\${INSTALLER_BASE}/latest")
|
||||
echo " Version: \${PKG_VER}"
|
||||
|
||||
curl -# -L -o "installer-\${PKG_VER}.tar.gz" "\${INSTALLER_BASE}/installer-\${PKG_VER}.tar.gz"
|
||||
|
||||
echo " Extracting..."
|
||||
tar xf "installer-\${PKG_VER}.tar.gz"
|
||||
|
||||
# Download our custom installer_data.json (installer reads it as a local file)
|
||||
echo " Downloading custom installer data from bastion..."
|
||||
curl -sfL -o installer_data.json "\${BASTION}/asahi/installer_data.json"
|
||||
|
||||
# Pre-download the rootfs package (avoids Python HTTP streaming issues on macOS)
|
||||
echo " Downloading rootfs package from bastion..."
|
||||
mkdir -p os
|
||||
curl -# -L -o os/fedora-asahi-lab.zip "\${BASTION}/asahi/repo/fedora-asahi-lab.zip"
|
||||
|
||||
# Point installer to local directory (REPO_BASE + /os/ + package name)
|
||||
export REPO_BASE="\${PWD}"
|
||||
|
||||
echo ""
|
||||
echo " Using custom partition layout + rootfs from bastion."
|
||||
echo " This will create:"
|
||||
echo " - Standard Asahi boot infrastructure (m1n1 + U-Boot)"
|
||||
echo " - Fedora Asahi Remix root partition"
|
||||
echo " - LVM data partition (remaining space)"
|
||||
echo ""
|
||||
echo " On first boot, LVM volumes are created automatically."
|
||||
echo ""
|
||||
|
||||
# Run the installer
|
||||
if [ "$USER" != "root" ]; then
|
||||
echo "The installer needs root. Enter your sudo password if prompted."
|
||||
exec caffeinate -dis sudo -E ./install.sh "$@"
|
||||
else
|
||||
exec caffeinate -dis ./install.sh "$@"
|
||||
fi
|
||||
`;
|
||||
return reply.type("text/x-shellscript").send(script);
|
||||
});
|
||||
|
||||
// Custom installer_data.json — serves built config or fallback
|
||||
app.get("/asahi/installer_data.json", async (_request, reply) => {
|
||||
// Prefer the built installer_data.json (from build-asahi-rootfs.sh)
|
||||
if (repoDir) {
|
||||
const builtConfig = join(repoDir, "installer_data.json");
|
||||
if (existsSync(builtConfig)) {
|
||||
const data = JSON.parse(readFileSync(builtConfig, "utf-8"));
|
||||
return reply.type("application/json").send(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: minimal config (won't have boot.img, for testing only)
|
||||
return reply.type("application/json").send({
|
||||
os_list: [{
|
||||
name: "Fedora Asahi Lab",
|
||||
default_os_name: "Fedora Linux with Lab LVM",
|
||||
boot_object: "m1n1.bin",
|
||||
next_object: "m1n1/boot.bin",
|
||||
package: "fedora-asahi-lab.zip",
|
||||
supported_fw: ["13.5"],
|
||||
partitions: [
|
||||
{ name: "EFI", type: "EFI", size: "524288000B", format: "fat",
|
||||
copy_firmware: true, copy_installer_data: true, source: "esp" },
|
||||
{ name: "Root", type: "Linux", size: "5368709120B", image: "root.img", expand: false },
|
||||
{ name: "Data", type: "Linux", size: "1073741824B", expand: true },
|
||||
],
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
// First-boot script — for manual download or embedding in rootfs
|
||||
app.get<{
|
||||
Querystring: { hostname?: string; role?: string; mac?: string; user?: string };
|
||||
}>("/asahi/firstboot.sh", async (request, reply) => {
|
||||
const hostname = request.query.hostname ?? "mac-studio";
|
||||
const role = (request.query.role ?? "infra") as Role;
|
||||
const mac = request.query.mac ?? "unknown";
|
||||
const user = request.query.user ?? config.adminUser;
|
||||
|
||||
const script = renderFirstbootScript({
|
||||
hostname,
|
||||
role,
|
||||
serverIp: config.serverIp,
|
||||
httpPort: config.httpPort,
|
||||
sshKeys: config.sshKeys ?? [],
|
||||
adminUser: user,
|
||||
mac,
|
||||
});
|
||||
|
||||
return reply.type("text/x-shellscript").send(script);
|
||||
});
|
||||
|
||||
// Systemd unit file for first-boot service
|
||||
app.get("/asahi/firstboot.service", async (_request, reply) => {
|
||||
return reply.type("text/plain").send(renderFirstbootUnit());
|
||||
});
|
||||
}
|
||||
@@ -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<typeof Fastify>; state: StateManager; installLog: InstallLogBuffer; syslog: SyslogListener } {
|
||||
@@ -45,6 +46,7 @@ export function createApp(config: BastionConfig): { app: ReturnType<typeof Fasti
|
||||
registerDispatchRoutes(app, config, state);
|
||||
registerKickstartRoutes(app, config, state, syslog);
|
||||
registerApiRoutes(app, state, installLog, syslog);
|
||||
registerAsahiRoutes(app, config);
|
||||
// boot.iso is generated at startup and served as a static file from httpDir
|
||||
// (static serving supports HTTP Range requests, required by JetKVM streaming)
|
||||
|
||||
|
||||
294
bastion/src/bastion/src/templates/asahi-firstboot.sh.ts
Normal file
294
bastion/src/bastion/src/templates/asahi-firstboot.sh.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// First-boot LVM setup script for Asahi-provisioned machines.
|
||||
// Embedded in the custom rootfs as a systemd service that runs once on first boot.
|
||||
// Creates the standard lab LVM layout on the data partition, matching install.ks.ts.
|
||||
|
||||
import type { Role } from "@lab/shared";
|
||||
|
||||
export interface AsahiFirstbootParams {
|
||||
hostname: string;
|
||||
role: Role;
|
||||
serverIp: string;
|
||||
httpPort: number;
|
||||
sshKeys: string[];
|
||||
adminUser: string;
|
||||
mac: string;
|
||||
}
|
||||
|
||||
export function renderFirstbootScript(params: AsahiFirstbootParams): string {
|
||||
const { hostname, role, serverIp, httpPort, sshKeys, adminUser, mac } = params;
|
||||
|
||||
const isWorker = role === "worker";
|
||||
const isInfra = role === "infra" || role === "labcontroller";
|
||||
|
||||
// Role-specific LV creation commands
|
||||
const roleLvLines: string[] = [];
|
||||
const roleFormatLines: string[] = [];
|
||||
const roleMountLines: string[] = [];
|
||||
const roleFstabLines: string[] = [];
|
||||
|
||||
if (isInfra) {
|
||||
roleLvLines.push('lvcreate -L 20480M -n rancher labvg -y');
|
||||
roleFormatLines.push('mkfs.xfs /dev/labvg/rancher');
|
||||
roleMountLines.push('mount_lv rancher /var/lib/rancher');
|
||||
roleFstabLines.push('echo "/dev/labvg/rancher /var/lib/rancher xfs defaults 0 0" >> /etc/fstab');
|
||||
}
|
||||
if (isWorker || isInfra) {
|
||||
roleLvLines.push('lvcreate -l 100%FREE -n longhorn labvg -y');
|
||||
roleFormatLines.push('mkfs.xfs /dev/labvg/longhorn');
|
||||
roleMountLines.push('mount_lv longhorn /var/lib/longhorn');
|
||||
roleFstabLines.push('echo "/dev/labvg/longhorn /var/lib/longhorn xfs defaults 0 0" >> /etc/fstab');
|
||||
}
|
||||
|
||||
// SSH key injection block (empty if no keys)
|
||||
const sshKeyBlock = sshKeys.length > 0
|
||||
? sshKeys.map(k => `echo '${k}' >> "$ADMIN_SSH/authorized_keys"`).join('\n')
|
||||
: 'true # no SSH keys configured';
|
||||
const rootSshKeyBlock = sshKeys.length > 0
|
||||
? sshKeys.map(k => `echo '${k}' >> /root/.ssh/authorized_keys`).join('\n')
|
||||
: 'true # no SSH keys configured';
|
||||
|
||||
// NOTE: All bash $ references use $VAR not \${VAR} to avoid TS template conflicts.
|
||||
// Where ${} is needed in bash, we use \\${...} to escape.
|
||||
return `#!/bin/bash
|
||||
# Lab first-boot LVM setup — generated by bastion
|
||||
# This script runs once on first boot via systemd, then disables itself.
|
||||
set -euo pipefail
|
||||
|
||||
MARKER="/etc/lab-lvm-setup-done"
|
||||
LOG="/var/log/lab-firstboot.log"
|
||||
|
||||
exec > >(tee -a "$LOG") 2>&1
|
||||
echo "=== Lab first-boot LVM setup ==="
|
||||
date
|
||||
|
||||
# Already done?
|
||||
if [ -f "$MARKER" ]; then
|
||||
echo "LVM setup already completed, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Find the data partition ──────────────────────────────────────
|
||||
# The data partition/disk is a large block device that is NOT the root filesystem.
|
||||
# Handles: NVMe partitions, SCSI partitions, whole unpartitioned disks.
|
||||
ROOT_DEV=$(findmnt -n -o SOURCE / | sed 's/\\[.*\\]//') # strip btrfs subvol
|
||||
ROOT_DISK=$(lsblk -n -o PKNAME "$ROOT_DEV" 2>/dev/null | head -1)
|
||||
echo "Root device: $ROOT_DEV (disk: $ROOT_DISK)"
|
||||
|
||||
DATA_PART=""
|
||||
# Scan partitions first, then whole disks
|
||||
for part in /dev/nvme*n*p* /dev/sd*[0-9] /dev/vd*[0-9] /dev/nvme*n* /dev/sd[b-z] /dev/vd[b-z]; do
|
||||
[ -b "$part" ] || continue
|
||||
# Skip root device and root disk
|
||||
[ "$part" = "$ROOT_DEV" ] && continue
|
||||
PART_DISK=$(basename "$part" | sed 's/p[0-9]*$//' | sed 's/[0-9]*$//')
|
||||
[ "$PART_DISK" = "$ROOT_DISK" ] && continue
|
||||
# Skip small devices (<50GB) — EFI, boot, APFS stubs
|
||||
SIZE_BYTES=$(blockdev --getsize64 "$part" 2>/dev/null || echo 0)
|
||||
SIZE_GB=$((SIZE_BYTES / 1073741824))
|
||||
[ "$SIZE_GB" -lt 50 ] && continue
|
||||
# Use if unformatted or already LVM
|
||||
FSTYPE=$(blkid -o value -s TYPE "$part" 2>/dev/null || echo "")
|
||||
if [ -z "$FSTYPE" ] || [ "$FSTYPE" = "LVM2_member" ]; then
|
||||
DATA_PART="$part"
|
||||
echo "Found data device: $DATA_PART ($SIZE_GB GB)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$DATA_PART" ]; then
|
||||
echo "ERROR: No suitable data partition found for LVM."
|
||||
echo "Expected a large (>50GB) unformatted partition."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Helper function ──────────────────────────────────────────────
|
||||
mount_lv() {
|
||||
local lv="$1" mp="$2"
|
||||
if lvs "labvg/$lv" &>/dev/null; then
|
||||
mkdir -p "$mp"
|
||||
mount "/dev/labvg/$lv" "$mp" 2>/dev/null || true
|
||||
echo " Mounted $lv -> $mp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Check for existing VG ────────────────────────────────────────
|
||||
if vgs labvg &>/dev/null; then
|
||||
echo "Volume group 'labvg' already exists — reprovision detected."
|
||||
echo "Activating existing volumes..."
|
||||
vgchange -ay labvg
|
||||
|
||||
mount_lv var /var
|
||||
mount_lv varlog /var/log
|
||||
mount_lv home /home
|
||||
mount_lv srv /srv
|
||||
${roleMountLines.map(l => ` ${l}`).join('\n')}
|
||||
|
||||
# Enable swap
|
||||
if lvs labvg/swap &>/dev/null; then
|
||||
swapon /dev/labvg/swap 2>/dev/null || true
|
||||
echo " Enabled swap"
|
||||
fi
|
||||
|
||||
# Ensure fstab entries exist
|
||||
grep -q "labvg" /etc/fstab || {
|
||||
echo "# Lab LVM volumes (re-added after reprovision)" >> /etc/fstab
|
||||
echo "/dev/labvg/swap none swap defaults 0 0" >> /etc/fstab
|
||||
echo "/dev/labvg/var /var xfs defaults 0 0" >> /etc/fstab
|
||||
echo "/dev/labvg/varlog /var/log xfs defaults 0 0" >> /etc/fstab
|
||||
echo "/dev/labvg/home /home xfs defaults 0 0" >> /etc/fstab
|
||||
echo "/dev/labvg/srv /srv xfs defaults 0 0" >> /etc/fstab
|
||||
${roleFstabLines.map(l => ` ${l}`).join('\n')}
|
||||
}
|
||||
|
||||
echo "Existing LVM volumes re-mounted."
|
||||
touch "$MARKER"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Fresh install: create LVM ────────────────────────────────────
|
||||
echo "Creating LVM on $DATA_PART..."
|
||||
|
||||
pvcreate "$DATA_PART"
|
||||
vgcreate labvg "$DATA_PART"
|
||||
|
||||
# Create LVs — sizes match install.ks.ts (in MiB)
|
||||
echo "Creating logical volumes..."
|
||||
lvcreate -L 27648M -n swap labvg -y # 27GB swap
|
||||
lvcreate -L 102400M -n var labvg -y # 100GB /var
|
||||
lvcreate -L 10240M -n varlog labvg -y # 10GB /var/log
|
||||
lvcreate -L 10240M -n home labvg -y # 10GB /home
|
||||
lvcreate -L 20480M -n srv labvg -y # 20GB /srv
|
||||
${roleLvLines.join('\n')}
|
||||
|
||||
# Format
|
||||
echo "Formatting volumes..."
|
||||
mkswap /dev/labvg/swap
|
||||
mkfs.xfs /dev/labvg/var
|
||||
mkfs.xfs /dev/labvg/varlog
|
||||
mkfs.xfs /dev/labvg/home
|
||||
mkfs.xfs /dev/labvg/srv
|
||||
${roleFormatLines.join('\n')}
|
||||
|
||||
# Migrate and mount volumes that can be switched live.
|
||||
# Copy existing content first so we don't shadow files (e.g. /home/user/.ssh).
|
||||
for LV_MOUNT in "home /home" "srv /srv"; do
|
||||
LV_NAME=$(echo "$LV_MOUNT" | awk '{print $1}')
|
||||
MOUNT_PT=$(echo "$LV_MOUNT" | awk '{print $2}')
|
||||
STAGING="/mnt/labvg-$LV_NAME-staging"
|
||||
mkdir -p "$STAGING"
|
||||
mount "/dev/labvg/$LV_NAME" "$STAGING"
|
||||
cp -a "$MOUNT_PT"/. "$STAGING/" 2>/dev/null || true
|
||||
umount "$STAGING"
|
||||
rmdir "$STAGING"
|
||||
mount_lv "$LV_NAME" "$MOUNT_PT"
|
||||
done
|
||||
|
||||
# Mount role-specific volumes (empty, no content to preserve)
|
||||
set +e
|
||||
${roleMountLines.join('\n')}
|
||||
set -e
|
||||
|
||||
# Copy existing /var content into the LV for next boot
|
||||
echo "Preparing /var LV for next boot..."
|
||||
TMPVAR="/mnt/labvg-var-staging"
|
||||
mkdir -p "$TMPVAR"
|
||||
mount /dev/labvg/var "$TMPVAR"
|
||||
cp -a /var/. "$TMPVAR/" 2>/dev/null || true
|
||||
umount "$TMPVAR"
|
||||
rmdir "$TMPVAR"
|
||||
|
||||
# Same for /var/log
|
||||
TMPVARLOG="/mnt/labvg-varlog-staging"
|
||||
mkdir -p "$TMPVARLOG"
|
||||
mount /dev/labvg/varlog "$TMPVARLOG"
|
||||
cp -a /var/log/. "$TMPVARLOG/" 2>/dev/null || true
|
||||
umount "$TMPVARLOG"
|
||||
rmdir "$TMPVARLOG"
|
||||
|
||||
echo "NOTE: /var and /var/log will switch to LVM on next reboot."
|
||||
|
||||
# Enable swap
|
||||
swapon /dev/labvg/swap 2>/dev/null || true
|
||||
|
||||
# Write fstab entries
|
||||
echo "" >> /etc/fstab
|
||||
echo "# Lab LVM volumes" >> /etc/fstab
|
||||
echo "/dev/labvg/swap none swap defaults 0 0" >> /etc/fstab
|
||||
echo "/dev/labvg/var /var xfs defaults 0 0" >> /etc/fstab
|
||||
echo "/dev/labvg/varlog /var/log xfs defaults 0 0" >> /etc/fstab
|
||||
echo "/dev/labvg/home /home xfs defaults 0 0" >> /etc/fstab
|
||||
echo "/dev/labvg/srv /srv xfs defaults 0 0" >> /etc/fstab
|
||||
${roleFstabLines.join('\n')}
|
||||
|
||||
echo "LVM setup complete."
|
||||
lvs labvg
|
||||
|
||||
# ── Set hostname ─────────────────────────────────────────────────
|
||||
hostnamectl set-hostname "${hostname}"
|
||||
|
||||
# ── Configure admin user ─────────────────────────────────────────
|
||||
if ! id "${adminUser}" &>/dev/null; then
|
||||
useradd -m -G wheel "${adminUser}"
|
||||
echo "${adminUser} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/${adminUser}
|
||||
chmod 440 /etc/sudoers.d/${adminUser}
|
||||
fi
|
||||
ADMIN_SSH="/home/${adminUser}/.ssh"
|
||||
mkdir -p "$ADMIN_SSH"
|
||||
chmod 700 "$ADMIN_SSH"
|
||||
${sshKeyBlock}
|
||||
chmod 600 "$ADMIN_SSH/authorized_keys"
|
||||
chown -R ${adminUser}:${adminUser} "$ADMIN_SSH"
|
||||
|
||||
# Also authorize root
|
||||
mkdir -p /root/.ssh
|
||||
chmod 700 /root/.ssh
|
||||
${rootSshKeyBlock}
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
|
||||
# ── Harden SSH (takes effect on next sshd restart/reboot) ────────
|
||||
sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
|
||||
sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
|
||||
|
||||
# ── Write provisioning metadata ──────────────────────────────────
|
||||
cat > /etc/lab-provisioned << LABMETA
|
||||
hostname=${hostname}
|
||||
role=${role}
|
||||
mac=${mac}
|
||||
provisioned_at=$(date -Iseconds)
|
||||
method=asahi-firstboot
|
||||
LABMETA
|
||||
|
||||
# ── Register with bastion ─────────────────────────────────────────
|
||||
IP=$(hostname -I | awk '{print $1}')
|
||||
echo "Registering with bastion at ${serverIp}:${httpPort}..."
|
||||
curl -sf -X POST "http://${serverIp}:${httpPort}/api/register" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d "{\\"mac\\":\\"${mac}\\",\\"hostname\\":\\"${hostname}\\",\\"role\\":\\"${role}\\",\\"ip\\":\\"$IP\\"}" \\
|
||||
2>/dev/null && echo " Registered as ${hostname} ($IP)" \\
|
||||
|| echo " WARNING: Could not reach bastion — register manually with: labctl provision register ${mac} ${hostname} --role ${role} --ip $IP"
|
||||
|
||||
# ── Mark done ────────────────────────────────────────────────────
|
||||
touch "$MARKER"
|
||||
echo "=== First-boot setup complete ==="
|
||||
`;
|
||||
}
|
||||
|
||||
/** Systemd unit file for the first-boot service */
|
||||
export function renderFirstbootUnit(): string {
|
||||
return `[Unit]
|
||||
Description=Lab first-boot LVM setup
|
||||
After=local-fs.target network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=!/etc/lab-lvm-setup-done
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/lab-firstboot.sh
|
||||
RemainAfterExit=yes
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`;
|
||||
}
|
||||
224
bastion/src/bastion/tests/asahi.test.ts
Normal file
224
bastion/src/bastion/tests/asahi.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { BastionConfig } from "@lab/shared";
|
||||
import { createApp } from "../src/server.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { renderFirstbootScript, renderFirstbootUnit } from "../src/templates/asahi-firstboot.sh.js";
|
||||
|
||||
function createTestConfig(testDir: string): BastionConfig {
|
||||
return {
|
||||
fedoraVersion: "43",
|
||||
arch: "x86_64",
|
||||
httpPort: 0,
|
||||
timezone: "Europe/London",
|
||||
locale: "en_GB.UTF-8",
|
||||
bastionDir: testDir,
|
||||
domain: "test.local",
|
||||
dhcpMode: "proxy",
|
||||
dhcpRangeStart: "",
|
||||
dhcpRangeEnd: "",
|
||||
ubuntuVersion: "26.04",
|
||||
ubuntuMirror: "https://releases.ubuntu.com/26.04",
|
||||
iface: "eth0",
|
||||
serverIp: "192.168.8.1",
|
||||
network: "192.168.8.0",
|
||||
gateway: "192.168.8.1",
|
||||
sshKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST test@lab"],
|
||||
adminUser: "michal",
|
||||
syslogPort: 15514,
|
||||
skipDnsmasq: true,
|
||||
skipArtifacts: true,
|
||||
fedoraMirror: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os",
|
||||
tftpDir: join(testDir, "tftp"),
|
||||
httpDir: join(testDir, "http"),
|
||||
stateFile: join(testDir, "state.json"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("asahi routes", () => {
|
||||
let testDir: string;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `bastion-asahi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
mkdirSync(join(testDir, "http"), { recursive: true });
|
||||
mkdirSync(join(testDir, "tftp"), { recursive: true });
|
||||
|
||||
const config = createTestConfig(testDir);
|
||||
const result = createApp(config);
|
||||
app = result.app;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("GET /asahi returns wrapper shell script", async () => {
|
||||
const resp = await app.inject({ method: "GET", url: "/asahi" });
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.headers["content-type"]).toContain("text/x-shellscript");
|
||||
expect(resp.body).toContain("#!/bin/bash");
|
||||
expect(resp.body).toContain("installer_data.json");
|
||||
expect(resp.body).toContain("192.168.8.1");
|
||||
expect(resp.body).toContain("install.sh");
|
||||
});
|
||||
|
||||
it("GET /asahi/installer_data.json returns valid config", async () => {
|
||||
const resp = await app.inject({ method: "GET", url: "/asahi/installer_data.json" });
|
||||
expect(resp.statusCode).toBe(200);
|
||||
const data = JSON.parse(resp.body);
|
||||
|
||||
expect(data.os_list).toHaveLength(1);
|
||||
const os = data.os_list[0];
|
||||
expect(os.name).toContain("Fedora Asahi Lab");
|
||||
|
||||
// 3 partitions (fallback) or 4 (built: EFI + Boot + Root + Data)
|
||||
expect(os.partitions.length).toBeGreaterThanOrEqual(3);
|
||||
expect(os.partitions[0].type).toBe("EFI");
|
||||
// Last partition should be the expanding Data partition
|
||||
const lastPart = os.partitions[os.partitions.length - 1];
|
||||
expect(lastPart.type).toBe("Linux");
|
||||
expect(lastPart.expand).toBe(true);
|
||||
// Root partition (second-to-last) should NOT expand
|
||||
const rootPart = os.partitions[os.partitions.length - 2];
|
||||
expect(rootPart.expand).toBe(false);
|
||||
expect(rootPart.image).toBe("root.img");
|
||||
});
|
||||
|
||||
it("GET /asahi/firstboot.sh returns parameterized script", async () => {
|
||||
const resp = await app.inject({
|
||||
method: "GET",
|
||||
url: "/asahi/firstboot.sh?hostname=mac-studio&role=infra&mac=00:11:22:33:44:55",
|
||||
});
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body).toContain("#!/bin/bash");
|
||||
expect(resp.body).toContain("mac-studio");
|
||||
expect(resp.body).toContain("labvg");
|
||||
expect(resp.body).toContain("rancher"); // infra gets rancher LV
|
||||
expect(resp.body).toContain("longhorn"); // infra also gets longhorn
|
||||
expect(resp.body).toContain("ssh-ed25519"); // SSH key injected
|
||||
});
|
||||
|
||||
it("GET /asahi/firstboot.service returns systemd unit", async () => {
|
||||
const resp = await app.inject({ method: "GET", url: "/asahi/firstboot.service" });
|
||||
expect(resp.statusCode).toBe(200);
|
||||
expect(resp.body).toContain("[Unit]");
|
||||
expect(resp.body).toContain("lab-firstboot.sh");
|
||||
expect(resp.body).toContain("ConditionPathExists=!/etc/lab-lvm-setup-done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFirstbootScript", () => {
|
||||
const baseParams = {
|
||||
hostname: "test-node",
|
||||
serverIp: "10.0.0.1",
|
||||
httpPort: 8080,
|
||||
sshKeys: ["ssh-ed25519 AAAA... user@host"],
|
||||
adminUser: "testadmin",
|
||||
mac: "aa:bb:cc:dd:ee:ff",
|
||||
};
|
||||
|
||||
it("generates valid bash with shebang", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||
expect(script.startsWith("#!/bin/bash")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes LVM creation commands", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||
expect(script).toContain("pvcreate");
|
||||
expect(script).toContain("vgcreate labvg");
|
||||
expect(script).toContain("lvcreate");
|
||||
});
|
||||
|
||||
it("uses correct LV sizes from kickstart layout", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||
expect(script).toContain("27648M"); // swap
|
||||
expect(script).toContain("102400M"); // /var
|
||||
expect(script).toContain("10240M"); // /var/log and /home
|
||||
expect(script).toContain("20480M"); // /srv and /rancher
|
||||
});
|
||||
|
||||
it("includes rancher LV for infra role", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||
expect(script).toContain("rancher");
|
||||
expect(script).toContain("/var/lib/rancher");
|
||||
});
|
||||
|
||||
it("includes longhorn for worker role", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||
expect(script).toContain("longhorn");
|
||||
expect(script).toContain("/var/lib/longhorn");
|
||||
// Worker should NOT have rancher
|
||||
expect(script).not.toContain("rancher");
|
||||
});
|
||||
|
||||
it("includes longhorn for infra role", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||
expect(script).toContain("longhorn");
|
||||
expect(script).toContain("/var/lib/longhorn");
|
||||
});
|
||||
|
||||
it("vanilla role gets no role-specific LVs", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "vanilla" });
|
||||
expect(script).not.toContain("rancher");
|
||||
expect(script).not.toContain("longhorn");
|
||||
});
|
||||
|
||||
it("handles reprovision (existing labvg)", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||
expect(script).toContain("reprovision detected");
|
||||
expect(script).toContain("vgchange -ay labvg");
|
||||
expect(script).toContain("mount_lv var /var");
|
||||
});
|
||||
|
||||
it("injects SSH keys for admin user and root", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||
expect(script).toContain("ssh-ed25519 AAAA...");
|
||||
expect(script).toContain("testadmin");
|
||||
expect(script).toContain("/root/.ssh/authorized_keys");
|
||||
});
|
||||
|
||||
it("sets hostname", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||
expect(script).toContain('hostnamectl set-hostname "test-node"');
|
||||
});
|
||||
|
||||
it("includes bastion self-registration", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||
expect(script).toContain("/api/register");
|
||||
expect(script).toContain("aa:bb:cc:dd:ee:ff");
|
||||
expect(script).toContain("test-node");
|
||||
});
|
||||
|
||||
it("writes provisioning metadata", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||
expect(script).toContain("/etc/lab-provisioned");
|
||||
expect(script).toContain("method=asahi-firstboot");
|
||||
});
|
||||
|
||||
it("creates marker file to prevent re-run", () => {
|
||||
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||
expect(script).toContain("/etc/lab-lvm-setup-done");
|
||||
expect(script).toContain('touch "$MARKER"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFirstbootUnit", () => {
|
||||
it("generates valid systemd unit", () => {
|
||||
const unit = renderFirstbootUnit();
|
||||
expect(unit).toContain("[Unit]");
|
||||
expect(unit).toContain("[Service]");
|
||||
expect(unit).toContain("[Install]");
|
||||
expect(unit).toContain("Type=oneshot");
|
||||
expect(unit).toContain("WantedBy=multi-user.target");
|
||||
});
|
||||
|
||||
it("only runs when marker is missing", () => {
|
||||
const unit = renderFirstbootUnit();
|
||||
expect(unit).toContain("ConditionPathExists=!/etc/lab-lvm-setup-done");
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,7 @@ export function registerAppCommand(program: Command): void {
|
||||
.command("install <target>")
|
||||
.description("Install k3s on a target machine (hostname, IP, or MAC)")
|
||||
.option("--role <role>", "k3s role: infra (server) or worker (agent)", "infra")
|
||||
.option("--user <user>", "SSH user", "michal")
|
||||
.option("--user <user>", "SSH user", "lab")
|
||||
.option("--k3s-server <url>", "k3s server URL (required for worker role)")
|
||||
.option("--k3s-token <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 <user>", "SSH user", "michal")
|
||||
.option("--user <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 <user>", "SSH user", "michal")
|
||||
.option("--user <user>", "SSH user", "lab")
|
||||
.action(async (opts: { user: string }) => {
|
||||
let state: BastionState;
|
||||
try {
|
||||
|
||||
69
bastion/src/cli/src/commands/asahi.ts
Normal file
69
bastion/src/cli/src/commands/asahi.ts
Normal file
@@ -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://<bastion-ip>: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@<ip> 'curl -sf ${bastionUrl}/asahi/firstboot.sh?hostname=<name>\\&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 <hostname> --role infra${RESET}`);
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function registerLabcontrollerCommands(appCmd: Command): void {
|
||||
lcCmd
|
||||
.command("deploy <target>")
|
||||
.description("Deploy labcontroller stack to a k3s node")
|
||||
.option("--user <user>", "SSH user", "michal")
|
||||
.option("--user <user>", "SSH user", "lab")
|
||||
.option("--crdb-replicas <n>", "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 <user>", "SSH user", "michal")
|
||||
.option("--user <user>", "SSH user", "lab")
|
||||
.action(async (target: string | undefined, opts: { user: string }) => {
|
||||
const sshKey = findSshKey();
|
||||
const sshOpts = sshKey ? { keyPath: sshKey } : {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
30
bastion/src/modules/modules/k3s/src/operations/iscsi.ts
Normal file
30
bastion/src/modules/modules/k3s/src/operations/iscsi.ts
Normal file
@@ -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<OperationResult> => {
|
||||
// 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` };
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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<OperationResult> => {
|
||||
// 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)" };
|
||||
};
|
||||
355
bastion/tests/integration/asahi-firstboot.test.ts
Normal file
355
bastion/tests/integration/asahi-firstboot.test.ts
Normal file
@@ -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<string, number>();
|
||||
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);
|
||||
});
|
||||
353
bastion/tests/integration/asahi-validate.test.ts
Normal file
353
bastion/tests/integration/asahi-validate.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown>[])[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<string, unknown>[])[0]!;
|
||||
const partitions = os["partitions"] as Record<string, unknown>[];
|
||||
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<string, unknown>[])[0]!;
|
||||
const efi = (os["partitions"] as Record<string, unknown>[])[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<string, unknown>[])[0]!;
|
||||
const boot = (os["partitions"] as Record<string, unknown>[])[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<string, unknown>[])[0]!;
|
||||
const root = (os["partitions"] as Record<string, unknown>[])[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<string, unknown>[])[0]!;
|
||||
const data = (os["partitions"] as Record<string, unknown>[])[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<string, unknown>[])[0]!;
|
||||
const partitions = os["partitions"] as Record<string, unknown>[];
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user