"""Archive pinned binary inputs in R2 and seed existing CI consumers.""" from __future__ import annotations import argparse from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass import json from pathlib import Path, PurePosixPath import re import shutil import sys import tempfile from urllib.parse import quote, urlsplit # The runner invokes this before setup-pm has installed the checkout. if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from pm.artifact_mirror import object_key from pm.downloader import Download, DownloadError, DownloadTransportError, Source from pm.lock import SCHEMA from pm.store import ALL_TARGETS, Store from scripts.releases import r2 TERMUX_TARGET = "linux-arm64-bionic" @dataclass(frozen=True) class InputPin: name: str url: str sha256: str kind: str def __post_init__(self): if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9+_.@-]*", self.name): raise ValueError(f"Invalid input name: {self.name!r}") object_key(self.sha256) parsed = urlsplit(self.url) loopback = parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost", "::1") if (parsed.scheme != "https" and not loopback) or not parsed.netloc or parsed.username or parsed.password: raise ValueError(f"{self.name}: pinned input needs an HTTPS URL") if self.kind not in ("library", "license", "tool"): raise ValueError(f"Invalid input kind: {self.kind}") def historical_url(url: str) -> str | None: parsed = urlsplit(url) prefix = "/apt/termux-main/pool/main/" if parsed.scheme != "https" or parsed.netloc != "packages.termux.dev" or not parsed.path.startswith(prefix): return None parts = parsed.path[len(prefix):].split("/") if len(parts) != 3 or any(not part or part in (".", "..") for part in parts): return None group, package, filename = (quote(part, safe="") for part in parts) return f"https://archive.org/download/termux_pkgs_archive_{group}/{package}/{filename}" def _pin(name: str, row: dict, kind: str) -> InputPin: if not isinstance(row, dict) or not isinstance(row.get("url"), str): raise ValueError(f"{name}: invalid pinned input row") return InputPin(name, row["url"], row.get("sha256"), kind) def pinned_inputs(repo: Path, *, target: str | None = None, packages: set[str] | None = None) -> list[InputPin]: """All pin references, including shared bytes needed at different paths.""" if target is not None and target not in ALL_TARGETS: raise ValueError(f"Unknown target: {target}") lock = json.loads((repo / "pm/lock.json").read_text(encoding="utf-8-sig")) if not isinstance(lock, dict) or lock.get("schema") != SCHEMA or not isinstance(lock.get("packages"), dict): raise ValueError("Invalid PM lockfile") if packages is not None and packages - lock["packages"].keys(): raise ValueError(f"Unknown pinned packages: {sorted(packages - lock['packages'].keys())}") pins = [] for name, package in lock["packages"].items(): if packages is not None and name not in packages: continue artifacts = package["artifacts"] if target is not None: key = target if target in artifacts else "any" artifacts = {key: artifacts[key]} if key in artifacts else {} for row_target, rows in artifacts.items(): for row in rows if isinstance(rows, list) else [rows]: label = f"{name}@{row_target}" if isinstance(row, dict) and isinstance(row.get("url"), str) and row["url"].startswith("docker://"): continue # OCI digests belong to the container registry, not HTTP archives. pins.append(_pin(label, row, "tool")) if packages is None and target in (None, TERMUX_TARGET): table = json.loads((repo / "pm" / "termux_runtime_libs.json").read_text(encoding="utf-8-sig")) pins.extend(_pin(name, row, "library") for name, row in table["libs"].items()) if table.get("licenses") is not None: pins.append(_pin("termux-licenses", table["licenses"], "license")) if not pins: raise ValueError("No pinned HTTP inputs selected") return pins @dataclass(frozen=True) class Archive: creds: dict[str, str] base: str bucket: str def fetch(self, pin: InputPin, destination: Path) -> str: """Only R2 404 permits upstream download; every result is read back.""" key = object_key(pin.sha256) url = f"{self.base}/{self.bucket}/{r2.encode_key_path(key)}" try: head = r2.signed_request("HEAD", url, creds=self.creds, now=r2.amz_timestamp()) except r2.R2RequestError as exc: if exc.status != 404: raise head = None destination.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".input-archive-", dir=destination.parent) as temporary: work = Path(temporary) local = work / "input" origin = "R2" if head is None: origin = _download_upstream(pin, local) size = local.stat().st_size r2.put_object(self.creds, self.base, self.bucket, key, str(local), r2.amz_timestamp(), "application/octet-stream", conditions={"If-None-Match": "*"}) else: length = head.header("content-length") if length is None: raise ValueError(f"R2 did not report the input size: {url}") size = int(length) verified = work / "verified" r2.download_object(self.creds, self.base, self.bucket, key, verified, r2.amz_timestamp(), expected_size=size, expected_sha256=pin.sha256) verified.replace(destination) return origin def _download_upstream(pin: InputPin, local: Path) -> str: try: Download([Source(pin.url, local, pin.sha256)], partials_dir=local.parent / "partials").run() return "upstream" except DownloadTransportError as exc: backup = historical_url(pin.url) if exc.status not in (404, 410) or backup is None: raise try: Download([Source(backup, local, pin.sha256)], partials_dir=local.parent / "partials").run() except DownloadError as failure: raise DownloadError(f"{exc}\n{failure}") from failure return "historical archive" def stage_inputs(pins: list[InputPin], *, archive: Archive, store: Store | None = None, payload: Path | None = None) -> int: """Archive all unique digests concurrently; optionally seed the stagers' inputs.""" from scripts.termux.stage_runtime_libs import download_path groups: dict[str, list[InputPin]] = {} for pin in pins: groups.setdefault(pin.sha256, []).append(pin) if not groups: return 0 def stage_digest(digest: str, references: list[InputPin]) -> None: # Each worker owns its downloads and cleanup, including on failure. with tempfile.TemporaryDirectory(prefix="hermes-inputs-") as temporary: local = Path(temporary) / "input" origin = archive.fetch(references[0], local) for pin in references: if pin.kind != "tool" and payload is not None: dest = download_path(payload, pin.name) dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(local, dest) if store is not None and any(pin.kind == "tool" for pin in references): tool = next(pin for pin in references if pin.kind == "tool") filename = PurePosixPath(urlsplit(tool.url).path).name if not filename or filename in (".", "..") or "\\" in filename: raise ValueError(f"Invalid archive filename: {tool.url}") with store.install_lock(), store.scratch() as scratch: staged = scratch / "archive" staged.mkdir() shutil.copyfile(local, staged / filename) entry = store.entry(f"fetch-{digest}") if entry.exists(): shutil.rmtree(entry) store.publish(staged, entry.name) print(f" {references[0].name}: {origin} -> {object_key(digest)}", flush=True) with ThreadPoolExecutor(max_workers=len(groups)) as pool: futures = [pool.submit(stage_digest, digest, references) for digest, references in groups.items()] for future in as_completed(futures): future.result() return len(groups) def main(argv=None) -> int: from pm import paths parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--target", choices=ALL_TARGETS) parser.add_argument("--payload", type=Path, help="seed Termux runtime-library downloads") parser.add_argument("--store", type=Path, help="seed this PM store's disposable input cache") args = parser.parse_args(argv) pins = pinned_inputs(paths.repo_root(), target=args.target) count = stage_inputs(pins, archive=Archive(*r2.credentials()), store=Store(args.store.resolve()) if args.store else None, payload=args.payload.resolve() if args.payload else None) print(f"Verified {count} unique pinned inputs in R2.", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main())