Files
computer-playbook/cli/contributing/mirror/sync/__main__.py
T
kevinveenbirkenbachandClaude Opus 5.5 1ba796d7f1 fix(ci): pull the images the runner uses itself from the GHCR mirror
The GHCR mirror covered role images only. The images the runner pulls on
its own went straight to Docker Hub: coredns from compose.yml, the cache
stack in compose/cache.override.yml and alpine:latest, the init image of
scripts/docker/cache/package-frontend/certs.sh. With the anonymous
githubactions quota spent, the guide step of Postmarks#0 (main run
37954203718) and Shopware#2 (#872) died on "toomanyrequests" while pulling
alpine:latest, after coredns had hit the same limit.

iter_runner_images (utils/docker/image/discovery.py) finds those images,
and the mirror sync and the fork-PR wait now cover them next to the role
images. On a real GitHub runner compose_file_args layers a generated
build/compose/runner-mirror.override.yml that points only the services of
the layered compose files at their mirror, and the certificate generator
gets the mirrored init image. Local runs and act are unchanged, and the
coredns literal stays in compose.yml for Dependabot. test-dns now waits for
images-mirror-missing like the deploy and workspace jobs, since it brings up
the stack too.

Verified with docker compose config on a simulated GitHub runner: the
service set is unchanged and only the runner images point at
ghcr.io/infinito-nexus/core/mirror/docker.io/..., and an existing role mirror
under that scheme pulls anonymously.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:37:41 +02:00

140 lines
4.2 KiB
Python

from __future__ import annotations
import argparse
import subprocess
import sys
import time
from itertools import chain
from pathlib import Path
from cli.contributing.mirror.providers import GHCRProvider
from utils.docker.image.discovery import iter_role_images, iter_runner_images
def _validate_positive_int(value: str) -> int:
try:
n = int(value)
except ValueError as e:
raise argparse.ArgumentTypeError("must be an integer") from e
if n <= 0:
raise argparse.ArgumentTypeError("must be > 0")
return n
def _throttle_before_next_copy(
*, images_per_hour: int | None, last_start_ts: float | None
) -> float:
"""
If images_per_hour is set, ensure at least (3600 / images_per_hour) seconds
have elapsed since last_start_ts before returning.
Returns the (possibly updated) start timestamp for the next copy.
"""
if not images_per_hour:
return time.monotonic()
min_interval = 3600.0 / float(images_per_hour)
now = time.monotonic()
if last_start_ts is not None:
elapsed = now - last_start_ts
remaining = min_interval - elapsed
if remaining > 0:
print(
f"[mirror] throttling: sleeping {remaining:.1f}s "
f"(limit={images_per_hour} images/hour)",
flush=True,
)
time.sleep(remaining)
return time.monotonic()
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Mirror every role-declared image and every image the CI runner "
"pulls itself to the configured GHCR registry, "
"optionally throttled and optionally skipping already-mirrored "
"tags."
),
)
parser.add_argument("--repo-root", default=".")
GHCRProvider.add_args(parser)
parser.add_argument(
"--only-missing",
action="store_true",
help="Mirror only images that do not exist in the destination registry.",
)
parser.add_argument(
"--images-per-hour",
type=_validate_positive_int,
default=None,
help="Optional throttle: max images to mirror per hour (best-effort).",
)
args = parser.parse_args()
provider = GHCRProvider.from_args(args)
repo_root = Path(args.repo_root).resolve()
failures: list[str] = []
total = 0
last_start_ts: float | None = None
for img in chain(iter_role_images(repo_root), iter_runner_images(repo_root)):
last_start_ts = _throttle_before_next_copy(
images_per_hour=args.images_per_hour,
last_start_ts=last_start_ts,
)
total += 1
src = f"docker://{img.source}"
dest = f"docker://{provider.image_base(img)}:{img.version}"
label = f"{img.role}:{img.service} ({img.name}:{img.version})"
try:
if args.only_missing and provider.tag_exists(img):
print(
f"[mirror] {label}: destination exists, skipping ({dest})",
flush=True,
)
continue
print(f"[mirror] {label}: {src} -> {dest}", flush=True)
provider.mirror(img)
except subprocess.CalledProcessError as e:
msg = (
f"{label}: FAILED (exit={e.returncode})\n"
f" cmd: {' '.join(map(str, e.cmd))}"
)
failures.append(msg)
print(
f"[mirror] {label}: FAILED, continuing...",
file=sys.stderr,
flush=True,
)
except Exception as e:
msg = f"{label}: FAILED (unexpected error: {e!r})"
failures.append(msg)
print(
f"[mirror] {label}: FAILED (unexpected), continuing...",
file=sys.stderr,
flush=True,
)
if failures:
print("\n[mirror] SUMMARY: some images failed:", file=sys.stderr)
for f in failures:
print(f"- {f}", file=sys.stderr)
print(f"\n[mirror] Result: {len(failures)}/{total} failed.", file=sys.stderr)
return 1
print(f"\n[mirror] Result: {total}/{total} succeeded.")
return 0
if __name__ == "__main__":
raise SystemExit(main())