mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
An http_regex source read one document, so a tag whose parts upstream keeps in two files could only carry the second part as a literal. Every pin also fetched its document on its own: a failed request left one pin of a release behind while the others moved. url now takes a list whose bodies are searched as one text, and every document is fetched once per run, so all pins that read it resolve against the same snapshot. The HTTP reads move into utils/update/fetch.py. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
490 lines
17 KiB
Python
490 lines
17 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.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``.
|
|
"""
|
|
|
|
role: str
|
|
entity: str
|
|
key: str
|
|
current: str
|
|
source: dict[str, Any]
|
|
config_path: Path
|
|
line: int
|
|
addon: bool = False
|
|
|
|
|
|
@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,
|
|
)
|
|
)
|
|
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 newest:
|
|
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
|