2 Commits

Author SHA1 Message Date
Markus Fritsche d9dc8bacd4 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).
2026-07-25 15:50:49 +00:00
marfrit 84cdb5b4ee Merge pull request 'fleet/ohm: pkgrel=6 — per-series converged (closes ka#29, includes besser#22 / #25 fixes)' (#37) from noether/ohm-pkgrel6-perseries-converged into main
Reviewed-on: #37
2026-05-21 10:35:31 +00:00
6 changed files with 875 additions and 3797 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())
+20 -14
View File
@@ -46,22 +46,28 @@ baseline:
# patches/driver/bes2600/cumulative-c5x-danctnix/README.md). This is # patches/driver/bes2600/cumulative-c5x-danctnix/README.md). This is
# the c5x stack as it shipped in pkgrel=3 on 2026-05-18. # the c5x stack as it shipped in pkgrel=3 on 2026-05-18.
includes: includes:
# bes2600 driver pkgrel=6 cumulative: 22 commits squashed, equivalent # bes2600 driver (c5x stack as shipped in pkgrel=3) — single-file
# to marfrit/bes2600-dkms bes2600/join-confirm-failure-reset (top # interim cumulative; per-series reconstruction tracked separately.
# commit 3d833f8) overlaid on v7.0-danctnix1 staging tree. Produces - driver/bes2600/cumulative-c5x-danctnix/
# srcversion 0E16463FA8D85F4704DE93F — bit-identical to the kernel
# running on ohm as of 2026-05-21.
#
# Includes c5.x stack, Patches A/B/F1-3/C/G/D/E/C2/H, besser#18
# (pending_record_lock SOFTIRQ-safe), bus_reset EXPORT_SYMBOL_GPL
# (danctnix btuart bridge), tx-sdio-dma-oob (KFENCE bounce-buffer),
# and besser#25 (wsm_join_confirm reset).
#
# Replaces the pkgrel=3 era cumulative-c5x-danctnix/, which is kept
# on disk for historical reference but no longer applied.
- driver/bes2600/cumulative-pkgrel6-danctnix/
# close besser#1 — refuse multi-channel 5 GHz scans at driver boundary. # close besser#1 — refuse multi-channel 5 GHz scans at driver boundary.
- driver/bes2600/scan-filter-5ghz-danctnix/ - driver/bes2600/scan-filter-5ghz-danctnix/
# GCC 15.2.1 build-fix for arm_neon.h + SHADOW_CALL_STACK interaction.
# Runtime no-op as long as the config has CONFIG_SHADOW_CALL_STACK=n
# (current ohm setting). Kept in the manifest for the day SCS gets
# re-enabled. See reference_arm64_scs_arm_neon_gcc15 memory.
- arch/arm64/scs-arm-neon-build-fix/
# close besser#18 — pending_record_lock SOFTIRQ-safe -> -unsafe inversion.
# Mirror of marfrit/bes2600-dkms#11 (d95453c). 5-site spin_lock -> _bh.
- driver/bes2600/queue-pending-record-lock-bh-danctnix/
# bounce-buffer fix for SDIO TX DMA OOB (KFENCE-detected on pkgrel=4 soak);
# the per-series mirror of marfrit/bes2600-dkms bes2600/tx-sdio-dma-oob.
# cumulative-c5x-danctnix did NOT include this — it was the regression
# surfaced during the per-series reconstruction.
- driver/bes2600/tx-sdio-dma-oob-danctnix/
# close besser#25 — wsm_reset + serialised unjoin on JOIN reject.
# cw1200 ancestor port (drivers/net/wireless/st/cw1200/sta.c:1339-1344)
# with bes2600-specific PASSIVE-gate compensation. pkgrel=6 verified.
- driver/bes2600/join-confirm-reset-danctnix/
# Explicitly NOT included (decision logged): # Explicitly NOT included (decision logged):
# - debian-copyright-fsf-address: Debian packaging metadata, not kernel # - debian-copyright-fsf-address: Debian packaging metadata, not kernel
@@ -1,48 +0,0 @@
# bes2600/cumulative-pkgrel6-danctnix
Single-file cumulative diff representing the bes2600 driver source state
that produces srcversion `0E16463FA8D85F4704DE93F` (pkgrel=6 on ohm,
soak-verified 2026-05-21).
## Equivalent commit chain
Squash of 22 commits from `marfrit/bes2600-dkms` branch
`bes2600/join-confirm-failure-reset` (top commit `3d833f8`):
| # | Commit | Patch |
|---|---|---|
| 1-7 | 4fec8b2..3942404 | c5.x scan-defer / firmware-recovery stack |
| 8-13 | 91640bd..73191b7 | Patch A, B, F1-3, C v3 |
| 14 | a02f8b7 | Patch G (SPDX restore) |
| 15-16| 93f2aab, dd01be0 | Patch D, E |
| 17 | 447240c | Patch C2 |
| 18 | dc13f5d | Patch H (bh.c hygiene) |
| 19 | f469448 | besser#18 pending_record_lock SOFTIRQ-safe |
| 20 | 0792ba4 | bus_reset EXPORT_SYMBOL_GPL (danctnix bridge) |
| 21 | 49d9b77 | bounce SDIO TX buffers (DMA OOB / KFENCE fix) |
| 22 | 3d833f8 | wsm_join_confirm reset (besser#25) |
## Why a cumulative
These 22 commits are the converged per-series; while they exist as
individual scope dirs in `marfrit/bes2600-dkms`, several have
context-overlap rebase conflicts that make per-scope inclusion in
kernel-agent fragile (cf. ka#29 / besser#22 reconstruction debacle).
Shipping the cumulative as one file in kernel-agent guarantees the
applied source state on `v7.0-danctnix1` is bit-identical to the
pkgrel=6 build on ohm, without dragging the besser-repo branch state
into kernel-agent's resolution path.
## Apply order
This patch is the **base** for the bes2600 driver scope. The remaining
non-bes2600 patch (`scan-filter-5ghz-danctnix` for besser#1) layers on
top via the apply order in `fleet/ohm.yaml`.
## Provenance
Generated by `git diff e0d752a..bes2600/join-confirm-failure-reset --
bes2600/` against `marfrit/bes2600-dkms`, then path-rewritten
`bes2600/``drivers/staging/bes2600/`. The baseline `e0d752a`
corresponds to the v7.0-danctnix1 bes2600 staging tree.
+275
View File
@@ -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 <fixture-name> [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/<host>.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" <<YAML
host: ${_host}
arch: arm64
soc: rockchip/rk3399
board: pinebook-pro
distro: archlinux-arm
baseline:
ref: ${_baseline_ref}
package:
name: ${_pkg_name}
versioning: "\${baseline_ref}.kafr\${pkgrel}"
kernel_suffix: ${_suffix}
boot_path: ${_boot_path}
bootloader: extlinux
bootloader_path: ${_bootloader_path}
install_mode: alongside
backup:
pre_install: hertz:/sparfuxdata/kernel-agent-backups/testhost/\${replaced_version}/
YAML
mkdir -p "$scratch/build/${_host}/${_baseline_ref}"
cat > "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" <<YAML
host: ${_host}
baseline:
ref: ${_baseline_ref}
resolved_patches:
- apply_order: 1
include: board/test/0001-test.patch
sha256: abc123
size: 100
cumulative:
path: cumulative.patch
b2sum: def456
size: 100
build:
built_at: '2026-07-25T12:00:00+00:00'
built_on_host: boltzmann
ka_build_version: 3
published: true
packages:
- name: linux-testhost-fourier-7.0.kafr3-aarch64.pkg.tar.zst
size: 12345678
b2sum: b2mock
YAML
;;
drift)
# Fleet manifest + manifest.lock but NO build receipt
cat > "$scratch/fleet/${_host}.yaml" <<YAML
host: ${_host}
arch: arm64
soc: rockchip/rk3399
board: pinebook-pro
distro: archlinux-arm
baseline:
ref: ${_baseline_ref}
package:
name: ${_pkg_name}
versioning: "\${baseline_ref}.kafr\${pkgrel}"
kernel_suffix: ${_suffix}
boot_path: ${_boot_path}
bootloader: extlinux
bootloader_path: ${_bootloader_path}
install_mode: alongside
backup:
pre_install: hertz:/sparfuxdata/kernel-agent-backups/testhost/\${replaced_version}/
YAML
mkdir -p "$scratch/build/${_host}/${_baseline_ref}"
cat > "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" <<YAML
host: ${_host}
baseline:
ref: ${_baseline_ref}
resolved_patches:
- apply_order: 1
include: board/test/0001-test.patch
sha256: abc123
size: 100
cumulative:
path: cumulative.patch
b2sum: def456
size: 100
YAML
;;
no-manifest-lock)
# Fleet manifest present, but build dir has no manifest.lock
cat > "$scratch/fleet/${_host}.yaml" <<YAML
host: ${_host}
arch: arm64
soc: rockchip/rk3399
board: pinebook-pro
distro: archlinux-arm
baseline:
ref: ${_baseline_ref}
package:
name: ${_pkg_name}
versioning: "\${baseline_ref}.kafr\${pkgrel}"
kernel_suffix: ${_suffix}
boot_path: ${_boot_path}
bootloader: extlinux
bootloader_path: ${_bootloader_path}
backup:
pre_install: hertz:/sparfuxdata/kernel-agent-backups/testhost/\${replaced_version}/
YAML
# No build dir at all — ka-promote not run
;;
no-host-manifest)
# build receipt present but fleet/<host>.yaml missing
# Don't create fleet/testhost.yaml
mkdir -p "$scratch/build/${_host}/${_baseline_ref}"
cat > "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" <<YAML
host: ${_host}
baseline:
ref: ${_baseline_ref}
resolved_patches: []
build:
built_at: '2026-07-25T12:00:00+00:00'
built_on_host: boltzmann
ka_build_version: 1
published: true
packages:
- name: linux-testhost-fourier-7.0.kafr1-aarch64.pkg.tar.zst
size: 12345678
b2sum: b2mock
YAML
;;
image-detection)
# Test x86-style kernel_suffix to verify vmlinuz detection
cat > "$scratch/fleet/${_host}.yaml" <<YAML
host: ${_host}
arch: x86_64
baseline:
ref: ${_baseline_ref}
package:
name: linux-testhost-x86
versioning: "\${baseline_ref}.kafr\${pkgrel}"
kernel_suffix: -x86-fourier
boot_path: /boot/
bootloader: extlinux
bootloader_path: /boot/extlinux/extlinux.conf
install_mode: alongside
backup:
pre_install: hertz:/sparfuxdata/kernel-agent-backups/testhost/\${replaced_version}/
YAML
mkdir -p "$scratch/build/${_host}/${_baseline_ref}"
cat > "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" <<YAML
host: ${_host}
baseline:
ref: ${_baseline_ref}
resolved_patches:
- apply_order: 1
include: board/test/0001-test.patch
sha256: abc123
size: 100
cumulative:
path: cumulative.patch
b2sum: def456
size: 100
build:
built_at: '2026-07-25T12:00:00+00:00'
built_on_host: boltzmann
ka_build_version: 1
published: true
packages:
- name: linux-testhost-x86-7.0.kafr1-x86_64.pkg.tar.zst
size: 23456789
b2sum: b2x86
YAML
;;
ampere-boot-firmware)
# Test /boot/firmware/ path (like ampere/genbook)
_baseline_ref="v7.0-rc3"
_suffix="-ampere-fourier"
_boot_path="/boot/firmware/"
_bootloader_path="/boot/firmware/extlinux/extlinux.conf"
_pkg_name="linux-ampere-fourier"
cat > "$scratch/fleet/${_host}.yaml" <<YAML
host: ${_host}
arch: arm64
soc: rockchip/rk3588
board: coolpi-cm5-genbook
distro: archlinux-arm
baseline:
ref: ${_baseline_ref}
package:
name: ${_pkg_name}
versioning: "\${baseline_ref}.kafr\${pkgrel}"
kernel_suffix: ${_suffix}
boot_path: ${_boot_path}
bootloader: extlinux
bootloader_path: ${_bootloader_path}
install_mode: alongside
backup:
pre_install: hertz:/sparfuxdata/kernel-agent-backups/testhost/\${replaced_version}/
YAML
mkdir -p "$scratch/build/${_host}/${_baseline_ref}"
cat > "$scratch/build/${_host}/${_baseline_ref}/manifest.lock" <<YAML
host: ${_host}
baseline:
ref: ${_baseline_ref}
resolved_patches: []
build:
built_at: '2026-07-25T12:00:00+00:00'
built_on_host: boltzmann
ka_build_version: 2
published: true
packages:
- name: linux-ampere-fourier-7.0.rc3.kafr2-aarch64.pkg.tar.zst
size: 34567890
b2sum: b2ampere
YAML
;;
*)
echo "unknown fixture: $fixture_name" >&2
echo "valid: happy-path drift no-manifest-lock no-host-manifest image-detection ampere-boot-firmware" >&2
exit 1
;;
esac
echo "$scratch"
+337
View File
@@ -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