Files
computer-playbook/utils/update/source.py
kevinveenbirkenbachandClaude Opus 5.5 c65c5ce48c fix(update): pin only image tags the registry carries
7e82ba6d71 moved web-app-bigbluebutton's bbb-pads to v1.5.11, the tag
https://raw.githubusercontent.com/bigbluebutton/docker/develop/repos/tags
names for the bbb-pads source. alangecker/bbb-docker-pads has no such
image (Docker Hub: newest v1.5.8), so every BigBlueButton deploy pulls a
missing tag and test-external fails "These pinned Docker images do not
exist in their registry (HTTP 404): web-app-bigbluebutton/bbb-pads".

bbb-pads goes back to v1.5.8. The version-source updater now knows the
image a `version` pin tags (pulled directly or as the base of a custom
build) and withholds a bump when the registry answers 404 for the new
tag; an indeterminate answer does not block it, the same line
test-external draws. Run against main, the updater now reports no
update for bbb-pads instead of v1.5.11.

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

500 lines
18 KiB
Python

"""Resolve version pins that declare their own upstream source.
The Docker updater watches ``image`` plus ``version``, the repository updater
``repository`` plus ``ref``. A pin outside both - an ``app_version``, a
``release``, a tag in a registry neither updater speaks - declares where its
upstream lives::
stalwart:
webui_version: "v1.0.5"
update:
key: webui_version
type: git_tags
repository: https://github.com/stalwartlabs/webui.git
``key`` names the pinned key and defaults to ``version``; an entity with
several pins declares a list of such blocks. The types are
``git_tags`` (repository), ``registry_tags`` (image), ``npm`` (package),
``http_regex`` (one url or several, pattern, optionally a template over the
pattern's named groups) and ``script`` (path, run with the current version and
printing the latest one).
An addon in ``meta/addons/<id>.yml`` declares the same fields in its
``update:`` block, beside ``monitored``, ``catalog`` and ``upstream_id``. A
monitored addon of the ``github-releases`` catalog needs no ``type``: its
``upstream_id`` already names the repository whose tags are its versions. An
addon's ``config.archive`` URL carries the pinned version and moves with it.
"""
from __future__ import annotations
import json
import re
import subprocess
import urllib.error
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
from utils.annotations.suppress import is_suppressed_at
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml
from utils.docker.registry import manifest_exists
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update.addons import GITHUB_RELEASES_CATALOG, iter_addon_files
from utils.update.base import (
captured_versions,
is_maintained,
is_semver,
newer_version,
)
from utils.update.docker import (
dockerhub_repo,
fetch_dockerhub_tags,
fetch_ghcr_tags,
fetch_mcr_tags,
is_dockerhub,
is_ghcr,
is_mcr,
)
from utils.update.fetch import TIMEOUT_SECONDS, documents, get
from utils.update.repository import git_ls_remote_tags
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
NOCHECK_MARKER = "version-source"
UNWATCHED_MARKER = "unwatched-version"
UPDATE_KEY = "update"
DEFAULT_KEY = "version"
TYPES = ("git_tags", "registry_tags", "npm", "http_regex", "script")
@dataclass(frozen=True)
class VersionSourceEntry:
"""One pin and the upstream it declares.
Args:
role: role that carries the pin.
entity: service entity in ``meta/services.yml``, or the addon id.
key: pinned key.
current: pinned version.
source: the ``update:`` block.
config_path: file that carries the pin.
line: 1-indexed line of the pin.
addon: whether the pin sits in ``meta/addons/<id>.yml``.
image: image the pin tags when it is the entity's ``version``, pulled
directly or as the base of a ``custom`` build; a bump to a tag
its registry answers 404 for is withheld, an indeterminate answer
does not block it.
"""
role: str
entity: str
key: str
current: str
source: dict[str, Any]
config_path: Path
line: int
addon: bool = False
image: str = ""
@dataclass(frozen=True)
class VersionSourceUpdate:
entry: VersionSourceEntry
latest: str
def dockerhub_named_tags(image: str, match: str) -> list[str]:
"""Return the Docker Hub tags of *image* whose name contains *match*.
Args:
image: Docker Hub reference.
match: substring the registry filters on, reaching tags beyond the
first pages of the unfiltered listing.
"""
query = urlencode({"name": match, "page_size": 100})
url = f"https://hub.docker.com/v2/repositories/{dockerhub_repo(image)}/tags?{query}"
try:
payload = json.loads(get(url))
except (OSError, ValueError):
return []
return [str(result["name"]) for result in payload.get("results") or []]
def registry_v2_tags(image: str, match: str = "") -> list[str]:
"""Return the tags of *image* from its registry's v2 API.
Args:
image: reference including the registry host, e.g.
``gcr.io/cadvisor/cadvisor``.
match: when set and the registry can filter server-side, the
substring every returned tag carries.
"""
if is_dockerhub(image):
return (
dockerhub_named_tags(image, match) if match else fetch_dockerhub_tags(image)
)
if is_ghcr(image):
return fetch_ghcr_tags(image)
if is_mcr(image):
return fetch_mcr_tags(image)
host, _, repository = image.partition("/")
url = f"https://{host}/v2/{repository}/tags/list"
try:
return list(json.loads(get(url)).get("tags") or [])
except urllib.error.HTTPError as error:
if error.code != 401:
return []
challenge = error.headers.get("WWW-Authenticate", "")
except (OSError, ValueError):
return []
fields = dict(re.findall(r'(\w+)="([^"]*)"', challenge))
realm = fields.pop("realm", "")
if not realm:
return []
query = "&".join(f"{name}={value}" for name, value in fields.items())
try:
token = json.loads(get(f"{realm}?{query}")).get("token", "")
payload = get(url, {"Authorization": f"Bearer {token}"})
return list(json.loads(payload).get("tags") or [])
except (OSError, ValueError):
return []
def npm_versions(package: str) -> list[str]:
"""Return every published version of an npm *package*."""
try:
payload = json.loads(get(f"https://registry.npmjs.org/{package}"))
except (OSError, ValueError):
return []
return list((payload.get("versions") or {}).keys())
def http_regex_versions(
urls: str | list[str], pattern: str, template: str = ""
) -> list[str]:
"""Return every version *pattern* names in the documents at *urls*."""
try:
body = documents(urls)
except OSError:
return []
return captured_versions(body, pattern, template)
def script_versions(repo_root: Path, role: str, path: str, current: str) -> list[str]:
"""Return what a role-local updater prints for its pin.
Args:
repo_root: repository root.
role: role the script belongs to.
path: role-relative path of the script.
current: the version currently pinned, passed as the first argument.
"""
script = repo_root / "roles" / role / path
try:
proc = subprocess.run(
[str(script), current],
capture_output=True,
text=True,
timeout=TIMEOUT_SECONDS,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return []
return proc.stdout.split() if proc.returncode == 0 else []
def candidates(entry: VersionSourceEntry, repo_root: Path) -> list[str]:
"""Return every upstream version the entry's declared source offers.
``match`` keeps only the versions carrying that prefix, and ``strip``
removes it again, so a pin without the upstream's ``v`` stays without it.
"""
source = entry.source
kind = source.get("type")
match = str(source.get("match", ""))
if kind == "git_tags":
found = git_ls_remote_tags(str(source["repository"]))
elif kind == "registry_tags":
found = registry_v2_tags(str(source["image"]), match)
elif kind == "npm":
found = npm_versions(str(source["package"]))
elif kind == "http_regex":
found = http_regex_versions(
source["url"],
str(source["pattern"]),
str(source.get("template", "")),
)
elif kind == "script":
found = script_versions(
repo_root, entry.role, str(source["path"]), entry.current
)
else:
return []
if match:
found = [version for version in found if version.startswith(match)]
if source.get("strip"):
found = [version.removeprefix(match) for version in found]
return found
def _key_line(lines: list[str], entity: str, key: str) -> int | None:
"""Return the 1-indexed line of ``key`` inside the block of ``entity``."""
inside = False
for number, line in enumerate(lines, start=1):
if re.match(rf"^{re.escape(entity)}\s*:", line):
inside = True
continue
if inside and line and not line[0].isspace():
inside = False
if inside and re.match(rf"^\s+{re.escape(key)}\s*:", line):
return number
return None
def _top_level_line(lines: list[str], key: str) -> int | None:
"""Return the 1-indexed line of ``key`` at the root of an addon file."""
for number, line in enumerate(lines, start=1):
if re.match(rf"^{re.escape(key)}\s*:", line):
return number
return None
def _archive_index(lines: list[str]) -> int | None:
"""Return the 0-indexed line of ``config.archive`` in an addon file."""
for index, line in enumerate(lines):
if re.match(r"^\s+archive\s*:", line):
return index
return None
def _archive(spec: Any) -> str:
"""Return the ``config.archive`` URL of an addon, or an empty string."""
config = spec.get("config") if isinstance(spec, dict) else None
return str(config.get("archive", "")) if isinstance(config, dict) else ""
def addon_sources(spec: Any) -> list[dict[str, Any]]:
"""Return the version sources an addon declares.
Args:
spec: root mapping of a ``meta/addons/<id>.yml`` file.
"""
declared = spec.get(UPDATE_KEY) if isinstance(spec, dict) else None
if not isinstance(declared, dict):
return []
if "type" in declared:
return [declared]
if (
declared.get("monitored")
and declared.get("catalog") == GITHUB_RELEASES_CATALOG
and declared.get("upstream_id")
):
repository = f"https://github.com/{declared['upstream_id']}.git"
return [{**declared, "type": "git_tags", "repository": repository}]
return []
def _monitored_pin(spec: Any) -> str:
"""Return the version a monitored addon pins, or an empty string."""
declared = spec.get(UPDATE_KEY) if isinstance(spec, dict) else None
if not isinstance(declared, dict) or not declared.get("monitored"):
return ""
return str(spec.get(DEFAULT_KEY) or "").strip()
def _not_semver(label: str, pinned: Any) -> str:
return (
f"{label}: '{pinned}' is not a semver, so no upstream version orders "
"above it and the pin never moves"
)
def _declaration_problems(
label: str, config: dict[str, Any], source: Any, owner: str
) -> list[str]:
"""Return what keeps one ``update:`` block from resolving.
Args:
label: ``<role>/<entity>`` as it appears in the message.
config: mapping that carries the pin.
source: the ``update:`` block.
owner: what ``config`` is, ``entity`` or ``addon``.
"""
if not isinstance(source, dict):
return [f"{label}: update entry is not a mapping"]
problems: list[str] = []
key = str(source.get("key", DEFAULT_KEY))
if key not in config:
problems.append(f"{label}: update.key '{key}' names no key of the {owner}")
elif not is_semver(str(config[key])):
problems.append(_not_semver(f"{label}.{key}", config[key]))
if source.get("type") not in TYPES:
problems.append(
f"{label}.{key}: unknown update.type "
f"'{source.get('type')}', expected one of {', '.join(TYPES)}"
)
archive = _archive(config) if owner == "addon" else ""
if archive and str(config.get(key, "")) not in archive:
problems.append(
f"{label}.{key}: config.archive does not carry the pinned version, "
"so a bump would move only one of the two"
)
return problems
def invalid_declarations(repo_root: Path) -> list[str]:
"""Return one message per ``update:`` block that cannot be resolved.
A block naming a key the entity does not carry, a type the resolver does
not know, or a pin that is not a semver would otherwise move nothing and
say nothing. A monitored addon the resolver does not read is held to the
same semver rule.
Args:
repo_root: repository root.
"""
problems: list[str] = []
for config_path in sorted(
(repo_root / "roles").glob(f"*/{ROLE_FILE_META_SERVICES}")
):
role = config_path.parts[-3]
for entity, config in (load_yaml(str(config_path)) or {}).items():
declared = config.get(UPDATE_KEY) if isinstance(config, dict) else None
if declared is None:
continue
sources = declared if isinstance(declared, list) else [declared]
for source in sources:
problems += _declaration_problems(
f"{role}/{entity}", config, source, "entity"
)
for role, addon_path in iter_addon_files(repo_root / "roles"):
spec = load_yaml(str(addon_path))
label = f"{role}/addons/{addon_path.stem}"
sources = addon_sources(spec)
for source in sources:
problems += _declaration_problems(label, spec, source, "addon")
pinned = "" if sources else _monitored_pin(spec)
if pinned and not is_semver(pinned):
problems.append(_not_semver(f"{label}.{DEFAULT_KEY}", pinned))
return problems
def _addon_entries(repo_root: Path) -> list[VersionSourceEntry]:
roles_root = repo_root / "roles"
entries: list[VersionSourceEntry] = []
for role, addon_path in iter_addon_files(roles_root):
if not is_maintained(roles_root, role):
continue
spec = load_yaml(str(addon_path))
lines = read_text(str(addon_path)).splitlines()
for source in addon_sources(spec):
key = str(source.get("key", DEFAULT_KEY))
current = str(spec.get(key, "")).strip()
line = _top_level_line(lines, key)
if not current or line is None:
continue
if any(
is_suppressed_at(lines, line, marker)
for marker in (NOCHECK_MARKER, UNWATCHED_MARKER)
):
continue
entries.append(
VersionSourceEntry(
role=role,
entity=addon_path.stem,
key=key,
current=current,
source=source,
config_path=addon_path,
line=line,
addon=True,
)
)
return entries
def collect_entries(repo_root: Path) -> list[VersionSourceEntry]:
"""Return every declared version source that is not suppressed."""
roles_root = repo_root / "roles"
entries: list[VersionSourceEntry] = []
for config_path in sorted(roles_root.glob(f"*/{ROLE_FILE_META_SERVICES}")):
role = config_path.parts[-3]
if not is_maintained(roles_root, role):
continue
lines = read_text(str(config_path)).splitlines()
for entity, config in (load_yaml(str(config_path)) or {}).items():
declared = config.get(UPDATE_KEY) if isinstance(config, dict) else None
sources = declared if isinstance(declared, list) else [declared]
for source in sources:
if not isinstance(source, dict):
continue
key = str(source.get("key", DEFAULT_KEY))
current = str(config.get(key, "")).strip()
line = _key_line(lines, str(entity), key)
if not current or line is None:
continue
if is_suppressed_at(lines, line, NOCHECK_MARKER):
continue
entries.append(
VersionSourceEntry(
role=role,
entity=str(entity),
key=key,
current=current,
source=source,
config_path=config_path,
line=line,
image=config.get("image", "") if key == DEFAULT_KEY else "",
)
)
return entries + _addon_entries(repo_root)
def outdated(
entries: Iterable[VersionSourceEntry], repo_root: Path
) -> list[VersionSourceUpdate]:
"""Return one update per entry whose source offers a newer version."""
updates: list[VersionSourceUpdate] = []
for entry in entries:
newest = newer_version(
entry.current,
candidates(entry, repo_root),
assembled=bool(entry.source.get("template")),
)
if not newest:
continue
if entry.image and manifest_exists(entry.image, newest) is False:
continue
updates.append(VersionSourceUpdate(entry=entry, latest=newest))
return updates
def find_outdated_updates(repo_root: Path) -> list[VersionSourceUpdate]:
"""Return every declared source that moved ahead of its pin."""
return outdated(collect_entries(repo_root), repo_root)
def apply_updates(updates: Iterable[VersionSourceUpdate]) -> list[Path]:
"""Rewrite every pin to its newer version and return the changed files."""
changed: list[Path] = []
for update in updates:
path = update.entry.config_path
lines = path.read_text( # nocheck: cache-read read-modify-write: a cached read serves the pre-write content when two pins of one file are bumped in the same run
encoding="utf-8"
).splitlines(keepends=True)
index = update.entry.line - 1
lines[index] = lines[index].replace(update.entry.current, update.latest, 1)
archive = _archive_index(lines) if update.entry.addon else None
if archive is not None:
lines[archive] = lines[archive].replace(
update.entry.current, update.latest, 1
)
path.write_text("".join(lines), encoding="utf-8")
if path not in changed:
changed.append(path)
return changed