Files
computer-playbook/docs/contributing/artefact/image/mirror.md
kevinveenbirkenbachandClaude Opus 5.5 088c8be14f fix(ci): pull workspace images through the GHCR mirror like the deploy jobs
The workspace jobs pulled every image anonymously from Docker Hub and
two of them failed on its rate limit in run 39059128678 (#990): the
fedora swarm drill on busybox:1.38 on all three nodes, the centos
compose drill while building tor-smtp_custom. The regular deploy jobs
pull through the GHCR mirror, but the mirror was tied to being a real
GitHub runner: Profile.image_mirror_enabled() returned
runs_on_github(), and the swarm routine generated mirrors.yml only when
cli.meta.runtime reported github, which is act inside make
swarm-zombie.

The mirror is now its own capability, INFINITO_GHCR_MIRROR (next to
INFINITO_GHCR_MIRROR_PREFIX), written as a run fact like
INFINITO_CACHE_STACK: true on a real GitHub runner,
false everywhere else, and a non-empty exported value wins for the run.
Profile.image_mirror_enabled() reads the declaration and keeps
runs_on_github() as the fallback, and 02_provision_inventory.sh gates
on the same value instead of the runtime name. make swarm-zombie
forwards it into act through ACT_ENV, since act ignores the host env.

call-test-workspace.yml declares INFINITO_GHCR_MIRROR=true and passes
INFINITO_GHCR_MIRROR_PREFIX into the workspace container. Nothing else
of the workspace changes: is_ci, runs_on_github, the cache stack and
the docker root stay local-like. The step that loads the rescue paths
now removes the .env it generated on the runner, because on debian
rows load.sh sourced it inside the container and the run inherited
INFINITO_RUNNING_ON_GITHUB=true.

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

6.2 KiB

Image Mirroring 🪞

This document explains why image mirroring exists, how it works, and what each component does. For how images are declared in roles, see origin.md.

Why Mirrors Exist 🎯

Upstream registries (Docker Hub, quay.io, mcr.microsoft.com, ghcr.io) impose rate limits and occasionally have availability issues. CI on GitHub-hosted runners also sees IP-range- and geo-dependent failures, plus transient 403, 429, and 5xx responses when many jobs pull the same image directly from upstream. This is especially visible with Playwright images sourced from mcr.microsoft.com.

To solve this, CI mirrors all upstream images used in the project to GHCR (ghcr.io) under the project namespace before deploy tests run. Deploy jobs then pull from the GHCR mirror instead of the upstream registry, so a temporary upstream rate limit, outage, or geo-block does not fail an otherwise healthy change.

This also enables fork PRs to pull images without needing upstream credentials, because the mirror is public.

Naming Convention 📛

Mirrored images follow this scheme:

ghcr.io/{namespace}/{repository}/mirror/{registry}/{name}:{version}
Segment Example
namespace kevinveenbirkenbach
repository infinito-nexus-core
registry docker.io, quay.io, mcr.microsoft.com, ghcr.io
name nextcloud, keycloak/keycloak, prom/prometheus
version latest, 31.0.0

Example: ghcr.io/kevinveenbirkenbach/infinito-nexus-core/mirror/docker.io/nextcloud:production-fpm-alpine

Components 🧩

Image Discovery 🔍

utils/docker/image/discovery.py discovers the role images via iter_role_images(). It leaves out every role whose lifecycle is eol (is_maintained() in utils/update/base.py), so neither the mirror nor mirrors.yml covers those images. See origin.md for the declaration format and supported registries.

GHCRProvider 🐳

cli/contributing/mirror/providers.py contains the GHCRProvider class, which computes the destination image name via image_base(img) and provides add_args / from_args so all CLI tools share a single argument definition (SPOT).

Resolver 🗺️

cli/contributing/mirror/resolver/__main__.py reads all role images and outputs a mirrors.yml file under a single applications.<role>.services.<service> map:

applications:
  <role>:
    services:
      <service>:
        image: ghcr.io/{namespace}/{repository}/mirror/{registry}/{name}
        version: <tag>

This file is consumed by the inventory creator to substitute mirror URLs into host variables.

Sync 🔄

cli/contributing/mirror/sync/__main__.py copies each image from the upstream source to the GHCR mirror destination using skopeo copy. Supports --only-missing to skip already-mirrored images and --images-per-hour for throttling.

Wait Script ⏳

scripts/meta/wait/mirrors.sh waits for fork PRs until all required mirror images are available in GHCR before letting the deploy tests proceed. This is necessary because fork PRs cannot push images themselves; the pull_request_target event handles that.

CI Integration 🔄

The mirror workflow runs as stage 8 of the CI pipeline. See ci.md. It runs in parallel with the DNS tests and MUST complete before deploy tests start.

CI Flow 📋

  1. Image discovery scans role declarations in meta/services.yml.
  2. call-images-mirror-missing.yml copies only missing upstream refs into GHCR via cli.contributing.mirror.sync --only-missing. Optional Docker Hub credentials reduce source-side rate limits during that sync.
  3. Fork PRs cannot publish packages themselves, so their untrusted pull_request runs wait in scripts/meta/wait/mirrors.sh until a trusted producer run has published the required refs.
  4. Inventory generation writes the resulting mirror refs from mirrors.yml back into host vars.
  5. Deploy and test jobs resolve images from ghcr.io/{namespace}/{repository}/mirror/... instead of pulling directly from Docker Hub, MCR, or other upstream registries.

INFINITO_GHCR_MIRROR decides whether a run uses the mirror. The generated .env sets it to true on a real GitHub runner and to false everywhere else, act included. A non-empty exported value overrides that for one run. call-test-workspace.yml exports true, so the workspace suite runs like a local setup and still pulls from GHCR, in its compose drills and, through make swarm-zombie, in its swarm drills.

The mirrors file is generated into the inventory directory. The inventory creator applies mirror image overrides to host variables via cli/administration/inventory/provision/mirror_overrides.py, which reads mirrors.yml.applications and writes applications.{role}.services.{service}.image (and .version) into host vars subject to the per-service mirror_policy.

Role templates read image refs via lookup('config', '<role>', 'services.<service>.image') / .version, which resolves against the merged applications.* map. The mirror override applied above is what consumers see.

Mirror Policy 📋

Each service in host vars MAY carry a mirror_policy field that controls how the override is applied:

Policy Behavior
if_missing (default) Fill image/version only if they are blank or absent
force Always overwrite image/version from the mirror
skip Never touch this service

Adding a New Mirrored Image 🆕

No manual registration is needed. Images declared in a role are automatically discovered and included in the next mirror run. See origin.md for the correct declaration format.

For CI-critical images, you SHOULD pin an exact upstream tag instead of using mutable tags such as latest. This is especially important for Playwright images mirrored from mcr.microsoft.com, where exact tags avoid CDN propagation races and make flaky upstream releases easier to diagnose.

Cleanup 🗑️

To delete stale private GHCR mirror packages (e.g. packages pushed before GITHUB_TOKEN authentication was adopted), see cleanup.md.