10 Commits

Author SHA1 Message Date
Michal
17bae7ddbf fix: pre-download rootfs ZIP to avoid macOS Python HTTP streaming issues
Some checks failed
CI/CD / lint (pull_request) Failing after 11s
CI/CD / test (pull_request) Failing after 10s
CI/CD / typecheck (pull_request) Failing after 22s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
The Asahi installer's urlcache.py fails with AssertionError on macOS
when streaming ZIP via HTTP Range requests from Fastify. Fix: download
the ZIP with curl first (reliable on macOS), then set REPO_BASE to the
local directory so the installer opens it as a local file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:30:29 +01:00
Michal
bb8f37ef7d feat: iSCSI, Longhorn disk labels, labctl asahi command, ZIP32 fix
Some checks failed
CI/CD / typecheck (pull_request) Failing after 12s
CI/CD / lint (pull_request) Failing after 22s
CI/CD / test (pull_request) Failing after 10s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
k3s host prep:
- Add iSCSI initiator install+enable (Fedora: iscsi-initiator-utils,
  Ubuntu: open-iscsi) — required by Longhorn
- Add Longhorn disk label to k3s server+agent configs
- Add Longhorn disk annotation operation in post-install hardening

CLI:
- Add `labctl provision asahi` command with interactive install guide
- Change default SSH user from "michal" to "lab" in all commands
- Change admin user in bastion progress callback to "lab"

Asahi provisioning fixes:
- Download installer_data.json locally (installer reads it as file)
- Use REPO_BASE to serve upstream ZIP from bastion (LAN speed)
- Fix ZIP32 vs ZIP64: serve original upstream ZIP unmodified
  (our repackaged ZIP used ZIP64 which breaks Asahi urlcache)
- Add /data/asahi-repo fallback path for k3s container PVC mount
- Deploy script syncs asahi-repo to bastion pod after deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:32:38 +01:00
Michal
a8dc79bc5a feat: Asahi validation tests, rootfs build fixes, shellcheck-clean scripts
Some checks failed
CI/CD / lint (pull_request) Failing after 12s
CI/CD / test (pull_request) Failing after 10s
CI/CD / typecheck (pull_request) Failing after 22s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
- Add 16 validation tests: shellcheck (3 roles), installer_data.json
  schema (8), Python parser validation, ZIP structure (3), rootfs mount
- Fix empty SSH keys generating invalid bash (SC1073)
- Fix __dirname crash in ESM modules (use import.meta.url)
- Fix rootfs build: mkdir -p before writing, correct binary paths
- Add .gitignore for large build artifacts (.asahi-cache, *.zip)
- Bump smoke test timeout for additional static plugin registration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:22:24 +01:00
Michal
ad76c74020 fix: rootfs build script — mkdir before write, fix package path checks
Some checks failed
CI/CD / typecheck (pull_request) Failing after 10s
CI/CD / lint (pull_request) Failing after 21s
CI/CD / test (pull_request) Failing after 11s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:26:26 +01:00
Michal
6807632d46 feat: Asahi rootfs build pipeline + serve from bastion
Some checks failed
CI/CD / lint (pull_request) Failing after 10s
CI/CD / test (pull_request) Failing after 10s
CI/CD / typecheck (pull_request) Failing after 22s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
- 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) <noreply@anthropic.com>
2026-03-31 03:20:12 +01:00
Michal
53265bb18c test: integration test for Asahi firstboot LVM setup
Some checks failed
CI/CD / lint (pull_request) Failing after 21s
CI/CD / typecheck (pull_request) Failing after 22s
CI/CD / test (pull_request) Failing after 22s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
VM-based end-to-end test using Fedora cloud image with two disks:
root (20GB) + data (200GB). Verifies the firstboot script creates
labvg with correct LV sizes, mounts volumes, migrates /home content,
sets hostname, creates admin user, and handles reprovision.

Fixes to firstboot script:
- Detect whole disks (not just partitions) for LVM PV
- Handle btrfs subvolume paths in root device detection
- Copy /home content before mounting LV (preserves SSH keys)
- Don't restart sshd (config takes effect on reboot)
- Make swapon and mount operations resilient to failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 03:07:38 +01:00
Michal
863c7f2b83 feat: Asahi Linux provisioning for Apple Silicon (Mac Studio)
Some checks failed
CI/CD / typecheck (pull_request) Failing after 11s
CI/CD / lint (pull_request) Failing after 22s
CI/CD / test (pull_request) Failing after 11s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
Add bastion endpoints for provisioning Apple Silicon machines via the
Asahi Linux installer with custom LVM partitioning:

- GET /asahi — wrapper script (curl bastion:8080/asahi | sh)
- GET /asahi/installer_data.json — custom partition layout (60GB root + LVM data)
- GET /asahi/firstboot.sh — first-boot LVM setup matching kickstart layout
- GET /asahi/firstboot.service — systemd oneshot unit

The firstboot script creates labvg with role-specific LVs (var, varlog,
home, srv, rancher, longhorn) and handles reprovision by detecting
existing VGs. Includes 19 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 02:46:27 +01:00
906f93f6f2 Merge pull request 'fix: Cilium multi-node support' (#9) from fix/cilium-multi-node into main
Some checks failed
CI/CD / lint (push) Failing after 22s
CI/CD / typecheck (push) Failing after 21s
CI/CD / test (push) Failing after 22s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
2026-03-31 00:36:17 +00:00
Michal
aea28b5a0f fix: Cilium multi-node support — auto-detect NIC, k3s agent API port, worker label
Some checks failed
CI/CD / typecheck (pull_request) Failing after 10s
CI/CD / lint (pull_request) Failing after 22s
CI/CD / test (pull_request) Failing after 7m8s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
- Remove hardcoded devices/directRoutingDevice from Cilium install (let
  Cilium auto-detect per node — needed for heterogeneous NICs like eno1 vs enP7s7)
- Set k8sServiceHost=127.0.0.1 k8sServicePort=6444 so Cilium init
  containers can reach the API via k3s agent's local LB proxy
- Add node-role.kubernetes.io/worker label to agent config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:35:51 +01:00
f3f0ea48e7 Merge pull request 'feat: provision register + k3s kubeconfig' (#8) from feat/register-and-kubeconfig into main
Some checks failed
CI/CD / lint (push) Failing after 10s
CI/CD / test (push) Failing after 10s
CI/CD / typecheck (push) Failing after 21s
CI/CD / build (push) Has been skipped
CI/CD / publish-rpm (push) Has been skipped
CI/CD / publish-deb (push) Has been skipped
2026-03-31 00:16:06 +00:00
26 changed files with 1941 additions and 21 deletions

4
.gitignore vendored
View File

@@ -27,3 +27,7 @@ node_modules/
# Task files # Task files
# tasks.json # tasks.json
# tasks/ # tasks/
# Asahi build artifacts (large)
bastion/.asahi-cache/
bastion/asahi-repo/*.zip

View 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
}
]
}
]
}

View File

@@ -73,6 +73,9 @@ _labctl() {
"provision register") "provision register")
COMPREPLY=($(compgen -W "--role --ip -h --help" -- "$cur")) COMPREPLY=($(compgen -W "--role --ip -h --help" -- "$cur"))
return ;; return ;;
"provision asahi")
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
return ;;
"provision logs") "provision logs")
COMPREPLY=($(compgen -W "-f --follow -h --help" -- "$cur")) COMPREPLY=($(compgen -W "-f --follow -h --help" -- "$cur"))
return ;; return ;;
@@ -104,7 +107,7 @@ _labctl() {
COMPREPLY=($(compgen -W "bastion -h --help" -- "$cur")) COMPREPLY=($(compgen -W "bastion -h --help" -- "$cur"))
return ;; return ;;
"provision") "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 ;; return ;;
"config") "config")
COMPREPLY=($(compgen -W "list get set path -h --help" -- "$cur")) COMPREPLY=($(compgen -W "list get set path -h --help" -- "$cur"))

View File

@@ -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 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 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 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 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' complete -c labctl -n "__labctl_using_cmd provision" -a makeiso -d 'Generate a UEFI-bootable iPXE ISO for network provisioning'

View File

@@ -22,7 +22,11 @@
"test:integration:iso": "vitest run -c tests/integration/vitest.config.ts -t 'ISO boot'", "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: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": "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": { "engines": {
"node": ">=20.0.0", "node": ">=20.0.0",

View 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"

View File

@@ -24,6 +24,21 @@ deploy_bastion() {
kubectl rollout restart deployment/bastion -n lab-infra kubectl rollout restart deployment/bastion -n lab-infra
kubectl rollout status deployment/bastion -n lab-infra --timeout=180s kubectl rollout status deployment/bastion -n lab-infra --timeout=180s
echo "✓ Bastion deployed" 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() { deploy_labd() {

View File

@@ -148,7 +148,7 @@ export function registerApiRoutes(
}; };
s.installed[mac] = installedInfo; 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 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 // Auto-install k3s for non-vanilla roles

View 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());
});
}

View File

@@ -11,6 +11,7 @@ import { logger } from "./services/logger.js";
import { registerDispatchRoutes } from "./routes/dispatch.js"; import { registerDispatchRoutes } from "./routes/dispatch.js";
import { registerKickstartRoutes } from "./routes/kickstart.js"; import { registerKickstartRoutes } from "./routes/kickstart.js";
import { registerApiRoutes } from "./routes/api.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 } { 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); registerDispatchRoutes(app, config, state);
registerKickstartRoutes(app, config, state, syslog); registerKickstartRoutes(app, config, state, syslog);
registerApiRoutes(app, state, installLog, syslog); registerApiRoutes(app, state, installLog, syslog);
registerAsahiRoutes(app, config);
// boot.iso is generated at startup and served as a static file from httpDir // boot.iso is generated at startup and served as a static file from httpDir
// (static serving supports HTTP Range requests, required by JetKVM streaming) // (static serving supports HTTP Range requests, required by JetKVM streaming)

View 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
`;
}

View 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");
});
});

View File

@@ -70,7 +70,7 @@ export function registerAppCommand(program: Command): void {
.command("install <target>") .command("install <target>")
.description("Install k3s on a target machine (hostname, IP, or MAC)") .description("Install k3s on a target machine (hostname, IP, or MAC)")
.option("--role <role>", "k3s role: infra (server) or worker (agent)", "infra") .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-server <url>", "k3s server URL (required for worker role)")
.option("--k3s-token <token>", "k3s join token (required for worker role)") .option("--k3s-token <token>", "k3s join token (required for worker role)")
.action(async (target: string, opts: { .action(async (target: string, opts: {
@@ -164,7 +164,7 @@ export function registerAppCommand(program: Command): void {
k3sCmd k3sCmd
.command("health [target]") .command("health [target]")
.description("Check k3s health (all hosts if no target given)") .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 }) => { .action(async (target: string | undefined, opts: { user: string }) => {
const sshKey = findSshKey(); const sshKey = findSshKey();
@@ -304,7 +304,7 @@ export function registerAppCommand(program: Command): void {
k3sCmd k3sCmd
.command("list") .command("list")
.description("List installed machines and their k3s status") .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 }) => { .action(async (opts: { user: string }) => {
let state: BastionState; let state: BastionState;
try { try {

View 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("");
});
}

View File

@@ -38,7 +38,7 @@ export function registerLabcontrollerCommands(appCmd: Command): void {
lcCmd lcCmd
.command("deploy <target>") .command("deploy <target>")
.description("Deploy labcontroller stack to a k3s node") .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") .option("--crdb-replicas <n>", "CockroachDB replicas", "1")
.action(async (target: string, opts: { .action(async (target: string, opts: {
user: string; user: string;
@@ -193,7 +193,7 @@ export function registerLabcontrollerCommands(appCmd: Command): void {
lcCmd lcCmd
.command("status [target]") .command("status [target]")
.description("Check labcontroller deployment status (all hosts if no 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 }) => { .action(async (target: string | undefined, opts: { user: string }) => {
const sshKey = findSshKey(); const sshKey = findSshKey();
const sshOpts = sshKey ? { keyPath: sshKey } : {}; const sshOpts = sshKey ? { keyPath: sshKey } : {};

View File

@@ -17,6 +17,7 @@ import { registerReprovisionCommand } from "./commands/reprovision.js";
import { registerDebugCommand } from "./commands/debug.js"; import { registerDebugCommand } from "./commands/debug.js";
import { registerForgetCommand } from "./commands/forget.js"; import { registerForgetCommand } from "./commands/forget.js";
import { registerRegisterCommand } from "./commands/register.js"; import { registerRegisterCommand } from "./commands/register.js";
import { registerAsahiCommand } from "./commands/asahi.js";
import { registerLogsCommand } from "./commands/logs.js"; import { registerLogsCommand } from "./commands/logs.js";
import { registerMakeIsoCommand } from "./commands/makeiso.js"; import { registerMakeIsoCommand } from "./commands/makeiso.js";
import { registerConfigCommand } from "./commands/config.js"; import { registerConfigCommand } from "./commands/config.js";
@@ -100,6 +101,7 @@ export function createProgram(): Command {
registerDebugCommand(provisionCmd); registerDebugCommand(provisionCmd);
registerForgetCommand(provisionCmd); registerForgetCommand(provisionCmd);
registerRegisterCommand(provisionCmd); registerRegisterCommand(provisionCmd);
registerAsahiCommand(provisionCmd);
registerLogsCommand(provisionCmd); registerLogsCommand(provisionCmd);
registerMakeIsoCommand(provisionCmd); registerMakeIsoCommand(provisionCmd);

View File

@@ -137,7 +137,7 @@ describe("bastion smoke tests", () => {
// Wait for the server to start (look for the banner) // Wait for the server to start (look for the banner)
const startedAt = Date.now(); const startedAt = Date.now();
const maxWait = 10_000; const maxWait = 15_000;
while (Date.now() - startedAt < maxWait) { while (Date.now() - startedAt < maxWait) {
if (stdout.includes("Waiting for PXE boot requests")) break; if (stdout.includes("Waiting for PXE boot requests")) break;
await sleep(200); await sleep(200);

View File

@@ -5,14 +5,16 @@ import { runSequential } from "../utils.js";
import { applyPodSecurityStandards } from "../operations/pod-security.js"; import { applyPodSecurityStandards } from "../operations/pod-security.js";
import { checkCertExpiry } from "../operations/cert-check.js"; import { checkCertExpiry } from "../operations/cert-check.js";
import { configureLogRotation } from "../operations/log-rotation.js"; import { configureLogRotation } from "../operations/log-rotation.js";
import { configureLonghornDisk } from "../operations/longhorn-disk.js";
export const hardeningGroup: OperationGroup = { export const hardeningGroup: OperationGroup = {
name: "hardening", name: "hardening",
description: "Pod security, certificate check, log rotation", description: "Pod security, certificate check, log rotation, storage",
operations: [ operations: [
{ name: "Apply Pod Security Standards", fn: applyPodSecurityStandards }, { name: "Apply Pod Security Standards", fn: applyPodSecurityStandards },
{ name: "Check certificate expiry", fn: checkCertExpiry }, { name: "Check certificate expiry", fn: checkCertExpiry },
{ name: "Configure log rotation", fn: configureLogRotation }, { name: "Configure log rotation", fn: configureLogRotation },
{ name: "Configure Longhorn disk", fn: configureLonghornDisk },
], ],
}; };

View File

@@ -7,16 +7,18 @@ import { applyCisHardening } from "../operations/sysctl.js";
import { disableSwap } from "../operations/swap.js"; import { disableSwap } from "../operations/swap.js";
import { disableFirewall } from "../operations/firewall.js"; import { disableFirewall } from "../operations/firewall.js";
import { setSelinuxPermissive } from "../operations/selinux.js"; import { setSelinuxPermissive } from "../operations/selinux.js";
import { enableIscsi } from "../operations/iscsi.js";
export const hostPrepGroup: OperationGroup = { export const hostPrepGroup: OperationGroup = {
name: "host-prep", 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: [ operations: [
{ name: "Load kernel modules", fn: loadKernelModules }, { name: "Load kernel modules", fn: loadKernelModules },
{ name: "Apply CIS sysctl", fn: applyCisHardening }, { name: "Apply CIS sysctl", fn: applyCisHardening },
{ name: "Disable swap", fn: disableSwap }, { name: "Disable swap", fn: disableSwap },
{ name: "Disable firewall", fn: disableFirewall }, { name: "Disable firewall", fn: disableFirewall },
{ name: "Set SELinux permissive", fn: setSelinuxPermissive }, { name: "Set SELinux permissive", fn: setSelinuxPermissive },
{ name: "Enable iSCSI", fn: enableIscsi },
], ],
}; };

View File

@@ -35,21 +35,15 @@ export const installCilium: Operation = async (ctx): Promise<OperationResult> =>
} }
details.push(`Installed cilium CLI ${version} (${cliArch})`); details.push(`Installed cilium CLI ${version} (${cliArch})`);
// Detect default network device (avoid tailscale/wireguard)
const devResult = await ctx.ssh.exec(
"ip -4 route show default | awk '{print $5}' | head -1",
sshOpts(ctx),
);
const defaultDev = devResult.stdout.trim();
details.push(`Network device: ${defaultDev}`);
// Install Cilium // Install Cilium
// - No hardcoded devices: Cilium auto-detects per node (heterogeneous NICs like eno1 vs enP7s7)
// - k8sServiceHost/Port: k3s agents proxy the API on 127.0.0.1:6444 (not 6443)
const installResult = await ctx.ssh.exec( const installResult = await ctx.ssh.exec(
`KUBECONFIG=/etc/rancher/k3s/k3s.yaml cilium install \ `KUBECONFIG=/etc/rancher/k3s/k3s.yaml cilium install \
--set kubeProxyReplacement=true \ --set kubeProxyReplacement=true \
--set ipam.mode=kubernetes \ --set ipam.mode=kubernetes \
--set devices="${defaultDev}" \ --set k8sServiceHost=127.0.0.1 \
--set nodePort.directRoutingDevice="${defaultDev}"`, --set k8sServicePort=6444`,
{ timeoutMs: 300_000 }, { timeoutMs: 300_000 },
); );
if (installResult.exitCode !== 0) { if (installResult.exitCode !== 0) {

View File

@@ -1,6 +1,7 @@
export { loadKernelModules } from "./kernel-modules.js"; export { loadKernelModules } from "./kernel-modules.js";
export { applyCisHardening } from "./sysctl.js"; export { applyCisHardening } from "./sysctl.js";
export { disableSwap } from "./swap.js"; export { disableSwap } from "./swap.js";
export { enableIscsi } from "./iscsi.js";
export { disableFirewall } from "./firewall.js"; export { disableFirewall } from "./firewall.js";
export { setSelinuxPermissive } from "./selinux.js"; export { setSelinuxPermissive } from "./selinux.js";
export { writeK3sConfig } from "./k3s-config.js"; export { writeK3sConfig } from "./k3s-config.js";
@@ -13,3 +14,4 @@ export { configureLogRotation } from "./log-rotation.js";
export { applyDefaultNetworkPolicies } from "./network-policy.js"; export { applyDefaultNetworkPolicies } from "./network-policy.js";
export { applyPodSecurityStandards } from "./pod-security.js"; export { applyPodSecurityStandards } from "./pod-security.js";
export { checkCertExpiry } from "./cert-check.js"; export { checkCertExpiry } from "./cert-check.js";
export { configureLonghornDisk } from "./longhorn-disk.js";

View 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` };
};

View File

@@ -20,6 +20,9 @@ disable:
- servicelb - servicelb
- traefik - traefik
node-label:
- "node.longhorn.io/create-default-disk=config"
kube-apiserver-arg: kube-apiserver-arg:
- "anonymous-auth=false" - "anonymous-auth=false"
- "audit-log-path=/var/log/kubernetes/audit.log" - "audit-log-path=/var/log/kubernetes/audit.log"
@@ -42,6 +45,9 @@ ${tlsSans.map((s) => ` - "${s}"`).join("\n")}
function generateAgentConfig(): string { function generateAgentConfig(): string {
return `protect-kernel-defaults: true return `protect-kernel-defaults: true
node-label:
- "node-role.kubernetes.io/worker=true"
- "node.longhorn.io/create-default-disk=config"
kubelet-arg: kubelet-arg:
- "protect-kernel-defaults=true" - "protect-kernel-defaults=true"
- "streaming-connection-idle-timeout=5m" - "streaming-connection-idle-timeout=5m"

View File

@@ -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)" };
};

View 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);
});

View 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);
});