from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from utils.cache.yaml import load_yaml_any as _load_yaml_cached from utils.docker.image.pin import pull_reference from utils.roles.mapping import ROLE_FILE_META_SERVICES from utils.update.base import is_maintained if TYPE_CHECKING: from collections.abc import Iterable from pathlib import Path DOCKER_HUB_PREFIXES = ( "docker.io/", "registry-1.docker.io/", "index.docker.io/", ) _ALL_REGISTRY_PREFIXES = ( *DOCKER_HUB_PREFIXES, "quay.io/", "ghcr.io/", "mcr.microsoft.com/", "registry.opencode.de/", "registry.gitlab.com/", ) _MIRRORABLE_REGISTRIES = frozenset( { "docker.io", "registry-1.docker.io", "index.docker.io", "quay.io", "ghcr.io", "mcr.microsoft.com", "registry.opencode.de", "registry.gitlab.com", } ) @dataclass(frozen=True) class ImageRef: """Role-declared container image reference. Fields: role: e.g. ``svc-db-postgres`` service: e.g. ``postgres`` name: canonical image name without registry, e.g. ``postgis/postgis`` version: e.g. ``17-3.5`` source: full pull ref, e.g. ``docker.io/library/postgres:16`` registry: source registry hostname, e.g. ``docker.io``, ``quay.io`` source_file: file the ref was read from, role-relative for a role image and repository-relative for a ``runner`` image """ role: str service: str name: str version: str source: str registry: str = "docker.io" source_file: str = ROLE_FILE_META_SERVICES def load_yaml(path: Path) -> dict: obj = _load_yaml_cached(str(path), default_if_missing={}) or {} return obj if isinstance(obj, dict) else {} def _detect_registry(image: str) -> str: """Return the normalised registry hostname for *image*. Implicit Docker Hub images (e.g. ``postgres``, ``postgis/postgis``) return ``"docker.io"``. """ image = image.strip() first = image.split("/", 1)[0] if first in ("docker.io", "registry-1.docker.io", "index.docker.io"): return "docker.io" if "." in first or ":" in first: return first # e.g. "quay.io", "ghcr.io", "mcr.microsoft.com" return "docker.io" # implicit Docker Hub def _strip_registry_prefix(image: str) -> str: """Remove the registry prefix (if any) from *image*, return bare reference.""" image = image.strip() for prefix in _ALL_REGISTRY_PREFIXES: if image.startswith(prefix): return image[len(prefix) :] return image def is_docker_hub_image(image: str) -> bool: """Return True iff *image* is hosted on Docker Hub (kept for backward compat).""" return _detect_registry(image) == "docker.io" def is_mirrorable_image(image: str) -> bool: """Return True for images from any supported public registry that we mirror.""" image = image.strip() if not image: return False return _detect_registry(image) in _MIRRORABLE_REGISTRIES def normalize_docker_hub(image: str) -> str: """Remove Docker Hub registry prefix if present (backward compat).""" image = image.strip() for prefix in DOCKER_HUB_PREFIXES: if image.startswith(prefix): return image[len(prefix) :] return image def split_name_and_suffix(image: str) -> tuple[str, str]: """ Split image into base name and suffix: repo/name:tag -> (repo/name, :tag) repo/name@sha.. -> (repo/name, @sha..) repo/name -> (repo/name, "") """ image = image.strip() if "@sha256:" in image: base, digest = image.split("@", 1) return base, f"@{digest}" last_slash = image.rfind("/") last_colon = image.rfind(":") if last_colon > last_slash: return image[:last_colon], image[last_colon:] return image, "" def docker_hub_source(image: str, version: str) -> str: """Build canonical Docker Hub source ref for skopeo. postgres + 16 -> docker.io/library/postgres:16 postgis/postgis + 17 -> docker.io/postgis/postgis:17 """ image = normalize_docker_hub(image) base, _suffix = split_name_and_suffix(image) if "/" not in base: base = f"library/{base}" return pull_reference(f"docker.io/{base}", version) def image_source(image: str, version: str) -> str: """Full pull reference for skopeo (without the ``docker://`` scheme). Examples:: postgres + 16 -> docker.io/library/postgres:16 postgis/postgis + 17-3.5 -> docker.io/postgis/postgis:17-3.5 quay.io/keycloak/keycloak + latest -> quay.io/keycloak/keycloak:latest ghcr.io/mastodon/mastodon + latest -> ghcr.io/mastodon/mastodon:latest """ registry = _detect_registry(image) bare = _strip_registry_prefix(image) base, _suffix = split_name_and_suffix(bare) if registry == "docker.io" and "/" not in base: base = f"library/{base}" return pull_reference(f"{registry}/{base}", version) def canonical_image_name(image: str) -> str: """Canonical image name without registry and without tag/digest. Strips any known registry prefix so that the name can be used uniformly regardless of the source registry: quay.io/keycloak/keycloak -> keycloak/keycloak ghcr.io/mastodon/mastodon -> mastodon/mastodon docker.io/postgres -> postgres postgres -> postgres """ bare = _strip_registry_prefix(image) base, _suffix = split_name_and_suffix(bare) return base def iter_role_images(repo_root: Path) -> Iterable[ImageRef]: """ Yield all ImageRef entries discovered across all roles in *repo_root*. Source: roles/**/meta/services.yml → .{image,version}. Roles the updaters do not maintain (``is_maintained``) are left out. See docs/contributing/artefact/image.md for the full format reference. """ roles_dir = (repo_root / "roles").resolve() for services_file in roles_dir.glob(f"**/{ROLE_FILE_META_SERVICES}"): role_name = services_file.parent.parent.name if not is_maintained(roles_dir, role_name): continue services = load_yaml(services_file) if not isinstance(services, dict): continue for service_name, service in services.items(): if not isinstance(service, dict): continue image = str(service.get("image") or "").strip() version = str(service.get("version") or "").strip() if not image or not version: continue if not is_mirrorable_image(image): continue yield ImageRef( role=role_name, service=str(service_name), name=canonical_image_name(image), version=version, source=image_source(image, version), registry=_detect_registry(image), source_file=ROLE_FILE_META_SERVICES, ) RUNNER_ROLE = "runner" RUNNER_COMPOSE_FILES = ("compose.yml", "compose/cache.override.yml") RUNNER_ENV_IMAGES = ("INFINITO_CACHE_PACKAGE_FRONTEND_INIT_IMAGE",) def _runner_image(service: str, image: str, source_file: str) -> ImageRef | None: base, suffix = split_name_and_suffix(image) if not suffix.startswith(":") or not is_mirrorable_image(base): return None version = suffix[1:] return ImageRef( role=RUNNER_ROLE, service=service, name=canonical_image_name(base), version=version, source=image_source(base, version), registry=_detect_registry(base), source_file=source_file, ) def iter_runner_images(repo_root: Path) -> Iterable[ImageRef]: """Yield the images the CI runner pulls itself, outside every role. Args: repo_root: repository root holding the RUNNER_COMPOSE_FILES and ``default.env``. A compose ``image`` that interpolates a variable, carries no tag or is pinned by digest yields nothing. """ from utils.env.parser import parse_static_env for source_file in RUNNER_COMPOSE_FILES: services = load_yaml(repo_root / source_file).get("services") or {} for service, spec in services.items(): image = ( str(spec.get("image") or "").strip() if isinstance(spec, dict) else "" ) if not image or "${" in image: continue ref = _runner_image(str(service), image, source_file) if ref is not None: yield ref declared = parse_static_env(repo_root / "default.env") for key in RUNNER_ENV_IMAGES: ref = _runner_image(key, declared[key], "default.env") if ref is not None: yield ref