From c5c45c1178340db918fa06cd22ae89a1b4e70913 Mon Sep 17 00:00:00 2001 From: Paulo Date: Sat, 3 Oct 2026 10:02:26 +0200 Subject: [PATCH] Separate registry access from template publishing --- docs/deploy.md | 37 ++++++++++- docs/security.md | 4 +- src/core/settings.py | 49 +++++++++++++- src/core/tests/test_settings.py | 57 ++++++++++++++++ src/providers/docker/api.py | 6 +- src/providers/docker/images.py | 12 +++- src/providers/docker/provider.py | 1 + src/providers/docker/tests/test_api.py | 6 +- src/providers/docker/tests/test_images.py | 29 +++++++- src/providers/docker/tests/test_provider.py | 25 ++++++- src/providers/exe/provider.py | 44 +++--------- src/providers/exe/settings.py | 15 ----- src/providers/exe/tests/test_provider.py | 74 +++++++++++---------- 13 files changed, 260 insertions(+), 99 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index baa4843..97c3fb5 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -313,6 +313,36 @@ with auth-key write scope, and tailnet ACLs that (a) own the tags in `TAILSCALE_AUTH_TAGS` and (b) permit tailscaled-SSH to the tagged nodes. +## Private image registry + +`REGISTRY_HOST`, `REGISTRY_USERNAME`, and `REGISTRY_PASSWORD` give drukbox +access to private images on one registry host. Set the three together. +drukbox sends the credentials only for an image on that host: + +- `exe` passes them to exe.dev with the host image. +- `docker` uses them when the Docker engine pulls a host image that it + does not have. + +`TEMPLATE_REPOSITORY` is the repository path on that host where drukbox +publishes template images. The credential needs push permission there. +Registry access does not require a template repository. + +```dotenv +REGISTRY_HOST=ghcr.io +REGISTRY_USERNAME=builder +REGISTRY_PASSWORD= +TEMPLATE_REPOSITORY=acme/sandbox-templates +``` + +`exe` boots hosts from a registry, so its templates require +`TEMPLATE_REPOSITORY`. `docker` and `docker-sbx` publish each template when +it is set, and keep the image local when it is not. The `docker-sbx` daemon +has its own registry login. drukbox loads each template into that daemon +and does not give it these credentials. + +AWS, Hetzner, and Exoscale boot from machine images. They have no +templates and do not use these settings. + ## AWS credentials and IAM AWS credentials come from the SDK's default chain (instance profile, @@ -504,6 +534,10 @@ Core, optional: | `SERVICE_LABEL` | `drukbox` | Label stamped onto provider resources (VM tags, SG tags). | | `UVICORN_HOST` | `0.0.0.0` | API bind address. Set `127.0.0.1` to restrict to loopback. | | `PROVISIONING_GRACE_SECONDS` | `600` | Safety TTL on in-flight hosts so the janitor reaps row + VM if the client disconnects mid-provision. Must exceed the worst-case provision duration. | +| `REGISTRY_HOST` | — | Registry host for private images, such as `ghcr.io` or `docker.io`, with no scheme or path. See [Private image registry](#private-image-registry). | +| `REGISTRY_USERNAME` | — | Registry user for private image pulls and template pushes. | +| `REGISTRY_PASSWORD` | — | Registry password or token. | +| `TEMPLATE_REPOSITORY` | — | Repository path on `REGISTRY_HOST` for template images, with no tag or digest. | | `TEMPLATE_BUILD_TIMEOUT` | `3600` | Max age in seconds of an unfinished template build before the janitor marks it failed. | | `TEMPLATE_FAILED_RETENTION` | `86400` | Seconds that failed template records and diagnostics remain before the janitor deletes them. | | `TEMPLATE_UNUSED_TTL` | `1209600` | Seconds that an available template remains after its last use, or creation when never used. | @@ -546,9 +580,6 @@ exe.dev provider: | --- | --- | --- | | `EXE_API_TOKEN` | — (required) | Bearer token for the exe.dev exec API. | | `EXE_DEFAULT_IMAGE` | — (required) | Image used when the caller omits `image`. | -| `EXE_IMAGE_REGISTRY` | — | Repository prefix for derived template images. A VM created from this registry gets `--registry-auth` so exe.dev can pull a private image. | -| `EXE_REGISTRY_USERNAME` | — | Username for the derived-template image registry. | -| `EXE_REGISTRY_PASSWORD` | — | Password or token for the derived-template image registry. | | `EXE_API_URL` | `https://exe.dev` | API base URL. | | `EXE_API_TIMEOUT` | `30.0` | Timeout for exe.dev API calls. | | `EXE_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `30.0` | ssh-keyscan retry budget for a fresh exe.dev sandbox. | diff --git a/docs/security.md b/docs/security.md index cc1b5b6..145e232 100644 --- a/docs/security.md +++ b/docs/security.md @@ -91,8 +91,8 @@ Drukbox encrypts the entry in the database with AES-256-GCM under can decrypt it. Rotate the key by prepending a new one. Remove an old key only after no stored row needs it. -Provider tokens (`EXE_API_TOKEN`, `EXE_REGISTRY_PASSWORD`, -`HETZNER_API_TOKEN`, Tailscale OAuth) and AWS credentials are read from the +Provider tokens (`EXE_API_TOKEN`, `HETZNER_API_TOKEN`, Tailscale OAuth), the +registry password (`REGISTRY_PASSWORD`), and AWS credentials are read from the environment or the AWS SDK default chain. They are never written to the database and never returned by the API. diff --git a/src/core/settings.py b/src/core/settings.py index 7630815..a3668f1 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -1,7 +1,7 @@ from functools import lru_cache -from typing import Annotated +from typing import Annotated, Self -from pydantic import BeforeValidator, Field, SecretStr +from pydantic import BeforeValidator, Field, SecretStr, model_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from sqlalchemy_encrypted_field import validate_keys @@ -94,6 +94,31 @@ class Settings(BaseSettings): validation_alias="PROVISIONING_GRACE_SECONDS", description="Safety TTL on the host row while provisioning is in flight.", ) + registry_host: str = Field( + default="", + validation_alias="REGISTRY_HOST", + pattern=r"^$|^[a-z0-9.-]+(?::[0-9]+)?$", + description="Registry host for private images, such as ghcr.io or docker.io.", + ) + registry_username: str = Field( + default="", + validation_alias="REGISTRY_USERNAME", + description="Registry user for private image pulls and template pushes.", + ) + registry_password: SecretStr = Field( + default=SecretStr(""), + validation_alias="REGISTRY_PASSWORD", + description="Registry password or token.", + ) + template_repository: str = Field( + default="", + validation_alias="TEMPLATE_REPOSITORY", + pattern=( + r"^$|^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*" + r"(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$" + ), + description="Repository path on REGISTRY_HOST for template images, without a tag.", + ) template_build_timeout: int = Field( default=3600, gt=0, @@ -152,6 +177,26 @@ class Settings(BaseSettings): description="Upper bound on pool-maintainer provisions per tick, across all providers.", ) + @model_validator(mode="after") + def validate_registry(self) -> Self: + access = { + "REGISTRY_HOST": self.registry_host, + "REGISTRY_USERNAME": self.registry_username, + "REGISTRY_PASSWORD": self.registry_password.get_secret_value(), + } + if (self.template_repository or any(access.values())) and not all(access.values()): + missing = ", ".join(name for name, value in access.items() if not value) + raise ValueError(f"Registry access is incomplete. Set: {missing}") + return self + + def get_registry_auth(self, image: str) -> dict[str, str] | None: + # The credentials go only to the registry host that they belong to. + if self.registry_host and image.partition("/")[0] == self.registry_host: + return { + "username": self.registry_username, + "password": self.registry_password.get_secret_value(), + } + def get_pool_targets(self) -> dict[str, int]: # POOL_SIZE seeds the default provider's target and POOL_SIZES # overrides per provider; providers at zero drop out entirely. diff --git a/src/core/tests/test_settings.py b/src/core/tests/test_settings.py index 5e7e245..14fcc91 100644 --- a/src/core/tests/test_settings.py +++ b/src/core/tests/test_settings.py @@ -185,3 +185,60 @@ def test_load_test_env_overrides_ambient_values(monkeypatch: pytest.MonkeyPatch) monkeypatch.setenv("TAILSCALE_ENABLED", "false") conftest.load_test_env() assert os.environ["TAILSCALE_ENABLED"] == "true" + + +def _registry_env(**overrides: str) -> dict[str, str | None]: + return { + **_base_env(), + "REGISTRY_HOST": "ghcr.io", + "REGISTRY_USERNAME": "builder", + "REGISTRY_PASSWORD": "private-token", + "TEMPLATE_REPOSITORY": "", + **overrides, + } + + +def test_registry_access_does_not_require_a_template_repository( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = _settings_with(monkeypatch, _registry_env()) + + assert settings.registry_host == "ghcr.io" + assert settings.registry_password.get_secret_value() == "private-token" + assert "private-token" not in repr(settings) + + +def test_template_repository_requires_registry_access(monkeypatch: pytest.MonkeyPatch) -> None: + env = _registry_env( + REGISTRY_HOST="", + REGISTRY_USERNAME="", + REGISTRY_PASSWORD="", + TEMPLATE_REPOSITORY="acme/templates", + ) + + with pytest.raises(ValueError, match="REGISTRY_HOST, REGISTRY_USERNAME, REGISTRY_PASSWORD"): + _settings_with(monkeypatch, env) + + +def test_partial_registry_access_names_the_missing_setting_without_the_secret( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(ValueError) as error: + _settings_with(monkeypatch, _registry_env(REGISTRY_USERNAME="")) + + assert "Set: REGISTRY_USERNAME" in str(error.value) + assert "private-token" not in str(error.value) + + +@pytest.mark.parametrize("host", ["https://ghcr.io", "ghcr.io/acme"]) +def test_registry_host_rejects_a_scheme_or_path(monkeypatch: pytest.MonkeyPatch, host: str) -> None: + with pytest.raises(ValueError, match="REGISTRY_HOST"): + _settings_with(monkeypatch, _registry_env(REGISTRY_HOST=host)) + + +@pytest.mark.parametrize("repository", ["acme/templates:latest", "acme/templates@sha256:abc"]) +def test_template_repository_rejects_a_tag_or_digest( + monkeypatch: pytest.MonkeyPatch, repository: str +) -> None: + with pytest.raises(ValueError, match="TEMPLATE_REPOSITORY"): + _settings_with(monkeypatch, _registry_env(TEMPLATE_REPOSITORY=repository)) diff --git a/src/providers/docker/api.py b/src/providers/docker/api.py index ed62881..dfc33c0 100644 --- a/src/providers/docker/api.py +++ b/src/providers/docker/api.py @@ -48,6 +48,7 @@ async def run_container( env: dict[str, str], labels: dict[str, str], ssh_host: str, + registry_auth: dict[str, str] | None = None, ) -> str: config = { "Image": image, @@ -59,7 +60,10 @@ async def run_container( }, } try: - container = await self._get_client().containers.run(config, name=name) + # The engine pulls an image that it does not have. + container = await self._get_client().containers.run( + config, name=name, auth=registry_auth + ) except (aiodocker.DockerError, aiohttp.ClientError) as exc: raise DockerTransportError(_detail(exc)) from exc return container.id diff --git a/src/providers/docker/images.py b/src/providers/docker/images.py index 4d69737..2043649 100644 --- a/src/providers/docker/images.py +++ b/src/providers/docker/images.py @@ -2,6 +2,7 @@ import io import tarfile +from core.settings import get_settings from providers.exceptions import ProviderNotFoundError, ProviderTransportError from .api import DockerAPI @@ -41,8 +42,11 @@ async def build_derived_image( *, base_image: str, setup_script: str, - repository: str = "drukbox-template", ) -> str: + settings = get_settings() + repository = "drukbox-template" + if settings.template_repository: + repository = f"{settings.registry_host}/{settings.template_repository}" image = derive_image_name( base_image=base_image, setup_script=setup_script, @@ -51,6 +55,12 @@ async def build_derived_image( context_tar = create_build_context(base_image=base_image, setup_script=setup_script) try: await docker.build_image(image, context_tar) + if settings.template_repository: + await docker.push_image( + image, + username=settings.registry_username, + password=settings.registry_password.get_secret_value(), + ) except DockerProviderError as exc: raise ProviderTransportError(str(exc)) from exc return image diff --git a/src/providers/docker/provider.py b/src/providers/docker/provider.py index 498c406..bd96050 100644 --- a/src/providers/docker/provider.py +++ b/src/providers/docker/provider.py @@ -106,6 +106,7 @@ async def create_vm( env=container_env, labels=labels, ssh_host=str(self.settings.ssh_host), + registry_auth=get_settings().get_registry_auth(image), ) except DockerProviderError as exc: raise ProviderTransportError(str(exc)) from exc diff --git a/src/providers/docker/tests/test_api.py b/src/providers/docker/tests/test_api.py index 893bd58..2f8a1ad 100644 --- a/src/providers/docker/tests/test_api.py +++ b/src/providers/docker/tests/test_api.py @@ -52,11 +52,15 @@ async def test_run_container_publishes_on_the_ssh_host_and_passes_env() -> None: env={"KEY": "value", "MULTI": "line one\nline two"}, labels={"managed-by": "drukbox"}, ssh_host="100.64.0.10", + registry_auth={"username": "bot", "password": "secret"}, ) assert container_id == "abc123" config = fake.containers.run.await_args.args[0] - assert fake.containers.run.await_args.kwargs == {"name": "sb-test"} + assert fake.containers.run.await_args.kwargs == { + "name": "sb-test", + "auth": {"username": "bot", "password": "secret"}, + } assert config["Image"] == "sandbox:latest" assert config["Env"] == ["KEY=value", "MULTI=line one\nline two"] assert config["Labels"] == {"managed-by": "drukbox"} diff --git a/src/providers/docker/tests/test_images.py b/src/providers/docker/tests/test_images.py index a2766a8..f911596 100644 --- a/src/providers/docker/tests/test_images.py +++ b/src/providers/docker/tests/test_images.py @@ -1,7 +1,16 @@ import io import tarfile +from unittest.mock import AsyncMock, MagicMock -from providers.docker.images import create_build_context, derive_image_name +import pytest +from pydantic import SecretStr + +from core.settings import get_settings +from providers.docker.images import ( + build_derived_image, + create_build_context, + derive_image_name, +) def test_create_build_context_contains_the_base_and_verbatim_script() -> None: @@ -31,3 +40,21 @@ def test_derive_image_name_is_deterministic_and_base_specific() -> None: assert first.startswith("drukbox-template:") assert len(first.removeprefix("drukbox-template:")) == 12 assert different_base != first + + +async def test_build_derived_image_publishes_to_the_template_repository( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(get_settings(), "registry_host", "ghcr.io") + monkeypatch.setattr(get_settings(), "registry_username", "bot") + monkeypatch.setattr(get_settings(), "registry_password", SecretStr("secret")) + monkeypatch.setattr(get_settings(), "template_repository", "acme/templates") + docker = MagicMock(build_image=AsyncMock(), push_image=AsyncMock()) + + image = await build_derived_image( + docker, base_image="sandbox:base", setup_script="apt-get update" + ) + + assert image.startswith("ghcr.io/acme/templates:") + 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 97d9e70..1c9927a 100644 --- a/src/providers/docker/tests/test_provider.py +++ b/src/providers/docker/tests/test_provider.py @@ -2,8 +2,9 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from pydantic import ValidationError +from pydantic import SecretStr, ValidationError +from core.settings import get_settings from providers.docker.exceptions import ( DockerImageNotFoundError, DockerTransportError, @@ -56,6 +57,28 @@ async def test_create_vm_publishes_and_advertises_the_ssh_host(ssh_host: str): assert "-----BEGIN OPENSSH PRIVATE KEY-----" in result.private_key +@pytest.mark.parametrize( + ("image", "registry_auth"), + [ + ("ghcr.io/acme/private-base:latest", {"username": "bot", "password": "secret"}), + ("docker.io/library/ubuntu:24.04", None), + ], +) +@pytest.mark.asyncio +async def test_create_vm_sends_registry_auth_only_to_the_registry_host( + monkeypatch: pytest.MonkeyPatch, image: str, registry_auth: dict[str, str] | None +): + monkeypatch.setattr(get_settings(), "registry_host", "ghcr.io") + monkeypatch.setattr(get_settings(), "registry_username", "bot") + monkeypatch.setattr(get_settings(), "registry_password", SecretStr("secret")) + api = _api_mock() + provider = DockerProvider(api, _settings()) + + await provider.create_vm(name="sb-test", image=image, env={}) + + assert api.run_container.await_args.kwargs["registry_auth"] == registry_auth + + @pytest.mark.parametrize("ssh_host", ["0.0.0.0", "::", "", "sandbox.example"]) def test_settings_reject_an_ssh_host_callers_cannot_dial(ssh_host: str): with pytest.raises(ValidationError): diff --git a/src/providers/exe/provider.py b/src/providers/exe/provider.py index ea41d3f..4e602f9 100644 --- a/src/providers/exe/provider.py +++ b/src/providers/exe/provider.py @@ -4,7 +4,6 @@ from providers.base import VMCreateResult, VMProvider from providers.capabilities import TemplateCapability from providers.docker.api import DockerAPI -from providers.docker.exceptions import DockerProviderError from providers.docker.images import build_derived_image, remove_derived_image from providers.exceptions import ( ProviderCommandError, @@ -12,7 +11,6 @@ ProviderHttpProxyNotFoundError, ProviderNotFoundError, ProviderTargetVMNotFoundError, - ProviderTransportError, ) from providers.exe.api import ExeAPI from providers.exe.exceptions import ( @@ -68,19 +66,11 @@ async def create_vm( instance_type: str | None = None, disk_gb: int | None = None, ) -> VMCreateResult: - # exe.dev assumes a public image. A template pushed to the configured - # registry is private, so its pull gets --registry-auth (see - # https://exe.dev/docs/private-image). The credentials go only to - # the registry that they belong to. + # exe.dev assumes a public image. An image on the configured registry + # host gets --registry-auth (see https://exe.dev/docs/private-image). registry_auth = None - registry = self.settings.image_registry - if ( - registry - and self.settings.registry_username - and self.settings.registry_password - and image.partition("/")[0] == registry.partition("/")[0] - ): - registry_auth = f"{self.settings.registry_username}:{self.settings.registry_password}" + if auth := get_settings().get_registry_auth(image): + registry_auth = f"{auth['username']}:{auth['password']}" # Tags are operator-facing: `exe ls --tag=managed-by-` shows what this deployment owns. payload = await self.api.create_vm( @@ -115,35 +105,17 @@ async def build_template_image( setup_script: str, label: str, ) -> str: - registry = self.settings.image_registry - username = self.settings.registry_username - password = self.settings.registry_password - - if not (registry and username and password): - missing_settings = [ - name - for name, value in ( - ("EXE_IMAGE_REGISTRY", registry), - ("EXE_REGISTRY_USERNAME", username), - ("EXE_REGISTRY_PASSWORD", password), - ) - if not value - ] + # exe.dev pulls every image from a registry, so a local template cannot boot. + if not get_settings().template_repository: raise ProviderCommandError( - f"exe template registry is not configured. Set: {', '.join(missing_settings)}" + "Template repository is not configured. Set TEMPLATE_REPOSITORY." ) - image = await build_derived_image( + return await build_derived_image( self.docker, base_image=base_image, setup_script=setup_script, - repository=registry, ) - try: - await self.docker.push_image(image, username=username, password=password) - except DockerProviderError as exc: - raise ProviderTransportError(str(exc)) from exc - return image async def delete_template_image(self, image: str) -> None: # Registry deletion is registry-specific. This provider only removes diff --git a/src/providers/exe/settings.py b/src/providers/exe/settings.py index 602694f..7572654 100644 --- a/src/providers/exe/settings.py +++ b/src/providers/exe/settings.py @@ -22,21 +22,6 @@ class ExeSettings(BaseSettings): default_image: str = Field( description="Default VM image passed to exe.dev when provisioning.", ) - image_registry: str | None = Field( - default=None, - description=( - "Repository prefix for derived template images. A VM created from " - "this registry gets --registry-auth so exe.dev can pull a private image." - ), - ) - registry_username: str | None = Field( - default=None, - description="Username for the derived-template image registry.", - ) - registry_password: str | None = Field( - default=None, - description="Password or token for the derived-template image registry.", - ) api_timeout: float = Field( default=30.0, description="Timeout in seconds for exe.dev API calls.", diff --git a/src/providers/exe/tests/test_provider.py b/src/providers/exe/tests/test_provider.py index e859466..b16f974 100644 --- a/src/providers/exe/tests/test_provider.py +++ b/src/providers/exe/tests/test_provider.py @@ -3,7 +3,9 @@ from unittest.mock import AsyncMock import pytest +from pydantic import SecretStr +from core.settings import get_settings from providers.docker.api import DockerAPI from providers.docker.exceptions import DockerImageNotFoundError, DockerTransportError from providers.exceptions import ProviderCommandError, ProviderNotFoundError, ProviderTransportError @@ -69,30 +71,35 @@ def _vm_payload() -> dict[str, str]: return {"vm_name": "sb-1", "ssh_port": "22", "ssh_dest": "sb-1.public.exe.dev"} -async def test_create_vm_sends_registry_auth_for_configured_registry_images() -> None: +@pytest.fixture +def registry_access(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(get_settings(), "registry_host", "ghcr.io") + monkeypatch.setattr(get_settings(), "registry_username", "bot") + monkeypatch.setattr(get_settings(), "registry_password", SecretStr("secret")) + + +async def test_create_vm_sends_registry_auth_without_a_template_repository( + registry_access: None, +) -> None: api = SimpleNamespace(create_vm=AsyncMock(return_value=_vm_payload())) - settings = _settings( - image_registry="ghcr.io/acme/templates", - registry_username="bot", - registry_password="secret", - ) - provider = ExeProvider(api, settings, docker=_docker_mock()) # type: ignore[arg-type] + provider = _make_provider(api) - await provider.create_vm(name="sb-1", image="ghcr.io/acme/templates:abc123") + await provider.create_vm(name="sb-1", image="ghcr.io/acme/private-base:abc123") assert api.create_vm.await_args.kwargs["registry_auth"] == "bot:secret" -async def test_create_vm_keeps_credentials_off_other_registries() -> None: +@pytest.mark.parametrize( + "image", + ["docker.io/library/ubuntu:24.04", "ghcr.io.evil.example/acme/templates:abc123"], +) +async def test_create_vm_keeps_credentials_off_other_registry_hosts( + registry_access: None, image: str +) -> None: api = SimpleNamespace(create_vm=AsyncMock(return_value=_vm_payload())) - settings = _settings( - image_registry="ghcr.io/acme/templates", - registry_username="bot", - registry_password="secret", - ) - provider = ExeProvider(api, settings, docker=_docker_mock()) # type: ignore[arg-type] + provider = _make_provider(api) - await provider.create_vm(name="sb-1", image="docker.io/library/ubuntu:24.04") + await provider.create_vm(name="sb-1", image=image) assert api.create_vm.await_args.kwargs["registry_auth"] is None @@ -165,15 +172,14 @@ def test_from_settings_constructs_with_exeapi() -> None: assert isinstance(provider.docker, DockerAPI) -async def test_build_template_image_builds_logs_in_and_pushes() -> None: +async def test_build_template_image_builds_logs_in_and_pushes( + registry_access: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(get_settings(), "template_repository", "acme/drukbox-templates") docker = _docker_mock() provider = ExeProvider( SimpleNamespace(), # type: ignore[arg-type] - _settings( - image_registry="ghcr.io/acme/drukbox-templates", - registry_username="builder", - registry_password="registry-secret", - ), + _settings(), docker=docker, # type: ignore[arg-type] ) @@ -186,12 +192,12 @@ async def test_build_template_image_builds_logs_in_and_pushes() -> None: assert image.startswith("ghcr.io/acme/drukbox-templates:") assert len(image.rpartition(":")[2]) == 12 assert docker.build_image.await_args.args[0] == image - docker.push_image.assert_awaited_once_with( - image, username="builder", password="registry-secret" - ) + docker.push_image.assert_awaited_once_with(image, username="bot", password="secret") -async def test_build_template_image_names_each_missing_registry_setting() -> None: +async def test_build_template_image_requires_a_template_repository( + registry_access: None, +) -> None: docker = _docker_mock() provider = ExeProvider( SimpleNamespace(), # type: ignore[arg-type] @@ -199,29 +205,25 @@ async def test_build_template_image_names_each_missing_registry_setting() -> Non docker=docker, # type: ignore[arg-type] ) - with pytest.raises(ProviderCommandError) as error: + with pytest.raises(ProviderCommandError, match="TEMPLATE_REPOSITORY"): await provider.build_template_image( base_image="exe/base:latest", setup_script="apt-get update", label="Node tools", ) - assert "EXE_IMAGE_REGISTRY" in str(error.value) - assert "EXE_REGISTRY_USERNAME" in str(error.value) - assert "EXE_REGISTRY_PASSWORD" in str(error.value) docker.build_image.assert_not_awaited() -async def test_build_template_image_translates_push_failure() -> None: +async def test_build_template_image_translates_push_failure( + registry_access: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(get_settings(), "template_repository", "acme/drukbox-templates") docker = _docker_mock() docker.push_image.side_effect = DockerTransportError("push log tail") provider = ExeProvider( SimpleNamespace(), # type: ignore[arg-type] - _settings( - image_registry="ghcr.io/acme/drukbox-templates", - registry_username="builder", - registry_password="registry-secret", - ), + _settings(), docker=docker, # type: ignore[arg-type] )