ka-install: implement --render/--dry-run mode (issue #38)

Add ka-install verb — the third of the three writing verbs (ka-promote →
ka-build → ka-install). Always read-only: computes and prints the install
plan that ka-install <host> would execute, without touching any host.

New files:
  bin/ka-install                        — Python3, 243 lines
  tests/ka-install/run-tests.sh         — 20 hermetic tests, 337 lines
  tests/ka-install/fixtures/make-sandbox.sh — fixture builder, 275 lines

What --render computes:
  1. Read fleet/<host>.yaml for packaging metadata
  2. Read build/<host>/<baseline>/manifest.lock (from ka-promote)
  3. Detect drift: build receipt missing/empty → exit 3
  4. Determine backup destination from manifest template
  5. Detect Image vs vmlinuz naming, select mkinitcpio hook
  6. Print structured YAML plan, exit 0

Exit codes: 0 (plan ready), 2 (missing input), 3 (drift), 4 (error).

Test coverage (20/20 PASS):
  - Argument parsing: requires host, --version, --dry-run alias
  - Error paths: missing fleet manifest, missing manifest.lock,
    unknown host
  - Drift detection: no build receipt -> exit 3
  - Happy path: full build receipt -> exit 0, plan printed
  - Plan fields: source package, host, backup path, hook, version
  - Image/vmlinuz detection for ARM and x86
  - mkinitcpio --hook flag for Image (ARM), plain for vmlinuz
  - /boot/firmware/ path (ampere-style boot layout)
  - Grep-implementation: no scp/ssh/pacman/reboot calls

Existing suites unaffected (ka-promote: 6/6 PASS).
This commit is contained in:
Markus Fritsche
2026-07-25 15:50:49 +00:00
parent 84cdb5b4ee
commit d9dc8bacd4
3 changed files with 855 additions and 0 deletions
Executable
+243
View File
@@ -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 <host>
ka-install <host> --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 <host> 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/<host>/<baseline>/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 <host>' 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())