Files
computer-playbook/utils/docker/version_updater.py
T
Environment TestandClaude Sonnet 4.6 eebb3b9788 refactor(utils): move utils/docker/image_*.py into utils/docker/image/ subpackage
- Move image_discovery.py → utils/docker/image/discovery.py
- Move image_ref.py → utils/docker/image/ref.py
- Add utils/docker/image/__init__.py
- Update all imports and references across cli/, tests/, utils/, and docs/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 19:00:02 +02:00

356 lines
11 KiB
Python

from __future__ import annotations
import json
import re
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote, urlencode
import yaml
from utils.docker.image.discovery import iter_role_images
from utils.docker.image.ref import (
DOCKER_HUB_REGISTRIES,
GHCR_REGISTRY,
split_registry_and_name,
)
_SEMVER_RE = re.compile(r"^v?\d+(\.\d+){0,3}$")
_NOCHECK_TAG = "# nocheck: docker-version"
_KEY_RE = re.compile(r"^(?P<indent>\s*)(?P<key>[A-Za-z0-9_-]+):(?P<rest>.*)$")
_VERSION_VALUE_RE = re.compile(
r"^(?P<prefix>\s*version\s*:\s*)(?P<quote>[\"']?)(?P<value>[^\"'#\s]+)(?P=quote)(?P<suffix>\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_semver(value: str) -> bool:
return bool(_SEMVER_RE.match(str(value).strip()))
def version_key(tag: str) -> tuple[int, ...]:
value = str(tag).strip().lstrip("v")
parts = tuple(int(part) for part in value.split("."))
return parts + (0,) * (4 - len(parts))
def version_depth(tag: str) -> int:
return len(str(tag).strip().lstrip("v").split("."))
def latest_semver(tags: list[str], depth: int) -> str | None:
candidates = [tag for tag in tags if is_semver(tag) and version_depth(tag) == depth]
return max(candidates, key=version_key, default=None)
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(
url, headers={"User-Agent": "infinito-nexus-version-updater"}
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
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(
token_url, headers={"User-Agent": "infinito-nexus-version-updater"}
)
with urllib.request.urlopen(req, timeout=15) as resp:
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(
tags_url,
headers={
"Authorization": f"Bearer {token}",
"User-Agent": "infinito-nexus-version-updater",
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
body = json.loads(resp.read().decode())
except (urllib.error.URLError, OSError, json.JSONDecodeError):
return []
return body.get("tags") or []
def suppressed_services(config_path: Path) -> set[str]:
raw = config_path.read_text(encoding="utf-8")
lines = raw.splitlines()
suppressed_lines: set[int] = set()
for index, line in enumerate(lines):
if not re.search(r"^\s+version\s*:", line):
continue
for prev_index in range(index - 1, -1, -1):
prev = lines[prev_index].strip()
if prev == _NOCHECK_TAG:
suppressed_lines.add(index)
break
if prev and not prev.startswith("#"):
break
if not suppressed_lines:
return set()
root = yaml.compose(raw)
if not isinstance(root, yaml.MappingNode):
return set()
names: set[str] = set()
for key, value in root.value:
if key.value != "compose" or not isinstance(value, yaml.MappingNode):
continue
for child_key, child_value in value.value:
if child_key.value != "services" or not isinstance(
child_value, yaml.MappingNode
):
continue
for service_key, service_value in child_value.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 not ref.role.startswith("web-"):
continue
if ref.source_file != "config/main.yml":
continue
if not is_semver(ref.version):
continue
config_path = roles_root / ref.role / "config" / "main.yml"
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)
image_tags: dict[str, list[str]] = {}
updates: list[DockerImageVersionUpdate] = []
for entry in entries:
if entry.image in image_tags:
continue
if is_dockerhub(entry.image):
image_tags[entry.image] = fetch_dockerhub_tags(entry.image)
elif is_ghcr(entry.image):
image_tags[entry.image] = fetch_ghcr_tags(entry.image)
for entry in entries:
tags = image_tags.get(entry.image, [])
if not tags:
continue
latest = latest_semver(tags, version_depth(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:
lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
changed = False
compose_indent: int | None = None
services_indent: int | None = None
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 compose_indent is not None and indent <= compose_indent and key != "compose":
compose_indent = None
services_indent = None
current_service = None
current_service_indent = None
if compose_indent is None:
if key == "compose":
compose_indent = indent
continue
if (
services_indent is not None
and indent <= services_indent
and key != "services"
):
services_indent = None
current_service = None
current_service_indent = None
if key == "services" and indent > compose_indent:
services_indent = indent
current_service = None
current_service_indent = None
continue
if services_indent is None:
continue
if indent == services_indent + 2:
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