fix(images): leave a retired role out of image discovery and the pin watch

0a3da88912 closed the EOL contract at the declaration level: the four retired
roles carry `enabled: false`, an end-of-life README banner and three lints.
iter_role_images still enumerated their images, so the mirror job kept trying
to copy tags nobody can consume. MinIO's quay tags answer 401 to anonymous
pulls and exist nowhere else, which failed that job on every run while no
deploy could have reached the result: an eol role sits outside the lifecycle
envelope default.env tests.

role_lifecycle() reads the stage off the one entry that carries a `lifecycle`
field, which is the role's primary service, and iter_role_images skips the
retired ones. Measured against the tree: 202 images, with web-app-confluence,
web-app-jira, web-app-minio and web-app-phpldapadmin all absent.

test_version_pins_are_watched.py gains the same skip. utils/update/docker.py:271
builds its watched set from iter_role_images, so dropping the retired roles
there made their `version:` pins look unwatched and the lint went red on three
of them. The coherent reading is that a role no updater can reach cannot
declare a watcher either, and the lint docstring now says so rather than
claiming every pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-05 18:30:50 +02:00
1 parent f675a7ce67
commit 475ab9b386
3 files changed
+108 -2

No files matched your search

@@ -8,7 +8,9 @@ or an oversight.
Scope: every ``roles/*/meta/services.yml`` value whose key names a version and
whose content is a semver. Moving tags (``latest``, ``stable``, a branch name)
are not pins and are ignored.
are not pins and are ignored. A role whose primary service is ``lifecycle:
eol`` is skipped entirely: ``iter_role_images`` drops it, so no updater can
reach its pins and a watcher is not something it could declare.
Per-line opt-out: ``# nocheck: unwatched-version`` above the pin, with a
reason, for a version that must stay where it is. An existing
@@ -24,6 +26,7 @@ import unittest
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 RETIRED, role_lifecycle
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.update.base import is_semver
from utils.update.docker import collect_entries as docker_entries
@@ -55,7 +58,10 @@ def _findings() -> list[str]:
):
role = config_path.parts[-3]
lines = read_text(str(config_path)).splitlines()
for entity, config in (load_yaml(str(config_path)) or {}).items():
services = load_yaml(str(config_path)) or {}
if role_lifecycle(services) == RETIRED:
continue
for entity, config in services.items():
if not isinstance(config, dict):
continue
for key, value in config.items():
@@ -0,0 +1,74 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from utils.docker.image.discovery import iter_role_images, role_lifecycle
from utils.roles.mapping import ROLE_FILE_META_SERVICES
SUPPORTED = """---
engine:
image: libretranslate/libretranslate
version: v1.9.6
lifecycle: stable
helper:
image: alpine
version: "3.22"
"""
RETIRED = """---
store:
image: quay.io/minio/minio
version: RELEASE.2025-09-07T16-13-09Z
lifecycle: eol
client:
image: quay.io/minio/mc
version: RELEASE.2025-08-13T08-35-41Z
"""
UNSTAGED = """---
engine:
image: libretranslate/libretranslate
version: v1.9.6
"""
class TestImageDiscoveryLifecycle(unittest.TestCase):
def _images(self, services_yaml: str) -> set[str]:
with tempfile.TemporaryDirectory() as tmp:
services = Path(tmp) / "roles" / "web-app-demo" / ROLE_FILE_META_SERVICES
services.parent.mkdir(parents=True)
services.write_text(services_yaml, encoding="utf-8")
return {ref.service for ref in iter_role_images(Path(tmp))}
def test_a_supported_role_yields_every_service(self) -> None:
self.assertEqual({"engine", "helper"}, self._images(SUPPORTED))
def test_a_retired_role_yields_nothing(self) -> None:
"""Its upstream may withdraw the images; no deploy can reach them."""
self.assertEqual(set(), self._images(RETIRED))
def test_the_sibling_of_a_retired_service_goes_with_it(self) -> None:
"""Only the primary entry carries the stage, and it speaks for the role."""
self.assertNotIn("client", self._images(RETIRED))
def test_a_role_that_names_no_stage_is_kept(self) -> None:
self.assertEqual({"engine"}, self._images(UNSTAGED))
class TestRoleLifecycle(unittest.TestCase):
def test_it_reads_the_entry_that_carries_the_field(self) -> None:
self.assertEqual(
"eol", role_lifecycle({"a": {"image": "x"}, "b": {"lifecycle": "eol"}})
)
def test_it_is_empty_when_no_entry_carries_one(self) -> None:
self.assertEqual("", role_lifecycle({"a": {"image": "x"}}))
def test_a_non_mapping_entry_does_not_break_it(self) -> None:
self.assertEqual("", role_lifecycle({"a": "scalar"}))
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -38,6 +38,7 @@ _MIRRORABLE_REGISTRIES = frozenset(
"registry.gitlab.com",
}
)
RETIRED = "eol"
@dataclass(frozen=True)
@@ -190,6 +191,22 @@ def canonical_image_name(image: str) -> str:
return base
def role_lifecycle(services: dict) -> str:
"""The role's lifecycle stage, empty when no service names one.
Args:
services: the parsed ``meta/services.yml`` of one role.
Returns:
The stage of the one entry that carries a ``lifecycle`` field, which
is the role's primary service.
"""
for entry in services.values():
if isinstance(entry, dict) and entry.get("lifecycle"):
return str(entry["lifecycle"])
return ""
def iter_role_images(repo_root: Path) -> Iterable[ImageRef]:
"""
Yield all ImageRef entries discovered across all roles in ``repo_root``.
@@ -201,6 +218,12 @@ def iter_role_images(repo_root: Path) -> Iterable[ImageRef]:
function is the only enumeration the mirror sees and a tag it never emits
can never be mirrored.
An ``eol`` role is left out entirely. It is outside the lifecycle envelope
``default.env`` tests, so no deploy can reach its images, and a retired
role's upstream is free to withdraw them: MinIO's quay tags answer 401 to
anonymous pulls and exist nowhere else, which failed the mirror job on
every run while nothing could have consumed the result.
See docs/contributing/artefact/image.md for the full format reference.
"""
roles_dir = repo_root / "roles"
@@ -212,6 +235,9 @@ def iter_role_images(repo_root: Path) -> Iterable[ImageRef]:
if not isinstance(services, dict):
continue
if role_lifecycle(services) == RETIRED:
continue
for service_name, service in services.items():
if not isinstance(service, dict):
continue