Files
computer-playbook/utils/docker/image/discovery.py
kevinveenbirkenbachandClaude Opus 5.5 8a29ba0bfd fix(images): leave eol roles out of image discovery
The scheduled mirror job has failed every day since 2026-09-25 on the same two
images: quay.io/minio/minio and quay.io/minio/mc answer `unauthorized` to
anonymous pulls ("2/197 failed", run 38352569503). Both belong to
web-app-minio, an eol role outside the lifecycle envelope that default.env
tests, so no CI deploy pulls them.

iter_role_images now skips every role that is_maintained() reports as
unmaintained, the check the version updaters already apply to the same list.
On the tree the discovery yields 187 images instead of 192; the five it drops
belong to the four eol roles web-app-confluence, web-app-jira, web-app-minio
and web-app-phpldapadmin. The mirror sync, resolver and wait read this list,
and their descriptions and mirror.md now say so.

The roles directory is resolved first: is_maintained reads a relative path as
a role name and would keep every role.

475ab9b386 on feature/store-i18n takes the same approach with a lifecycle
reader of its own; main already has is_maintained for it.

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

284 lines
8.7 KiB
Python

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 → <entity>.{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