Skip to content

Commit d4b8ff6

Browse files
authored
feat(core): auto-detect DOCKER_HOST from current docker context (#1026)
Fall back to the active docker context's host (via docker.context.ContextAPI) when neither tc.host nor DOCKER_HOST is set. Closes #1025
1 parent 3e487f4 commit d4b8ff6

3 files changed

Lines changed: 83 additions & 5 deletions

File tree

core/testcontainers/core/docker_client.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union, cast
2323

2424
import docker
25+
from docker.context import ContextAPI
2526
from docker.models.containers import Container, ContainerCollection
2627
from docker.models.images import Image, ImageCollection
2728
from typing_extensions import ParamSpec
@@ -320,12 +321,33 @@ def get_container_inspect_info(self, container_id: str) -> "ContainerInspectInfo
320321

321322

322323
def get_docker_host() -> Optional[str]:
323-
host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST")
324+
host = c.tc_properties_get_tc_host() or os.getenv("DOCKER_HOST") or _get_docker_host_from_context()
324325
if host:
325326
return _sanitize_docker_host(host)
326327
return None
327328

328329

330+
def _get_docker_host_from_context() -> Optional[str]:
331+
"""
332+
Look up the docker host from the current docker context (e.g. as set by``docker context use``).
333+
This allows users with a remote docker host configured via docker contexts to use testcontainers
334+
without having to additionally export ``DOCKER_HOST``.
335+
"""
336+
try:
337+
context = ContextAPI.get_current_context()
338+
except Exception as e:
339+
LOGGER.debug(f"failed to read current docker context: {e}")
340+
return None
341+
if context is None:
342+
return None
343+
host = context.Host
344+
# The default context points at the local unix socket / named pipe; let
345+
# docker-py fall back to its own defaults in that case.
346+
if not host or context.Name == "default":
347+
return None
348+
return cast("str", host)
349+
350+
329351
def get_docker_host_hostname() -> Optional[str]:
330352
"""Extract the remote hostname from an SSH-based DOCKER_HOST.
331353

core/tests/test_docker_client.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ def test_get_docker_host_hostname(monkeypatch: pytest.MonkeyPatch, docker_host:
338338
from testcontainers.core.docker_client import get_docker_host_hostname
339339

340340
monkeypatch.setattr(c, "tc_properties_get_tc_host", lambda: None)
341+
monkeypatch.setattr("testcontainers.core.docker_client._get_docker_host_from_context", lambda: None)
341342
if docker_host:
342343
monkeypatch.setenv("DOCKER_HOST", docker_host)
343344
else:
@@ -355,3 +356,55 @@ def test_ssh_docker_host(monkeypatch: pytest.MonkeyPatch) -> None:
355356
client = DockerClient()
356357
mock_docker.from_env.assert_called_once_with(use_ssh_client=True)
357358
assert client.host() == "10.0.0.1"
359+
360+
361+
def _mock_docker_context(name: str, host: str) -> MagicMock:
362+
context = MagicMock()
363+
context.Name = name
364+
context.Host = host
365+
return context
366+
367+
368+
@pytest.mark.parametrize(
369+
"context, expected",
370+
[
371+
pytest.param(_mock_docker_context("remote", "ssh://user@10.0.0.1"), "ssh://user@10.0.0.1", id="returns_host"),
372+
pytest.param(
373+
_mock_docker_context("default", "unix:///var/run/docker.sock"), None, id="default_context_skipped"
374+
),
375+
pytest.param(_mock_docker_context("remote", ""), None, id="empty_host_returns_none"),
376+
pytest.param(None, None, id="no_current_context"),
377+
],
378+
)
379+
def test_get_docker_host_from_context(monkeypatch: pytest.MonkeyPatch, context, expected) -> None:
380+
from testcontainers.core.docker_client import _get_docker_host_from_context
381+
382+
monkeypatch.setattr(
383+
"testcontainers.core.docker_client.ContextAPI.get_current_context",
384+
lambda: context,
385+
)
386+
assert _get_docker_host_from_context() == expected
387+
388+
389+
def test_get_docker_host_from_context_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None:
390+
"""A malformed docker config should not crash; we fall through to None."""
391+
from testcontainers.core.docker_client import _get_docker_host_from_context
392+
393+
def _raise() -> None:
394+
raise RuntimeError("broken docker config")
395+
396+
monkeypatch.setattr("testcontainers.core.docker_client.ContextAPI.get_current_context", _raise)
397+
assert _get_docker_host_from_context() is None
398+
399+
400+
def test_get_docker_host_falls_back_to_context(monkeypatch: pytest.MonkeyPatch) -> None:
401+
"""When tc.host and DOCKER_HOST are unset, the current docker context wins."""
402+
from testcontainers.core.docker_client import get_docker_host
403+
404+
monkeypatch.setattr(c, "tc_properties_get_tc_host", lambda: None)
405+
monkeypatch.delenv("DOCKER_HOST", raising=False)
406+
monkeypatch.setattr(
407+
"testcontainers.core.docker_client.ContextAPI.get_current_context",
408+
lambda: _mock_docker_context("remote", "ssh://user@10.0.0.1"),
409+
)
410+
assert get_docker_host() == "ssh://user@10.0.0.1"

docs/features/configuration.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,21 @@ However, sometimes customization is required. Testcontainers-Python will respect
7070
3. Read the **DOCKER_HOST** environment variable. E.g. `DOCKER_HOST=unix:///var/run/docker.sock`
7171
See [Docker environment variables](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables) for more information.
7272

73-
4. Read the default Docker socket path, without the unix schema. E.g. `/var/run/docker.sock`
73+
4. Read the **current Docker context** (as set by `docker context use`) and use its host. E.g. with a context pointing at `ssh://user@remote-host`, no extra configuration is needed.
74+
The built-in `default` context is skipped so local sockets / named pipes go through the standard fallback.
7475

75-
5. Read the **docker.host** property in the `~/.testcontainers.properties` file. E.g. `docker.host=tcp://my.docker.host:1234`
76+
5. Read the default Docker socket path, without the unix schema. E.g. `/var/run/docker.sock`
7677

77-
6. Read the rootless Docker socket path, checking the following alternative locations:
78+
6. Read the **docker.host** property in the `~/.testcontainers.properties` file. E.g. `docker.host=tcp://my.docker.host:1234`
79+
80+
7. Read the rootless Docker socket path, checking the following alternative locations:
7881

7982
1. `${XDG_RUNTIME_DIR}/.docker/run/docker.sock`
8083
2. `${HOME}/.docker/run/docker.sock`
8184
3. `${HOME}/.docker/desktop/docker.sock`
8285
4. `/run/user/${UID}/docker.sock`, where `${UID}` is the user ID of the current user
8386

84-
7. The library will raise a `DockerHostError` if none of the above are set, meaning that the Docker host was not detected.
87+
8. The library will raise a `DockerHostError` if none of the above are set, meaning that the Docker host was not detected.
8588

8689
## Docker Socket Path Detection
8790

0 commit comments

Comments
 (0)