From 861ad27cdc262fcb424b2d77bb582f47d93f137e Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 11 Oct 2026 11:32:57 +0200 Subject: [PATCH] DRU-774 -- Lead each template image tag with its label --- src/providers/docker/images.py | 8 ++++++ src/providers/docker/provider.py | 1 + src/providers/docker/tests/test_images.py | 29 +++++++++++++++++---- src/providers/docker/tests/test_provider.py | 3 +-- src/providers/docker_sbx/provider.py | 1 + src/providers/exe/provider.py | 1 + src/providers/exe/tests/test_provider.py | 3 +-- src/templates/tests/conftest.py | 2 +- src/templates/tests/test_api.py | 1 + 9 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/providers/docker/images.py b/src/providers/docker/images.py index 2043649..49b0b0e 100644 --- a/src/providers/docker/images.py +++ b/src/providers/docker/images.py @@ -1,5 +1,6 @@ import hashlib import io +import re import tarfile from core.settings import get_settings @@ -13,10 +14,15 @@ def derive_image_name( *, base_image: str, setup_script: str, + label: str, repository: str = "drukbox-template", ) -> str: identity = base_image.encode("utf-8") + b"\0" + setup_script.encode("utf-8") digest = hashlib.sha256(identity).hexdigest()[:12] + # A tag holds at most 128 characters, and the digest needs 13 of them. + purpose = re.sub(r"[^a-z0-9]+", "-", label.lower())[:115].strip("-") + if purpose: + return f"{repository}:{purpose}-{digest}" return f"{repository}:{digest}" @@ -42,6 +48,7 @@ async def build_derived_image( *, base_image: str, setup_script: str, + label: str, ) -> str: settings = get_settings() repository = "drukbox-template" @@ -50,6 +57,7 @@ async def build_derived_image( image = derive_image_name( base_image=base_image, setup_script=setup_script, + label=label, repository=repository, ) context_tar = create_build_context(base_image=base_image, setup_script=setup_script) diff --git a/src/providers/docker/provider.py b/src/providers/docker/provider.py index 6bca58e..27123f0 100644 --- a/src/providers/docker/provider.py +++ b/src/providers/docker/provider.py @@ -158,6 +158,7 @@ async def build_template_image( self.api, base_image=base_image, setup_script=setup_script, + label=label, ) async def delete_template_image(self, image: str) -> None: diff --git a/src/providers/docker/tests/test_images.py b/src/providers/docker/tests/test_images.py index f911596..d87b55d 100644 --- a/src/providers/docker/tests/test_images.py +++ b/src/providers/docker/tests/test_images.py @@ -32,9 +32,11 @@ def test_create_build_context_contains_the_base_and_verbatim_script() -> None: def test_derive_image_name_is_deterministic_and_base_specific() -> None: - first = derive_image_name(base_image="sandbox:base", setup_script="apt-get update") - repeated = derive_image_name(base_image="sandbox:base", setup_script="apt-get update") - different_base = derive_image_name(base_image="sandbox:other", setup_script="apt-get update") + first = derive_image_name(base_image="sandbox:base", setup_script="apt-get update", label="") + repeated = derive_image_name(base_image="sandbox:base", setup_script="apt-get update", label="") + different_base = derive_image_name( + base_image="sandbox:other", setup_script="apt-get update", label="" + ) assert first == repeated assert first.startswith("drukbox-template:") @@ -42,6 +44,23 @@ def test_derive_image_name_is_deterministic_and_base_specific() -> None: assert different_base != first +@pytest.mark.parametrize( + ("label", "purpose"), + [ + ("site-builder-build", "site-builder-build"), + ("Site_Builder / Build.sh", "site-builder-build-sh"), + ("x" * 200, "x" * 115), + ], +) +def test_derive_image_name_leads_the_tag_with_the_label(label: str, purpose: str) -> None: + image = derive_image_name(base_image="sandbox:base", setup_script="apt-get update", label=label) + + tag = image.removeprefix("drukbox-template:") + assert tag.startswith(f"{purpose}-") + assert len(tag) == len(purpose) + 13 + assert len(tag) <= 128 + + async def test_build_derived_image_publishes_to_the_template_repository( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -52,9 +71,9 @@ async def test_build_derived_image_publishes_to_the_template_repository( docker = MagicMock(build_image=AsyncMock(), push_image=AsyncMock()) image = await build_derived_image( - docker, base_image="sandbox:base", setup_script="apt-get update" + docker, base_image="sandbox:base", setup_script="apt-get update", label="node-tools" ) - assert image.startswith("ghcr.io/acme/templates:") + assert image.startswith("ghcr.io/acme/templates:node-tools-") assert docker.build_image.await_args.args[0] == image docker.push_image.assert_awaited_once_with(image, username="bot", password="secret") diff --git a/src/providers/docker/tests/test_provider.py b/src/providers/docker/tests/test_provider.py index 1c9927a..3652861 100644 --- a/src/providers/docker/tests/test_provider.py +++ b/src/providers/docker/tests/test_provider.py @@ -186,8 +186,7 @@ async def test_build_template_image_builds_and_returns_the_derived_tag(): label="Node tools", ) - assert image.startswith("drukbox-template:") - assert len(image.removeprefix("drukbox-template:")) == 12 + assert image.startswith("drukbox-template:node-tools-") assert api.build_image.await_args.args[0] == image diff --git a/src/providers/docker_sbx/provider.py b/src/providers/docker_sbx/provider.py index 3eca6c0..b057992 100644 --- a/src/providers/docker_sbx/provider.py +++ b/src/providers/docker_sbx/provider.py @@ -201,6 +201,7 @@ async def build_template_image( self.docker, base_image=base_image, setup_script=setup_script, + label=label, ) # sbx keeps its own image store. The Docker image only carries the build. try: diff --git a/src/providers/exe/provider.py b/src/providers/exe/provider.py index 88c990a..4033b72 100644 --- a/src/providers/exe/provider.py +++ b/src/providers/exe/provider.py @@ -125,6 +125,7 @@ async def build_template_image( self.docker, base_image=base_image, setup_script=setup_script, + label=label, ) async def delete_template_image(self, image: str) -> None: diff --git a/src/providers/exe/tests/test_provider.py b/src/providers/exe/tests/test_provider.py index b16f974..57e939c 100644 --- a/src/providers/exe/tests/test_provider.py +++ b/src/providers/exe/tests/test_provider.py @@ -189,8 +189,7 @@ async def test_build_template_image_builds_logs_in_and_pushes( label="Node tools", ) - assert image.startswith("ghcr.io/acme/drukbox-templates:") - assert len(image.rpartition(":")[2]) == 12 + assert image.startswith("ghcr.io/acme/drukbox-templates:node-tools-") assert docker.build_image.await_args.args[0] == image docker.push_image.assert_awaited_once_with(image, username="bot", password="secret") diff --git a/src/templates/tests/conftest.py b/src/templates/tests/conftest.py index e979c7e..bb36d16 100644 --- a/src/templates/tests/conftest.py +++ b/src/templates/tests/conftest.py @@ -71,7 +71,7 @@ async def build_template_image( self.built.append((base_image, setup_script, label)) if self.build_error: raise self.build_error - return derive_image_name(base_image=base_image, setup_script=setup_script) + return derive_image_name(base_image=base_image, setup_script=setup_script, label=label) async def delete_template_image(self, image: str) -> None: self.deleted.append(image) diff --git a/src/templates/tests/test_api.py b/src/templates/tests/test_api.py index 665adf8..626221c 100644 --- a/src/templates/tests/test_api.py +++ b/src/templates/tests/test_api.py @@ -54,6 +54,7 @@ async def test_create_template_returns_building_then_becomes_available(client, t assert polled.json()["image"] == derive_image_name( base_image=template_provider.base_image_ref, setup_script=SETUP_SCRIPT, + label="Node tools", ) assert "setup_script" not in polled.json() assert template_provider.built == [