The termux-main pool deletes a package's previous archive when it rebuilds, so the runtime-lib pin table and the bionic lock rows rot without warning. The last rotation broke a build on eight rows at once, and the stager's concurrent downloads only surfaced whichever 404 won the race. pm now owns the pin table it repairs: scripts/termux/runtime_libs.json moves to pm/termux_runtime_libs.json, so pins live in pm/ and scripts consume them — the direction scripts/ci/archive_inputs.py already reads pm/lock.json in. `hermes pm update --termux` repins exactly the rows whose archive the pool has replaced, hashing each replacement against the index SHA256 before writing url/version/hash together. `--check` reports without writing and exits 1, so a retired pin can fail a cheap preflight instead of a payload build. It is a repair, not an update: an alive pin is never moved, because a repin can land a rebuilt library under a moved soname and the table is the payload's recursive DT_NEEDED closure. A pin whose package the pool has dropped outright is reported and left alone. `--termux` runs alone — names/--target/--uv/--npm are ignored, since repairing foreign-target pins is not a version resolution. Verified: `pm update --termux --check` against the live pool reports 89 rows served; a table deliberately pinned to the retired libiconv 1.18-1 repins to 1.19 with the pool's hash through the real network path; 19 new tests; the tests/pm, tests/ci and tests/scripts suites have the same failure set as the base commit (91 pre-existing Windows environment failures, none new).
211 lines
9.4 KiB
Python
211 lines
9.4 KiB
Python
"""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())
|