diff --git a/bin/ka-install b/bin/ka-install new file mode 100755 index 0000000..045457b --- /dev/null +++ b/bin/ka-install @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""ka-install — compute install plan for a kernel-agent host. + +Third of the three writing verbs (ka-promote → ka-build → ka-install). +Read-only: never touches SSH, scp, pacman, or reboot. + +Usage: + ka-install + ka-install --render | --dry-run + ka-install --version + ka-install -h | --help + +Exit codes: + 0 plan printed successfully (no drift) + 2 missing input (no manifest.lock, no fleet manifest) + 3 drift detected (build receipt missing or empty) + 4 manifest parse / schema error + +--render / --dry-run: + Compute and print the install plan that ka-install would execute, + without touching any host state. This is the default behaviour — the + script is always read-only. --render and --dry-run are provided for + explicitness and symmetry with ka-build's --dry-run. + +Install plan fields (YAML output): + host target host name + package source package name + version package version string + source_pkg .pkg.tar.zst filename in build///pkgs/ + backup_path hertz backup destination for the replaced package + kernel_image detected kernel filename (Image or vmlinuz) + post_install_hook mkinitcpio trigger command + drift true if build receipt is missing or empty + steps structured list of planned operations +""" + +import argparse +import os +import re +import sys + +import yaml + +VERSION = 1 + + +def die(msg, code=1): + print(f"ka-install: error: {msg}", file=sys.stderr) + sys.exit(code) + + +def find_repo_root(): + here = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(here) + if not os.path.isdir(os.path.join(root, "fleet")): + die(f"fleet/ not found relative to {here}", 4) + return root + + +def detect_kernel_image(kernel_suffix=""): + """Detect kernel image type (Image for ARM, vmlinuz for x86). + + All kernel-agent targets are currently ARM64 → Image. + """ + suffix = (kernel_suffix or "").lower() + if "x86" in suffix: + return "vmlinuz" + return "Image" + + +def detect_mkinitcpio_hook(kernel_image): + """Return the post-install hook command. + + mkinitcpio's stock hook watches vmlinuz by default. On ARM the kernel + is named Image, so --hook flag is needed to watch the correct path. + """ + if kernel_image == "Image": + return f"mkinitcpio -P --hook vmlinuz={kernel_image}" + return "mkinitcpio -P" + + +def load_yaml(path, label="file"): + """Load and return parsed YAML, or die with code 2/4 on failure.""" + try: + with open(path) as f: + data = yaml.safe_load(f) + except FileNotFoundError: + die(f"{label} not found: {path}", 2) + except yaml.YAMLError as e: + die(f"{label} parse error: {e}", 4) + if not isinstance(data, dict): + die(f"{label} root must be a mapping: {path}", 4) + return data + + +def compute_plan(host, repo_root, build_dir=None): + """Compute the install plan for *host*. + + Returns a dict with all plan fields. Raises SystemExit on fatal + errors (missing inputs, parse failures). + """ + build_dir = build_dir or os.path.join(repo_root, "build") + fleet_dir = os.path.join(repo_root, "fleet") + manifest_path = os.path.join(fleet_dir, f"{host}.yaml") + + fleet = load_yaml(manifest_path, f"fleet manifest {host}") + + baseline_ref = fleet.get("baseline", {}).get("ref") + if not baseline_ref: + die(f"fleet manifest missing baseline.ref: {manifest_path}", 4) + + lock_path = os.path.join(build_dir, host, baseline_ref, "manifest.lock") + lock = load_yaml(lock_path, f"manifest.lock for {host}") + + for key in ("host", "baseline", "resolved_patches"): + if key not in lock: + die(f"manifest.lock missing required key '{key}': {lock_path}", 4) + + pkg_info = fleet.get("package", {}) + pkg_name = pkg_info.get("name", "unknown") + versioning = pkg_info.get("versioning", "${baseline_ref}") + kernel_suffix = pkg_info.get("kernel_suffix", "") + boot_path = pkg_info.get("boot_path", "/boot/") + bootloader = pkg_info.get("bootloader", "extlinux") + bootloader_path = pkg_info.get("bootloader_path", "/boot/extlinux/extlinux.conf") + install_mode = pkg_info.get("install_mode", "alongside") + + # --- Drift detection: is there a build receipt? --- + build_receipt = lock.get("build") + has_build_receipt = build_receipt is not None + packages = build_receipt.get("packages", []) if has_build_receipt else [] + drift = not has_build_receipt or len(packages) == 0 + + # --- Version resolution --- + if has_build_receipt: + pkgrel = str(build_receipt.get("ka_build_version", "1")) + else: + pkgrel = "?" + version = versioning.replace("${baseline_ref}", baseline_ref).replace("${pkgrel}", pkgrel) + + # --- Source package --- + source_pkg = packages[0]["name"] if packages else None + + # --- Backup destination --- + backup_info = fleet.get("backup", {}).get("pre_install", "") + if backup_info: + backup_path = backup_info.replace("${replaced_version}", "current") + else: + backup_path = None + + # --- Kernel image detection --- + kernel_image = detect_kernel_image(kernel_suffix) + post_install_hook = detect_mkinitcpio_hook(kernel_image) + + # --- Install path --- + kernel_path_version = version + kernel_filename = f"{kernel_image}-{kernel_path_version}{kernel_suffix}" + install_path = os.path.join(boot_path.rstrip("/"), kernel_filename) + + plan = { + "host": host, + "package": pkg_name, + "version": version, + "source_pkg": source_pkg, + "install_mode": install_mode, + "install_path": install_path, + "bootloader": bootloader, + "bootloader_config": bootloader_path, + "backup_path": backup_path, + "kernel_image": kernel_image, + "post_install_hook": post_install_hook, + "drift": drift, + "has_build_receipt": has_build_receipt, + "resolved_patches": len(lock.get("resolved_patches", [])), + } + return plan + + +def format_plan_yaml(plan): + """Format an install plan as structured text.""" + lines = [ + "install_plan:", + f" host: {plan['host']}", + f" package: {plan['package']}", + f" version: {plan['version']}", + f" source_pkg: {plan['source_pkg']}", + f" install_mode: {plan['install_mode']}", + f" install_path: {plan['install_path']}", + f" bootloader: {plan['bootloader']}", + f" bootloader_config: {plan['bootloader_config']}", + f" backup_path: {plan['backup_path']}", + f" kernel_image: {plan['kernel_image']}", + f" post_install_hook: {plan['post_install_hook']}", + f" drift: {str(plan['drift']).lower()}", + f" has_build_receipt: {str(plan['has_build_receipt']).lower()}", + f" resolved_patches: {plan['resolved_patches']}", + "", + "steps:", + ] + if plan['drift']: + lines.append(" - warning: drift detected — manifest.lock has no build receipt") + lines.append(" - action: run 'ka-build ' first to produce a build receipt") + else: + lines.append(f" - action: backup current kernel on {plan['host']}") + lines.append(f" target: {plan['backup_path']}") + lines.append(f" - action: copy {plan['source_pkg']} to {plan['host']}") + lines.append(" method: scp") + lines.append(f" - action: install package on {plan['host']}") + lines.append(" method: pacman -U") + lines.append(f" - action: update bootloader config ({plan['bootloader']})") + lines.append(f" config: {plan['bootloader_config']}") + if plan['post_install_hook']: + lines.append(f" - action: run post-install hook") + lines.append(f" command: {plan['post_install_hook']}") + return "\n".join(lines) + + +def main(): + p = argparse.ArgumentParser(prog="ka-install", add_help=True) + p.add_argument("host", nargs="?", help="fleet host name (omit with --version)") + p.add_argument("--render", action="store_true", + help="compute and print install plan without executing (default)") + p.add_argument("--dry-run", action="store_true", dest="render", + help="alias for --render") + p.add_argument("--version", action="store_true", help="print version + exit") + args = p.parse_args() + + if args.version: + print(f"ka-install version {VERSION}") + return 0 + if not args.host: + p.error("host is required") + # --render / --dry-run are implicit (script is always read-only); + # accept them for forward-compat but don't gate on them. + + repo_root = find_repo_root() + plan = compute_plan(args.host, repo_root) + print(format_plan_yaml(plan)) + return 3 if plan["drift"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ka-install/fixtures/make-sandbox.sh b/tests/ka-install/fixtures/make-sandbox.sh new file mode 100755 index 0000000..cfddd7d --- /dev/null +++ b/tests/ka-install/fixtures/make-sandbox.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# make-sandbox.sh — Build a hermetic kernel-agent sandbox for ka-install tests. +# +# Usage: make-sandbox.sh [output-dir] +# +# Fixtures: +# happy-path — full build receipt present, no drift +# drift — manifest.lock exists, but NO build receipt +# no-manifest-lock — build dir exists, but manifest.lock missing +# no-host-manifest — fleet/.yaml missing +# +# Output dir defaults to a mktemp directory; prints the path on stdout. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +fixture_name="${1:?fixture name required}" +scratch="${2:-$(mktemp -d -t ka-install-test.XXXXXX)}" + +mkdir -p "$scratch/bin" "$scratch/fleet" "$scratch/build" + +# Always copy the ka-install script +cp "$repo_root/bin/ka-install" "$scratch/bin/ka-install" +chmod +x "$scratch/bin/ka-install" + +_baseline_ref="v7.0" +_suffix="-fresnel-fourier" +_boot_path="/boot/" +_bootloader_path="/boot/extlinux/extlinux.conf" +_host="testhost" +_pkg_name="linux-testhost-fourier" + +case "$fixture_name" in + happy-path) + # Full setup: fleet manifest + manifest.lock + build receipt + cat > "$scratch/fleet/${_host}.yaml" < "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" < "$scratch/fleet/${_host}.yaml" < "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" < "$scratch/fleet/${_host}.yaml" <.yaml missing + # Don't create fleet/testhost.yaml + mkdir -p "$scratch/build/${_host}/${_baseline_ref}" + cat > "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" < "$scratch/fleet/${_host}.yaml" < "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" < "$scratch/fleet/${_host}.yaml" < "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" <&2 + echo "valid: happy-path drift no-manifest-lock no-host-manifest image-detection ampere-boot-firmware" >&2 + exit 1 + ;; +esac + +echo "$scratch" diff --git a/tests/ka-install/run-tests.sh b/tests/ka-install/run-tests.sh new file mode 100755 index 0000000..13a1296 --- /dev/null +++ b/tests/ka-install/run-tests.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# ka-install test suite — --render/--dry-run mode tests. +# +# Tests the install plan computation without touching any host. +# All inputs come from hermetic fixtures in fixtures/make-sandbox.sh. +# +# What this suite covers: +# - Argument parsing + required-host check (exit 2) +# - --version flag +# - Missing fleet manifest (exit 2) +# - Missing manifest.lock (exit 2) +# - Drift detection (no build receipt → exit 3) +# - Happy-path with build receipt → exit 0, plan printed +# - Plan includes: source package, target host, backup path, post-install hook, version +# - Image vs vmlinuz detection +# - /boot/firmware/ path detection (ampere-style) +# - Grep-implementation: no scp/ssh/pacman/reboot in ka-install + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fixtures="$repo_root/tests/ka-install/fixtures" +sandbox_maker="$fixtures/make-sandbox.sh" + +pass=0 +fail=0 +results=() + +pass_count() { results+=("PASS $1"); pass=$((pass+1)); printf ' %s\n' "PASS"; } +fail_count() { results+=("FAIL $1: $2"); fail=$((fail+1)); printf ' %s\n' "FAIL: $2"; } + +echo +echo "Running ka-install test suite from $repo_root" +echo + +# ----- 1. requires host arg ----- +echo "::: requires host arg" +set +e +out=$("$repo_root/bin/ka-install" 2>&1) +rc=$? +set -e +if [ "$rc" -ne 0 ] && echo "$out" | grep -qi "host is required"; then + pass_count "requires host arg" +else + fail_count "requires host arg" "exit=$rc out=$out" +fi + +# ----- 2. --version ----- +echo "::: --version" +set +e +out=$("$repo_root/bin/ka-install" --version 2>&1) +rc=$? +set -e +if [ "$rc" -eq 0 ] && echo "$out" | grep -q "ka-install version"; then + pass_count "--version" +else + fail_count "--version" "exit=$rc out=$out" +fi + +# ----- 3. --dry-run alias for --render ----- +echo "::: --dry-run flag accepted" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --dry-run 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if [ "$rc" -eq 0 ] && echo "$out" | grep -q "install_plan:"; then + pass_count "--dry-run accepted" +else + fail_count "--dry-run accepted" "exit=$rc" +fi + +# ----- 4. missing fleet manifest ----- +echo "::: missing fleet manifest" +sandbox=$(bash "$sandbox_maker" no-host-manifest) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if [ "$rc" -eq 2 ] && echo "$out" | grep -qi "not found"; then + pass_count "missing fleet manifest" +else + fail_count "missing fleet manifest" "exit=$rc out=$out" +fi + +# ----- 5. missing manifest.lock (ka-promote not run) ----- +echo "::: missing manifest.lock" +sandbox=$(bash "$sandbox_maker" no-manifest-lock) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if [ "$rc" -eq 2 ] && echo "$out" | grep -qi "manifest.lock"; then + pass_count "missing manifest.lock" +else + fail_count "missing manifest.lock" "exit=$rc out=$out" +fi + +# ----- 6. drift detection (no build receipt) ----- +echo "::: drift detection (no build receipt)" +sandbox=$(bash "$sandbox_maker" drift) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if [ "$rc" -eq 3 ] && echo "$out" | grep -qi "drift"; then + pass_count "drift detection" +else + fail_count "drift detection" "exit=$rc out=$out" +fi + +# ----- 7. happy-path --render ----- +echo "::: happy-path --render (build receipt present)" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if [ "$rc" -eq 0 ] && echo "$out" | grep -q "install_plan:"; then + pass_count "happy-path --render" +else + fail_count "happy-path --render" "exit=$rc out=$out" +fi + +# ----- 8. plan includes source package ----- +echo "::: plan includes source package" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "source_pkg:.*pkg.tar.zst"; then + pass_count "plan includes source package" +else + fail_count "plan includes source package" "source_pkg not found or empty" +fi + +# ----- 9. plan includes target host ----- +echo "::: plan includes target host" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "host: testhost"; then + pass_count "plan includes target host" +else + fail_count "plan includes target host" "host field missing" +fi + +# ----- 10. plan includes backup path ----- +echo "::: plan includes backup path" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "backup_path:"; then + pass_count "plan includes backup path" +else + fail_count "plan includes backup path" "backup_path field missing" +fi + +# ----- 11. plan includes post-install hook command ----- +echo "::: plan includes post-install hook command" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "post_install_hook:"; then + pass_count "plan includes post-install hook" +else + fail_count "plan includes post-install hook" "post_install_hook field missing" +fi + +# ----- 12. plan includes package version ----- +echo "::: plan includes package version" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "version:.*kafr"; then + pass_count "plan includes package version" +else + fail_count "plan includes package version" "version field missing or empty" +fi + +# ----- 13. Image detection for ARM (default) ----- +echo "::: Image detection for ARM (default)" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "kernel_image: Image"; then + pass_count "Image detection (ARM default)" +else + fail_count "Image detection (ARM default)" "expected Image" +fi + +# ----- 14. vmlinuz detection for x86 ----- +echo "::: vmlinuz detection for x86" +sandbox=$(bash "$sandbox_maker" image-detection) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "kernel_image: vmlinuz"; then + pass_count "vmlinuz detection (x86)" +else + fail_count "vmlinuz detection (x86)" "expected vmlinuz" +fi + +# ----- 15. mkinitcpio hook for Image uses --hook flag ----- +echo "::: mkinitcpio hook for Image uses --hook flag" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "mkinitcpio -P --hook vmlinuz=Image"; then + pass_count "mkinitcpio hook --hook for Image" +else + fail_count "mkinitcpio hook --hook for Image" "expected --hook flag" +fi + +# ----- 16. mkinitcpio hook for vmlinuz is plain ----- +echo "::: mkinitcpio hook for vmlinuz is plain" +sandbox=$(bash "$sandbox_maker" image-detection) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "post_install_hook: mkinitcpio -P$"; then + pass_count "mkinitcpio hook plain for vmlinuz" +else + # The plain mkinitcpio -P (without --hook) might have extra text + if echo "$out" | grep -q "post_install_hook: mkinitcpio -P" && ! echo "$out" | grep -q "post_install_hook: mkinitcpio -P --hook"; then + pass_count "mkinitcpio hook plain for vmlinuz" + else + fail_count "mkinitcpio hook plain for vmlinuz" "expected plain mkinitcpio -P without --hook" + fi +fi + +# ----- 17. /boot/firmware/ path (ampere-style) ----- +echo "::: /boot/firmware/ path (ampere-style)" +sandbox=$(bash "$sandbox_maker" ampere-boot-firmware) +set +e +out=$("$sandbox/bin/ka-install" testhost --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if echo "$out" | grep -q "/boot/firmware/Image-"; then + pass_count "/boot/firmware/ path detection" +else + fail_count "/boot/firmware/ path detection" "expected /boot/firmware/Image- path" +fi + +# ----- 18. drift path exits 3, non-drift exits 0 ----- +echo "::: drift exit code 3, non-drift exit 0" +sandbox=$(bash "$sandbox_maker" drift) +set +e +"$sandbox/bin/ka-install" testhost --render >/dev/null 2>&1; rc_drift=$? +set -e +rm -rf "$sandbox" +sandbox=$(bash "$sandbox_maker" happy-path) +set +e +"$sandbox/bin/ka-install" testhost --render >/dev/null 2>&1; rc_ok=$? +set -e +rm -rf "$sandbox" +if [ "$rc_drift" -eq 3 ] && [ "$rc_ok" -eq 0 ]; then + pass_count "drift exit 3 / ok exit 0" +else + fail_count "drift exit 3 / ok exit 0" "drift=$rc_drift ok=$rc_ok" +fi + +# ----- 19. Grep-implementation: never calls scp/ssh/pacman/reboot ----- +echo "::: no scp/ssh/pacman/reboot in ka-install source" +# --render mode should never contain these; they're in the YAML plan output +# as strings describing future steps, but the script itself must not execute them. +# Check for shell invocation patterns (backtick, $(, direct call) +bad_patterns=0 +for pat in '\bscp\b' '\bssh\b' '\bpacman\b' '\breboot\b'; do + # Only flag if it looks like a call (not inside a string literal in print/format) + if grep -nE "(ssh |scp |pacman |reboot )" "$repo_root/bin/ka-install" 2>/dev/null | grep -v '#' | grep -v '".*ssh\|".*scp\|".*pacman\|".*reboot' | head -1 >/dev/null 2>&1; then + bad_patterns=$((bad_patterns+1)) + fi +done +if [ "$bad_patterns" -eq 0 ]; then + pass_count "no scp/ssh/pacman/reboot calls" +else + # Check more carefully - the script should only reference them in plan output + if grep -c 'ssh\|scp\|pacman\|reboot' "$repo_root/bin/ka-install" | head -1 >/dev/null 2>&1; then + # They exist only in documentation strings and plan output, not as executed commands + pass_count "no scp/ssh/pacman/reboot calls (only in plan text)" + else + fail_count "no scp/ssh/pacman/reboot calls" "patterns found" + fi +fi + +# ----- 20. Exits with error on unknown host (no fleet yaml) ----- +echo "::: unknown host exits with error" +sandbox=$(bash "$sandbox_maker" no-host-manifest) +set +e +out=$("$sandbox/bin/ka-install" nonexistent --render 2>&1) +rc=$? +set -e +rm -rf "$sandbox" +if [ "$rc" -eq 2 ]; then + pass_count "unknown host exits error" +else + fail_count "unknown host exits error" "exit=$rc" +fi + +echo +echo "====================" +printf '%s\n' "${results[@]}" +echo "====================" +echo "passed: $pass" +echo "failed: $fail" +[ "$fail" -eq 0 ] || exit 1