|
| 1 | +"""Slim, test-private copy of ``DockerRegistryContainer``. |
| 2 | +
|
| 3 | +``core/tests`` should not import from sibling modules (``testcontainers.registry`` in this case). |
| 4 | +This keeps ``core`` self-contained as the package layout evolves into a separate ``core`` / ``community`` split. |
| 5 | +
|
| 6 | +We keep the integration coverage by inlining only the bits needed to spin up an authenticated ``registry:2``. |
| 7 | +That means: htpasswd creds setup, an HTTP readiness probe, and an accessor for the published address. |
| 8 | +
|
| 9 | +This is intentional duplication of the production class. |
| 10 | +Behavioural changes here are not expected to track ``testcontainers.registry`` and vice versa. |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +import time |
| 16 | +from io import BytesIO |
| 17 | +from tarfile import TarFile, TarInfo |
| 18 | +from typing import TYPE_CHECKING, Any, Optional |
| 19 | + |
| 20 | +import bcrypt |
| 21 | +from requests import get |
| 22 | +from requests.auth import HTTPBasicAuth |
| 23 | +from requests.exceptions import ConnectionError as RequestsConnectionError |
| 24 | +from requests.exceptions import ReadTimeout |
| 25 | + |
| 26 | +from testcontainers.core.container import DockerContainer |
| 27 | +from testcontainers.core.waiting_utils import wait_container_is_ready |
| 28 | + |
| 29 | +if TYPE_CHECKING: |
| 30 | + from requests import Response |
| 31 | + |
| 32 | + |
| 33 | +class _LocalRegistryContainer(DockerContainer): |
| 34 | + """Private ``registry:2`` fixture used by ``core/tests`` only. |
| 35 | +
|
| 36 | + Mirrors the public ``DockerRegistryContainer`` API surface used by tests. |
| 37 | + That is: ``__init__``, ``start``, and ``get_registry``. |
| 38 | + """ |
| 39 | + |
| 40 | + credentials_path: str = "/htpasswd/credentials.txt" |
| 41 | + |
| 42 | + def __init__( |
| 43 | + self, |
| 44 | + image: str = "registry:2", |
| 45 | + port: int = 5000, |
| 46 | + username: Optional[str] = None, |
| 47 | + password: Optional[str] = None, |
| 48 | + **kwargs: Any, |
| 49 | + ) -> None: |
| 50 | + super().__init__(image=image, **kwargs) |
| 51 | + self.port: int = port |
| 52 | + self.username: Optional[str] = username |
| 53 | + self.password: Optional[str] = password |
| 54 | + self.with_exposed_ports(self.port) |
| 55 | + |
| 56 | + def _copy_credentials(self) -> None: |
| 57 | + if self.password is None: |
| 58 | + raise ValueError("Password cannot be None") |
| 59 | + hashed_password: str = bcrypt.hashpw( |
| 60 | + self.password.encode("utf-8"), |
| 61 | + bcrypt.gensalt(rounds=12, prefix=b"2a"), |
| 62 | + ).decode("utf-8") |
| 63 | + content: bytes = f"{self.username}:{hashed_password}".encode() |
| 64 | + |
| 65 | + with BytesIO() as tar_archive_object, TarFile(fileobj=tar_archive_object, mode="w") as tmp_tarfile: |
| 66 | + tarinfo: TarInfo = TarInfo(name=self.credentials_path) |
| 67 | + tarinfo.size = len(content) |
| 68 | + tarinfo.mtime = int(time.time()) |
| 69 | + |
| 70 | + tmp_tarfile.addfile(tarinfo, BytesIO(content)) |
| 71 | + tar_archive_object.seek(0) |
| 72 | + self.get_wrapped_container().put_archive("/", tar_archive_object) |
| 73 | + |
| 74 | + @wait_container_is_ready(RequestsConnectionError, ReadTimeout) |
| 75 | + def _readiness_probe(self) -> None: |
| 76 | + url: str = f"http://{self.get_registry()}/v2" |
| 77 | + if self.username and self.password: |
| 78 | + auth_response: Response = get(url, auth=HTTPBasicAuth(self.username, self.password), timeout=1) |
| 79 | + auth_response.raise_for_status() |
| 80 | + else: |
| 81 | + response: Response = get(url, timeout=1) |
| 82 | + response.raise_for_status() |
| 83 | + |
| 84 | + def start(self) -> "_LocalRegistryContainer": |
| 85 | + if self.username and self.password: |
| 86 | + self.with_env("REGISTRY_AUTH_HTPASSWD_REALM", "local-registry") |
| 87 | + self.with_env("REGISTRY_AUTH_HTPASSWD_PATH", self.credentials_path) |
| 88 | + super().start() |
| 89 | + self._copy_credentials() |
| 90 | + else: |
| 91 | + super().start() |
| 92 | + |
| 93 | + self._readiness_probe() |
| 94 | + return self |
| 95 | + |
| 96 | + def get_registry(self) -> str: |
| 97 | + host: str = self.get_container_host_ip() |
| 98 | + port: int = self.get_exposed_port(self.port) |
| 99 | + return f"{host}:{port}" |
0 commit comments