Files
pkh/tests/e2e/run.py
T
vhaudiquet ed6d88008d test: rebuild a stale repo pkh binary before the e2e run
resolve_pkh preferred target/release/pkh however old it was: after a
commit without a release rebuild, the harness measured the previous
code — the 54-package run recorded before this change exercised the
pre-nested-namespace builds and recorded no phase profiles, the
profile feature itself being newer than that binary. A repo binary is
now rebuilt when it is older than HEAD or than the newest source edit;
an explicit --pkh still pins whatever binary it names.
2026-10-03 13:55:13 +02:00

970 lines
38 KiB
Python
Executable File

#!/usr/bin/env python3
"""pkh end-to-end package harness.
Runs real packages from catalog.yml through the full pkh workflow:
pkh pull (from the archive, by default)
pkh chlog --rebuild (no-change rebuild entry; EDITOR is stubbed out)
pkh build (.dsc + _source.changes next to the tree)
pkh deb (binary packages in an ephemeral chroot)
Every stage is logged per package, timed, and checked for its artifacts;
results land in <work>/runs/<id>/results.json and results.md. Pulls run in
parallel (network-bound), the build pipeline runs sequentially (one heavy
build at a time, and pkh's shared chroot/apt/ccache caches stay warm).
Usage:
run.py --list
run.py --dry-run
run.py # smoke + standard tiers
run.py --tier long # the multi-hour builds
run.py --only hello,dracut # exact names
run.py --resume runs/<id>/results.json # continue an interrupted run
See README.md in this directory for the full documentation.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import datetime as dt
import json
import os
import platform
import shlex
import shutil
import signal
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
TIERS = ("smoke", "standard", "long")
DEFAULT_TIERS = ("smoke", "standard")
TIER_TIMEOUT_MIN = {"smoke": 30, "standard": 120, "long": 360}
STAGES = ("pull", "chlog", "build", "deb")
PIPELINE_STAGES = ("chlog", "build", "deb")
TERMINAL_OUTCOMES = ("pass", "xpass", "xfail")
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_DIR = Path(__file__).resolve().parent
class CatalogError(Exception):
"""Invalid catalog content or unusable options."""
@dataclass
class Entry:
"""One catalog entry: a source package and how to build it."""
name: str
category: str = "misc"
tier: str = "standard"
dist: str = "debian"
series: str = ""
arch: str = ""
cross: bool = False
pocket: str = ""
ppa: list[str] = field(default_factory=list)
timeout: float = 0.0 # minutes; 0 = the tier default
xfail: str = ""
notes: str = ""
tags: list[str] = field(default_factory=list)
def timeout_min(self) -> float:
return self.timeout if self.timeout else TIER_TIMEOUT_MIN.get(self.tier, 120)
# ---------------------------------------------------------------------------
# Catalog loading: PyYAML when available, a schema-specific fallback parser
# otherwise (the subset it accepts is documented in catalog.yml).
# ---------------------------------------------------------------------------
def _scalar(text: str):
text = text.strip()
if text.startswith("[") and text.endswith("]"):
inner = text[1:-1].strip()
return [v.strip().strip("'\"") for v in inner.split(",") if v.strip()]
if len(text) >= 2 and text[0] == text[-1] and text[0] in "'\"":
return text[1:-1]
if text in ("true", "false"):
return text == "true"
if text.isdigit():
return int(text)
return text
def _parse_mini_yaml(text: str) -> dict:
"""Parse the flat 'defaults' mapping + flat 'packages' list subset."""
doc: dict = {"defaults": {}, "packages": []}
mode = None # None | "defaults" | "packages"
item: dict | None = None
for lineno, raw in enumerate(text.splitlines(), 1):
if not raw.strip() or raw.lstrip().startswith("#"):
continue
indent = len(raw) - len(raw.lstrip(" "))
stripped = raw.strip()
if indent == 0:
if stripped in ("defaults:", "packages:"):
mode = stripped[:-1]
item = None
continue
raise CatalogError(f"catalog:{lineno}: unexpected top-level '{stripped}'")
if mode is None:
raise CatalogError(f"catalog:{lineno}: content outside a top-level key")
if mode == "defaults":
key, sep, value = stripped.partition(":")
if not sep:
raise CatalogError(f"catalog:{lineno}: expected 'key: value'")
doc["defaults"][key.strip()] = _scalar(value)
else:
if stripped.startswith("- "):
item = {}
doc["packages"].append(item)
stripped = stripped[2:].strip()
if not stripped:
continue
if item is None:
raise CatalogError(f"catalog:{lineno}: list items must start with '- '")
key, sep, value = stripped.partition(":")
if not sep:
raise CatalogError(f"catalog:{lineno}: expected 'key: value'")
item[key.strip()] = _scalar(value)
return doc
def load_catalog(path: Path) -> list[Entry]:
text = path.read_text(encoding="utf-8")
use_pyyaml = not os.environ.get("PKH_E2E_NO_PYYAML")
try:
if use_pyyaml:
import yaml # type: ignore import-not-found
doc = yaml.safe_load(text)
else:
doc = _parse_mini_yaml(text)
except CatalogError:
raise
except Exception as exc:
if use_pyyaml:
raise CatalogError(f"cannot parse {path}: {exc}") from exc
raise
defaults = doc.get("defaults") or {}
raw_entries = doc.get("packages") or []
if not raw_entries:
raise CatalogError(f"{path}: no packages listed")
entries: list[Entry] = []
seen: set[str] = set()
for i, raw in enumerate(raw_entries):
name = str(raw.get("name", "")).strip()
if not name:
raise CatalogError(f"{path}: package #{i + 1} has no name")
if name in seen:
raise CatalogError(f"{path}: duplicate package '{name}'")
seen.add(name)
entry = Entry(
name=name,
category=str(raw.get("category", "misc")),
tier=str(raw.get("tier", defaults.get("tier", "standard"))),
dist=str(raw.get("dist", defaults.get("dist", "debian"))),
series=str(raw.get("series", "")),
arch=str(raw.get("arch", "")),
cross=bool(raw.get("cross", False)),
pocket=str(raw.get("pocket", "")),
ppa=[str(p) for p in (raw.get("ppa") or [])],
timeout=float(raw.get("timeout", 0)),
xfail=str(raw.get("xfail", "")),
notes=str(raw.get("notes", "")),
tags=[str(t) for t in (raw.get("tags") or [])],
)
if entry.tier not in TIERS:
raise CatalogError(f"{path}: {name}: unknown tier '{entry.tier}'")
if entry.dist not in ("debian", "ubuntu"):
raise CatalogError(f"{path}: {name}: unknown dist '{entry.dist}'")
if entry.cross and not entry.arch:
raise CatalogError(f"{path}: {name}: cross: true needs an arch")
entries.append(entry)
return entries
# ---------------------------------------------------------------------------
# Selection and rendering
# ---------------------------------------------------------------------------
def select_entries(
entries: list[Entry], args: argparse.Namespace
) -> tuple[list[Entry], list[str]]:
"""Apply the CLI filters; returns the selection and the filtered-out names."""
tiers = (
list(TIERS) if args.tier == "all" else [t.strip() for t in args.tier.split(",")]
)
for tier in tiers:
if tier not in TIERS:
raise CatalogError(f"unknown tier '{tier}' (expected: {', '.join(TIERS)})")
selected = [e for e in entries if e.tier in tiers]
dropped = [e.name for e in entries if e.tier not in tiers]
if args.category:
wanted = {c.strip() for c in args.category.split(",") if c.strip()}
dropped += [e.name for e in selected if e.category not in wanted]
selected = [e for e in selected if e.category in wanted]
if args.tag:
wanted = {t.strip() for t in args.tag.split(",") if t.strip()}
dropped += [e.name for e in selected if not wanted.intersection(e.tags)]
selected = [e for e in selected if wanted.intersection(e.tags)]
if args.only:
names = {n.strip() for n in args.only.split(",") if n.strip()}
unknown = sorted(names - {e.name for e in entries})
if unknown:
raise CatalogError(f"--only: not in the catalog: {', '.join(unknown)}")
dropped += [e.name for e in selected if e.name not in names]
selected = [e for e in selected if e.name in names]
if args.skip:
names = {n.strip() for n in args.skip.split(",") if n.strip()}
dropped += [e.name for e in selected if e.name in names]
selected = [e for e in selected if e.name not in names]
return selected, dropped
def fmt_duration(seconds: float | None) -> str:
if seconds is None:
return "-"
seconds = int(seconds)
if seconds < 60:
return f"{seconds}s"
if seconds < 3600:
return f"{seconds // 60}m{seconds % 60:02d}s"
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
def render_list(entries: list[Entry]) -> str:
lines = [
f"{'PACKAGE':<20} {'TIER':<9} {'DIST':<7} {'CATEGORY':<14} "
f"{'TAGS':<30} NOTES"
]
for e in entries:
extra = []
if e.cross:
extra.append(f"cross {e.arch}")
if e.series:
extra.append(f"series {e.series}")
note = " ".join(extra + [e.xfail or e.notes])
lines.append(
f"{e.name:<20} {e.tier:<9} {e.dist:<7} {e.category:<14} "
f"{','.join(e.tags):<30} {note}"
)
return "\n".join(lines) + f"\n\n{len(entries)} package(s)\n"
# ---------------------------------------------------------------------------
# Command construction and child-process plumbing
# ---------------------------------------------------------------------------
def stage_command(args: argparse.Namespace, entry: Entry, stage: str, pkh: Path) -> list[str]:
cmd = [str(pkh), stage]
if stage == "pull":
cmd += [entry.name, "-d", args.dist or entry.dist]
if args.series or entry.series:
cmd += ["-s", args.series or entry.series]
if args.pocket or entry.pocket:
cmd += ["-p", args.pocket or entry.pocket]
if not args.git:
cmd += ["--archive"]
elif stage == "chlog":
cmd += ["--rebuild"]
if args.series or entry.series:
cmd += ["-s", args.series or entry.series]
elif stage == "build":
cmd += ["--verbose"]
elif stage == "deb":
cmd += ["--verbose"]
if args.series or entry.series:
cmd += ["-s", args.series or entry.series]
arch = args.arch or entry.arch
if arch:
cmd += ["-a", arch]
if entry.cross or args.cross:
cmd += ["--cross"]
if args.pocket or entry.pocket:
cmd += ["-p", args.pocket or entry.pocket]
for ppa in entry.ppa + args.ppa:
cmd += ["--ppa", ppa]
if args.jobs:
cmd += ["-j", str(args.jobs)]
if args.no_ccache:
cmd += ["--no-ccache"]
return cmd
def child_env() -> dict:
"""Environment for pkh children: no editor, a deterministic identity."""
env = dict(os.environ)
env["EDITOR"] = "true" # chlog launches it after generating; nothing to edit
env.setdefault("DEBEMAIL", "pkh-e2e@invalid")
env.setdefault("DEBFULLNAME", "pkh e2e")
return env
def run_stage(
cmd: list[str], cwd: Path, log_path: Path, timeout_min: float
) -> tuple[str, float]:
"""Run one stage, logging to log_path; returns (status, seconds).
Status is 'ok', 'fail' or 'timeout'. The whole process group is killed
on timeout: builds fan out into make/compiler children, and killing only
the direct child would leave them running. On Ctrl+C the group gets a
graceful SIGTERM first, mirroring pkh's own interrupt cleanup.
"""
with log_path.open("ab") as log:
log.write(f"\n$ {shlex.join(cmd)}\n(cwd: {cwd})\n".encode())
log.flush()
start = time.monotonic()
proc = subprocess.Popen(
cmd,
cwd=cwd,
env=child_env(),
stdin=subprocess.DEVNULL,
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
proc.wait(timeout=timeout_min * 60)
except subprocess.TimeoutExpired:
_kill_group(proc, signal.SIGKILL)
return "timeout", time.monotonic() - start
except KeyboardInterrupt:
# A graceful stop first: SIGTERM lets pkh run its own interrupt
# cleanup; a child ignoring it is killed after a grace period.
_kill_group(proc, signal.SIGTERM, wait=10)
_kill_group(proc, signal.SIGKILL)
raise
return ("ok" if proc.returncode == 0 else "fail"), time.monotonic() - start
def _kill_group(
proc: subprocess.Popen, sig: signal.Signals, wait: float | None = None
) -> None:
try:
os.killpg(os.getpgid(proc.pid), sig)
except ProcessLookupError:
pass
try:
proc.wait(timeout=wait)
except subprocess.TimeoutExpired:
pass
# ---------------------------------------------------------------------------
# The harness proper
# ---------------------------------------------------------------------------
class Harness:
def __init__(self, args: argparse.Namespace, entries: list[Entry], pkh: Path):
self.args = args
self.entries = entries
self.pkh = pkh
self.stages = [s.strip() for s in args.stages.split(",") if s.strip()]
self.records: dict[str, dict] = {}
self.pull_results: dict[str, tuple[str, float]] = {}
self.lock = threading.Lock()
self.run_dir = self._open_run_dir()
self.results_path = self.run_dir / "results.json"
(self.run_dir / "logs").mkdir(parents=True, exist_ok=True)
# -- layout -------------------------------------------------------------
def _open_run_dir(self) -> Path:
if self.args.resume:
try:
data = json.loads(Path(self.args.resume).read_text())
run_dir = Path(data["run_dir"])
except (OSError, KeyError, json.JSONDecodeError) as exc:
raise CatalogError(f"--resume: cannot read {self.args.resume}: {exc}")
if not run_dir.is_dir():
raise CatalogError(f"--resume: missing run directory {run_dir}")
for record in data.get("entries", []):
self.records[record["name"]] = record
return run_dir
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
return SCRIPT_DIR / "runs" / f"{stamp}-{os.getpid()}"
def log_path(self, name: str) -> Path:
return self.run_dir / "logs" / f"{name}.log"
def workroot(self, entry: Entry) -> Path:
# pkh pull (run from the run root) puts the tree at
# ./<pkg>/<pkg>/: this directory is both the tree's parent and
# where pkh build / pkh deb drop their artifacts.
return self.run_dir / entry.name
def tree(self, entry: Entry) -> Path:
return self.workroot(entry) / entry.name
# -- stages ---------------------------------------------------------------
def do_pull(self, entry: Entry) -> tuple[str, float]:
# Pull from the run root: pkh pull (cwd-less) extracts to
# ./<pkg>/<pkg>/ under its cwd, so every package still gets its own
# subtree and parallel pulls never collide. A failed pull leaves a
# partial tree that pkh pull would refuse to overwrite and that a
# later --resume must not trust: wipe it before retrying and after
# the last attempt.
for attempt in range(self.args.pull_retries + 1):
if attempt:
shutil.rmtree(self.workroot(entry), ignore_errors=True)
status, seconds = run_stage(
stage_command(self.args, entry, "pull", self.pkh),
self.run_dir,
self.log_path(entry.name),
entry.timeout_min(),
)
if status == "ok":
return status, seconds
shutil.rmtree(self.workroot(entry), ignore_errors=True)
return status, seconds
def do_chlog(self, entry: Entry) -> tuple[str, float]:
return run_stage(
stage_command(self.args, entry, "chlog", self.pkh),
self.tree(entry),
self.log_path(entry.name),
entry.timeout_min(),
)
def do_build(self, entry: Entry) -> tuple[tuple[str, float], list[str]]:
record = run_stage(
stage_command(self.args, entry, "build", self.pkh),
self.tree(entry),
self.log_path(entry.name),
entry.timeout_min(),
)
artifacts = self.build_artifacts(entry)
return self._check_artifacts(entry, record, artifacts, "dsc/changes"), artifacts
def do_deb(self, entry: Entry) -> tuple[tuple[str, float], list[str]]:
record = run_stage(
stage_command(self.args, entry, "deb", self.pkh),
self.tree(entry),
self.log_path(entry.name),
entry.timeout_min(),
)
debs = self.deb_artifacts(entry)
return self._check_artifacts(entry, record, debs, ".deb"), debs
def build_artifacts(self, entry: Entry) -> list[str]:
return sorted(
p.name
for pattern in ("*.dsc", "*_source.changes")
for p in self.workroot(entry).glob(pattern)
)
def deb_artifacts(self, entry: Entry) -> list[str]:
return sorted(p.name for p in self.workroot(entry).glob("*.deb"))
def _check_artifacts(
self, entry: Entry, record: tuple[str, float], artifacts: list[str], what: str
) -> tuple[str, float]:
"""A green stage with no artifacts is a failure: log it and flip."""
if record[0] == "ok" and not artifacts:
with self.log_path(entry.name).open("ab") as log:
log.write(
f"\npkh-e2e: stage reported success but produced no {what}\n".encode()
)
return ("fail", record[1])
return record
def changelog_version(self, entry: Entry) -> str:
try:
out = subprocess.run(
["dpkg-parsechangelog", "-S", "Version"],
cwd=self.tree(entry),
capture_output=True,
text=True,
check=True,
)
return out.stdout.strip()
except Exception:
return ""
# -- per-entry pipeline ------------------------------------------------------
def run_entry(self, entry: Entry) -> dict:
"""Full pipeline for one entry; never raises."""
# A resumed run keeps what the previous record already achieved
previous = self.records.get(entry.name)
record = {
"name": entry.name,
"category": entry.category,
"tier": entry.tier,
"dist": self.args.dist or entry.dist,
"series": self.args.series or entry.series,
"arch": self.args.arch or entry.arch or platform.machine(),
"cross": entry.cross or self.args.cross,
"tags": entry.tags,
"xfail": entry.xfail,
"notes": entry.notes,
"outcome": "fail",
"version": "",
"stages": {},
"artifacts": [],
"total_seconds": 0.0,
"log": str(self.log_path(entry.name).relative_to(self.run_dir)),
}
started = time.monotonic()
try:
self._run_entry(record, entry, previous)
finally:
record["total_seconds"] = time.monotonic() - started
if entry.xfail:
record["outcome"] = "xfail" if record["outcome"] == "fail" else "xpass"
with self.lock:
self.records[entry.name] = record
self.flush()
color = {"pass": 32, "xfail": 33, "xpass": 33}.get(record["outcome"], 31)
print(
f"[{entry.name}] \033[{color}m{record['outcome']}\033[0m "
f"({fmt_duration(record['total_seconds'])})"
+ (f" — {record['error']}" if record.get("error") else "")
)
return record
def _run_entry(self, record: dict, entry: Entry, previous: dict | None) -> None:
# Stages the resumed record already completed are reused rather than
# re-run: a resumed chlog must not bump the version a second time.
# Reuse only while the build selection is unchanged.
reusable = previous is not None and all(
previous.get(key) == record.get(key)
for key in ("dist", "series", "arch", "cross")
)
prev_stages = previous.get("stages", {}) if previous else {}
if "pull" in self.stages:
if (self.tree(entry) / "debian").is_dir():
# Resumed run: the tree survived, keep it (pkh pull refuses
# existing directories anyway).
record["stages"]["pull"] = {"status": "ok", "seconds": 0.0,
"cached": True}
print(f"[{entry.name}] pull ok (tree already present)")
else:
status, seconds = self.pull_results.get(entry.name, ("fail", 0.0))
record["stages"]["pull"] = {"status": status, "seconds": seconds}
if status != "ok":
record["error"] = f"pull {status}"
return
# Already reported by the pull phase; no duplicate line.
for stage in PIPELINE_STAGES:
if stage not in self.stages:
continue
prior = prev_stages.get(stage) if reusable else None
if prior and prior.get("status") == "ok":
record["stages"][stage] = {**prior, "reused": True}
if stage == "chlog":
record["version"] = previous.get("version", "")
elif stage == "build":
record["artifacts"] += self.build_artifacts(entry)
elif stage == "deb":
record["artifacts"] += self.deb_artifacts(entry)
print(f"[{entry.name}] {stage} ok (reused)")
continue
if not (self.tree(entry) / "debian").is_dir():
record["error"] = (
f"no source tree for {stage}; include the pull stage or "
"resume the original run directory"
)
return
if stage == "chlog":
status, seconds = self.do_chlog(entry)
record["stages"]["chlog"] = {"status": status, "seconds": seconds}
if status != "ok":
record["error"] = f"chlog {status}"
return
record["version"] = self.changelog_version(entry)
print(
f"[{entry.name}] chlog ok ({fmt_duration(seconds)})"
+ (f" -> {record['version']}" if record["version"] else "")
)
elif stage == "build":
(status, seconds), artifacts = self.do_build(entry)
record["stages"]["build"] = {"status": status, "seconds": seconds}
record["artifacts"] += artifacts
if status != "ok":
record["error"] = f"build {status}"
return
print(f"[{entry.name}] build ok ({fmt_duration(seconds)})")
elif stage == "deb":
(status, seconds), debs = self.do_deb(entry)
record["stages"]["deb"] = {"status": status, "seconds": seconds}
record["artifacts"] += debs
if status != "ok":
record["error"] = f"deb {status}"
return
print(
f"[{entry.name}] deb ok ({fmt_duration(seconds)}), "
f"{len(debs)} binary package(s)"
)
record["outcome"] = "pass"
# -- run orchestration ---------------------------------------------------------
def preflight(self) -> None:
version = subprocess.run(
[str(self.pkh), "--version"], capture_output=True, text=True
)
print(f"[pkh-e2e] pkh: {self.pkh} ({version.stdout.strip() or '?'})")
if "deb" in self.stages:
missing = [
tool
for tool in ("mmdebstrap", "unshare", "sudo")
if shutil.which(tool) is None
]
if missing:
print(f"[pkh-e2e] WARNING: deb needs: {', '.join(missing)}")
if subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode:
print(
"[pkh-e2e] WARNING: passwordless sudo unavailable; "
"ephemeral chroot builds will fail"
)
free_g = shutil.disk_usage(self.run_dir).free / 2**30
if free_g < 20:
print(f"[pkh-e2e] WARNING: only {free_g:.0f}G free on {self.run_dir}")
print(
f"[pkh-e2e] {len(self.entries)} package(s), stages: "
f"{'+'.join(self.stages)} -> {self.run_dir}"
)
def resume_skip(self, entry: Entry) -> bool:
previous = self.records.get(entry.name)
if not previous or previous.get("outcome") not in TERMINAL_OUTCOMES:
return False
return all(s in previous.get("stages", {}) for s in self.stages)
def pull_phase(self) -> None:
if "pull" not in self.stages:
return
todo = [
e
for e in self.entries
if not self.resume_skip(e) and not (self.tree(e) / "debian").is_dir()
]
if not todo:
return
print(
f"[pkh-e2e] pulling {len(todo)} package(s) "
f"({self.args.pull_jobs} at a time)"
)
with concurrent.futures.ThreadPoolExecutor(
max_workers=self.args.pull_jobs
) as pool:
futures = {pool.submit(self.do_pull, e): e for e in todo}
for future in concurrent.futures.as_completed(futures):
entry = futures[future]
try:
status, seconds = future.result()
except Exception as exc: # keep the run going on worker errors
status, seconds = "fail", 0.0
print(f"[{entry.name}] pull crashed: {exc}")
self.pull_results[entry.name] = (status, seconds)
print(
f"[{entry.name}] pull {'ok' if status == 'ok' else status} "
f"({fmt_duration(seconds)})"
)
def run(self) -> int:
self.preflight()
self.flush()
self.pull_phase()
failures = 0
for entry in self.entries:
if self.resume_skip(entry):
print(f"[{entry.name}] already {self.records[entry.name]['outcome']},"
" skipping")
continue
if self.args.fail_fast and failures:
print(f"[{entry.name}] skipped (fail-fast)")
continue
record = self.run_entry(entry)
if record["outcome"] in ("fail", "timeout"):
failures += 1
self.write_report()
self.print_summary()
return self.exit_code(failures)
def print_summary(self) -> None:
rows = sorted(self.records.values(), key=lambda r: r["name"])
print(f"\n=== summary ({len(rows)} recorded) ===")
print(
f"{'PACKAGE':<20} {'TIER':<9} {'RESULT':<8} "
f"{'PULL':>7} {'CHLOG':>7} {'BUILD':>8} {'DEB':>8} {'TOTAL':>8} VERSION"
)
for r in rows:
def sec(stage):
data = r["stages"].get(stage)
return fmt_duration(data["seconds"]) if data else "-"
print(
f"{r['name']:<20} {r['tier']:<9} {r['outcome']:<8} "
f"{sec('pull'):>7} {sec('chlog'):>7} {sec('build'):>8} "
f"{sec('deb'):>8} {fmt_duration(r['total_seconds']):>8} "
f"{r['version']}"
)
tally: dict[str, int] = {}
for r in rows:
tally[r["outcome"]] = tally.get(r["outcome"], 0) + 1
print("\n" + ", ".join(f"{v} {k}" for k, v in sorted(tally.items())))
def exit_code(self, failures: int) -> int:
if failures:
return 1
if self.args.strict and any(
r["outcome"] == "xpass" for r in self.records.values()
):
return 1
return 0
# -- persistence ---------------------------------------------------------------
def flush(self) -> None:
"""Persist results progressively: a killed run keeps its records."""
if not hasattr(self, "_started"):
self._started = dt.datetime.now().astimezone().isoformat()
meta = {
"run_dir": str(self.run_dir),
"pkh": str(self.pkh),
"filters": {
"tier": self.args.tier,
"category": self.args.category,
"tag": self.args.tag,
"only": self.args.only,
"skip": self.args.skip,
"stages": self.stages,
},
"host": {
"kernel": platform.release(),
"arch": platform.machine(),
"python": platform.python_version(),
},
"started": self._started,
"updated": dt.datetime.now().astimezone().isoformat(),
"entries": sorted(self.records.values(), key=lambda r: r["name"]),
}
tmp = self.results_path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(meta, indent=2) + "\n")
tmp.replace(self.results_path)
def write_report(self) -> None:
rows = sorted(self.records.values(), key=lambda r: (r["outcome"], r["name"]))
lines = [
f"# pkh e2e run — {self.run_dir.name}",
"",
f"- pkh: `{self.pkh}`",
f"- started: {getattr(self, '_started', '?')}",
f"- stages: {'+'.join(self.stages)}",
"",
"| package | tier | result | pull | chlog | build | deb | total |"
" version |",
"|---|---|---|---|---|---|---|---|---|",
]
for r in rows:
def sec(stage):
data = r["stages"].get(stage)
return fmt_duration(data["seconds"]) if data else "-"
lines.append(
f"| {r['name']} | {r['tier']} | {r['outcome']} | {sec('pull')} | "
f"{sec('chlog')} | {sec('build')} | {sec('deb')} | "
f"{fmt_duration(r['total_seconds'])} | {r['version']} |"
)
(self.run_dir / "results.md").write_text("\n".join(lines) + "\n")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--catalog", type=Path, default=SCRIPT_DIR / "catalog.yml",
help="catalog file (default: catalog.yml)")
parser.add_argument("--tier", default=",".join(DEFAULT_TIERS),
help="tiers to run, comma-separated: smoke, standard, long, "
"or 'all' (default: smoke,standard)")
parser.add_argument("--category", default="",
help="comma-separated categories to keep")
parser.add_argument("--tag", default="",
help="comma-separated tags; keep packages carrying any")
parser.add_argument("--only", default="",
help="comma-separated exact package names to run")
parser.add_argument("--skip", default="",
help="comma-separated exact package names to skip")
parser.add_argument("--stages", default=",".join(STAGES),
help=f"stages to run (default: {','.join(STAGES)})")
parser.add_argument("--pkh", default="",
help="pkh binary to exercise (default: the repo build, "
"then PATH)")
parser.add_argument("--build-pkh", action="store_true",
help="run 'cargo build --release' before resolving pkh")
parser.add_argument("--git", action="store_true",
help="pull through the package VCS instead of the archive")
parser.add_argument("--dist", default="",
help="override the catalog default dist (per-entry dist wins)")
parser.add_argument("--series", default="",
help="override the series (per-entry series wins)")
parser.add_argument("--arch", default="",
help="force the 'pkh deb -a' architecture on every entry")
parser.add_argument("--cross", action="store_true",
help="pass --cross to every deb build")
parser.add_argument("--pocket", default="",
help="distribution pocket passed to pull and deb")
parser.add_argument("--ppa", action="append", default=[],
help="PPA passed to 'pkh deb --ppa' (repeatable)")
parser.add_argument("--jobs", type=int, default=0,
help="pass -j N to 'pkh deb' (default: pkh chooses)")
parser.add_argument("--no-ccache", action="store_true",
help="pass --no-ccache to 'pkh deb'")
parser.add_argument("--pull-jobs", type=int, default=4,
help="parallel pulls (default: 4)")
parser.add_argument("--pull-retries", type=int, default=1,
help="retries per failed pull (default: 1)")
parser.add_argument("--fail-fast", action="store_true",
help="stop the pipeline at the first failure")
parser.add_argument("--strict", action="store_true",
help="also fail the run on xpass")
parser.add_argument("--resume", default="",
help="results.json of a previous run: reuse its run "
"directory and skip finished packages")
parser.add_argument("--list", action="store_true",
help="print the selected packages and exit")
parser.add_argument("--dry-run", action="store_true",
help="print the plan and the exact commands, run nothing")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
entries = load_catalog(args.catalog)
selected, dropped = select_entries(entries, args)
stages = [s.strip() for s in args.stages.split(",") if s.strip()]
for stage in stages:
if stage not in STAGES:
raise CatalogError(
f"unknown stage '{stage}' (expected: {', '.join(STAGES)})"
)
if args.pull_jobs < 1:
raise CatalogError("--pull-jobs must be at least 1")
if args.list:
print(render_list(selected))
if dropped:
print(f"(filtered out: {len(dropped)})")
return 0
if not selected:
print("nothing selected", file=sys.stderr)
return 2
pkh = resolve_pkh(args)
if args.dry_run:
print(f"pkh: {pkh}")
print(f"selected {len(selected)} package(s), stages: {args.stages}")
for entry in selected:
where = f" {entry.series}" if entry.series else ""
print(f"\n[{entry.name}] {entry.tier}/{entry.category} "
f"({entry.dist}{where})")
for stage in stages:
cmd = stage_command(args, entry, stage, pkh)
print(f" {shlex.join(cmd)}"
f" # timeout {entry.timeout_min():.0f}m")
return 0
harness = Harness(args, selected, pkh)
return harness.run()
except CatalogError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except KeyboardInterrupt:
print("\ninterrupted", file=sys.stderr)
return 130
def repo_source_epoch() -> float | None:
"""The newest evidence of source changes: HEAD's commit time, or the
newest mtime under src/ for uncommitted edits. None outside a git
checkout with no src/ tree."""
stamps = []
try:
out = subprocess.run(
["git", "log", "-1", "--format=%ct"],
cwd=REPO_ROOT, capture_output=True, text=True, check=True,
)
stamps.append(int(out.stdout.strip()))
except Exception:
pass
src = REPO_ROOT / "src"
if src.is_dir():
stamps.extend(path.stat().st_mtime for path in src.rglob("*.rs"))
return max(stamps) if stamps else None
def resolve_pkh(args: argparse.Namespace) -> Path:
if args.pkh:
path = Path(args.pkh).resolve()
if not path.is_file():
raise CatalogError(f"--pkh: no such file: {path}")
return path
# A repo binary older than the sources measures whatever the last
# `cargo build --release` happened to include — yesterday's code.
# Rebuild unless the caller pinned a binary or asked for it explicitly.
if not args.build_pkh:
head = repo_source_epoch()
fresh = head is not None and any(
candidate.is_file() and candidate.stat().st_mtime >= head
for candidate in (REPO_ROOT / "target/release/pkh",
REPO_ROOT / "target/debug/pkh")
)
if fresh:
return next(candidate
for candidate in (REPO_ROOT / "target/release/pkh",
REPO_ROOT / "target/debug/pkh")
if candidate.is_file()
and candidate.stat().st_mtime >= head)
print("[pkh-e2e] the repo pkh binary is older than the sources; "
"running 'cargo build --release'")
subprocess.run(["cargo", "build", "--release"], cwd=REPO_ROOT, check=True)
for candidate in (REPO_ROOT / "target/release/pkh", REPO_ROOT / "target/debug/pkh"):
if candidate.is_file():
return candidate
found = shutil.which("pkh")
if not found:
raise CatalogError(
"no pkh binary: pass --pkh, use --build-pkh, or install pkh on PATH"
)
return Path(found)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))