diff --git a/cli/contributing/update/README.md b/cli/contributing/update/README.md index 00eff7b60f..84e5e19a02 100644 --- a/cli/contributing/update/README.md +++ b/cli/contributing/update/README.md @@ -21,7 +21,9 @@ stalwart: ``` `key` names the pinned key and defaults to `version`; an entity with several -pins declares a list of such blocks. Types: +pins declares a list of such blocks. An `image` + `version` pin that declares a +source for `version` is bumped from that source alone, the Docker updater skips +it. Types: | `type` | Fields | Reads | | --------------- | --------------------- | -------------------------------------- | diff --git a/tests/unit/python/utils/update/test_docker.py b/tests/unit/python/utils/update/test_docker.py index 4fac5a7802..b44f72ef2a 100644 --- a/tests/unit/python/utils/update/test_docker.py +++ b/tests/unit/python/utils/update/test_docker.py @@ -5,7 +5,33 @@ import unittest from pathlib import Path from utils.cache.files import read_text -from utils.update.docker import _registry_cursor, update_config_versions +from utils.roles.mapping import ROLE_FILE_META_SERVICES +from utils.update.docker import ( + _registry_cursor, + collect_entries, + sourced_services, + update_config_versions, +) + +SOURCED = """bundled: + image: example/app + version: v1.0.0 + update: + type: http_regex + url: https://example.invalid/bundle + pattern: "app:(v[0-9.]+)" +tracked: + image: example/side + version: v2.0.0 + other_version: v3.0.0 + update: + - key: other_version + type: git_tags + repository: https://example.invalid/side.git +plain: + image: example/plain + version: v4.0.0 +""" class TestRegistryCursor(unittest.TestCase): @@ -16,6 +42,20 @@ class TestRegistryCursor(unittest.TestCase): self.assertIsNone(_registry_cursor("19.1.1")) +class TestDeclaredSource(unittest.TestCase): + def test_a_version_with_its_own_source_is_left_to_that_source(self) -> None: + root = Path(tempfile.mkdtemp()) + config = root / "roles" / "web-app-example" / ROLE_FILE_META_SERVICES + config.parent.mkdir(parents=True) + config.write_text(SOURCED, encoding="utf-8") + + self.assertEqual(sourced_services(config), {"bundled"}) + self.assertEqual( + sorted(entry.service for entry in collect_entries(root)), + ["plain", "tracked"], + ) + + class TestUpdateDocker(unittest.TestCase): def test_update_config_versions_updates_only_target_services(self) -> None: original = """moodle: diff --git a/utils/update/base.py b/utils/update/base.py index 892cbd7be3..c4fa188346 100644 --- a/utils/update/base.py +++ b/utils/update/base.py @@ -105,3 +105,19 @@ def is_maintained(roles_root: Path, role: str) -> bool: """ lifecycle = get_role_lifecycle(roles_root / role, role_name=role) return lifecycle not in UNMAINTAINED_LIFECYCLES + + +def declares_source(config: object, key: str) -> bool: + """Report whether a services entity names its own upstream for a pin. + + Args: + config: one entity of ``meta/services.yml``. + key: the pinned key; an ``update:`` block without ``key`` names + ``version``. + """ + declared = config.get("update") if isinstance(config, dict) else None + sources = declared if isinstance(declared, list) else [declared] + return any( + isinstance(source, dict) and source.get("key", "version") == key + for source in sources + ) diff --git a/utils/update/docker.py b/utils/update/docker.py index 65af50cd0d..7c44bed6bf 100644 --- a/utils/update/docker.py +++ b/utils/update/docker.py @@ -8,6 +8,9 @@ walker that rewrites the matching ``version:`` line. Suppress a check by placing ``# nocheck: docker-version`` on the line directly above the ``version:`` key. + +A ``version`` that names its own upstream in an ``update:`` block belongs to +:mod:`utils.update.source` and is not collected here. """ from __future__ import annotations @@ -28,6 +31,7 @@ import yaml 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.image.discovery import iter_role_images from utils.docker.image.ref import ( DOCKER_HUB_REGISTRIES, @@ -38,6 +42,7 @@ from utils.docker.image.ref import ( from utils.docker.registry import fetch_registry_tags from utils.roles.mapping import ROLE_FILE_META_SERVICES from utils.update.base import ( + declares_source, is_maintained, is_semver, latest_semver, @@ -265,6 +270,20 @@ def suppressed_services(config_path: Path) -> set[str]: return names +def sourced_services(config_path: Path) -> set[str]: + """Return service names whose ``version`` declares its own upstream. + + Args: + config_path: a role's ``meta/services.yml``. + """ + services = load_yaml(str(config_path)) or {} + return { + str(service) + for service, config in services.items() + if declares_source(config, "version") + } + + def collect_entries(repo_root: Path) -> list[DockerImageVersionEntry]: roles_root = repo_root / "roles" entries: list[DockerImageVersionEntry] = [] @@ -280,6 +299,8 @@ def collect_entries(repo_root: Path) -> list[DockerImageVersionEntry]: config_path = roles_root / ref.role / ROLE_FILE_META_SERVICES if ref.service in suppressed_services(config_path): continue + if ref.service in sourced_services(config_path): + continue if ref.registry == "docker.io": image = ref.name