"""Detect and rewrite outdated Docker image `version:` tags in `roles/*/meta/services.yml`. Counterpart to :mod:`utils.update.repository` (which handles git refs). Both share the semver primitives in :mod:`utils.update.base`; this module owns the Docker-Hub / GHCR registry lookups and the YAML walker that rewrites the matching ``version:`` line. Suppress a check by placing ``# nocheck: docker-version`` on the line directly above the ``version:`` key. """ from __future__ import annotations import concurrent.futures import json import re import time import urllib.error import urllib.request from dataclasses import dataclass from typing import TYPE_CHECKING from urllib.parse import quote, urlencode import yaml from utils.annotations.suppress import is_suppressed_at from utils.cache.files import read_text from utils.docker.image.discovery import iter_role_images from utils.docker.image.ref import ( DOCKER_HUB_REGISTRIES, GHCR_REGISTRY, MCR_REGISTRY, split_registry_and_name, ) from utils.roles.mapping import ROLE_FILE_META_SERVICES from utils.update.base import ( is_semver, latest_semver, resolve_max_fetch_workers, version_depth, version_flavor, version_key, ) if TYPE_CHECKING: from pathlib import Path _KEY_RE = re.compile(r"^(?P\s*)(?P[A-Za-z0-9_-]+):(?P.*)$") _VERSION_VALUE_RE = re.compile( r"^(?P\s*version\s*:\s*)" r"(?P[\"']?)(?P[^\"'#\s]+)(?P=quote)" r"(?P\s*(?:#.*)?)$" ) @dataclass(frozen=True) class DockerImageVersionEntry: role: str service: str image: str version: str config_path: Path @dataclass(frozen=True) class DockerImageVersionUpdate: entry: DockerImageVersionEntry latest: str def is_dockerhub(image: str) -> bool: parsed = split_registry_and_name(image) if parsed is None: return False registry, _name = parsed return registry is None or registry in DOCKER_HUB_REGISTRIES def dockerhub_repo(image: str) -> str: parsed = split_registry_and_name(image) if parsed is None: raise ValueError(f"Invalid Docker image reference: {image!r}") registry, name = parsed if registry is not None and registry not in DOCKER_HUB_REGISTRIES: raise ValueError(f"Image is not a Docker Hub reference: {image!r}") return name if "/" in name else f"library/{name}" def fetch_dockerhub_tags(image: str, max_pages: int = 5) -> list[str]: repo = dockerhub_repo(image) tags: list[str] = [] for page in range(1, max_pages + 1): url = ( f"https://hub.docker.com/v2/repositories/{repo}/tags/" f"?page_size=100&page={page}" ) req = urllib.request.Request( # noqa: S310 url, headers={"User-Agent": "infinito-nexus-version-updater"} ) try: with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 if resp.status == 429: time.sleep(2) continue body = json.loads(resp.read().decode()) except (urllib.error.URLError, OSError, json.JSONDecodeError): break tags.extend(item["name"] for item in body.get("results", [])) if not body.get("next"): break return tags def is_ghcr(image: str) -> bool: parsed = split_registry_and_name(image) return parsed is not None and parsed[0] == GHCR_REGISTRY def ghcr_repo(image: str) -> str: parsed = split_registry_and_name(image) if parsed is None or parsed[0] != GHCR_REGISTRY: raise ValueError(f"Image is not a GHCR reference: {image!r}") return parsed[1] def fetch_ghcr_tags(image: str) -> list[str]: name = ghcr_repo(image) token_query = urlencode( {"scope": f"repository:{name}:pull", "service": GHCR_REGISTRY} ) token_url = f"https://{GHCR_REGISTRY}/token?{token_query}" try: req = urllib.request.Request( # noqa: S310 token_url, headers={"User-Agent": "infinito-nexus-version-updater"} ) with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 token_body = json.loads(resp.read().decode()) token = token_body.get("token") or token_body.get("access_token") except (urllib.error.URLError, OSError, json.JSONDecodeError): return [] if not token: return [] tags_url = f"https://{GHCR_REGISTRY}/v2/{quote(name, safe='/')}/tags/list" req = urllib.request.Request( # noqa: S310 tags_url, headers={ "Authorization": f"Bearer {token}", "User-Agent": "infinito-nexus-version-updater", }, ) try: with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 body = json.loads(resp.read().decode()) except (urllib.error.URLError, OSError, json.JSONDecodeError): return [] return body.get("tags") or [] def is_mcr(image: str) -> bool: parsed = split_registry_and_name(image) return parsed is not None and parsed[0] == MCR_REGISTRY def mcr_repo(image: str) -> str: parsed = split_registry_and_name(image) if parsed is None or parsed[0] != MCR_REGISTRY: raise ValueError(f"Image is not an MCR reference: {image!r}") return parsed[1] _LINK_NEXT_RE = re.compile(r'<([^>]+)>\s*;\s*rel="next"', re.IGNORECASE) def fetch_mcr_tags(image: str, max_pages: int = 10) -> list[str]: # MCR speaks the standard Docker Registry V2 API (anonymous for public # repos). Tag pages are returned in 100-tag chunks; the next page # location ships via the standard RFC 5988 `Link: <…>; rel="next"` # response header. name = mcr_repo(image) base = f"https://{MCR_REGISTRY}" url = f"{base}/v2/{quote(name, safe='/')}/tags/list?n=100" tags: list[str] = [] for _ in range(max_pages): req = urllib.request.Request( # noqa: S310 url, headers={"User-Agent": "infinito-nexus-version-updater"} ) try: with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310 body = json.loads(resp.read().decode()) link_header = resp.headers.get("Link", "") except (urllib.error.URLError, OSError, json.JSONDecodeError): break tags.extend(body.get("tags") or []) match = _LINK_NEXT_RE.search(link_header) if not match: break next_link = match.group(1) url = next_link if next_link.startswith("http") else f"{base}{next_link}" return tags def suppressed_services(config_path: Path) -> set[str]: """Return service names whose ``version:`` line is annotated with the unified ``# nocheck: docker-version`` marker. The file root of ``meta/services.yml`` IS the services map; there is no ``services.`` wrapper to walk into. """ raw = read_text(str(config_path)) lines = raw.splitlines() suppressed_lines: set[int] = { index for index, line in enumerate(lines) if re.search(r"^\s+version\s*:", line) and is_suppressed_at(lines, index + 1, "docker-version") } if not suppressed_lines: return set() root = yaml.compose(raw) if not isinstance(root, yaml.MappingNode): return set() names: set[str] = set() for service_key, service_value in root.value: if not isinstance(service_value, yaml.MappingNode): continue for field_key, _field_value in service_value.value: if ( field_key.value == "version" and field_key.start_mark.line in suppressed_lines ): names.add(service_key.value) return names def collect_entries(repo_root: Path) -> list[DockerImageVersionEntry]: roles_root = repo_root / "roles" entries: list[DockerImageVersionEntry] = [] for ref in iter_role_images(repo_root): if ref.source_file != ROLE_FILE_META_SERVICES: continue if not is_semver(ref.version): continue config_path = roles_root / ref.role / ROLE_FILE_META_SERVICES if ref.service in suppressed_services(config_path): continue if ref.registry == "docker.io": image = ref.name else: image = f"{ref.registry}/{ref.name}" entries.append( DockerImageVersionEntry( role=ref.role, service=ref.service, image=image, version=ref.version, config_path=config_path, ) ) return entries def find_outdated_updates(repo_root: Path) -> list[DockerImageVersionUpdate]: entries = collect_entries(repo_root) updates: list[DockerImageVersionUpdate] = [] def _fetch(image: str) -> tuple[str, list[str]]: if is_dockerhub(image): return image, fetch_dockerhub_tags(image) if is_ghcr(image): return image, fetch_ghcr_tags(image) if is_mcr(image): return image, fetch_mcr_tags(image) return image, [] unique_images = { entry.image for entry in entries if is_dockerhub(entry.image) or is_ghcr(entry.image) or is_mcr(entry.image) } with concurrent.futures.ThreadPoolExecutor( max_workers=resolve_max_fetch_workers() ) as pool: image_tags: dict[str, list[str]] = dict(pool.map(_fetch, unique_images)) for entry in entries: tags = image_tags.get(entry.image, []) if not tags: continue latest = latest_semver( tags, version_depth(entry.version), version_flavor(entry.version), ) if latest and version_key(entry.version) < version_key(latest): updates.append(DockerImageVersionUpdate(entry=entry, latest=latest)) return updates def update_config_versions(config_path: Path, service_versions: dict[str, str]) -> bool: """Rewrite each ``.version:`` value in ``meta/services.yml``. The file root IS the services map (no ``services.`` wrapper), so the walker tracks one nesting level: top-level service keys and their immediate ``version:`` field. """ lines = config_path.read_text( # nocheck: cache-read — read-then-write of the same file; cached read would go stale encoding="utf-8" ).splitlines(keepends=True) changed = False services_indent: int = 0 current_service: str | None = None current_service_indent: int | None = None for index, line in enumerate(lines): match = _KEY_RE.match(line) if match is None: continue indent = len(match.group("indent")) key = match.group("key") if indent == services_indent: current_service = key current_service_indent = indent continue if current_service is None or current_service_indent is None: continue if indent <= current_service_indent: current_service = None current_service_indent = None continue if key != "version" or indent != current_service_indent + 2: continue replacement = service_versions.get(current_service) if replacement is None: continue version_match = _VERSION_VALUE_RE.match(line.rstrip("\n")) if version_match is None: continue quote = version_match.group("quote") current_value = version_match.group("value") if current_value == replacement: continue suffix = version_match.group("suffix") new_line = f"{version_match.group('prefix')}{quote}{replacement}{quote}{suffix}" if line.endswith("\n"): new_line += "\n" lines[index] = new_line changed = True if changed: config_path.write_text("".join(lines), encoding="utf-8") return changed def apply_updates(updates: list[DockerImageVersionUpdate]) -> list[Path]: grouped: dict[Path, dict[str, str]] = {} for update in updates: grouped.setdefault(update.entry.config_path, {})[update.entry.service] = ( update.latest ) changed_paths: list[Path] = [] for config_path, service_versions in sorted(grouped.items()): if update_config_versions(config_path, service_versions): changed_paths.append(config_path) return changed_paths