199 lines
9.0 KiB
Python
199 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Args:
|
||
|
|
--port: port of the Agent Orchestrator; the signature's origin is
|
||
|
|
http://localhost:<port>.
|
||
|
|
--jobs: the directory the hub writes signed jobs into, e.g.
|
||
|
|
~/.local/state/kvb-orchestrator/cleanup/jobs.
|
||
|
|
--results: the directory each job's result goes to as <id>.json.
|
||
|
|
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
|
||
|
|
--replay: this runner's replay store, outside the hub's reach, e.g.
|
||
|
|
~/.local/state/kvb-orchestrator-approvals/cleanup.json.
|
||
|
|
--tree: the tree file `pkgmgr branch tree` writes; it names the
|
||
|
|
repositories pkgmgr knows and is rewritten after a deletion.
|
||
|
|
--pkgmgr: the pkgmgr executable built from a signed tag.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Runs once over every job in --jobs, as the operator on the host: per item
|
||
|
|
it verifies the operator's passkey signature over repository, branch,
|
||
|
|
worktree and tip, then in the repository runs `pkgmgr branch cleanup
|
||
|
|
--json --local --branch <name>` as a dry run and, only when that plan
|
||
|
|
names exactly this branch as merged-clean without skip reason at the
|
||
|
|
signed tip and worktree, `pkgmgr branch cleanup --apply --json --local
|
||
|
|
--branch <name>`. Every other item is refused and nothing changes for
|
||
|
|
it. Writes each item's result, rewrites the tree file after a deletion
|
||
|
|
and removes the job. The exit code is 0.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
import time
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import signing
|
||
|
|
import verifier
|
||
|
|
|
||
|
|
JOB = re.compile(r"[0-9a-f]{16}\.json")
|
||
|
|
ITEM_KEYS = ("repository", "branch", "worktree", "tip")
|
||
|
|
TIP = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
||
|
|
UNSAFE = re.compile(r"[*?\[\]\\\s\x00-\x1f\x7f]|^-|\.\.|@\{")
|
||
|
|
OUTPUT_LIMIT = 16 << 10
|
||
|
|
TIMEOUT = 600
|
||
|
|
# the cwd is a repository agents can write: its config may name hooks, and
|
||
|
|
# pkgmgr releases before 4c8b137 run git branch -D without scan mode
|
||
|
|
GIT_OVERRIDES = (
|
||
|
|
("core.hooksPath", "/dev/null"),
|
||
|
|
("core.fsmonitor", "false"),
|
||
|
|
("hook.reference-transaction.enabled", "false"),
|
||
|
|
("hook.post-checkout.enabled", "false"),
|
||
|
|
("hook.post-index-change.enabled", "false"),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def git_overrides() -> dict[str, str]:
|
||
|
|
env = {"GIT_CONFIG_COUNT": str(len(GIT_OVERRIDES))}
|
||
|
|
for index, (name, value) in enumerate(GIT_OVERRIDES):
|
||
|
|
env[f"GIT_CONFIG_KEY_{index}"] = name
|
||
|
|
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||
|
|
return env
|
||
|
|
|
||
|
|
|
||
|
|
def known_repositories(tree: Path) -> set[str]:
|
||
|
|
try:
|
||
|
|
data = json.loads(tree.read_text(encoding="utf-8"))
|
||
|
|
return {entry["path"] for entry in data["repos"] if entry.get("known") is True and isinstance(entry.get("path"), str)}
|
||
|
|
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||
|
|
return set()
|
||
|
|
|
||
|
|
|
||
|
|
def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]:
|
||
|
|
try:
|
||
|
|
done = subprocess.run([str(pkgmgr), *argv], cwd=cwd, env={**os.environ, **git_overrides()}, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace", timeout=TIMEOUT, check=False)
|
||
|
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||
|
|
return 127, "", str(error)
|
||
|
|
return done.returncode, done.stdout, done.stderr[-OUTPUT_LIMIT:]
|
||
|
|
|
||
|
|
|
||
|
|
def plan_items(stdout: str) -> list[dict] | None:
|
||
|
|
try:
|
||
|
|
data = json.loads(stdout)
|
||
|
|
except ValueError:
|
||
|
|
return None
|
||
|
|
items = data.get("items") if isinstance(data, dict) else data
|
||
|
|
if not isinstance(items, list) or not all(isinstance(entry, dict) for entry in items):
|
||
|
|
return None
|
||
|
|
return items
|
||
|
|
|
||
|
|
|
||
|
|
def shaped(item: object) -> bool:
|
||
|
|
if not isinstance(item, dict) or set(item) != set(ITEM_KEYS) or not all(isinstance(item[key], str) for key in ITEM_KEYS):
|
||
|
|
return False
|
||
|
|
return item["repository"].startswith("/") and bool(TIP.fullmatch(item["tip"])) and (item["worktree"] == "" or item["worktree"].startswith("/"))
|
||
|
|
|
||
|
|
|
||
|
|
def mismatch(entry: dict, item: dict) -> str | None:
|
||
|
|
if entry.get("state") != "merged-clean":
|
||
|
|
return f"state is {entry.get('state')}, not merged-clean"
|
||
|
|
if entry.get("skip"):
|
||
|
|
return f"gc skip reason: {entry['skip']}"
|
||
|
|
tip = entry.get("local_sha") or entry.get("head")
|
||
|
|
if tip != item["tip"]:
|
||
|
|
return f"tip changed: signed {item['tip'][:12]}, now {str(tip)[:12]}"
|
||
|
|
if (entry.get("worktree") or "") != item["worktree"]:
|
||
|
|
return f"worktree changed: signed {item['worktree'] or 'none'}, now {entry.get('worktree') or 'none'}"
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
class Cleanup:
|
||
|
|
def __init__(self, trust: verifier.Trust, tree: Path, pkgmgr: Path) -> None:
|
||
|
|
self.trust = trust
|
||
|
|
self.tree = tree
|
||
|
|
self.pkgmgr = pkgmgr
|
||
|
|
|
||
|
|
def item(self, item: dict, proof: object, known: set[str], now: float) -> dict:
|
||
|
|
"""Args:
|
||
|
|
item: one signed item with repository, branch, worktree and tip.
|
||
|
|
proof: the job's {"payload", "assertion"}.
|
||
|
|
known: the repositories the tree file says pkgmgr knows.
|
||
|
|
now: the current time in epoch seconds.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The item with status done, refused or failed and the reason;
|
||
|
|
refused and failed items changed nothing.
|
||
|
|
"""
|
||
|
|
def refused(reason: str) -> dict:
|
||
|
|
return {**item, "status": "refused", "reason": reason}
|
||
|
|
|
||
|
|
try:
|
||
|
|
verifier.check(proof, signing.cleanup_item(item["repository"], item["branch"], item["worktree"], item["tip"]), "", self.trust, now)
|
||
|
|
except (signing.Refused, OSError) as error:
|
||
|
|
return refused(f"no valid operator signature: {error}")
|
||
|
|
if item["repository"] not in known or not Path(item["repository"]).is_dir():
|
||
|
|
return refused("unknown repository: pkgmgr does not list it")
|
||
|
|
if UNSAFE.search(item["branch"]) or not item["branch"]:
|
||
|
|
return refused("the branch name holds pattern or option characters; only an exact branch name is deleted")
|
||
|
|
selector = ["--json", "--local", "--branch", item["branch"]]
|
||
|
|
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", *selector], item["repository"])
|
||
|
|
plan = plan_items(stdout) if code == 0 else None
|
||
|
|
if plan is None:
|
||
|
|
return refused(f"the dry run failed ({code}): {stderr.strip()[-500:]}")
|
||
|
|
others = sorted({str(entry.get("branch")) for entry in plan} - {item["branch"]})
|
||
|
|
if others:
|
||
|
|
return refused(f"the dry run names other branches too, a pattern match: {', '.join(others)[:500]}")
|
||
|
|
if len(plan) != 1:
|
||
|
|
return refused(f"the dry run names {len(plan)} items for {item['branch']}")
|
||
|
|
reason = mismatch(plan[0], item)
|
||
|
|
if reason:
|
||
|
|
return refused(reason)
|
||
|
|
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", "--apply", *selector], item["repository"])
|
||
|
|
applied = plan_items(stdout)
|
||
|
|
status = "done" if code == 0 else "failed"
|
||
|
|
return {**item, "status": status, "reason": "" if code == 0 else stderr.strip()[-500:], "code": code, "applied": applied if applied is not None else []}
|
||
|
|
|
||
|
|
def job(self, path: Path, now: float) -> dict:
|
||
|
|
try:
|
||
|
|
job = json.loads(path.read_text(encoding="utf-8"))
|
||
|
|
except (OSError, ValueError) as error:
|
||
|
|
return {"id": path.stem, "finished": now, "items": [], "error": f"unreadable job: {error}"}
|
||
|
|
items = job.get("items") if isinstance(job, dict) else None
|
||
|
|
if not isinstance(items, list) or not items or not all(map(shaped, items)) or job.get("id") != path.stem:
|
||
|
|
return {"id": path.stem, "finished": now, "items": [], "error": "malformed job: refused, nothing deleted"}
|
||
|
|
known = known_repositories(self.tree)
|
||
|
|
results = [self.item(item, job.get("proof"), known, now) for item in items]
|
||
|
|
result = {"id": path.stem, "finished": time.time(), "items": results}
|
||
|
|
if any(entry["status"] == "done" for entry in results):
|
||
|
|
code, _, stderr = run(self.pkgmgr, ["branch", "tree", "--format", "json", "--output", str(self.tree)], str(self.tree.parent))
|
||
|
|
result["tree"] = {"code": code, "error": stderr.strip()[-500:]}
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def cycle(jobs: Path, results: Path, cleanup: Cleanup) -> int:
|
||
|
|
handled = 0
|
||
|
|
for path in sorted(jobs.iterdir()):
|
||
|
|
if not JOB.fullmatch(path.name) or not path.is_file():
|
||
|
|
path.unlink(missing_ok=True)
|
||
|
|
continue
|
||
|
|
verifier.write_private(results / path.name, cleanup.job(path, time.time()))
|
||
|
|
path.unlink(missing_ok=True)
|
||
|
|
handled += 1
|
||
|
|
return handled
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
parser = argparse.ArgumentParser(prog="cleanup")
|
||
|
|
parser.add_argument("--port", type=int, required=True)
|
||
|
|
for name in ("--jobs", "--results", "--approvers", "--replay", "--tree", "--pkgmgr"):
|
||
|
|
parser.add_argument(name, type=Path, required=True)
|
||
|
|
args = parser.parse_args()
|
||
|
|
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
|
||
|
|
args.results.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||
|
|
cycle(args.jobs, args.results, Cleanup(trust, args.tree, args.pkgmgr))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|