diff --git a/cli/contributing/mirror/resolver/__main__.py b/cli/contributing/mirror/resolver/__main__.py index 01df2b1a08..4085479356 100644 --- a/cli/contributing/mirror/resolver/__main__.py +++ b/cli/contributing/mirror/resolver/__main__.py @@ -12,8 +12,9 @@ from utils.docker.image.discovery import iter_role_images def main() -> int: parser = argparse.ArgumentParser( description=( - "Emit the mirrors.yml mapping that redirects every role-declared " - "image (applications.*.services.*) to its GHCR mirror URI." + "Emit the mirrors.yml mapping that redirects every image a " + "non-eol role declares (applications.*.services.*) to its GHCR " + "mirror URI." ), ) parser.add_argument("--repo-root", default=".") diff --git a/cli/contributing/mirror/sync/__main__.py b/cli/contributing/mirror/sync/__main__.py index 2212f332db..d453732b46 100644 --- a/cli/contributing/mirror/sync/__main__.py +++ b/cli/contributing/mirror/sync/__main__.py @@ -53,8 +53,8 @@ def _throttle_before_next_copy( def main() -> int: parser = argparse.ArgumentParser( description=( - "Mirror every role-declared image and every image the CI runner " - "pulls itself to the configured GHCR registry, " + "Mirror every image a non-eol role declares and every image the " + "CI runner pulls itself to the configured GHCR registry, " "optionally throttled and optionally skipping already-mirrored " "tags." ), diff --git a/cli/contributing/mirror/wait/__main__.py b/cli/contributing/mirror/wait/__main__.py index 85769b2f7c..0820284b5c 100644 --- a/cli/contributing/mirror/wait/__main__.py +++ b/cli/contributing/mirror/wait/__main__.py @@ -23,8 +23,8 @@ def _validate_positive_int(value: str) -> int: def main() -> int: parser = argparse.ArgumentParser( description=( - "Wait until every role-declared image and every image the CI runner " - "pulls itself has been mirrored to the " + "Wait until every image a non-eol role declares and every image " + "the CI runner pulls itself has been mirrored to the " "configured GHCR registry. Used on fork pull_request runs that depend " "on a pull_request_target mirror job populating the destination first." ), diff --git a/docs/contributing/artefact/image/mirror.md b/docs/contributing/artefact/image/mirror.md index a0a8af6a47..e2f97cf848 100644 --- a/docs/contributing/artefact/image/mirror.md +++ b/docs/contributing/artefact/image/mirror.md @@ -32,7 +32,7 @@ Example: `ghcr.io/kevinveenbirkenbach/infinito-nexus-core/mirror/docker.io/nextc ### Image Discovery 🔍 -`utils/docker/image/discovery.py` discovers all role images via `iter_role_images()`. See [origin.md](origin.md) for the declaration format and supported registries. +`utils/docker/image/discovery.py` discovers the role images via `iter_role_images()`. It leaves out every role whose lifecycle is `eol` (`is_maintained()` in `utils/update/base.py`), so neither the mirror nor `mirrors.yml` covers those images. See [origin.md](origin.md) for the declaration format and supported registries. ### GHCRProvider 🐳 diff --git a/tests/unit/python/utils/docker/test_image_discovery_lifecycle.py b/tests/unit/python/utils/docker/test_image_discovery_lifecycle.py new file mode 100644 index 0000000000..5c7386125a --- /dev/null +++ b/tests/unit/python/utils/docker/test_image_discovery_lifecycle.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +from utils.docker.image.discovery import iter_role_images +from utils.roles.mapping import ROLE_FILE_META_SERVICES + +SUPPORTED = """--- +demo: + image: libretranslate/libretranslate + version: v1.9.6 + lifecycle: stable +helper: + image: alpine + version: "3.22" +""" + +RETIRED = """--- +demo: + 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 = """--- +demo: + image: libretranslate/libretranslate + version: v1.9.6 +""" + + +class TestImageDiscoveryLifecycle(unittest.TestCase): + def _images(self, services_yaml: str, *, relative: bool = False) -> set[str]: + """Discover the images of one role written into a temporary repository. + + Args: + services_yaml: the role's ``meta/services.yml``. + relative: pass the repository root relative to the working directory. + """ + 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") + root = Path(os.path.relpath(tmp)) if relative else Path(tmp) + return {ref.service for ref in iter_role_images(root)} + + def test_a_supported_role_yields_every_service(self) -> None: + self.assertEqual({"demo", "helper"}, self._images(SUPPORTED)) + + def test_a_retired_role_yields_none_of_its_services(self) -> None: + self.assertEqual(set(), self._images(RETIRED)) + + def test_a_role_that_names_no_stage_is_kept(self) -> None: + self.assertEqual({"demo"}, self._images(UNSTAGED)) + + def test_a_relative_root_still_leaves_a_retired_role_out(self) -> None: + self.assertEqual(set(), self._images(RETIRED, relative=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/docker/image/discovery.py b/utils/docker/image/discovery.py index 3246b8fdd5..7f1c7bac0d 100644 --- a/utils/docker/image/discovery.py +++ b/utils/docker/image/discovery.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from utils.cache.yaml import load_yaml_any as _load_yaml_cached from utils.docker.image.pin import pull_reference from utils.roles.mapping import ROLE_FILE_META_SERVICES +from utils.update.base import is_maintained if TYPE_CHECKING: from collections.abc import Iterable @@ -191,14 +192,18 @@ def iter_role_images(repo_root: Path) -> Iterable[ImageRef]: """ Yield all ImageRef entries discovered across all roles in *repo_root*. - Source: roles/**/meta/services.yml → .{image,version}. + Source: roles/**/meta/services.yml → .{image,version}. Roles the + updaters do not maintain (``is_maintained``) are left out. See docs/contributing/artefact/image.md for the full format reference. """ - roles_dir = repo_root / "roles" + roles_dir = (repo_root / "roles").resolve() for services_file in roles_dir.glob(f"**/{ROLE_FILE_META_SERVICES}"): role_name = services_file.parent.parent.name + if not is_maintained(roles_dir, role_name): + continue + services = load_yaml(services_file) if not isinstance(services, dict):