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>
354 lines
13 KiB
TypeScript
354 lines
13 KiB
TypeScript
// 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);
|
|
});
|