From 863c7f2b832a50da849f406eaf39f2797f856b6f Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 31 Mar 2026 02:46:27 +0100 Subject: [PATCH] feat: Asahi Linux provisioning for Apple Silicon (Mac Studio) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bastion/src/bastion/src/routes/asahi.ts | 150 ++++++++++ bastion/src/bastion/src/server.ts | 2 + .../src/templates/asahi-firstboot.sh.ts | 260 ++++++++++++++++++ bastion/src/bastion/tests/asahi.test.ts | 222 +++++++++++++++ 4 files changed, 634 insertions(+) create mode 100644 bastion/src/bastion/src/routes/asahi.ts create mode 100644 bastion/src/bastion/src/templates/asahi-firstboot.sh.ts create mode 100644 bastion/src/bastion/tests/asahi.test.ts diff --git a/bastion/src/bastion/src/routes/asahi.ts b/bastion/src/bastion/src/routes/asahi.ts new file mode 100644 index 0000000..fb88312 --- /dev/null +++ b/bastion/src/bastion/src/routes/asahi.ts @@ -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()); + }); +} diff --git a/bastion/src/bastion/src/server.ts b/bastion/src/bastion/src/server.ts index 9a2979a..d3078d8 100644 --- a/bastion/src/bastion/src/server.ts +++ b/bastion/src/bastion/src/server.ts @@ -11,6 +11,7 @@ import { logger } from "./services/logger.js"; import { registerDispatchRoutes } from "./routes/dispatch.js"; import { registerKickstartRoutes } from "./routes/kickstart.js"; import { registerApiRoutes } from "./routes/api.js"; +import { registerAsahiRoutes } from "./routes/asahi.js"; export function createApp(config: BastionConfig): { app: ReturnType; state: StateManager; installLog: InstallLogBuffer; syslog: SyslogListener } { @@ -45,6 +46,7 @@ export function createApp(config: BastionConfig): { app: ReturnType> /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 +`; +} diff --git a/bastion/src/bastion/tests/asahi.test.ts b/bastion/src/bastion/tests/asahi.test.ts new file mode 100644 index 0000000..d60ebf1 --- /dev/null +++ b/bastion/src/bastion/tests/asahi.test.ts @@ -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"); + }); +});