diff --git a/.gitignore b/.gitignore index 9dc1e1c..3e57f35 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ node_modules/ # Task files # tasks.json # tasks/ + +# Asahi build artifacts (large) +bastion/.asahi-cache/ +bastion/asahi-repo/*.zip diff --git a/bastion/asahi-repo/installer_data.json b/bastion/asahi-repo/installer_data.json new file mode 100644 index 0000000..e232600 --- /dev/null +++ b/bastion/asahi-repo/installer_data.json @@ -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 + } + ] + } + ] +} diff --git a/bastion/package.json b/bastion/package.json index 79a41ca..fc1b5a3 100644 --- a/bastion/package.json +++ b/bastion/package.json @@ -24,7 +24,9 @@ "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: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:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'asahi firstboot'", + "test:integration:asahi-validate": "vitest run -c tests/integration/vitest.config.ts -t 'asahi.*validation'", + "test:integration:asahi-validate:host": "sudo -E $(which npx) vitest run -c tests/integration/vitest.config.ts -t 'asahi.*validation'" }, "engines": { "node": ">=20.0.0", diff --git a/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts b/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts index 36a695d..f42b530 100644 --- a/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts +++ b/bastion/src/bastion/src/templates/asahi-firstboot.sh.ts @@ -39,8 +39,13 @@ export function renderFirstbootScript(params: AsahiFirstbootParams): string { 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'); + // 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. @@ -230,14 +235,14 @@ fi ADMIN_SSH="/home/${adminUser}/.ssh" mkdir -p "$ADMIN_SSH" chmod 700 "$ADMIN_SSH" -(${sshKeyLines}) >> "$ADMIN_SSH/authorized_keys" +${sshKeyBlock} 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 +${rootSshKeyBlock} chmod 600 /root/.ssh/authorized_keys # ── Harden SSH (takes effect on next sshd restart/reboot) ──────── diff --git a/bastion/tests/integration/asahi-validate.test.ts b/bastion/tests/integration/asahi-validate.test.ts new file mode 100644 index 0000000..bf23a03 --- /dev/null +++ b/bastion/tests/integration/asahi-validate.test.ts @@ -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; + + 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[])[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[])[0]!; + const partitions = os["partitions"] as Record[]; + 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[])[0]!; + const efi = (os["partitions"] as Record[])[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[])[0]!; + const boot = (os["partitions"] as Record[])[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[])[0]!; + const root = (os["partitions"] as Record[])[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[])[0]!; + const data = (os["partitions"] as Record[])[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[])[0]!; + const partitions = os["partitions"] as Record[]; + 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); +});