test: add the e2e package harness

Exercises pkh the way it is used in anger: 63 real source packages
(from hello and micro to dracut, grub2 and the kernel) run through
pull -> chlog --rebuild -> build -> deb, each stage timed, logged and
checked for its artifacts, with the catalog alternating between debian
and ubuntu within every category.

run.py keeps runs self-contained (per-package logs, progressive
results.json, a markdown summary), pulls in parallel while builds run
sequentially to keep pkh's shared chroot/apt/ccache caches warm,
resumes interrupted runs reusing the stages that already succeeded,
and marks known-broken entries as xfail with a reason.

The first catalog run caught four pkh defects within an hour - the
unstable/sid suite alias, the bz2 origs, the hard link extraction and
the wrapper unwrapping - which is exactly what it is for.
This commit is contained in:
2026-10-01 22:24:07 +02:00
parent a4be046aaa
commit 87f5b31602
5 changed files with 1411 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Harness run output: trees, logs, results
runs/
+103
View File
@@ -0,0 +1,103 @@
# pkh end-to-end package harness
Exercises pkh the way it is used in anger: real packages, the whole
workflow. For every package in `catalog.yml` it runs
```
pkh pull <name> --archive # sources from the Debian/Ubuntu archive
pkh chlog --rebuild # no-change rebuild entry (1.0-1 -> 1.0-1build1)
pkh build # .dsc + _source.changes next to the tree
pkh deb # binary packages in an ephemeral chroot
```
The catalog spans ~60 source packages: smoke-test smalls (`hello`,
`micro`, `jq`...), core utilities, server software, databases, desktop
stacks, toolchains and the weird corners — `dracut`, `opensbi`, `grub2`,
a cross `u-boot`, the kbuild-based `kbuild`, and (opt-in, hours each)
`gcc-14`, `glibc`, `rustc`, `libreoffice`, `firefox`, the Ubuntu `linux`
kernel...
## Prerequisites
- A pkh binary: the harness prefers the repo build
(`target/release/pkh`, then `target/debug/pkh`), falls back to `pkh`
on PATH; override with `--pkh PATH` or build first with `--build-pkh`.
- The pkh runtime toolchain: `mmdebstrap`, `sudo` (passwordless, for
the unshare chroot builds), `dpkg-dev`, `distro-info` data, and
network access to the Debian and Ubuntu archives.
- Python 3 only (stdlib; PyYAML is used when installed, with a built-in
fallback parser for the catalog's YAML subset).
- Disk: tens of GB under `runs/` for trees and artifacts, plus pkh's own
caches (`/var/tmp/pkh`); expect a full default-tier run to take many
hours — the tiers exist so you never have to.
## Usage
```
./run.py --list # what would run, with tiers and notes
./run.py --dry-run # the exact commands, nothing executed
./run.py # smoke + standard tiers
./run.py --tier long # the multi-hour builds
./run.py --tier all # everything
./run.py --only hello,dracut # exact names
./run.py --category database # or server, desktop, boot-firmware...
./run.py --tag cross --arch arm64 # tag/arch sweeps
./run.py --stages pull # catalog sanity: can we pull everything?
./run.py --resume runs/<id>/results.json # continue an interrupted run
```
Useful global pass-throughs: `--dist`, `--series`, `--arch`, `--cross`,
`--pocket`, `--ppa user/ppa` (deb dependencies), `--jobs N`,
`--no-ccache`, `--git` (pull through the VCS instead of the archive).
Exit codes: `0` nothing unexpected failed, `1` at least one package
failed (or xpassed under `--strict`), `2` usage/catalog error, `130`
interrupted.
## Selection: tiers, categories, tags
Entries carry a **tier**: `smoke` (minutes), `standard` (the default,
up to ~1-2h each), `long` (hours: compilers, browsers, the kernel —
only run when asked). Default selection is smoke + standard. On top of
that, filters compose: `--category`, `--tag`, `--only`, `--skip`.
An entry can be marked `xfail: <reason>` when it is known-broken; it
then reports `xfail` on failure and `xpass` on unexpected success
(xpass fails the run only under `--strict`).
## What a run produces
```
runs/<id>/
logs/<pkg>.log # full output of every stage of that package
<pkg>/<pkg>/ # the pulled source tree
<pkg>/*.dsc ... # build artifacts, next to the tree as pkh puts them
results.json # machine-readable, written progressively
results.md # the summary table
```
The run directory is per invocation; `--resume <id>/results.json`
reuses it, keeps the recorded outcomes and only reruns packages that
did not finish. Already-pulled trees are kept (`pkh pull` refuses
existing directories anyway — delete `runs/<id>/<pkg>/` to force a
re-pull), and stages that already succeeded are reused rather than
re-run, so a resumed `chlog` does not bump the version twice; change
`--dist`/`--series`/`--arch`/`--cross` and everything re-runs.
Pulls run a few at a time (`--pull-jobs`), the build pipeline is
sequential by design: one heavy build at a time keeps pkh's shared
chroot tarball, apt and ccache caches warm, so repeat runs get
dramatically faster.
Each stage gets the package's timeout budget (tier default, overridable
per entry with `timeout:`); on timeout the whole build process group is
killed and the package is marked `timeout`.
## Adding packages
Edit `catalog.yml`. Fields are documented at the top of the file;
keep **source** names as they exist in the archive — not binary names
(`make-dfsg` and `rust-ripgrep`, not `make` and `ripgrep`;
`xz-utils`, not `xz`; `postgresql-17`, not `postgresql`). The file
stays within a small YAML subset so the stdlib fallback parser can read
it: flat mappings, inline lists, no colons in values.
Binary file not shown.
+375
View File
@@ -0,0 +1,375 @@
# pkh end-to-end test catalog
#
# One entry per source package, exercised by run.py as:
# pkh pull -> pkh chlog --rebuild -> pkh build -> pkh deb
#
# The catalog deliberately alternates between debian and ubuntu within
# every category, so a default run exercises both archives, series
# namings and chroots.
#
# The file is plain YAML but is also parsed by a small built-in parser when
# PyYAML is unavailable: keep to the subset it supports — full-line comments,
# 'key: value' scalars, inline [a, b] lists, no nested mappings, no block
# scalars, no colons inside values.
#
# Fields (all optional except 'name'):
# category free grouping label (smoke, server, desktop, ...) [defaults.tier]
# tier smoke | standard | long — 'long' is only run when asked for
# dist debian | ubuntu [defaults.dist]
# series pin a series (default: the dist development series)
# arch pass '-a ARCH' to 'pkh deb'
# cross true -> also pass '--cross' (needs 'arch') [false]
# pocket pass '-p POCKET' to pull/deb
# ppa inline list of PPAs passed to 'pkh deb --ppa'
# timeout per-stage budget in minutes [tier default]
# xfail known-broken marker: the reason it is expected to fail
# notes free text, shown by --list and in reports
defaults:
dist: debian
tier: standard
packages:
# --- smoke: small, fast, reliable; the first line of defense -------------
- name: hello
category: smoke
tier: smoke
tags: [gnu, autotools, tiny]
notes: the canonical packaging smoke test
- name: micro
category: smoke
tier: smoke
tags: [go, editor, tiny]
notes: Go build, golang build-dependencies resolved offline
- name: jq
category: smoke
tier: smoke
tags: [c, autotools, tiny]
- name: htop
category: smoke
dist: ubuntu
tier: smoke
tags: [c, autotools, tiny]
- name: tmux
category: smoke
tier: smoke
tags: [c, autotools, tiny]
- name: tree
category: smoke
dist: ubuntu
tier: smoke
tags: [c, make, tiny]
- name: figlet
category: smoke
dist: ubuntu
tier: smoke
tags: [c, make, tiny]
- name: dash
category: smoke
tier: smoke
tags: [c, autotools, shell]
- name: file
category: smoke
tier: smoke
tags: [c, autotools]
- name: zlib
category: smoke
dist: ubuntu
tier: smoke
tags: [c, autotools, lib]
- name: xz-utils
category: smoke
tier: smoke
tags: [c, autotools]
xfail: sid carries the 5.8.1-1+deb13u1 trixie upload whose packaging fails at dh_installdocs (doxygen api docs missing) - package-side, under triage
- name: busybox
category: smoke
dist: ubuntu
tier: smoke
tags: [c, kconfig, static]
- name: dnsmasq
category: smoke
tier: smoke
tags: [c, make, network]
- name: memcached
category: smoke
dist: ubuntu
tier: smoke
tags: [c, autotools, server]
- name: kbuild
category: smoke
tier: smoke
tags: [c, kbuild, weird]
notes: the virtualbox build system; exercises the kbuild log classifier
# --- core -----------------------------------------------------------------
- name: bash
category: core
tags: [c, autotools, shell]
- name: coreutils
category: core
dist: ubuntu
tags: [c, autotools]
xfail: pkh deb builds as root in the chroot and coreutils configure refuses root (needs a non-root build user, as Launchpad buildds use)
- name: util-linux
category: core
tags: [c, autotools]
- name: openssl
category: core
dist: ubuntu
tags: [c, perl-build, lib]
- name: vim
category: core
tags: [c, autotools, editor]
- name: neovim
category: core
dist: ubuntu
tags: [c, cmake, lua, editor]
- name: git
category: core
tags: [c, make, vcs]
- name: rust-ripgrep
category: core
dist: ubuntu
tags: [rust, cargo]
notes: debcargo-style vendored Rust build (binary name is ripgrep)
- name: make-dfsg
category: core
tier: smoke
tags: [c, autotools, tiny]
notes: the GNU make source package (binary name is make)
- name: systemd
category: core
dist: ubuntu
tags: [c, meson, init]
# --- server ---------------------------------------------------------------
- name: nginx
category: server
tags: [c, autoconf, server]
- name: apache2
category: server
dist: ubuntu
tags: [c, autotools, server]
- name: openssh
category: server
tags: [c, autotools, server]
- name: postfix
category: server
dist: ubuntu
tags: [c, make, mail, server]
- name: dovecot
category: server
tags: [c, autotools, mail, server]
- name: samba
category: server
dist: ubuntu
tags: [c, python-waf, server]
- name: bind9
category: server
tags: [c, autotools, dns, server]
- name: haproxy
category: server
dist: ubuntu
tags: [c, make, network, server]
- name: exim4
category: server
tags: [c, make, mail, server]
# --- databases --------------------------------------------------------------
- name: redis
category: database
tags: [c, make, server]
- name: sqlite3
category: database
dist: ubuntu
tags: [c, autotools]
- name: postgresql-17
category: database
tags: [c, autotools, server]
notes: long-ish but reliable; a good mid-weight benchmark
- name: leveldb
category: database
dist: ubuntu
tier: smoke
tags: [cpp, cmake]
- name: mysql-8.0
category: database
tier: long
dist: ubuntu
timeout: 240
tags: [cpp, cmake, server]
notes: Ubuntu-only source name; huge C++ build
# --- desktop ----------------------------------------------------------------
- name: gtk4
category: desktop
tags: [c, meson, gui]
- name: vlc
category: desktop
dist: ubuntu
tags: [cpp, autotools, gui]
- name: gimp
category: desktop
tags: [c, meson, gui]
- name: inkscape
category: desktop
dist: ubuntu
tags: [cpp, cmake, gui]
- name: mutter
category: desktop
tags: [c, meson, gui]
- name: pipewire
category: desktop
dist: ubuntu
tags: [c, meson, audio]
- name: plasma-desktop
category: desktop
tags: [cpp, cmake, kde, gui]
- name: gnome-control-center
category: desktop
dist: ubuntu
tags: [c, meson, gui]
- name: ffmpeg
category: desktop
tags: [c, autotools, media]
# --- toolchains ---------------------------------------------------------------
- name: binutils
category: toolchain
tags: [c, autotools]
- name: gdb
category: toolchain
dist: ubuntu
tags: [c, autotools]
- name: cmake
category: toolchain
tier: smoke
tags: [cpp, cmake, bootstrap]
# --- boot / firmware / weird ------------------------------------------------
- name: dracut
category: boot-firmware
tier: smoke
tags: [shell, make, initramfs, weird]
notes: unusual make logic; initramfs generator
- name: opensbi
category: boot-firmware
tier: smoke
tags: [c, make, firmware, weird]
notes: RISC-V firmware, cross-built inside a native build
- name: grub2
category: boot-firmware
tags: [c, autotools, bootloader, weird]
notes: multi-platform grub; heavy but dependable
- name: u-boot
category: boot-firmware
dist: ubuntu
arch: arm64
cross: true
tags: [c, kconfig, firmware, weird, cross]
notes: cross build for arm64; many board configs
# --- long: hours each; opt-in via --tier long or --tier all -----------------
- name: gcc-14
category: toolchain
dist: ubuntu
tier: long
timeout: 360
tags: [c, autotools, compiler]
- name: glibc
category: core
tier: long
timeout: 360
tags: [c, autotools, libc]
- name: rustc
category: toolchain
tier: long
timeout: 360
tags: [rust, compiler]
notes: bootstraps the Rust compiler; the longest single build here
- name: libreoffice
category: desktop
dist: ubuntu
tier: long
timeout: 480
tags: [cpp, make, office, gui]
- name: firefox
category: desktop
tier: long
timeout: 480
tags: [cpp, rust, browser, gui]
- name: qt6-base
category: desktop
dist: ubuntu
tier: long
timeout: 360
tags: [cpp, cmake, gui]
- name: linux
category: kernel
tier: long
dist: ubuntu
timeout: 480
tags: [c, kbuild, kernel, weird]
notes: the Ubuntu kernel; multi-hour, many flavours, kbuild quirks apply
- name: openjdk-21
category: toolchain
tier: long
dist: ubuntu
timeout: 360
tags: [java, make, compiler]
+931
View File
@@ -0,0 +1,931 @@
#!/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 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
if args.build_pkh:
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:]))