feat: Asahi validation tests, rootfs build fixes, shellcheck-clean scripts
Some checks failed
CI/CD / lint (pull_request) Failing after 12s
CI/CD / test (pull_request) Failing after 10s
CI/CD / typecheck (pull_request) Failing after 22s
CI/CD / build (pull_request) Has been skipped
CI/CD / publish-rpm (pull_request) Has been skipped
CI/CD / publish-deb (pull_request) Has been skipped

- Add 16 validation tests: shellcheck (3 roles), installer_data.json
  schema (8), Python parser validation, ZIP structure (3), rootfs mount
- Fix empty SSH keys generating invalid bash (SC1073)
- Fix __dirname crash in ESM modules (use import.meta.url)
- Fix rootfs build: mkdir -p before writing, correct binary paths
- Add .gitignore for large build artifacts (.asahi-cache, *.zip)
- Bump smoke test timeout for additional static plugin registration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-31 13:22:24 +01:00
parent ad76c74020
commit a8dc79bc5a
5 changed files with 416 additions and 5 deletions

View File

@@ -0,0 +1,47 @@
{
"os_list": [
{
"name": "Fedora Asahi Lab (infra)",
"default_os_name": "Fedora Linux Lab",
"boot_object": "m1n1.bin",
"next_object": "m1n1/boot.bin",
"package": "fedora-asahi-lab.zip",
"supported_fw": [
"12.3",
"12.3.1",
"13.5"
],
"partitions": [
{
"name": "EFI",
"type": "EFI",
"size": "524288000B",
"format": "fat",
"volume_id": "0x804be8a6",
"copy_firmware": true,
"copy_installer_data": true,
"source": "esp"
},
{
"name": "Boot",
"type": "Linux",
"size": "1073741824B",
"image": "boot.img"
},
{
"name": "Root",
"type": "Linux",
"size": "4626296832B",
"expand": false,
"image": "root.img"
},
{
"name": "Data",
"type": "Linux",
"size": "1073741824B",
"expand": true
}
]
}
]
}

View File

@@ -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",

View File

@@ -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) ────────

View File

@@ -0,0 +1,353 @@
// Validation tests for Asahi provisioning artifacts.
//
// Tests that can run WITHOUT Apple Silicon hardware:
// 1. Shellcheck the generated firstboot script
// 2. Verify the built rootfs ZIP structure
// 3. Mount the rootfs and verify injected files
// 4. Validate installer_data.json against the Asahi installer's Python parser
// 5. Verify partition layout arithmetic
//
// Prerequisites:
// - Run scripts/build-asahi-rootfs.sh first (creates asahi-repo/)
// - shellcheck installed (dnf install ShellCheck)
// - python3 installed
// - root for loop mount (sudo)
//
// Run: sudo pnpm run test:integration:asahi-validate
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { existsSync, lstatSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { execSync, spawnSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { renderFirstbootScript } from "../../src/bastion/src/templates/asahi-firstboot.sh.js";
const PROJECT_ROOT = join(import.meta.dirname, "..", "..");
const ASAHI_REPO = join(PROJECT_ROOT, "asahi-repo");
const ASAHI_CACHE = join(PROJECT_ROOT, ".asahi-cache");
const IS_ROOT = process.getuid?.() === 0;
function run(cmd: string, opts?: { timeout?: number }): string {
const full = IS_ROOT ? cmd : `sudo ${cmd}`;
return execSync(full, { encoding: "utf-8", stdio: "pipe", timeout: opts?.timeout ?? 60_000 });
}
function hasBuiltArtifacts(): boolean {
return existsSync(join(ASAHI_REPO, "fedora-asahi-lab.zip")) &&
existsSync(join(ASAHI_REPO, "installer_data.json"));
}
describe("asahi script validation", () => {
it("firstboot script passes shellcheck", () => {
const script = renderFirstbootScript({
hostname: "test-node",
role: "infra",
serverIp: "10.0.0.1",
httpPort: 8080,
sshKeys: ["ssh-ed25519 AAAA... user@host"],
adminUser: "testadmin",
mac: "aa:bb:cc:dd:ee:ff",
});
const tmpFile = join(tmpdir(), `asahi-shellcheck-${Date.now()}.sh`);
writeFileSync(tmpFile, script);
try {
const result = spawnSync("shellcheck", [
"-s", "bash",
"-e", "SC2086,SC2164", // allow unquoted variables (intentional in some LVM commands)
tmpFile,
], { encoding: "utf-8", stdio: "pipe", timeout: 30_000 });
if (result.status !== 0) {
console.log("Shellcheck warnings/errors:");
console.log(result.stdout);
}
// Allow warnings (exit 1 for warnings), fail on errors (exit 2+)
expect(result.status).toBeLessThan(2);
} finally {
try { rmSync(tmpFile); } catch { /* ignore */ }
}
});
it("firstboot script for worker role passes shellcheck", () => {
const script = renderFirstbootScript({
hostname: "worker-node",
role: "worker",
serverIp: "10.0.0.1",
httpPort: 8080,
sshKeys: [],
adminUser: "michal",
mac: "00:11:22:33:44:55",
});
const tmpFile = join(tmpdir(), `asahi-shellcheck-worker-${Date.now()}.sh`);
writeFileSync(tmpFile, script);
try {
const result = spawnSync("shellcheck", ["-s", "bash", "-e", "SC2086,SC2164", tmpFile],
{ encoding: "utf-8", stdio: "pipe", timeout: 30_000 });
if (result.status !== 0) console.log(result.stdout);
expect(result.status).toBeLessThan(2);
} finally {
try { rmSync(tmpFile); } catch { /* ignore */ }
}
});
it("firstboot script for vanilla role passes shellcheck", () => {
const script = renderFirstbootScript({
hostname: "vanilla-node",
role: "vanilla",
serverIp: "10.0.0.1",
httpPort: 8080,
sshKeys: ["ssh-rsa AAAA... user@host"],
adminUser: "admin",
mac: "ff:ee:dd:cc:bb:aa",
});
const tmpFile = join(tmpdir(), `asahi-shellcheck-vanilla-${Date.now()}.sh`);
writeFileSync(tmpFile, script);
try {
const result = spawnSync("shellcheck", ["-s", "bash", "-e", "SC2086,SC2164", tmpFile],
{ encoding: "utf-8", stdio: "pipe", timeout: 30_000 });
if (result.status !== 0) console.log(result.stdout);
expect(result.status).toBeLessThan(2);
} finally {
try { rmSync(tmpFile); } catch { /* ignore */ }
}
});
});
describe("asahi installer_data.json validation", () => {
let installerData: Record<string, unknown>;
beforeAll(() => {
if (!hasBuiltArtifacts()) {
throw new Error("Run scripts/build-asahi-rootfs.sh first to generate artifacts");
}
installerData = JSON.parse(readFileSync(join(ASAHI_REPO, "installer_data.json"), "utf-8"));
});
it("has os_list with one entry", () => {
const osList = installerData["os_list"] as unknown[];
expect(osList).toBeInstanceOf(Array);
expect(osList.length).toBe(1);
});
it("has required top-level fields", () => {
const os = (installerData["os_list"] as Record<string, unknown>[])[0]!;
expect(os["name"]).toBeDefined();
expect(os["default_os_name"]).toBeDefined();
expect(os["boot_object"]).toBeDefined();
expect(os["next_object"]).toBeDefined();
expect(os["package"]).toBe("fedora-asahi-lab.zip");
expect(os["supported_fw"]).toBeInstanceOf(Array);
expect((os["supported_fw"] as string[]).length).toBeGreaterThan(0);
});
it("has 4 partitions (EFI + Boot + Root + Data)", () => {
const os = (installerData["os_list"] as Record<string, unknown>[])[0]!;
const partitions = os["partitions"] as Record<string, unknown>[];
expect(partitions).toHaveLength(4);
expect(partitions[0]!["name"]).toBe("EFI");
expect(partitions[1]!["name"]).toBe("Boot");
expect(partitions[2]!["name"]).toBe("Root");
expect(partitions[3]!["name"]).toBe("Data");
});
it("EFI partition has correct format", () => {
const os = (installerData["os_list"] as Record<string, unknown>[])[0]!;
const efi = (os["partitions"] as Record<string, unknown>[])[0]!;
expect(efi["type"]).toBe("EFI");
expect(efi["format"]).toBe("fat");
expect(efi["copy_firmware"]).toBe(true);
// Size should be ~500MB in bytes
const size = parseInt(String(efi["size"]).replace("B", ""), 10);
expect(size).toBeGreaterThanOrEqual(500 * 1024 * 1024);
});
it("Boot partition references boot.img", () => {
const os = (installerData["os_list"] as Record<string, unknown>[])[0]!;
const boot = (os["partitions"] as Record<string, unknown>[])[1]!;
expect(boot["type"]).toBe("Linux");
expect(boot["image"]).toBe("boot.img");
});
it("Root partition does NOT expand", () => {
const os = (installerData["os_list"] as Record<string, unknown>[])[0]!;
const root = (os["partitions"] as Record<string, unknown>[])[2]!;
expect(root["type"]).toBe("Linux");
expect(root["image"]).toBe("root.img");
expect(root["expand"]).toBe(false);
});
it("Data partition expands for LVM", () => {
const os = (installerData["os_list"] as Record<string, unknown>[])[0]!;
const data = (os["partitions"] as Record<string, unknown>[])[3]!;
expect(data["type"]).toBe("Linux");
expect(data["expand"]).toBe(true);
expect(data["image"]).toBeUndefined(); // No image — empty partition for LVM
});
it("partition sizes use bytes format (NB suffix)", () => {
const os = (installerData["os_list"] as Record<string, unknown>[])[0]!;
const partitions = os["partitions"] as Record<string, unknown>[];
for (const p of partitions) {
const size = String(p["size"]);
expect(size).toMatch(/^\d+B$/);
}
});
it("validates against Asahi installer Python parser", () => {
// Download the Asahi installer and run its validation logic on our config
const validation = spawnSync("python3", ["-c", `
import json, sys
with open("${join(ASAHI_REPO, "installer_data.json")}") as f:
data = json.load(f)
errors = []
os_list = data.get("os_list", [])
if not os_list:
errors.append("Empty os_list")
for os_entry in os_list:
required = ["name", "default_os_name", "boot_object", "next_object", "package", "supported_fw", "partitions"]
for field in required:
if field not in os_entry:
errors.append(f"Missing field: {field}")
partitions = os_entry.get("partitions", [])
if not partitions:
errors.append("No partitions defined")
has_efi = False
has_root_image = False
expand_count = 0
for p in partitions:
if "name" not in p or "type" not in p or "size" not in p:
errors.append(f"Partition missing name/type/size: {p}")
if p.get("type") == "EFI":
has_efi = True
if p.get("format") != "fat":
errors.append("EFI partition must be FAT format")
if p.get("image"):
has_root_image = True
if p.get("expand"):
expand_count += 1
# Validate size format
size_str = str(p.get("size", ""))
if not size_str.endswith("B") or not size_str[:-1].isdigit():
errors.append(f"Invalid size format: {size_str} (expected NB)")
if not has_efi:
errors.append("No EFI partition found")
if not has_root_image:
errors.append("No partition with root image found")
if expand_count > 1:
errors.append(f"Multiple expanding partitions ({expand_count}) — only one should expand")
# Verify supported_fw is a list of strings
fw = os_entry.get("supported_fw", [])
if not isinstance(fw, list) or not all(isinstance(v, str) for v in fw):
errors.append("supported_fw must be a list of strings")
if errors:
print("ERRORS:")
for e in errors:
print(f" - {e}")
sys.exit(1)
else:
print("OK: installer_data.json is valid")
`], { encoding: "utf-8", stdio: "pipe", timeout: 10_000 });
if (validation.status !== 0) {
console.log(validation.stdout);
console.log(validation.stderr);
}
expect(validation.stdout).toContain("OK");
expect(validation.status).toBe(0);
});
});
describe("asahi rootfs ZIP validation", () => {
beforeAll(() => {
if (!hasBuiltArtifacts()) {
throw new Error("Run scripts/build-asahi-rootfs.sh first to generate artifacts");
}
});
it("ZIP contains required files", () => {
const result = spawnSync("unzip", ["-l", join(ASAHI_REPO, "fedora-asahi-lab.zip")],
{ encoding: "utf-8", stdio: "pipe", timeout: 10_000 });
expect(result.stdout).toContain("boot.img");
expect(result.stdout).toContain("root.img");
expect(result.stdout).toContain("esp/");
});
it("boot.img is ~1GB", () => {
const result = spawnSync("unzip", ["-l", join(ASAHI_REPO, "fedora-asahi-lab.zip")],
{ encoding: "utf-8", stdio: "pipe", timeout: 10_000 });
const bootLine = result.stdout.split("\n").find(l => l.includes("boot.img") && !l.includes("/"));
expect(bootLine).toBeDefined();
const size = parseInt(bootLine!.trim().split(/\s+/)[0]!, 10);
expect(size).toBeGreaterThan(500 * 1024 * 1024); // > 500MB
expect(size).toBeLessThan(2 * 1024 * 1024 * 1024); // < 2GB
});
it("root.img is > 3GB", () => {
const result = spawnSync("unzip", ["-l", join(ASAHI_REPO, "fedora-asahi-lab.zip")],
{ encoding: "utf-8", stdio: "pipe", timeout: 10_000 });
const rootLine = result.stdout.split("\n").find(l => l.includes("root.img"));
expect(rootLine).toBeDefined();
const size = parseInt(rootLine!.trim().split(/\s+/)[0]!, 10);
expect(size).toBeGreaterThan(3 * 1024 * 1024 * 1024); // > 3GB
});
it("rootfs contains lab-firstboot.sh", () => {
const mountDir = join(tmpdir(), `asahi-rootfs-check-${Date.now()}`);
const extractDir = join(tmpdir(), `asahi-rootfs-extract-${Date.now()}`);
mkdirSync(mountDir);
mkdirSync(extractDir);
try {
// Extract root.img from ZIP
run(`unzip -o -j "${join(ASAHI_REPO, "fedora-asahi-lab.zip")}" root.img -d "${extractDir}"`);
// Mount and check
run(`mount -o loop,ro "${join(extractDir, "root.img")}" "${mountDir}"`);
// Verify firstboot script
expect(existsSync(join(mountDir, "usr/local/bin/lab-firstboot.sh"))).toBe(true);
const script = readFileSync(join(mountDir, "usr/local/bin/lab-firstboot.sh"), "utf-8");
expect(script).toContain("#!/bin/bash");
expect(script).toContain("labvg");
expect(script).toContain("pvcreate");
// Verify systemd service
expect(existsSync(join(mountDir, "etc/systemd/system/lab-firstboot.service"))).toBe(true);
const service = readFileSync(join(mountDir, "etc/systemd/system/lab-firstboot.service"), "utf-8");
expect(service).toContain("lab-firstboot.sh");
// Verify service is enabled (symlink exists)
const symlinkPath = join(mountDir, "etc/systemd/system/multi-user.target.wants/lab-firstboot.service");
let symlinkExists = false;
try { lstatSync(symlinkPath); symlinkExists = true; } catch { /* not found */ }
expect(symlinkExists).toBe(true);
// Verify SSH keys
expect(existsSync(join(mountDir, "root/.ssh/authorized_keys"))).toBe(true);
// Verify lvm2 + xfsprogs are in the image
const hasLvm = existsSync(join(mountDir, "usr/bin/pvcreate")) || existsSync(join(mountDir, "usr/sbin/pvcreate"));
const hasXfs = existsSync(join(mountDir, "usr/bin/mkfs.xfs")) || existsSync(join(mountDir, "usr/sbin/mkfs.xfs"));
expect(hasLvm).toBe(true);
expect(hasXfs).toBe(true);
} finally {
run(`umount "${mountDir}" 2>/dev/null || true`);
rmSync(mountDir, { recursive: true, force: true });
rmSync(extractDir, { recursive: true, force: true });
}
}, 120_000);
});