Skip to content

Commit 766c382

Browse files
authored
fix(core): Improve private registry support (tolerate not implemented fields in DOCKER_AUTH_CONFIG) (#647)
Continuing #562, got some feedback regarding an issue with unsupported use cases. In this PR we will try to: 1. Map the use cases 2. Raise a warning regarding unsupported uses cases (hopefully they will be added later) 3. Address/Fix the issue where unsupported JSON schema for `DOCKER_AUTH_CONFIG` leads to an error As always any feedback will be much appreciated. _Please note this PR does not implement all use-cases just does a better job at preparing and handling them for now_
1 parent df07586 commit 766c382

File tree

6 files changed

+232
-78
lines changed

6 files changed

+232
-78
lines changed

core/testcontainers/core/auth.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import base64 as base64
2+
import json as json
3+
from collections import namedtuple
4+
from logging import warning
5+
from typing import Optional
6+
7+
DockerAuthInfo = namedtuple("DockerAuthInfo", ["registry", "username", "password"])
8+
9+
_AUTH_WARNINGS = {
10+
"credHelpers": "DOCKER_AUTH_CONFIG is experimental, credHelpers not supported yet",
11+
"credsStore": "DOCKER_AUTH_CONFIG is experimental, credsStore not supported yet",
12+
}
13+
14+
15+
def process_docker_auth_config_encoded(auth_config_dict: dict) -> list[DockerAuthInfo]:
16+
"""
17+
Process the auths config.
18+
19+
Example:
20+
{
21+
"auths": {
22+
"https://index.docker.io/v1/": {
23+
"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="
24+
}
25+
}
26+
}
27+
28+
Returns a list of DockerAuthInfo objects.
29+
"""
30+
auth_info: list[DockerAuthInfo] = []
31+
32+
auths = auth_config_dict.get("auths")
33+
for registry, auth in auths.items():
34+
auth_str = auth.get("auth")
35+
auth_str = base64.b64decode(auth_str).decode("utf-8")
36+
username, password = auth_str.split(":")
37+
auth_info.append(DockerAuthInfo(registry, username, password))
38+
39+
return auth_info
40+
41+
42+
def process_docker_auth_config_cred_helpers(auth_config_dict: dict) -> None:
43+
"""
44+
Process the credHelpers config.
45+
46+
Example:
47+
{
48+
"credHelpers": {
49+
"<aws_account_id>.dkr.ecr.<region>.amazonaws.com": "ecr-login"
50+
}
51+
}
52+
53+
This is not supported yet.
54+
"""
55+
if "credHelpers" in _AUTH_WARNINGS:
56+
warning(_AUTH_WARNINGS.pop("credHelpers"))
57+
58+
59+
def process_docker_auth_config_store(auth_config_dict: dict) -> None:
60+
"""
61+
Process the credsStore config.
62+
63+
Example:
64+
{
65+
"credsStore": "ecr-login"
66+
}
67+
68+
This is not supported yet.
69+
"""
70+
if "credsStore" in _AUTH_WARNINGS:
71+
warning(_AUTH_WARNINGS.pop("credsStore"))
72+
73+
74+
def parse_docker_auth_config(auth_config: str) -> Optional[list[DockerAuthInfo]]:
75+
"""Parse the docker auth config from a string and handle the different formats."""
76+
try:
77+
auth_config_dict: dict = json.loads(auth_config)
78+
if "credHelpers" in auth_config:
79+
process_docker_auth_config_cred_helpers(auth_config_dict)
80+
if "credsStore" in auth_config:
81+
process_docker_auth_config_store(auth_config_dict)
82+
if "auths" in auth_config:
83+
return process_docker_auth_config_encoded(auth_config_dict)
84+
85+
except (json.JSONDecodeError, KeyError, ValueError) as exp:
86+
raise ValueError("Could not parse docker auth config") from exp
87+
88+
return None

core/testcontainers/core/docker_client.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
from docker.models.images import Image, ImageCollection
2525
from typing_extensions import ParamSpec
2626

27+
from testcontainers.core.auth import DockerAuthInfo, parse_docker_auth_config
2728
from testcontainers.core.config import testcontainers_config as c
2829
from testcontainers.core.labels import SESSION_ID, create_labels
29-
from testcontainers.core.utils import default_gateway_ip, inside_container, parse_docker_auth_config, setup_logger
30+
from testcontainers.core.utils import default_gateway_ip, inside_container, setup_logger
3031

3132
LOGGER = setup_logger(__name__)
3233

@@ -67,8 +68,11 @@ def __init__(self, **kwargs) -> None:
6768
self.client.api.headers["x-tc-sid"] = SESSION_ID
6869
self.client.api.headers["User-Agent"] = "tc-python/" + importlib.metadata.version("testcontainers")
6970

71+
# Verify if we have a docker auth config and login if we do
7072
if docker_auth_config := get_docker_auth_config():
71-
self.login(docker_auth_config)
73+
LOGGER.debug(f"DOCKER_AUTH_CONFIG found: {docker_auth_config}")
74+
if auth_config := parse_docker_auth_config(docker_auth_config):
75+
self.login(auth_config[0]) # Only using the first auth config)
7276

7377
@_wrapped_container_collection
7478
def run(
@@ -203,11 +207,10 @@ def host(self) -> str:
203207
return ip_address
204208
return "localhost"
205209

206-
def login(self, docker_auth_config: str) -> None:
210+
def login(self, auth_config: DockerAuthInfo) -> None:
207211
"""
208212
Login to a docker registry using the given auth config.
209213
"""
210-
auth_config = parse_docker_auth_config(docker_auth_config)[0] # Only using the first auth config
211214
login_info = self.client.login(**auth_config._asdict())
212215
LOGGER.debug(f"logged in using {login_info}")
213216

core/testcontainers/core/utils.py

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
1-
import base64
2-
import json
31
import logging
42
import os
53
import platform
64
import subprocess
75
import sys
8-
from collections import namedtuple
96

107
LINUX = "linux"
118
MAC = "mac"
129
WIN = "win"
1310

14-
DockerAuthInfo = namedtuple("DockerAuthInfo", ["registry", "username", "password"])
15-
1611

1712
def setup_logger(name: str) -> logging.Logger:
1813
logger = logging.getLogger(name)
@@ -82,29 +77,3 @@ def raise_for_deprecated_parameter(kwargs: dict, name: str, replacement: str) ->
8277
if kwargs.pop(name, None):
8378
raise ValueError(f"Use `{replacement}` instead of `{name}`")
8479
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_auth.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import json
2+
import pytest
3+
4+
from testcontainers.core.auth import parse_docker_auth_config, DockerAuthInfo
5+
6+
7+
def test_parse_docker_auth_config_encoded():
8+
auth_config_json = '{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcm5hbWU6cGFzc3dvcmQ="}}}'
9+
auth_info = parse_docker_auth_config(auth_config_json)
10+
assert len(auth_info) == 1
11+
assert auth_info[0] == DockerAuthInfo(
12+
registry="https://index.docker.io/v1/",
13+
username="username",
14+
password="password",
15+
)
16+
17+
18+
def test_parse_docker_auth_config_cred_helpers():
19+
auth_dict = {"credHelpers": {"<aws_account_id>.dkr.ecr.<region>.amazonaws.com": "ecr-login"}}
20+
auth_config_json = json.dumps(auth_dict)
21+
assert parse_docker_auth_config(auth_config_json) is None
22+
23+
24+
def test_parse_docker_auth_config_store():
25+
auth_dict = {"credsStore": "ecr-login"}
26+
auth_config_json = json.dumps(auth_dict)
27+
assert parse_docker_auth_config(auth_config_json) is None
28+
29+
30+
def test_parse_docker_auth_config_encoded_multiple():
31+
auth_dict = {
32+
"auths": {
33+
"localhost:5000": {"auth": "dXNlcjE6cGFzczE=="},
34+
"https://example.com": {"auth": "dXNlcl9uZXc6cGFzc19uZXc=="},
35+
"example2.com": {"auth": "YWJjOjEyMw==="},
36+
}
37+
}
38+
auth_config_json = json.dumps(auth_dict)
39+
auth_info = parse_docker_auth_config(auth_config_json)
40+
assert len(auth_info) == 3
41+
assert auth_info[0] == DockerAuthInfo(
42+
registry="localhost:5000",
43+
username="user1",
44+
password="pass1",
45+
)
46+
assert auth_info[1] == DockerAuthInfo(
47+
registry="https://example.com",
48+
username="user_new",
49+
password="pass_new",
50+
)
51+
assert auth_info[2] == DockerAuthInfo(
52+
registry="example2.com",
53+
username="abc",
54+
password="123",
55+
)
56+
57+
58+
def test_parse_docker_auth_config_unknown():
59+
auth_config_str = '{"key": "value"}'
60+
assert parse_docker_auth_config(auth_config_str) is None
61+
62+
63+
def test_parse_docker_auth_config_error():
64+
auth_config_str = "bad//string"
65+
with pytest.raises(ValueError):
66+
parse_docker_auth_config(auth_config_str)
67+
68+
69+
def test_parse_docker_auth_all():
70+
test_dict = {
71+
"auths": {
72+
"localhost:5000": {"auth": "dXNlcjE6cGFzczE=="},
73+
},
74+
"credHelpers": {"<aws_account_id>.dkr.ecr.<region>.amazonaws.com": "ecr-login"},
75+
"credsStore": "ecr-login",
76+
}
77+
auth_config_json = json.dumps(test_dict)
78+
assert parse_docker_auth_config(auth_config_json) == [
79+
DockerAuthInfo(
80+
registry="localhost:5000",
81+
username="user1",
82+
password="pass1",
83+
)
84+
]

core/tests/test_docker_client.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import json
23
from collections import namedtuple
34
from unittest import mock
45
from unittest.mock import MagicMock, patch
@@ -8,9 +9,11 @@
89
from testcontainers.core.config import testcontainers_config as c
910
from testcontainers.core.container import DockerContainer
1011
from testcontainers.core.docker_client import DockerClient
11-
from testcontainers.core.utils import parse_docker_auth_config
12+
from testcontainers.core.auth import parse_docker_auth_config
1213
from testcontainers.core.image import DockerImage
1314

15+
from pytest import mark
16+
1417

1518
def test_docker_client_from_env():
1619
test_kwargs = {"test_kw": "test_value"}
@@ -48,6 +51,55 @@ def test_docker_client_login():
4851
mock_docker.from_env.return_value.login.assert_called_with(**{"value": "test"})
4952

5053

54+
def test_docker_client_login_empty_get_docker_auth_config():
55+
mock_docker = MagicMock(spec=docker)
56+
mock_get_docker_auth_config = MagicMock()
57+
mock_get_docker_auth_config.return_value = None
58+
59+
with (
60+
mock.patch.object(c, "_docker_auth_config", "test"),
61+
patch("testcontainers.core.docker_client.docker", mock_docker),
62+
patch("testcontainers.core.docker_client.get_docker_auth_config", mock_get_docker_auth_config),
63+
):
64+
DockerClient()
65+
66+
mock_docker.from_env.return_value.login.assert_not_called()
67+
68+
69+
def test_docker_client_login_empty_parse_docker_auth_config():
70+
mock_docker = MagicMock(spec=docker)
71+
mock_parse_docker_auth_config = MagicMock(spec=parse_docker_auth_config)
72+
mock_utils = MagicMock()
73+
mock_utils.parse_docker_auth_config = mock_parse_docker_auth_config
74+
mock_parse_docker_auth_config.return_value = None
75+
76+
with (
77+
mock.patch.object(c, "_docker_auth_config", "test"),
78+
patch("testcontainers.core.docker_client.docker", mock_docker),
79+
patch("testcontainers.core.docker_client.parse_docker_auth_config", mock_parse_docker_auth_config),
80+
):
81+
DockerClient()
82+
83+
mock_docker.from_env.return_value.login.assert_not_called()
84+
85+
86+
# This is used to make sure we don't fail (nor try to login) when we have unsupported auth config
87+
@mark.parametrize("auth_config_sample", [{"credHelpers": {"test": "login"}}, {"credsStore": "login"}])
88+
def test_docker_client_login_unsupported_auth_config(auth_config_sample):
89+
mock_docker = MagicMock(spec=docker)
90+
mock_get_docker_auth_config = MagicMock()
91+
mock_get_docker_auth_config.return_value = json.dumps(auth_config_sample)
92+
93+
with (
94+
mock.patch.object(c, "_docker_auth_config", "test"),
95+
patch("testcontainers.core.docker_client.docker", mock_docker),
96+
patch("testcontainers.core.docker_client.get_docker_auth_config", mock_get_docker_auth_config),
97+
):
98+
DockerClient()
99+
100+
mock_docker.from_env.return_value.login.assert_not_called()
101+
102+
51103
def test_container_docker_client_kw():
52104
test_kwargs = {"test_kw": "test_value"}
53105
mock_docker = MagicMock(spec=docker)

core/tests/test_utils.py

Lines changed: 0 additions & 42 deletions
This file was deleted.

0 commit comments

Comments
 (0)