Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ ${TESTS}: %/tests:
quick-core-tests: ## Run core tests excluding long_running
uv run coverage run --parallel -m pytest -v -m "not long_running" core/tests

core-tests: ## Run tests for the core package
uv run coverage run --parallel -m pytest -v core/tests

coverage: ## Target to combine and report coverage.
uv run coverage combine
uv run coverage report
Expand Down
18 changes: 15 additions & 3 deletions core/testcontainers/compose/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from types import TracebackType
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast

from testcontainers.core.docker_client import get_docker_host_hostname
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
from testcontainers.core.waiting_utils import WaitStrategy

Expand Down Expand Up @@ -45,10 +46,21 @@ class PublishedPortModel:
Protocol: Optional[str] = None

def normalize(self) -> "PublishedPortModel":
url_not_usable = system() == "Windows" and self.URL == "0.0.0.0"
if url_not_usable:
url = self.URL

# For SSH-based DOCKER_HOST, local addresses (0.0.0.0, 127.0.0.1, localhost, ::, ::1)
# refer to the remote machine, not the local one.
# Replace them with the actual remote hostname.
ssh_host = get_docker_host_hostname()
if ssh_host and url in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"):
url = ssh_host
# On Windows, 0.0.0.0 is not usable — replace with 127.0.0.1
elif system() == "Windows" and url == "0.0.0.0":
url = "127.0.0.1"

if url != self.URL:
self_dict = asdict(self)
self_dict.update({"URL": "127.0.0.1"})
self_dict.update({"URL": url})
return PublishedPortModel(**self_dict)
return self

Expand Down
64 changes: 60 additions & 4 deletions core/testcontainers/core/docker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,12 @@ def __init__(self, **kwargs: Any) -> None:
if docker_host:
LOGGER.info(f"using host {docker_host}")
os.environ["DOCKER_HOST"] = docker_host
self.client = docker.from_env(**kwargs)
else:
self.client = docker.from_env(**kwargs)
# Use shell-based SSH client instead of paramiko to avoid conflicts with pytest stdin capture
# (paramiko's invoke library fails when reading from captured stdin).
if docker_host.startswith("ssh://"):
kwargs.setdefault("use_ssh_client", True)

self.client = docker.from_env(**kwargs)
self.client.api.headers["x-tc-sid"] = SESSION_ID
self.client.api.headers["User-Agent"] = "tc-python/" + importlib.metadata.version("testcontainers")

Expand Down Expand Up @@ -234,6 +237,14 @@ def host(self) -> str:
host = c.tc_host_override
if host:
return host

# For SSH-based connections, the Docker SDK rewrites base_url to
# "http+docker://ssh" which loses the original hostname.
# Extract it from the original DOCKER_HOST instead.
ssh_host = get_docker_host_hostname()
if ssh_host:
return ssh_host

try:
url = urllib.parse.urlparse(self.client.api.base_url)
except ValueError:
Expand Down Expand Up @@ -266,7 +277,52 @@ def client_networks_create(self, name: str, param: dict[str, Any]) -> "DockerNet


def get_docker_host() -> Optional[str]:
return c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
if host:
return _sanitize_docker_host(host)
return None


def get_docker_host_hostname() -> Optional[str]:
"""Extract the remote hostname from an SSH-based DOCKER_HOST.

Returns the hostname (e.g. '192.168.1.42') when DOCKER_HOST is an ssh:// URL, or None otherwise.
"""
docker_host = get_docker_host()
if docker_host and docker_host.startswith("ssh://"):
parsed = urllib.parse.urlparse(docker_host)
if parsed.hostname:
return parsed.hostname
return None


def is_ssh_docker_host() -> bool:
"""Check if the current DOCKER_HOST is an SSH-based connection."""
return get_docker_host_hostname() is not None


def _sanitize_docker_host(docker_host: str) -> str:
"""
Sanitize the DOCKER_HOST value for compatibility with the Docker SDK.

Strips path components from ``ssh://`` URLs because the Docker SDK
does not support them. A lone trailing ``/`` is treated as
equivalent to no path and silently normalised without a warning.
"""
if docker_host.startswith("ssh://"):
parsed = urllib.parse.urlparse(docker_host)
if parsed.path and parsed.path != "/":
sanitized = urllib.parse.urlunparse(parsed._replace(path=""))
LOGGER.warning(
"Stripped path from SSH DOCKER_HOST (unsupported by Docker SDK): %s -> %s",
docker_host,
sanitized,
)
return sanitized
if parsed.path == "/":
# Trailing slash is harmless — strip quietly.
return urllib.parse.urlunparse(parsed._replace(path=""))
return docker_host


def get_docker_auth_config() -> Optional[str]:
Expand Down
2 changes: 0 additions & 2 deletions core/tests/compose_fixtures/port_multiple/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ services:
- '82'
- target: 80
published: "5000-5999"
host_ip: 127.0.0.1
protocol: tcp
command:
- sh
Expand All @@ -20,7 +19,6 @@ services:
ports:
- target: 80
published: "5000-5999"
host_ip: 127.0.0.1
protocol: tcp
command:
- sh
Expand Down
1 change: 0 additions & 1 deletion core/tests/compose_fixtures/port_single/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ services:
init: true
ports:
- target: 80
host_ip: 127.0.0.1
protocol: tcp
command:
- sh
Expand Down
26 changes: 26 additions & 0 deletions core/tests/test_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,3 +382,29 @@ def test_compose_profile_support(profiles: Optional[list[str]], running: list[st
for service in not_running:
with pytest.raises(ContainerIsNotRunning):
compose.get_container(service)


@pytest.mark.parametrize(
"docker_host_env, url, expected_url",
[
pytest.param("ssh://user@10.0.0.5", "0.0.0.0", "10.0.0.5", id="ssh_replaces_wildcard"),
pytest.param("ssh://user@10.0.0.5", "127.0.0.1", "10.0.0.5", id="ssh_replaces_loopback"),
pytest.param("ssh://user@10.0.0.5", "::", "10.0.0.5", id="ssh_replaces_ipv6_any"),
pytest.param("tcp://localhost:2375", "0.0.0.0", "0.0.0.0", id="non_ssh_keeps_original"),
],
)
def test_compose_normalize_rewrites_local_url_for_ssh_docker_host(
monkeypatch: pytest.MonkeyPatch, docker_host_env: str, url: str, expected_url: str
) -> None:
"""When DOCKER_HOST is an SSH URL, normalize() should replace local addresses
with the remote hostname — exercising the real get_docker_host_hostname() path."""
from testcontainers.compose.compose import PublishedPortModel
from testcontainers.core.config import testcontainers_config as tc_config

monkeypatch.setenv("DOCKER_HOST", docker_host_env)
monkeypatch.setattr(tc_config, "tc_properties_get_tc_host", lambda: None)

model = PublishedPortModel(URL=url, TargetPort=80, PublishedPort=9999, Protocol="tcp")
result = model.normalize()
assert result.URL == expected_url
assert result.PublishedPort == 9999
13 changes: 12 additions & 1 deletion core/tests/test_core_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
Note: Using the testcontainers-python library to test the Docker registry.
This could be considered a bad practice as it is not recommended to use the same library to test itself.
However, it is a very good use case for DockerRegistryContainer and allows us to test it thoroughly.

Note2: These tests are skipped on macOS and SSH-based Docker hosts because they rely on insecure HTTP registries,
which are not supported in those environments without additional configuration.
"""

import json
Expand All @@ -14,7 +17,7 @@

from testcontainers.core.config import testcontainers_config
from testcontainers.core.container import DockerContainer
from testcontainers.core.docker_client import DockerClient
from testcontainers.core.docker_client import DockerClient, is_ssh_docker_host
from testcontainers.core.waiting_utils import wait_for_logs

from testcontainers.registry import DockerRegistryContainer
Expand All @@ -25,6 +28,10 @@
is_mac(),
reason="Docker Desktop on macOS does not support insecure private registries without daemon reconfiguration",
)
@pytest.mark.skipif(
is_ssh_docker_host(),
reason="Remote Docker via SSH requires HTTPS for non-localhost registries; insecure HTTP registries are not accessible",
)
def test_missing_on_private_registry(monkeypatch):
username = "user"
password = "pass"
Expand All @@ -50,6 +57,10 @@ def test_missing_on_private_registry(monkeypatch):
is_mac(),
reason="Docker Desktop on macOS does not support local insecure registries over HTTP without modifying daemon settings",
)
@pytest.mark.skipif(
is_ssh_docker_host(),
reason="Remote Docker via SSH requires HTTPS for non-localhost registries; insecure HTTP registries are not accessible",
)
@pytest.mark.parametrize(
"image,tag,username,password,expected_output",
[
Expand Down
70 changes: 66 additions & 4 deletions core/tests/test_docker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from testcontainers.core.config import testcontainers_config as c, ConnectionMode
from testcontainers.core.container import DockerContainer
from testcontainers.core.docker_client import DockerClient
from testcontainers.core.docker_client import DockerClient, is_ssh_docker_host
from testcontainers.core.auth import parse_docker_auth_config
from testcontainers.core.image import DockerImage
from testcontainers.core import utils
Expand All @@ -20,13 +20,23 @@
from docker.models.networks import Network


def _expected_from_env_kwargs(**kwargs: Any) -> dict[str, Any]:
"""Build the kwargs we expect ``docker.from_env`` to be called with.

When DOCKER_HOST is SSH-based, ``use_ssh_client=True`` is added automatically.
"""
if is_ssh_docker_host():
kwargs.setdefault("use_ssh_client", True)
return kwargs


def test_docker_client_from_env():
test_kwargs = {"test_kw": "test_value"}
mock_docker = MagicMock(spec=docker)
with patch("testcontainers.core.docker_client.docker", mock_docker):
DockerClient(**test_kwargs)

mock_docker.from_env.assert_called_with(**test_kwargs)
mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs))


def test_docker_client_login_no_login():
Expand Down Expand Up @@ -111,7 +121,7 @@ def test_container_docker_client_kw():
with patch("testcontainers.core.docker_client.docker", mock_docker):
DockerContainer(image="", docker_client_kw=test_kwargs)

mock_docker.from_env.assert_called_with(**test_kwargs)
mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs))


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

mock_docker.from_env.assert_called_with(**test_kwargs)
mock_docker.from_env.assert_called_with(**_expected_from_env_kwargs(**test_kwargs))


def test_host_prefer_host_override(monkeypatch: pytest.MonkeyPatch) -> None:
Expand All @@ -139,6 +149,8 @@ def test_host_prefer_host_override(monkeypatch: pytest.MonkeyPatch) -> None:
],
)
def test_host(monkeypatch: pytest.MonkeyPatch, base_url: str, expected: str) -> None:
if is_ssh_docker_host():
pytest.skip("base_url parsing is not exercised under SSH (host() returns SSH hostname)")
client = DockerClient()
monkeypatch.setattr(client.client.api, "base_url", base_url)
monkeypatch.setattr(c, "tc_host_override", None)
Expand Down Expand Up @@ -270,6 +282,8 @@ def test_run_uses_found_network(monkeypatch: pytest.MonkeyPatch) -> None:
"""
If a host network is found, use it
"""
if is_ssh_docker_host():
pytest.skip("Host network discovery is skipped when DOCKER_HOST is set")

client = DockerClient()

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

assert fake_client.containers.calls[0]["network"] == "new_bridge_network"


@pytest.mark.parametrize(
"docker_host, expected",
[
pytest.param("ssh://user@192.168.1.42", "ssh://user@192.168.1.42", id="no_path"),
pytest.param("ssh://user@host/", "ssh://user@host", id="trailing_slash"),
pytest.param("ssh://user@host/some/path", "ssh://user@host", id="strips_path"),
pytest.param("tcp://localhost:2375", "tcp://localhost:2375", id="tcp_unchanged"),
pytest.param("unix:///var/run/docker.sock", "unix:///var/run/docker.sock", id="unix_unchanged"),
],
)
def test_sanitize_docker_host(docker_host: str, expected: str) -> None:
from testcontainers.core.docker_client import _sanitize_docker_host

assert _sanitize_docker_host(docker_host) == expected


@pytest.mark.parametrize(
"docker_host, expected_hostname",
[
pytest.param("ssh://user@192.168.1.42", "192.168.1.42", id="ssh_ip"),
pytest.param("ssh://user@myhost.example.com", "myhost.example.com", id="ssh_fqdn"),
pytest.param("tcp://localhost:2375", None, id="tcp_returns_none"),
pytest.param(None, None, id="unset_returns_none"),
],
)
def test_get_docker_host_hostname(monkeypatch: pytest.MonkeyPatch, docker_host: str, expected_hostname) -> None:
from testcontainers.core.docker_client import get_docker_host_hostname

monkeypatch.setattr(c, "tc_properties_get_tc_host", lambda: None)
if docker_host:
monkeypatch.setenv("DOCKER_HOST", docker_host)
else:
monkeypatch.delenv("DOCKER_HOST", raising=False)
assert get_docker_host_hostname() == expected_hostname


def test_ssh_docker_host(monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify SSH DOCKER_HOST sets use_ssh_client and host() returns the remote hostname."""
monkeypatch.setenv("DOCKER_HOST", "ssh://user@10.0.0.1")
monkeypatch.setattr(c, "tc_properties_get_tc_host", lambda: None)
monkeypatch.setattr(c, "tc_host_override", None)
mock_docker = MagicMock(spec=docker)
with patch("testcontainers.core.docker_client.docker", mock_docker):
client = DockerClient()
mock_docker.from_env.assert_called_once_with(use_ssh_client=True)
assert client.host() == "10.0.0.1"
Loading
Loading