mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
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()
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user