Skip to content

Commit 8bd4f0f

Browse files
committed
test(core): replace registry import with a private _LocalRegistryContainer copy
1 parent 3cbd956 commit 8bd4f0f

3 files changed

Lines changed: 117 additions & 22 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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}"

core/tests/test_core_registry.py

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
1-
"""Integration test using login to a private registry.
1+
"""
2+
Integration tests for private-registry support in ``DockerClient``.
23
3-
Note: Using the testcontainers-python library to test the Docker registry.
4-
This could be considered a bad practice as it is not recommended to use the same library to test itself.
5-
However, it is a very good use case for DockerRegistryContainer and allows us to test it thoroughly.
4+
These tests spin up a real ``registry:2`` container and exercise the wiring that turns ``DOCKER_AUTH_CONFIG`` into a successful login + image pull.
5+
The registry is provided by the local ``_LocalRegistryContainer`` helper, kept as a private copy in ``core/tests`` so ``core`` does not import from a sibling module.
66
7-
Note2: These tests are skipped on macOS and SSH-based Docker hosts because they rely on insecure HTTP registries,
8-
which are not supported in those environments without additional configuration.
7+
Skipped where insecure HTTP registries cannot be reached without daemon reconfiguration.
8+
That includes macOS, Podman, and SSH-based Docker hosts.
99
"""
1010

11-
import json
12-
import os
1311
import base64
14-
import pytest
12+
import json
1513

14+
import pytest
15+
from _local_registry_container import _LocalRegistryContainer # type: ignore[import-not-found]
1616
from docker.errors import NotFound
1717

1818
from testcontainers.core.config import testcontainers_config
1919
from testcontainers.core.container import DockerContainer
20-
from testcontainers.core.docker_client import DockerClient, is_ssh_docker_host
21-
from testcontainers.core.waiting_utils import wait_for_logs
22-
23-
from testcontainers.registry import DockerRegistryContainer
20+
from testcontainers.core.docker_client import DockerClient, is_podman, is_ssh_docker_host
2421
from testcontainers.core.utils import is_mac
25-
from testcontainers.core.docker_client import is_podman
26-
22+
from testcontainers.core.waiting_utils import wait_for_logs
2723

2824
_skip_insecure_registry = pytest.mark.skipif(
2925
is_mac() or is_podman() or is_ssh_docker_host(),
@@ -38,7 +34,7 @@ def test_missing_on_private_registry(monkeypatch):
3834
image = "hello-world"
3935
tag = "test"
4036

41-
with DockerRegistryContainer(username=username, password=password) as registry:
37+
with _LocalRegistryContainer(username=username, password=password) as registry:
4238
registry_url = registry.get_registry()
4339

4440
# prepare auth config
@@ -65,7 +61,7 @@ def test_missing_on_private_registry(monkeypatch):
6561
def test_with_private_registry(image, tag, username, password, expected_output, monkeypatch):
6662
client = DockerClient().client
6763

68-
with DockerRegistryContainer(username=username, password=password) as registry:
64+
with _LocalRegistryContainer(username=username, password=password) as registry:
6965
registry_url = registry.get_registry()
7066

7167
# prepare image

uv.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)