Skip to content

Commit d48115d

Browse files
authored
feat(core): support SSH-based DOCKER_HOST (#993)
Fixes #992 ## Problem When `DOCKER_HOST` is set to an SSH URL (e.g. `ssh://user@remote-host`), the Docker Python SDK rewrites `base_url` to `http+docker://ssh`, losing the original hostname. This causes `DockerClient.host()` to return `"ssh"` instead of the actual remote address, breaking container connectivity. Additionally, the SDK defaults to paramiko for SSH connections, which crashes under pytest due to stdin capture conflicts. ## Changes ### `docker_client.py` - Extract the remote hostname from `DOCKER_HOST` in `host()` instead of relying on the SDK's rewritten `base_url` - Default to `use_ssh_client=True` for SSH connections to avoid paramiko/pytest stdin conflicts - Sanitize SSH URLs with unsupported path components before passing to the SDK - Add `get_docker_host_hostname()` and `is_ssh_docker_host()` helpers ### `compose.py` - Handle SSH in `PublishedPortModel.normalize()` — replace local bind addresses (`0.0.0.0`, `127.0.0.1`, etc.) with the remote SSH hostname ### Tests - Add SSH-specific tests for `DockerClient.host()`, connection mode, compose port normalization, and URL sanitization
1 parent baa5668 commit d48115d

File tree

9 files changed

+193
-16
lines changed

9 files changed

+193
-16
lines changed

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ ${TESTS}: %/tests:
2525
quick-core-tests: ## Run core tests excluding long_running
2626
uv run coverage run --parallel -m pytest -v -m "not long_running" core/tests
2727

28+
core-tests: ## Run tests for the core package
29+
uv run coverage run --parallel -m pytest -v core/tests
30+
2831
coverage: ## Target to combine and report coverage.
2932
uv run coverage combine
3033
uv run coverage report

core/testcontainers/compose/compose.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from types import TracebackType
1212
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast
1313

14+
from testcontainers.core.docker_client import get_docker_host_hostname
1415
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
1516
from testcontainers.core.waiting_utils import WaitStrategy
1617

@@ -45,10 +46,21 @@ class PublishedPortModel:
4546
Protocol: Optional[str] = None
4647

4748
def normalize(self) -> "PublishedPortModel":
48-
url_not_usable = system() == "Windows" and self.URL == "0.0.0.0"
49-
if url_not_usable:
49+
url = self.URL
50+
51+
# For SSH-based DOCKER_HOST, local addresses (0.0.0.0, 127.0.0.1, localhost, ::, ::1)
52+
# refer to the remote machine, not the local one.
53+
# Replace them with the actual remote hostname.
54+
ssh_host = get_docker_host_hostname()
55+
if ssh_host and url in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"):
56+
url = ssh_host
57+
# On Windows, 0.0.0.0 is not usable — replace with 127.0.0.1
58+
elif system() == "Windows" and url == "0.0.0.0":
59+
url = "127.0.0.1"
60+
61+
if url != self.URL:
5062
self_dict = asdict(self)
51-
self_dict.update({"URL": "127.0.0.1"})
63+
self_dict.update({"URL": url})
5264
return PublishedPortModel(**self_dict)
5365
return self
5466

core/testcontainers/core/docker_client.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,12 @@ def __init__(self, **kwargs: Any) -> None:
6868
if docker_host:
6969
LOGGER.info(f"using host {docker_host}")
7070
os.environ["DOCKER_HOST"] = docker_host
71-
self.client = docker.from_env(**kwargs)
72-
else:
73-
self.client = docker.from_env(**kwargs)
71+
# Use shell-based SSH client instead of paramiko to avoid conflicts with pytest stdin capture
72+
# (paramiko's invoke library fails when reading from captured stdin).
73+
if docker_host.startswith("ssh://"):
74+
kwargs.setdefault("use_ssh_client", True)
75+
76+
self.client = docker.from_env(**kwargs)
7477
self.client.api.headers["x-tc-sid"] = SESSION_ID
7578
self.client.api.headers["User-Agent"] = "tc-python/" + importlib.metadata.version("testcontainers")
7679

@@ -234,6 +237,14 @@ def host(self) -> str:
234237
host = c.tc_host_override
235238
if host:
236239
return host
240+
241+
# For SSH-based connections, the Docker SDK rewrites base_url to
242+
# "http+docker://ssh" which loses the original hostname.
243+
# Extract it from the original DOCKER_HOST instead.
244+
ssh_host = get_docker_host_hostname()
245+
if ssh_host:
246+
return ssh_host
247+
237248
try:
238249
url = urllib.parse.urlparse(self.client.api.base_url)
239250
except ValueError:
@@ -266,7 +277,52 @@ def client_networks_create(self, name: str, param: dict[str, Any]) -> "DockerNet
266277

267278

268279
def get_docker_host() -> Optional[str]:
269-
return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
280+
host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
281+
if host:
282+
return _sanitize_docker_host(host)
283+
return None
284+
285+
286+
def get_docker_host_hostname() -> Optional[str]:
287+
"""Extract the remote hostname from an SSH-based DOCKER_HOST.
288+
289+
Returns the hostname (e.g. '192.168.1.42') when DOCKER_HOST is an ssh:// URL, or None otherwise.
290+
"""
291+
docker_host = get_docker_host()
292+
if docker_host and docker_host.startswith("ssh://"):
293+
parsed = urllib.parse.urlparse(docker_host)
294+
if parsed.hostname:
295+
return parsed.hostname
296+
return None
297+
298+
299+
def is_ssh_docker_host() -> bool:
300+
"""Check if the current DOCKER_HOST is an SSH-based connection."""
301+
return get_docker_host_hostname() is not None
302+
303+
304+
def _sanitize_docker_host(docker_host: str) -> str:
305+
"""
306+
Sanitize the DOCKER_HOST value for compatibility with the Docker SDK.
307+
308+
Strips path components from ``ssh://`` URLs because the Docker SDK
309+
does not support them. A lone trailing ``/`` is treated as
310+
equivalent to no path and silently normalised without a warning.
311+
"""
312+
if docker_host.startswith("ssh://"):
313+
parsed = urllib.parse.urlparse(docker_host)
314+
if parsed.path and parsed.path != "/":
315+
sanitized = urllib.parse.urlunparse(parsed._replace(path=""))
316+
LOGGER.warning(
317+
"Stripped path from SSH DOCKER_HOST (unsupported by Docker SDK): %s -> %s",
318+
docker_host,
319+
sanitized,
320+
)
321+
return sanitized
322+
if parsed.path == "/":
323+
# Trailing slash is harmless — strip quietly.
324+
return urllib.parse.urlunparse(parsed._replace(path=""))
325+
return docker_host
270326

271327

272328
def get_docker_auth_config() -> Optional[str]:

core/tests/compose_fixtures/port_multiple/compose.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ services:
77
- '82'
88
- target: 80
99
published: "5000-5999"
10-
host_ip: 127.0.0.1
1110
protocol: tcp
1211
command:
1312
- sh
@@ -20,7 +19,6 @@ services:
2019
ports:
2120
- target: 80
2221
published: "5000-5999"
23-
host_ip: 127.0.0.1
2422
protocol: tcp
2523
command:
2624
- sh

core/tests/compose_fixtures/port_single/compose.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ services:
44
init: true
55
ports:
66
- target: 80
7-
host_ip: 127.0.0.1
87
protocol: tcp
98
command:
109
- sh

core/tests/test_compose.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,29 @@ def test_compose_profile_support(profiles: Optional[list[str]], running: list[st
382382
for service in not_running:
383383
with pytest.raises(ContainerIsNotRunning):
384384
compose.get_container(service)
385+
386+
387+
@pytest.mark.parametrize(
388+
"docker_host_env, url, expected_url",
389+
[
390+
pytest.param("ssh://user@10.0.0.5", "0.0.0.0", "10.0.0.5", id="ssh_replaces_wildcard"),
391+
pytest.param("ssh://user@10.0.0.5", "127.0.0.1", "10.0.0.5", id="ssh_replaces_loopback"),
392+
pytest.param("ssh://user@10.0.0.5", "::", "10.0.0.5", id="ssh_replaces_ipv6_any"),
393+
pytest.param("tcp://localhost:2375", "0.0.0.0", "0.0.0.0", id="non_ssh_keeps_original"),
394+
],
395+
)
396+
def test_compose_normalize_rewrites_local_url_for_ssh_docker_host(
397+
monkeypatch: pytest.MonkeyPatch, docker_host_env: str, url: str, expected_url: str
398+
) -> None:
399+
"""When DOCKER_HOST is an SSH URL, normalize() should replace local addresses
400+
with the remote hostname — exercising the real get_docker_host_hostname() path."""
401+
from testcontainers.compose.compose import PublishedPortModel
402+
from testcontainers.core.config import testcontainers_config as tc_config
403+
404+
monkeypatch.setenv("DOCKER_HOST", docker_host_env)
405+
monkeypatch.setattr(tc_config, "tc_properties_get_tc_host", lambda: None)
406+
407+
model = PublishedPortModel(URL=url, TargetPort=80, PublishedPort=9999, Protocol="tcp")
408+
result = model.normalize()
409+
assert result.URL == expected_url
410+
assert result.PublishedPort == 9999

core/tests/test_core_registry.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
Note: Using the testcontainers-python library to test the Docker registry.
44
This could be considered a bad practice as it is not recommended to use the same library to test itself.
55
However, it is a very good use case for DockerRegistryContainer and allows us to test it thoroughly.
6+
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.
69
"""
710

811
import json
@@ -14,7 +17,7 @@
1417

1518
from testcontainers.core.config import testcontainers_config
1619
from testcontainers.core.container import DockerContainer
17-
from testcontainers.core.docker_client import DockerClient
20+
from testcontainers.core.docker_client import DockerClient, is_ssh_docker_host
1821
from testcontainers.core.waiting_utils import wait_for_logs
1922

2023
from testcontainers.registry import DockerRegistryContainer
@@ -25,6 +28,10 @@
2528
is_mac(),
2629
reason="Docker Desktop on macOS does not support insecure private registries without daemon reconfiguration",
2730
)
31+
@pytest.mark.skipif(
32+
is_ssh_docker_host(),
33+
reason="Remote Docker via SSH requires HTTPS for non-localhost registries; insecure HTTP registries are not accessible",
34+
)
2835
def test_missing_on_private_registry(monkeypatch):
2936
username = "user"
3037
password = "pass"
@@ -50,6 +57,10 @@ def test_missing_on_private_registry(monkeypatch):
5057
is_mac(),
5158
reason="Docker Desktop on macOS does not support local insecure registries over HTTP without modifying daemon settings",
5259
)
60+
@pytest.mark.skipif(
61+
is_ssh_docker_host(),
62+
reason="Remote Docker via SSH requires HTTPS for non-localhost registries; insecure HTTP registries are not accessible",
63+
)
5364
@pytest.mark.parametrize(
5465
"image,tag,username,password,expected_output",
5566
[

core/tests/test_docker_client.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from testcontainers.core.config import testcontainers_config as c, ConnectionMode
1212
from testcontainers.core.container import DockerContainer
13-
from testcontainers.core.docker_client import DockerClient
13+
from testcontainers.core.docker_client import DockerClient, is_ssh_docker_host
1414
from testcontainers.core.auth import parse_docker_auth_config
1515
from testcontainers.core.image import DockerImage
1616
from testcontainers.core import utils
@@ -20,13 +20,23 @@
2020
from docker.models.networks import Network
2121

2222

23+
def _expected_from_env_kwargs(**kwargs: Any) -> dict[str, Any]:
24+
"""Build the kwargs we expect ``docker.from_env`` to be called with.
25+
26+
When DOCKER_HOST is SSH-based, ``use_ssh_client=True`` is added automatically.
27+
"""
28+
if is_ssh_docker_host():
29+
kwargs.setdefault("use_ssh_client", True)
30+
return kwargs
31+
32+
2333
def test_docker_client_from_env():
2434
test_kwargs = {"test_kw": "test_value"}
2535
mock_docker = MagicMock(spec=docker)
2636
with patch("testcontainers.core.docker_client.docker", mock_docker):
2737
DockerClient(**test_kwargs)
2838

29-
mock_docker.from_env.assert_called_with(**test_kwargs)
39+
mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs))
3040

3141

3242
def test_docker_client_login_no_login():
@@ -111,7 +121,7 @@ def test_container_docker_client_kw():
111121
with patch("testcontainers.core.docker_client.docker", mock_docker):
112122
DockerContainer(image="", docker_client_kw=test_kwargs)
113123

114-
mock_docker.from_env.assert_called_with(**test_kwargs)
124+
mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs))
115125

116126

117127
def test_image_docker_client_kw():
@@ -120,7 +130,7 @@ def test_image_docker_client_kw():
120130
with patch("testcontainers.core.docker_client.docker", mock_docker):
121131
DockerImage(name="", path="", docker_client_kw=test_kwargs)
122132

123-
mock_docker.from_env.assert_called_with(**test_kwargs)
133+
mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs))
124134

125135

126136
def test_host_prefer_host_override(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -139,6 +149,8 @@ def test_host_prefer_host_override(monkeypatch: pytest.MonkeyPatch) -> None:
139149
],
140150
)
141151
def test_host(monkeypatch: pytest.MonkeyPatch, base_url: str, expected: str) -> None:
152+
if is_ssh_docker_host():
153+
pytest.skip("base_url parsing is not exercised under SSH (host() returns SSH hostname)")
142154
client = DockerClient()
143155
monkeypatch.setattr(client.client.api, "base_url", base_url)
144156
monkeypatch.setattr(c, "tc_host_override", None)
@@ -270,6 +282,8 @@ def test_run_uses_found_network(monkeypatch: pytest.MonkeyPatch) -> None:
270282
"""
271283
If a host network is found, use it
272284
"""
285+
if is_ssh_docker_host():
286+
pytest.skip("Host network discovery is skipped when DOCKER_HOST is set")
273287

274288
client = DockerClient()
275289

@@ -293,3 +307,51 @@ def __init__(self) -> None:
293307
assert client.run("test") == "CONTAINER"
294308

295309
assert fake_client.containers.calls[0]["network"] == "new_bridge_network"
310+
311+
312+
@pytest.mark.parametrize(
313+
"docker_host, expected",
314+
[
315+
pytest.param("ssh://user@192.168.1.42", "ssh://user@192.168.1.42", id="no_path"),
316+
pytest.param("ssh://user@host/", "ssh://user@host", id="trailing_slash"),
317+
pytest.param("ssh://user@host/some/path", "ssh://user@host", id="strips_path"),
318+
pytest.param("tcp://localhost:2375", "tcp://localhost:2375", id="tcp_unchanged"),
319+
pytest.param("unix:///var/run/docker.sock", "unix:///var/run/docker.sock", id="unix_unchanged"),
320+
],
321+
)
322+
def test_sanitize_docker_host(docker_host: str, expected: str) -> None:
323+
from testcontainers.core.docker_client import _sanitize_docker_host
324+
325+
assert _sanitize_docker_host(docker_host) == expected
326+
327+
328+
@pytest.mark.parametrize(
329+
"docker_host, expected_hostname",
330+
[
331+
pytest.param("ssh://user@192.168.1.42", "192.168.1.42", id="ssh_ip"),
332+
pytest.param("ssh://user@myhost.example.com", "myhost.example.com", id="ssh_fqdn"),
333+
pytest.param("tcp://localhost:2375", None, id="tcp_returns_none"),
334+
pytest.param(None, None, id="unset_returns_none"),
335+
],
336+
)
337+
def test_get_docker_host_hostname(monkeypatch: pytest.MonkeyPatch, docker_host: str, expected_hostname) -> None:
338+
from testcontainers.core.docker_client import get_docker_host_hostname
339+
340+
monkeypatch.setattr(c, "tc_properties_get_tc_host", lambda: None)
341+
if docker_host:
342+
monkeypatch.setenv("DOCKER_HOST", docker_host)
343+
else:
344+
monkeypatch.delenv("DOCKER_HOST", raising=False)
345+
assert get_docker_host_hostname() == expected_hostname
346+
347+
348+
def test_ssh_docker_host(monkeypatch: pytest.MonkeyPatch) -> None:
349+
"""Verify SSH DOCKER_HOST sets use_ssh_client and host() returns the remote hostname."""
350+
monkeypatch.setenv("DOCKER_HOST", "ssh://user@10.0.0.1")
351+
monkeypatch.setattr(c, "tc_properties_get_tc_host", lambda: None)
352+
monkeypatch.setattr(c, "tc_host_override", None)
353+
mock_docker = MagicMock(spec=docker)
354+
with patch("testcontainers.core.docker_client.docker", mock_docker):
355+
client = DockerClient()
356+
mock_docker.from_env.assert_called_once_with(use_ssh_client=True)
357+
assert client.host() == "10.0.0.1"

0 commit comments

Comments
 (0)