mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-12 13:47:01 +00:00
services.yml (15 conflicts): kept main's design, docs, seaweedfs, resource and healthcheck changes and re-applied the PR's security.suppressions blocks on top. web-app-nextcloud: main replaced the aio-talk images of talk and talk_recording with custom builds, so their image_tag "latest" suppressions are dropped. web-app-seaweedfs: main renamed the proxy service to seaweedfs-console on the same nginx 1.31-alpine tag, so its suppression moves there. Stale image_tag suppressions dropped where main moved the version (penpot backend and exporter 2.19.1, checkmk 2.4.0p40, espocrm 10.1.1, fider v0.39.0, six gitlab services v19.5.4, element v1.12.34, odoo 19.0, pixelfed v0.15.2, semaphore v2.20.24, lmstudio 22.04); the files equal what drop_image_tag_entries yields. utils/github/variant: kept main's architecture axis, pins and runner; the engine stays the odometer digit above distro and filesystem and its glyph follows the architecture glyph in the label, regex, docstring and workflows README; TestEngineAxis passes network_input. utils/docker/registry.py: main made platform_digest(manifests, ...) public and added manifest_digest, so the PR's image level helper becomes image_platform_digest on top of both; the scan CLI and its test follow. compose.py: kept main's cache artifact rendering and the PR's vulnerability database preparation; main's two new up() tests mock _prepare_vulnerability_db like the PR's. act test_workflow.py (add/add): one file with both suites on main's NUL separated stub; env_items leaves out the wrapper's own database pin. call-test-deploy.yml: main's host metrics sampler runs before the PR's database restore and pin steps. passthrough.py, pools.py, swarm matrix.py: both sides' keys and lines kept; hlth-runtime README keeps the PR's scanner features without the Credits section main removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
4.9 KiB
Python
156 lines
4.9 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 image a non-eol role declares 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 Docker Hub copies per hour (best-effort); other registries are not throttled.",
|
|
)
|
|
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)):
|
|
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):
|
|
if not provider.is_stale(img):
|
|
print(
|
|
f"[mirror] {label}: destination exists, skipping ({dest})",
|
|
flush=True,
|
|
)
|
|
continue
|
|
print(
|
|
f"[mirror] {label}: upstream re-pushed the tag, refreshing "
|
|
f"{src} -> {dest}",
|
|
flush=True,
|
|
)
|
|
try:
|
|
provider.mirror(img)
|
|
except subprocess.CalledProcessError as e:
|
|
print(
|
|
f"[mirror] {label}: refresh FAILED (exit={e.returncode}), "
|
|
"keeping the existing copy",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
continue
|
|
|
|
if img.registry == "docker.io":
|
|
last_start_ts = _throttle_before_next_copy(
|
|
images_per_hour=args.images_per_hour,
|
|
last_start_ts=last_start_ts,
|
|
)
|
|
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())
|