Compare commits
10 Commits
feat/regis
...
feat/asahi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17bae7ddbf | ||
|
|
bb8f37ef7d | ||
|
|
a8dc79bc5a | ||
|
|
ad76c74020 | ||
|
|
6807632d46 | ||
|
|
53265bb18c | ||
|
|
863c7f2b83 | ||
| 906f93f6f2 | |||
|
|
aea28b5a0f | ||
| f3f0ea48e7 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
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")
|
"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"))
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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 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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 { 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)
|
||||||
|
|
||||||
|
|||||||
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>")
|
.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 {
|
||||||
|
|||||||
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
|
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 } : {};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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
|
- 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"
|
||||||
|
|||||||
@@ -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