feat: Asahi Linux provisioning for Apple Silicon (Mac Studio)
Some checks failed
CI/CD / typecheck (pull_request) Failing after 11s
CI/CD / lint (pull_request) Failing after 22s
CI/CD / test (pull_request) Failing after 11s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
Some checks failed
CI/CD / typecheck (pull_request) Failing after 11s
CI/CD / lint (pull_request) Failing after 22s
CI/CD / test (pull_request) Failing after 11s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped
Add bastion endpoints for provisioning Apple Silicon machines via the Asahi Linux installer with custom LVM partitioning: - GET /asahi — wrapper script (curl bastion:8080/asahi | sh) - GET /asahi/installer_data.json — custom partition layout (60GB root + LVM data) - GET /asahi/firstboot.sh — first-boot LVM setup matching kickstart layout - GET /asahi/firstboot.service — systemd oneshot unit The firstboot script creates labvg with role-specific LVs (var, varlog, home, srv, rancher, longhorn) and handles reprovision by detecting existing VGs. Includes 19 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
150
bastion/src/bastion/src/routes/asahi.ts
Normal file
150
bastion/src/bastion/src/routes/asahi.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Routes for Asahi Linux provisioning.
|
||||||
|
// GET /asahi — wrapper script (curl https://bastion:8080/asahi | sh)
|
||||||
|
// GET /asahi/installer_data.json — custom installer config with LVM partition layout
|
||||||
|
// GET /asahi/firstboot.sh — first-boot LVM setup script (for manual use)
|
||||||
|
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { BastionConfig } from "@lab/shared";
|
||||||
|
import { renderFirstbootScript, renderFirstbootUnit } from "../templates/asahi-firstboot.sh.js";
|
||||||
|
import type { Role } from "@lab/shared";
|
||||||
|
|
||||||
|
export function registerAsahiRoutes(app: FastifyInstance, config: BastionConfig): void {
|
||||||
|
// Wrapper script — user runs: curl http://bastion:8080/asahi | sh
|
||||||
|
app.get("/asahi", async (_request, reply) => {
|
||||||
|
const script = `#!/bin/bash
|
||||||
|
# Lab Asahi provisioner — sets up Apple Silicon machines with lab LVM layout.
|
||||||
|
# This wraps the standard Asahi installer with custom installer_data.json
|
||||||
|
# that creates a separate LVM data partition.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASTION="http://${config.serverIp}:${config.httpPort}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " ╔══════════════════════════════════════════════╗"
|
||||||
|
echo " ║ Lab Asahi Provisioner ║"
|
||||||
|
echo " ║ Bastion: \${BASTION} ║"
|
||||||
|
echo " ╚══════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check we're on macOS
|
||||||
|
if [ "$(uname)" != "Darwin" ]; then
|
||||||
|
echo "ERROR: This script must be run from macOS on the target Mac."
|
||||||
|
echo " It uses the Asahi Linux installer to set up Apple Silicon boot."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download the standard Asahi installer
|
||||||
|
echo "Downloading Asahi Linux installer..."
|
||||||
|
WORKDIR=$(mktemp -d)
|
||||||
|
cd "$WORKDIR"
|
||||||
|
|
||||||
|
INSTALLER_BASE="https://cdn.asahilinux.org/installer"
|
||||||
|
PKG_VER=$(curl -s "\${INSTALLER_BASE}/latest")
|
||||||
|
echo " Version: \${PKG_VER}"
|
||||||
|
|
||||||
|
curl -# -L -o "installer-\${PKG_VER}.tar.gz" "\${INSTALLER_BASE}/installer-\${PKG_VER}.tar.gz"
|
||||||
|
|
||||||
|
echo " Extracting..."
|
||||||
|
tar xf "installer-\${PKG_VER}.tar.gz"
|
||||||
|
|
||||||
|
# Point to our custom installer_data.json + rootfs repo
|
||||||
|
export INSTALLER_DATA="\${BASTION}/asahi/installer_data.json"
|
||||||
|
export REPO_BASE="\${BASTION}/asahi/repo"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Using custom installer data from bastion."
|
||||||
|
echo " This will create:"
|
||||||
|
echo " - Standard Asahi boot infrastructure (m1n1 + U-Boot)"
|
||||||
|
echo " - 60GB root partition (Fedora Asahi Remix)"
|
||||||
|
echo " - Remaining space as LVM data partition"
|
||||||
|
echo ""
|
||||||
|
echo " On first boot, LVM volumes will be created automatically:"
|
||||||
|
echo " labvg/var (100GB), labvg/varlog (10GB), labvg/home (10GB),"
|
||||||
|
echo " labvg/srv (20GB), labvg/rancher (20GB), labvg/longhorn (rest)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run the installer
|
||||||
|
if [ "$USER" != "root" ]; then
|
||||||
|
echo "The installer needs root. Enter your sudo password if prompted."
|
||||||
|
exec caffeinate -dis sudo -E ./install.sh "$@"
|
||||||
|
else
|
||||||
|
exec caffeinate -dis ./install.sh "$@"
|
||||||
|
fi
|
||||||
|
`;
|
||||||
|
return reply.type("text/x-shellscript").send(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom installer_data.json — two Linux partitions (root + LVM data)
|
||||||
|
app.get("/asahi/installer_data.json", async (_request, reply) => {
|
||||||
|
// This follows the Asahi installer_data.json schema.
|
||||||
|
// We define a fixed-size root and an expanding data partition for LVM.
|
||||||
|
const data = {
|
||||||
|
os_list: [
|
||||||
|
{
|
||||||
|
name: "Fedora Asahi Lab",
|
||||||
|
default_os_name: "Fedora Linux with Lab LVM",
|
||||||
|
boot_object: "m1n1.bin",
|
||||||
|
next_object: "u-boot-nodtb.bin",
|
||||||
|
package: "fedora-asahi-lab.zip",
|
||||||
|
supported_fw: ["13.5"],
|
||||||
|
partitions: [
|
||||||
|
{
|
||||||
|
name: "EFI",
|
||||||
|
type: "EFI",
|
||||||
|
size: "500MB",
|
||||||
|
format: "fat",
|
||||||
|
copy_firmware: true,
|
||||||
|
copy_installer_data: true,
|
||||||
|
source: "esp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Root",
|
||||||
|
type: "Linux",
|
||||||
|
size: "60GB",
|
||||||
|
image: "root.img",
|
||||||
|
expand: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Data",
|
||||||
|
type: "Linux",
|
||||||
|
size: "1GB",
|
||||||
|
expand: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return reply.type("application/json").send(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// First-boot script — for manual download or embedding in rootfs
|
||||||
|
app.get<{
|
||||||
|
Querystring: { hostname?: string; role?: string; mac?: string; user?: string };
|
||||||
|
}>("/asahi/firstboot.sh", async (request, reply) => {
|
||||||
|
const hostname = request.query.hostname ?? "mac-studio";
|
||||||
|
const role = (request.query.role ?? "infra") as Role;
|
||||||
|
const mac = request.query.mac ?? "unknown";
|
||||||
|
const user = request.query.user ?? "michal";
|
||||||
|
|
||||||
|
// Read SSH keys from bastion config
|
||||||
|
const sshKeys = config.sshKeys ?? [];
|
||||||
|
|
||||||
|
const script = renderFirstbootScript({
|
||||||
|
hostname,
|
||||||
|
role,
|
||||||
|
serverIp: config.serverIp,
|
||||||
|
httpPort: config.httpPort,
|
||||||
|
sshKeys,
|
||||||
|
adminUser: user,
|
||||||
|
mac,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.type("text/x-shellscript").send(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Systemd unit file for first-boot service
|
||||||
|
app.get("/asahi/firstboot.service", async (_request, reply) => {
|
||||||
|
return reply.type("text/plain").send(renderFirstbootUnit());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
260
bastion/src/bastion/src/templates/asahi-firstboot.sh.ts
Normal file
260
bastion/src/bastion/src/templates/asahi-firstboot.sh.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// 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 lines for authorized_keys
|
||||||
|
const sshKeyLines = sshKeys.map(k => `echo '${k}'`).join('\n');
|
||||||
|
|
||||||
|
// NOTE: All bash $ references use $VAR not \${VAR} to avoid TS template conflicts.
|
||||||
|
// Where ${} is needed in bash, we use \\${...} to escape.
|
||||||
|
return `#!/bin/bash
|
||||||
|
# Lab first-boot LVM setup — generated by bastion
|
||||||
|
# This script runs once on first boot via systemd, then disables itself.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MARKER="/etc/lab-lvm-setup-done"
|
||||||
|
LOG="/var/log/lab-firstboot.log"
|
||||||
|
|
||||||
|
exec > >(tee -a "$LOG") 2>&1
|
||||||
|
echo "=== Lab first-boot LVM setup ==="
|
||||||
|
date
|
||||||
|
|
||||||
|
# Already done?
|
||||||
|
if [ -f "$MARKER" ]; then
|
||||||
|
echo "LVM setup already completed, skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Find the data partition ──────────────────────────────────────
|
||||||
|
# The data partition is the large Linux partition that is NOT root.
|
||||||
|
ROOT_DEV=$(findmnt -n -o SOURCE /)
|
||||||
|
echo "Root device: $ROOT_DEV"
|
||||||
|
|
||||||
|
DATA_PART=""
|
||||||
|
for part in /dev/nvme*n*p* /dev/sd*[0-9]; do
|
||||||
|
[ -b "$part" ] || continue
|
||||||
|
# Skip root partition
|
||||||
|
[ "$part" = "$ROOT_DEV" ] && continue
|
||||||
|
# Skip small partitions (<50GB) — EFI, boot, APFS stubs
|
||||||
|
SIZE_BYTES=$(blockdev --getsize64 "$part" 2>/dev/null || echo 0)
|
||||||
|
SIZE_GB=$((SIZE_BYTES / 1073741824))
|
||||||
|
[ "$SIZE_GB" -lt 50 ] && continue
|
||||||
|
# Use if unformatted or already LVM
|
||||||
|
FSTYPE=$(blkid -o value -s TYPE "$part" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$FSTYPE" ] || [ "$FSTYPE" = "LVM2_member" ]; then
|
||||||
|
DATA_PART="$part"
|
||||||
|
echo "Found data partition: $DATA_PART ($SIZE_GB GB)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$DATA_PART" ]; then
|
||||||
|
echo "ERROR: No suitable data partition found for LVM."
|
||||||
|
echo "Expected a large (>50GB) unformatted partition."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Helper function ──────────────────────────────────────────────
|
||||||
|
mount_lv() {
|
||||||
|
local lv="$1" mp="$2"
|
||||||
|
if lvs "labvg/$lv" &>/dev/null; then
|
||||||
|
mkdir -p "$mp"
|
||||||
|
mount "/dev/labvg/$lv" "$mp" 2>/dev/null || true
|
||||||
|
echo " Mounted $lv -> $mp"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Check for existing VG ────────────────────────────────────────
|
||||||
|
if vgs labvg &>/dev/null; then
|
||||||
|
echo "Volume group 'labvg' already exists — reprovision detected."
|
||||||
|
echo "Activating existing volumes..."
|
||||||
|
vgchange -ay labvg
|
||||||
|
|
||||||
|
mount_lv var /var
|
||||||
|
mount_lv varlog /var/log
|
||||||
|
mount_lv home /home
|
||||||
|
mount_lv srv /srv
|
||||||
|
${roleMountLines.map(l => ` ${l}`).join('\n')}
|
||||||
|
|
||||||
|
# Enable swap
|
||||||
|
if lvs labvg/swap &>/dev/null; then
|
||||||
|
swapon /dev/labvg/swap 2>/dev/null || true
|
||||||
|
echo " Enabled swap"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure fstab entries exist
|
||||||
|
grep -q "labvg" /etc/fstab || {
|
||||||
|
echo "# Lab LVM volumes (re-added after reprovision)" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/swap none swap defaults 0 0" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/var /var xfs defaults 0 0" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/varlog /var/log xfs defaults 0 0" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/home /home xfs defaults 0 0" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/srv /srv xfs defaults 0 0" >> /etc/fstab
|
||||||
|
${roleFstabLines.map(l => ` ${l}`).join('\n')}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Existing LVM volumes re-mounted."
|
||||||
|
touch "$MARKER"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Fresh install: create LVM ────────────────────────────────────
|
||||||
|
echo "Creating LVM on $DATA_PART..."
|
||||||
|
|
||||||
|
pvcreate "$DATA_PART"
|
||||||
|
vgcreate labvg "$DATA_PART"
|
||||||
|
|
||||||
|
# Create LVs — sizes match install.ks.ts (in MiB)
|
||||||
|
echo "Creating logical volumes..."
|
||||||
|
lvcreate -L 27648M -n swap labvg -y # 27GB swap
|
||||||
|
lvcreate -L 102400M -n var labvg -y # 100GB /var
|
||||||
|
lvcreate -L 10240M -n varlog labvg -y # 10GB /var/log
|
||||||
|
lvcreate -L 10240M -n home labvg -y # 10GB /home
|
||||||
|
lvcreate -L 20480M -n srv labvg -y # 20GB /srv
|
||||||
|
${roleLvLines.join('\n')}
|
||||||
|
|
||||||
|
# Format
|
||||||
|
echo "Formatting volumes..."
|
||||||
|
mkswap /dev/labvg/swap
|
||||||
|
mkfs.xfs /dev/labvg/var
|
||||||
|
mkfs.xfs /dev/labvg/varlog
|
||||||
|
mkfs.xfs /dev/labvg/home
|
||||||
|
mkfs.xfs /dev/labvg/srv
|
||||||
|
${roleFormatLines.join('\n')}
|
||||||
|
|
||||||
|
# Move existing /var content to LV
|
||||||
|
echo "Migrating /var to LVM..."
|
||||||
|
TMPVAR="/var.old.$$"
|
||||||
|
mv /var "$TMPVAR"
|
||||||
|
mkdir -p /var
|
||||||
|
mount /dev/labvg/var /var
|
||||||
|
cp -a "$TMPVAR"/. /var/ 2>/dev/null || true
|
||||||
|
rm -rf "$TMPVAR"
|
||||||
|
|
||||||
|
# Mount remaining volumes
|
||||||
|
mount_lv varlog /var/log
|
||||||
|
mount_lv home /home
|
||||||
|
mount_lv srv /srv
|
||||||
|
${roleMountLines.join('\n')}
|
||||||
|
|
||||||
|
# Enable swap
|
||||||
|
swapon /dev/labvg/swap
|
||||||
|
|
||||||
|
# Write fstab entries
|
||||||
|
echo "" >> /etc/fstab
|
||||||
|
echo "# Lab LVM volumes" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/swap none swap defaults 0 0" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/var /var xfs defaults 0 0" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/varlog /var/log xfs defaults 0 0" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/home /home xfs defaults 0 0" >> /etc/fstab
|
||||||
|
echo "/dev/labvg/srv /srv xfs defaults 0 0" >> /etc/fstab
|
||||||
|
${roleFstabLines.join('\n')}
|
||||||
|
|
||||||
|
echo "LVM setup complete."
|
||||||
|
lvs labvg
|
||||||
|
|
||||||
|
# ── Set hostname ─────────────────────────────────────────────────
|
||||||
|
hostnamectl set-hostname "${hostname}"
|
||||||
|
|
||||||
|
# ── Configure admin user ─────────────────────────────────────────
|
||||||
|
if ! id "${adminUser}" &>/dev/null; then
|
||||||
|
useradd -m -G wheel "${adminUser}"
|
||||||
|
echo "${adminUser} ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/${adminUser}
|
||||||
|
chmod 440 /etc/sudoers.d/${adminUser}
|
||||||
|
fi
|
||||||
|
ADMIN_SSH="/home/${adminUser}/.ssh"
|
||||||
|
mkdir -p "$ADMIN_SSH"
|
||||||
|
chmod 700 "$ADMIN_SSH"
|
||||||
|
(${sshKeyLines}) >> "$ADMIN_SSH/authorized_keys"
|
||||||
|
chmod 600 "$ADMIN_SSH/authorized_keys"
|
||||||
|
chown -R ${adminUser}:${adminUser} "$ADMIN_SSH"
|
||||||
|
|
||||||
|
# Also authorize root
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
chmod 700 /root/.ssh
|
||||||
|
(${sshKeyLines}) >> /root/.ssh/authorized_keys
|
||||||
|
chmod 600 /root/.ssh/authorized_keys
|
||||||
|
|
||||||
|
# ── Harden SSH ───────────────────────────────────────────────────
|
||||||
|
sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
|
||||||
|
sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
|
||||||
|
systemctl restart sshd 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Write provisioning metadata ──────────────────────────────────
|
||||||
|
cat > /etc/lab-provisioned << LABMETA
|
||||||
|
hostname=${hostname}
|
||||||
|
role=${role}
|
||||||
|
mac=${mac}
|
||||||
|
provisioned_at=$(date -Iseconds)
|
||||||
|
method=asahi-firstboot
|
||||||
|
LABMETA
|
||||||
|
|
||||||
|
# ── Callback to bastion ──────────────────────────────────────────
|
||||||
|
IP=$(hostname -I | awk '{print $1}')
|
||||||
|
curl -sf -X POST "http://${serverIp}:${httpPort}/api/progress" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d "{\\"mac\\":\\"${mac}\\",\\"stage\\":\\"complete\\",\\"detail\\":\\"ready at $IP\\"}" \\
|
||||||
|
2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Mark done ────────────────────────────────────────────────────
|
||||||
|
touch "$MARKER"
|
||||||
|
echo "=== First-boot setup complete ==="
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Systemd unit file for the first-boot service */
|
||||||
|
export function renderFirstbootUnit(): string {
|
||||||
|
return `[Unit]
|
||||||
|
Description=Lab first-boot LVM setup
|
||||||
|
After=local-fs.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
ConditionPathExists=!/etc/lab-lvm-setup-done
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/bin/lab-firstboot.sh
|
||||||
|
RemainAfterExit=yes
|
||||||
|
StandardOutput=journal+console
|
||||||
|
StandardError=journal+console
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
`;
|
||||||
|
}
|
||||||
222
bastion/src/bastion/tests/asahi.test.ts
Normal file
222
bastion/src/bastion/tests/asahi.test.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import type { BastionConfig } from "@lab/shared";
|
||||||
|
import { createApp } from "../src/server.js";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { renderFirstbootScript, renderFirstbootUnit } from "../src/templates/asahi-firstboot.sh.js";
|
||||||
|
|
||||||
|
function createTestConfig(testDir: string): BastionConfig {
|
||||||
|
return {
|
||||||
|
fedoraVersion: "43",
|
||||||
|
arch: "x86_64",
|
||||||
|
httpPort: 0,
|
||||||
|
timezone: "Europe/London",
|
||||||
|
locale: "en_GB.UTF-8",
|
||||||
|
bastionDir: testDir,
|
||||||
|
domain: "test.local",
|
||||||
|
dhcpMode: "proxy",
|
||||||
|
dhcpRangeStart: "",
|
||||||
|
dhcpRangeEnd: "",
|
||||||
|
ubuntuVersion: "26.04",
|
||||||
|
ubuntuMirror: "https://releases.ubuntu.com/26.04",
|
||||||
|
iface: "eth0",
|
||||||
|
serverIp: "192.168.8.1",
|
||||||
|
network: "192.168.8.0",
|
||||||
|
gateway: "192.168.8.1",
|
||||||
|
sshKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITEST test@lab"],
|
||||||
|
adminUser: "michal",
|
||||||
|
syslogPort: 15514,
|
||||||
|
skipDnsmasq: true,
|
||||||
|
skipArtifacts: true,
|
||||||
|
fedoraMirror: "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os",
|
||||||
|
tftpDir: join(testDir, "tftp"),
|
||||||
|
httpDir: join(testDir, "http"),
|
||||||
|
stateFile: join(testDir, "state.json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("asahi routes", () => {
|
||||||
|
let testDir: string;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = join(tmpdir(), `bastion-asahi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
mkdirSync(join(testDir, "http"), { recursive: true });
|
||||||
|
mkdirSync(join(testDir, "tftp"), { recursive: true });
|
||||||
|
|
||||||
|
const config = createTestConfig(testDir);
|
||||||
|
const result = createApp(config);
|
||||||
|
app = result.app;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /asahi returns wrapper shell script", async () => {
|
||||||
|
const resp = await app.inject({ method: "GET", url: "/asahi" });
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.headers["content-type"]).toContain("text/x-shellscript");
|
||||||
|
expect(resp.body).toContain("#!/bin/bash");
|
||||||
|
expect(resp.body).toContain("INSTALLER_DATA");
|
||||||
|
expect(resp.body).toContain("REPO_BASE");
|
||||||
|
expect(resp.body).toContain("192.168.8.1");
|
||||||
|
expect(resp.body).toContain("install.sh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /asahi/installer_data.json returns valid config", async () => {
|
||||||
|
const resp = await app.inject({ method: "GET", url: "/asahi/installer_data.json" });
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
const data = JSON.parse(resp.body);
|
||||||
|
|
||||||
|
expect(data.os_list).toHaveLength(1);
|
||||||
|
const os = data.os_list[0];
|
||||||
|
expect(os.name).toBe("Fedora Asahi Lab");
|
||||||
|
|
||||||
|
// Three partitions: EFI + Root + Data
|
||||||
|
expect(os.partitions).toHaveLength(3);
|
||||||
|
expect(os.partitions[0].type).toBe("EFI");
|
||||||
|
expect(os.partitions[1].type).toBe("Linux");
|
||||||
|
expect(os.partitions[1].size).toBe("60GB");
|
||||||
|
expect(os.partitions[1].expand).toBe(false);
|
||||||
|
expect(os.partitions[2].type).toBe("Linux");
|
||||||
|
expect(os.partitions[2].expand).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /asahi/firstboot.sh returns parameterized script", async () => {
|
||||||
|
const resp = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/asahi/firstboot.sh?hostname=mac-studio&role=infra&mac=00:11:22:33:44:55",
|
||||||
|
});
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body).toContain("#!/bin/bash");
|
||||||
|
expect(resp.body).toContain("mac-studio");
|
||||||
|
expect(resp.body).toContain("labvg");
|
||||||
|
expect(resp.body).toContain("rancher"); // infra gets rancher LV
|
||||||
|
expect(resp.body).toContain("longhorn"); // infra also gets longhorn
|
||||||
|
expect(resp.body).toContain("ssh-ed25519"); // SSH key injected
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /asahi/firstboot.service returns systemd unit", async () => {
|
||||||
|
const resp = await app.inject({ method: "GET", url: "/asahi/firstboot.service" });
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body).toContain("[Unit]");
|
||||||
|
expect(resp.body).toContain("lab-firstboot.sh");
|
||||||
|
expect(resp.body).toContain("ConditionPathExists=!/etc/lab-lvm-setup-done");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderFirstbootScript", () => {
|
||||||
|
const baseParams = {
|
||||||
|
hostname: "test-node",
|
||||||
|
serverIp: "10.0.0.1",
|
||||||
|
httpPort: 8080,
|
||||||
|
sshKeys: ["ssh-ed25519 AAAA... user@host"],
|
||||||
|
adminUser: "testadmin",
|
||||||
|
mac: "aa:bb:cc:dd:ee:ff",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("generates valid bash with shebang", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||||
|
expect(script.startsWith("#!/bin/bash")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes LVM creation commands", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||||
|
expect(script).toContain("pvcreate");
|
||||||
|
expect(script).toContain("vgcreate labvg");
|
||||||
|
expect(script).toContain("lvcreate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct LV sizes from kickstart layout", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||||
|
expect(script).toContain("27648M"); // swap
|
||||||
|
expect(script).toContain("102400M"); // /var
|
||||||
|
expect(script).toContain("10240M"); // /var/log and /home
|
||||||
|
expect(script).toContain("20480M"); // /srv and /rancher
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes rancher LV for infra role", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||||
|
expect(script).toContain("rancher");
|
||||||
|
expect(script).toContain("/var/lib/rancher");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes longhorn for worker role", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||||
|
expect(script).toContain("longhorn");
|
||||||
|
expect(script).toContain("/var/lib/longhorn");
|
||||||
|
// Worker should NOT have rancher
|
||||||
|
expect(script).not.toContain("rancher");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes longhorn for infra role", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||||
|
expect(script).toContain("longhorn");
|
||||||
|
expect(script).toContain("/var/lib/longhorn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vanilla role gets no role-specific LVs", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "vanilla" });
|
||||||
|
expect(script).not.toContain("rancher");
|
||||||
|
expect(script).not.toContain("longhorn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles reprovision (existing labvg)", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||||
|
expect(script).toContain("reprovision detected");
|
||||||
|
expect(script).toContain("vgchange -ay labvg");
|
||||||
|
expect(script).toContain("mount_lv var /var");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects SSH keys for admin user and root", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||||
|
expect(script).toContain("ssh-ed25519 AAAA...");
|
||||||
|
expect(script).toContain("testadmin");
|
||||||
|
expect(script).toContain("/root/.ssh/authorized_keys");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets hostname", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||||
|
expect(script).toContain('hostnamectl set-hostname "test-node"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes bastion callback", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||||
|
expect(script).toContain("/api/progress");
|
||||||
|
expect(script).toContain("aa:bb:cc:dd:ee:ff");
|
||||||
|
expect(script).toContain("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes provisioning metadata", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "infra" });
|
||||||
|
expect(script).toContain("/etc/lab-provisioned");
|
||||||
|
expect(script).toContain("method=asahi-firstboot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates marker file to prevent re-run", () => {
|
||||||
|
const script = renderFirstbootScript({ ...baseParams, role: "worker" });
|
||||||
|
expect(script).toContain("/etc/lab-lvm-setup-done");
|
||||||
|
expect(script).toContain('touch "$MARKER"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderFirstbootUnit", () => {
|
||||||
|
it("generates valid systemd unit", () => {
|
||||||
|
const unit = renderFirstbootUnit();
|
||||||
|
expect(unit).toContain("[Unit]");
|
||||||
|
expect(unit).toContain("[Service]");
|
||||||
|
expect(unit).toContain("[Install]");
|
||||||
|
expect(unit).toContain("Type=oneshot");
|
||||||
|
expect(unit).toContain("WantedBy=multi-user.target");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only runs when marker is missing", () => {
|
||||||
|
const unit = renderFirstbootUnit();
|
||||||
|
expect(unit).toContain("ConditionPathExists=!/etc/lab-lvm-setup-done");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user