Skip to content

Commit 59fbcfa

Browse files
Tranquility2alexanderankin
andauthoredMay 25, 2024··
feat(core): Private registry (#566)
Ref #562 This enhancement adds capability to utilize the env var `DOCKER_AUTH_CONFIG` in-order to login to a private docker registry. --------- Co-authored-by: David Ankin <daveankin@gmail.com>
1 parent 9d2ceb6 commit 59fbcfa

File tree

5 files changed

+139
-2
lines changed

5 files changed

+139
-2
lines changed
 

‎core/testcontainers/core/config.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from dataclasses import dataclass, field
2+
from logging import warning
23
from os import environ
34
from os.path import exists
45
from pathlib import Path
5-
from typing import Union
6+
from typing import Optional, Union
67

78
MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120))
89
SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1))
@@ -37,6 +38,9 @@ def read_tc_properties() -> dict[str, str]:
3738
return settings
3839

3940

41+
_WARNINGS = {"DOCKER_AUTH_CONFIG": "DOCKER_AUTH_CONFIG is experimental, see testcontainers/testcontainers-python#566"}
42+
43+
4044
@dataclass
4145
class TestcontainersConfiguration:
4246
max_tries: int = MAX_TRIES
@@ -47,6 +51,19 @@ class TestcontainersConfiguration:
4751
ryuk_docker_socket: str = RYUK_DOCKER_SOCKET
4852
ryuk_reconnection_timeout: str = RYUK_RECONNECTION_TIMEOUT
4953
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
54+
_docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG"))
55+
56+
@property
57+
def docker_auth_config(self):
58+
if "DOCKER_AUTH_CONFIG" in _WARNINGS:
59+
warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG"))
60+
return self._docker_auth_config
61+
62+
@docker_auth_config.setter
63+
def docker_auth_config(self, value: str):
64+
if "DOCKER_AUTH_CONFIG" in _WARNINGS:
65+
warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG"))
66+
self._docker_auth_config = value
5067

5168
def tc_properties_get_tc_host(self) -> Union[str, None]:
5269
return self.tc_properties.get("tc.host")

‎core/testcontainers/core/docker_client.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from testcontainers.core.config import testcontainers_config as c
2626
from testcontainers.core.labels import SESSION_ID, create_labels
27-
from testcontainers.core.utils import default_gateway_ip, inside_container, setup_logger
27+
from testcontainers.core.utils import default_gateway_ip, inside_container, parse_docker_auth_config, setup_logger
2828

2929
LOGGER = setup_logger(__name__)
3030

@@ -57,6 +57,9 @@ def __init__(self, **kwargs) -> None:
5757
self.client.api.headers["x-tc-sid"] = SESSION_ID
5858
self.client.api.headers["User-Agent"] = "tc-python/" + importlib.metadata.version("testcontainers")
5959

60+
if docker_auth_config := get_docker_auth_config():
61+
self.login(docker_auth_config)
62+
6063
@_wrapped_container_collection
6164
def run(
6265
self,
@@ -183,6 +186,18 @@ def host(self) -> str:
183186
return ip_address
184187
return "localhost"
185188

189+
def login(self, docker_auth_config: str) -> None:
190+
"""
191+
Login to a docker registry using the given auth config.
192+
"""
193+
auth_config = parse_docker_auth_config(docker_auth_config)[0] # Only using the first auth config
194+
login_info = self.client.login(**auth_config._asdict())
195+
LOGGER.debug(f"logged in using {login_info}")
196+
186197

187198
def get_docker_host() -> Optional[str]:
188199
return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
200+
201+
202+
def get_docker_auth_config() -> Optional[str]:
203+
return c.docker_auth_config

‎core/testcontainers/core/utils.py

+31
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import base64
2+
import json
13
import logging
24
import os
35
import platform
46
import subprocess
57
import sys
8+
from collections import namedtuple
69

710
LINUX = "linux"
811
MAC = "mac"
912
WIN = "win"
1013

14+
DockerAuthInfo = namedtuple("DockerAuthInfo", ["registry", "username", "password"])
15+
1116

1217
def setup_logger(name: str) -> logging.Logger:
1318
logger = logging.getLogger(name)
@@ -77,3 +82,29 @@ def raise_for_deprecated_parameter(kwargs: dict, name: str, replacement: str) ->
7782
if kwargs.pop(name, None):
7883
raise ValueError(f"Use `{replacement}` instead of `{name}`")
7984
return kwargs
85+
86+
87+
def parse_docker_auth_config(auth_config: str) -> list[DockerAuthInfo]:
88+
"""
89+
Parse the docker auth config from a string.
90+
91+
Example:
92+
{
93+
"auths": {
94+
"https://index.docker.io/v1/": {
95+
"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="
96+
}
97+
}
98+
}
99+
"""
100+
auth_info: list[DockerAuthInfo] = []
101+
try:
102+
auth_config_dict: dict = json.loads(auth_config).get("auths")
103+
for registry, auth in auth_config_dict.items():
104+
auth_str = auth.get("auth")
105+
auth_str = base64.b64decode(auth_str).decode("utf-8")
106+
username, password = auth_str.split(":")
107+
auth_info.append(DockerAuthInfo(registry, username, password))
108+
return auth_info
109+
except (json.JSONDecodeError, KeyError, ValueError) as exp:
110+
raise ValueError("Could not parse docker auth config") from exp

‎core/tests/test_docker_client.py

+32
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import os
2+
from collections import namedtuple
3+
from unittest import mock
14
from unittest.mock import MagicMock, patch
25

36
import docker
47

8+
from testcontainers.core.config import testcontainers_config as c
59
from testcontainers.core.container import DockerContainer
610
from testcontainers.core.docker_client import DockerClient
11+
from testcontainers.core.utils import parse_docker_auth_config
712

813

914
def test_docker_client_from_env():
@@ -15,6 +20,33 @@ def test_docker_client_from_env():
1520
mock_docker.from_env.assert_called_with(**test_kwargs)
1621

1722

23+
def test_docker_client_login_no_login():
24+
with patch.dict(os.environ, {}, clear=True):
25+
mock_docker = MagicMock(spec=docker)
26+
with patch("testcontainers.core.docker_client.docker", mock_docker):
27+
DockerClient()
28+
29+
mock_docker.from_env.return_value.login.assert_not_called()
30+
31+
32+
def test_docker_client_login():
33+
mock_docker = MagicMock(spec=docker)
34+
mock_parse_docker_auth_config = MagicMock(spec=parse_docker_auth_config)
35+
mock_utils = MagicMock()
36+
mock_utils.parse_docker_auth_config = mock_parse_docker_auth_config
37+
TestAuth = namedtuple("Auth", "value")
38+
mock_parse_docker_auth_config.return_value = [TestAuth("test")]
39+
40+
with (
41+
mock.patch.object(c, "_docker_auth_config", "test"),
42+
patch("testcontainers.core.docker_client.docker", mock_docker),
43+
patch("testcontainers.core.docker_client.parse_docker_auth_config", mock_parse_docker_auth_config),
44+
):
45+
DockerClient()
46+
47+
mock_docker.from_env.return_value.login.assert_called_with(**{"value": "test"})
48+
49+
1850
def test_container_docker_client_kw():
1951
test_kwargs = {"test_kw": "test_value"}
2052
mock_docker = MagicMock(spec=docker)

‎core/tests/test_utils.py

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import json
2+
3+
from testcontainers.core.utils import parse_docker_auth_config, DockerAuthInfo
4+
5+
6+
def test_parse_docker_auth_config():
7+
auth_config_json = '{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcm5hbWU6cGFzc3dvcmQ="}}}'
8+
auth_info = parse_docker_auth_config(auth_config_json)
9+
assert len(auth_info) == 1
10+
assert auth_info[0] == DockerAuthInfo(
11+
registry="https://index.docker.io/v1/",
12+
username="username",
13+
password="password",
14+
)
15+
16+
17+
def test_parse_docker_auth_config_multiple():
18+
auth_dict = {
19+
"auths": {
20+
"localhost:5000": {"auth": "dXNlcjE6cGFzczE=="},
21+
"https://example.com": {"auth": "dXNlcl9uZXc6cGFzc19uZXc=="},
22+
"example2.com": {"auth": "YWJjOjEyMw==="},
23+
}
24+
}
25+
auth_config_json = json.dumps(auth_dict)
26+
auth_info = parse_docker_auth_config(auth_config_json)
27+
assert len(auth_info) == 3
28+
assert auth_info[0] == DockerAuthInfo(
29+
registry="localhost:5000",
30+
username="user1",
31+
password="pass1",
32+
)
33+
assert auth_info[1] == DockerAuthInfo(
34+
registry="https://example.com",
35+
username="user_new",
36+
password="pass_new",
37+
)
38+
assert auth_info[2] == DockerAuthInfo(
39+
registry="example2.com",
40+
username="abc",
41+
password="123",
42+
)

0 commit comments

Comments
 (0)
Please sign in to comment.