From ef9a8859f289eff84c242db41da5070b5b8d8019 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Mon, 16 Mar 2026 12:20:25 +0200 Subject: [PATCH 1/4] fix ssh --- Makefile | 3 + core/testcontainers/compose/compose.py | 18 +++++- core/testcontainers/core/docker_client.py | 64 +++++++++++++++++-- .../port_multiple/compose.yaml | 2 - .../compose_fixtures/port_single/compose.yaml | 1 - core/tests/test_compose.py | 26 ++++++++ core/tests/test_core_registry.py | 13 +++- core/tests/test_docker_client.py | 58 +++++++++++++++-- core/tests/test_docker_in_docker.py | 12 +++- 9 files changed, 181 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 680b5d038..9292a84df 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/core/testcontainers/compose/compose.py b/core/testcontainers/compose/compose.py index c959b1341..dbaa8f442 100644 --- a/core/testcontainers/compose/compose.py +++ b/core/testcontainers/compose/compose.py @@ -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 @@ -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 diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py index 12384c94c..74053455e 100644 --- a/core/testcontainers/core/docker_client.py +++ b/core/testcontainers/core/docker_client.py @@ -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") @@ -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: @@ -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]: diff --git a/core/tests/compose_fixtures/port_multiple/compose.yaml b/core/tests/compose_fixtures/port_multiple/compose.yaml index 662079f5e..21a4f5e8c 100644 --- a/core/tests/compose_fixtures/port_multiple/compose.yaml +++ b/core/tests/compose_fixtures/port_multiple/compose.yaml @@ -7,7 +7,6 @@ services: - '82' - target: 80 published: "5000-5999" - host_ip: 127.0.0.1 protocol: tcp command: - sh @@ -20,7 +19,6 @@ services: ports: - target: 80 published: "5000-5999" - host_ip: 127.0.0.1 protocol: tcp command: - sh diff --git a/core/tests/compose_fixtures/port_single/compose.yaml b/core/tests/compose_fixtures/port_single/compose.yaml index 88c19ab61..362a3c6b2 100644 --- a/core/tests/compose_fixtures/port_single/compose.yaml +++ b/core/tests/compose_fixtures/port_single/compose.yaml @@ -4,7 +4,6 @@ services: init: true ports: - target: 80 - host_ip: 127.0.0.1 protocol: tcp command: - sh diff --git a/core/tests/test_compose.py b/core/tests/test_compose.py index ee39ec0c0..f1faae5c4 100644 --- a/core/tests/test_compose.py +++ b/core/tests/test_compose.py @@ -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 diff --git a/core/tests/test_core_registry.py b/core/tests/test_core_registry.py index 38c37b5bd..fd65fcb0b 100644 --- a/core/tests/test_core_registry.py +++ b/core/tests/test_core_registry.py @@ -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 @@ -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 @@ -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" @@ -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", [ diff --git a/core/tests/test_docker_client.py b/core/tests/test_docker_client.py index 3cf7facd0..3728c8f9e 100644 --- a/core/tests/test_docker_client.py +++ b/core/tests/test_docker_client.py @@ -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 @@ -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(): @@ -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(): @@ -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: @@ -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) @@ -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() @@ -293,3 +307,39 @@ 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 diff --git a/core/tests/test_docker_in_docker.py b/core/tests/test_docker_in_docker.py index ada83c5ff..be9703621 100644 --- a/core/tests/test_docker_in_docker.py +++ b/core/tests/test_docker_in_docker.py @@ -15,7 +15,7 @@ from testcontainers.core.labels import SESSION_ID from testcontainers.core.network import Network from testcontainers.core.container import DockerContainer -from testcontainers.core.docker_client import DockerClient, LOGGER +from testcontainers.core.docker_client import DockerClient, LOGGER, is_ssh_docker_host from testcontainers.core.utils import inside_container from testcontainers.core.utils import is_mac from testcontainers.core.waiting_utils import wait_for_logs @@ -23,6 +23,11 @@ _DIND_PYTHON_VERSION = (3, 13) +SKIP_SSH_DOCKER = pytest.mark.skipif( + is_ssh_docker_host(), + reason="DinD/DooD tests require local Docker socket access, incompatible with SSH DOCKER_HOST", +) + RUN_ONCE_IN_CI = pytest.mark.skipif( bool(os.environ.get("CI")) and tuple([*sys.version_info][:2]) != _DIND_PYTHON_VERSION, reason=( @@ -51,6 +56,7 @@ def _wait_for_dind_return_ip(client: DockerClient, dind: Container): @pytest.mark.skipif(is_mac(), reason="Docker socket forwarding (socat) is unsupported on Docker Desktop for macOS") +@SKIP_SSH_DOCKER @RUN_ONCE_IN_CI def test_wait_for_logs_docker_in_docker(): # real dind isn't possible (AFAIK) in CI @@ -84,6 +90,7 @@ def test_wait_for_logs_docker_in_docker(): is_mac(), reason="Bridge networking and Docker socket forwarding are not supported on Docker Desktop for macOS", ) +@SKIP_SSH_DOCKER @RUN_ONCE_IN_CI def test_dind_inherits_network(): client = DockerClient() @@ -168,6 +175,7 @@ def get_docker_info() -> dict[str, Any]: @pytest.mark.xfail(reason="Does not work in rootless docker i.e. github actions") @pytest.mark.inside_docker_check @pytest.mark.skipif(not os.environ.get(EXPECTED_NETWORK_VAR), reason="No expected network given") +@SKIP_SSH_DOCKER def test_find_host_network_in_dood() -> None: """ Check that the correct host network is found for DooD @@ -185,6 +193,7 @@ def test_find_host_network_in_dood() -> None: reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS", ) @pytest.mark.skipif(not Path(tcc.ryuk_docker_socket).exists(), reason="No docker socket available") +@SKIP_SSH_DOCKER @RUN_ONCE_IN_CI def test_dood(python_testcontainer_image: str) -> None: """ @@ -225,6 +234,7 @@ def test_dood(python_testcontainer_image: str) -> None: is_mac(), reason="Docker socket mounting and container networking do not work reliably on Docker Desktop for macOS", ) +@SKIP_SSH_DOCKER @RUN_ONCE_IN_CI def test_dind(python_testcontainer_image: str, tmp_path: Path) -> None: """ From 67e981c4f413c6a49ddeb76c02f251f33900267a Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Fri, 20 Mar 2026 00:47:57 +0200 Subject: [PATCH 2/4] minor --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 76ad95040..a082f2033 100644 --- a/uv.lock +++ b/uv.lock @@ -4896,7 +4896,7 @@ wheels = [ [[package]] name = "testcontainers" -version = "4.14.1" +version = "4.14.2" source = { editable = "." } dependencies = [ { name = "docker" }, From 1f71e7f8acf680cbda1c1b00c402467078dc33c3 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Sun, 29 Mar 2026 22:06:47 +0300 Subject: [PATCH 3/4] Update lock --- uv.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uv.lock b/uv.lock index a082f2033..22c671f37 100644 --- a/uv.lock +++ b/uv.lock @@ -4933,6 +4933,7 @@ db2 = [ generic = [ { name = "httpx" }, { name = "redis" }, + { name = "sqlalchemy" }, ] google = [ { name = "google-cloud-datastore" }, @@ -5132,6 +5133,7 @@ requires-dist = [ { name = "redis", marker = "extra == 'redis'", specifier = ">=7" }, { name = "selenium", marker = "extra == 'selenium'", specifier = ">=4" }, { name = "sqlalchemy", marker = "extra == 'db2'", specifier = ">=2" }, + { name = "sqlalchemy", marker = "extra == 'generic'" }, { name = "sqlalchemy", marker = "extra == 'mssql'", specifier = ">=2" }, { name = "sqlalchemy", marker = "extra == 'mysql'", specifier = ">=2" }, { name = "sqlalchemy", marker = "extra == 'oracle'", specifier = ">=2" }, From 1a35b47dc23c0ee781cc83bc8afe88a2674a48c5 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Sun, 29 Mar 2026 22:06:52 +0300 Subject: [PATCH 4/4] Improve test --- core/tests/test_docker_client.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/tests/test_docker_client.py b/core/tests/test_docker_client.py index 3728c8f9e..23dfe0748 100644 --- a/core/tests/test_docker_client.py +++ b/core/tests/test_docker_client.py @@ -343,3 +343,15 @@ def test_get_docker_host_hostname(monkeypatch: pytest.MonkeyPatch, 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"