Files
kernel-agent/bin/ka-promote
T
claude-noether 91fe815c4c bin/ka-promote: implement resolver + cumulative + manifest.lock (Phase 6 of #22)
First of the three [ka:cli-build-out] verbs (umbrella #21). Reads
fleet/<host>.yaml, resolves includes[] (single-file + series-dir),
concatenates in apply order, emits build/<host>/<ref>/{cumulative.patch,
manifest.lock}. Phase-3 ground truth on fresnel parity: b2sum
4d9d93c655ea701b… matches bit-for-bit.

Five tests in tests/ka-promote/ (fresnel parity, series-dir resolver,
bad-include, missing-patch, dup-include) all pass.

Validator (--validate-against <linux-checkout>) hard-fails on: missing
.git, baseline.ref not in checkout, HEAD-tree != baseline.ref tree,
or uncommitted/untracked changes. Verified on boltzmann against the
torvalds v7.0 worktree (all 3 negative paths exit 3 with clear errors).

Side fix: fleet/fresnel.yaml baseline.tree mmind/linux-rockchip → torvalds/linux.
mmind doesn't ship a plain v7.0 tag; baseline was actually torvalds the
whole time. mmind kept as informational patch_authoring_context.

Phase-5 reviewer (sonnet outside-look, #22 comment 1135) followups
addressed: series-dir fixture count 7 (not 6), divergence = hard error,
raw-bytes manifest hash, duplicate-include pre-flight check, explicit
yaml.dump(sort_keys=True).

Language choice (vs ka-status's bash): pure python3 — YAML round-trip,
dict construction, and per-file hashing made bash+heredoc python quoting
hell with no readability gain.

Phase 7 (verify on ampere parity) + Phase 8 (close + README rewrite +
PR) to follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:52:42 +00:00

265 lines
9.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""ka-promote — resolve fleet/<host>.yaml + emit cumulative.patch + manifest.lock.
First of the three writing verbs (ka-promote → ka-build → ka-install).
Read-mostly: only writes to ${KA_BUILD_DIR:-./build}/<host>/<baseline_ref>/.
Usage:
ka-promote <host>
ka-promote <host> --output-dir <path>
ka-promote <host> --validate-against <linux-checkout>
ka-promote --list-hosts
ka-promote --version
Exit codes:
0 success
2 missing input (manifest, patch file, series-dir)
3 --validate-against failed (ref mismatch or apply-check failure)
4 manifest parse / schema error
Language note: pure python3 (not bash like ka-status). The data shape
here — YAML in, YAML out, dict construction, per-file hashing, glob
resolution — fits python naturally; bash + python -c heredocs would be
quoting hell for no readability gain. See issue #22 comment 1132.
"""
import argparse
import glob
import hashlib
import os
import subprocess
import sys
from datetime import datetime, timezone
import yaml
VERSION = 1
SCHEMA_VERSION = 1
COVER_LETTER = "0000-cover-letter.patch"
def die(msg, code=1):
print(f"ka-promote: 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 list_hosts(fleet_dir):
for path in sorted(glob.glob(os.path.join(fleet_dir, "*.yaml"))):
print(os.path.basename(path)[:-5])
def load_manifest(path):
try:
raw = open(path, "rb").read()
except FileNotFoundError:
die(f"manifest not found: {path}", 2)
sha = hashlib.sha256(raw).hexdigest()
try:
m = yaml.safe_load(raw)
except yaml.YAMLError as e:
die(f"manifest parse error: {e}", 4)
if not isinstance(m, dict):
die(f"manifest root must be a mapping: {path}", 4)
for key in ("host", "baseline", "includes"):
if key not in m:
die(f"manifest missing required key '{key}': {path}", 4)
if not isinstance(m["includes"], list) or not m["includes"]:
die(f"manifest.includes must be a non-empty list: {path}", 4)
return m, sha
def resolve_includes(includes, patches_root):
"""Walk manifest.includes, expand series-dirs, dedupe-check, hash."""
seen = set()
resolved = []
order = 0
for entry in includes:
if not isinstance(entry, str):
die(f"includes entry must be a string, got {type(entry).__name__}: {entry!r}", 4)
if entry in seen:
die(f"duplicate include: {entry}", 4)
seen.add(entry)
src_path = os.path.join(patches_root, entry)
if entry.endswith(".patch"):
if not os.path.isfile(src_path):
die(f"missing patch: {src_path}", 2)
order += 1
resolved.append({
"apply_order": order,
"include": entry,
"src": src_path,
"from_series": False,
})
elif entry.endswith("/"):
dir_path = src_path.rstrip("/")
if not os.path.isdir(dir_path):
die(f"missing series-dir: {dir_path}", 2)
files = sorted(glob.glob(os.path.join(dir_path, "*.patch")))
files = [f for f in files if os.path.basename(f) != COVER_LETTER]
if not files:
die(f"series-dir has no applied patches (only cover-letter or empty): {dir_path}", 2)
for f in files:
order += 1
resolved.append({
"apply_order": order,
"include": entry + os.path.basename(f),
"src": f,
"from_series": True,
})
else:
die(f"include must end in '.patch' or '/': {entry}", 4)
for r in resolved:
with open(r["src"], "rb") as f:
data = f.read()
r["sha256"] = hashlib.sha256(data).hexdigest()
r["size"] = len(data)
return resolved
def write_cumulative(resolved, out_path):
with open(out_path, "wb") as out:
for r in resolved:
with open(r["src"], "rb") as src:
out.write(src.read())
with open(out_path, "rb") as f:
b2 = hashlib.blake2b(f.read()).hexdigest()
size = os.path.getsize(out_path)
return size, b2
def write_lock(lock_path, *, host, manifest_rel, manifest_sha, baseline,
resolved, cumulative_size, cumulative_b2sum):
epoch = os.environ.get("SOURCE_DATE_EPOCH")
if epoch:
generated_at = datetime.fromtimestamp(int(epoch), tz=timezone.utc).isoformat()
else:
generated_at = datetime.now(tz=timezone.utc).isoformat()
lock = {
"ka_promote_version": VERSION,
"schema_version": SCHEMA_VERSION,
"generated_at": generated_at,
"host": host,
"manifest": {"path": manifest_rel, "sha256": manifest_sha},
"baseline": baseline,
"resolved_patches": [
{
"apply_order": r["apply_order"],
"include": r["include"],
"sha256": r["sha256"],
"size": r["size"],
"from_series": r["from_series"],
}
for r in resolved
],
"cumulative": {
"path": "cumulative.patch",
"size": cumulative_size,
"b2sum": cumulative_b2sum,
},
}
with open(lock_path, "w") as f:
yaml.dump(lock, f, sort_keys=True, default_flow_style=False)
def validate_against(checkout, baseline_ref, cumulative_path):
# `.git` is a directory in a plain checkout, a file (gitdir pointer)
# in a worktree. `os.path.exists` covers both.
if not os.path.exists(os.path.join(checkout, ".git")):
die(f"--validate-against: not a git checkout: {checkout}", 3)
def git(*args):
return subprocess.run(
["git", *args], cwd=checkout, capture_output=True, text=True
)
r = git("rev-parse", f"{baseline_ref}^{{tree}}")
if r.returncode != 0:
die(f"baseline ref '{baseline_ref}' not found in checkout {checkout}", 3)
baseline_tree = r.stdout.strip()
head_tree = git("rev-parse", "HEAD^{tree}").stdout.strip()
if head_tree != baseline_tree:
die(f"checkout HEAD tree {head_tree} != baseline.ref {baseline_ref} tree {baseline_tree}. "
"Refusing apply-check on diverged tree.", 3)
# Working tree must match HEAD too — `git apply --check` runs against
# the working tree, not HEAD, so a dirty tree gives false negatives.
r = git("status", "--porcelain")
if r.stdout.strip():
die(f"checkout {checkout} has uncommitted changes. "
"`git reset --hard {0} && git clean -fdx` first.".format(baseline_ref), 3)
r = git("apply", "--check", cumulative_path)
if r.returncode != 0:
die(f"git apply --check failed:\n{r.stderr}", 3)
def main():
p = argparse.ArgumentParser(prog="ka-promote", add_help=True)
p.add_argument("host", nargs="?", help="fleet host name (omit with --list-hosts/--version)")
p.add_argument("--output-dir", help="override ${KA_BUILD_DIR:-<repo>/build}")
p.add_argument("--validate-against", metavar="CHECKOUT",
help="run git apply --check against a clean baseline.ref checkout")
p.add_argument("--list-hosts", action="store_true", help="list available fleet/<host>.yaml manifests")
p.add_argument("--version", action="store_true", help="print ka-promote schema version + exit")
args = p.parse_args()
repo_root = find_repo_root()
fleet_dir = os.path.join(repo_root, "fleet")
patches_root = os.path.join(repo_root, "patches")
if args.version:
print(f"ka-promote version {VERSION} (schema {SCHEMA_VERSION})")
return 0
if args.list_hosts:
list_hosts(fleet_dir)
return 0
if not args.host:
p.error("host is required (or use --list-hosts / --version)")
manifest_path = os.path.join(fleet_dir, f"{args.host}.yaml")
manifest, manifest_sha = load_manifest(manifest_path)
if manifest.get("host") != args.host:
die(f"manifest.host {manifest.get('host')!r} does not match filename {args.host!r}", 4)
baseline = manifest["baseline"]
if "ref" not in baseline:
die("manifest.baseline.ref is required", 4)
baseline_ref = baseline["ref"]
resolved = resolve_includes(manifest["includes"], patches_root)
out_root = args.output_dir or os.environ.get("KA_BUILD_DIR") or os.path.join(repo_root, "build")
out_dir = os.path.join(out_root, args.host, baseline_ref)
os.makedirs(out_dir, exist_ok=True)
cumulative_path = os.path.join(out_dir, "cumulative.patch")
size, b2sum = write_cumulative(resolved, cumulative_path)
write_lock(
os.path.join(out_dir, "manifest.lock"),
host=args.host,
manifest_rel=os.path.relpath(manifest_path, repo_root),
manifest_sha=manifest_sha,
baseline=baseline,
resolved=resolved,
cumulative_size=size,
cumulative_b2sum=b2sum,
)
if args.validate_against:
validate_against(args.validate_against, baseline_ref, cumulative_path)
print(f"ka-promote: {args.host} -> {out_dir}")
print(f" cumulative: cumulative.patch ({size} bytes)")
print(f" b2sum: {b2sum}")
print(f" patches: {len(resolved)} resolved ({sum(1 for r in resolved if r['from_series'])} from series-dirs)")
return 0
if __name__ == "__main__":
sys.exit(main())