Skip to content

Commit 37093ab

Browse files
feat(core): add Podman compatibility (#1028)
Brings _Testcontainers for Python_ to feature parity with [testcontainers-go](https://golang.testcontainers.org/system_requirements/using_podman/) and [testcontainers-java](https://java.testcontainers.org/supported_docker_environment/#podman) for Podman: - Detect the runtime at the daemon level (cached). - Transparently adapt the code paths that differ between Docker and Podman (compose binary selection, port-binding parsing). - Keep existing Docker behavior unchanged when Docker is the active runtime. - Document the supported setup alongside Docker. Closes #1027. --------- Co-authored-by: David Ankin <daveankin@gmail.com>
1 parent 545e4bf commit 37093ab

10 files changed

Lines changed: 204 additions & 32 deletions

File tree

core/testcontainers/compose/compose.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import shutil
12
import sys
23
from dataclasses import asdict, dataclass, field
34
from functools import cached_property
@@ -11,7 +12,7 @@
1112
from types import TracebackType
1213
from typing import Any, Callable, Literal, Optional, TypeVar, Union, cast
1314

14-
from testcontainers.core.docker_client import DockerClient, get_docker_host_hostname
15+
from testcontainers.core.docker_client import DockerClient, get_docker_host_hostname, is_podman
1516
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
1617
from testcontainers.core.inspect import ContainerInspectInfo, _ignore_properties
1718
from testcontainers.core.waiting_utils import WaitStrategy
@@ -21,6 +22,20 @@
2122
logger = getLogger(__name__)
2223

2324

25+
def _default_compose_binary() -> str:
26+
"""Return the binary used to drive ``compose`` subcommands.
27+
28+
Prefers ``docker`` when available; otherwise falls back to ``podman``
29+
when the daemon is detected as podman. This means a pure-podman host
30+
without the ``podman-docker`` shim works out of the box.
31+
"""
32+
if shutil.which("docker"):
33+
return "docker"
34+
if is_podman() and shutil.which("podman"):
35+
return "podman"
36+
return "docker"
37+
38+
2439
@dataclass
2540
class PublishedPortModel:
2641
"""
@@ -38,8 +53,9 @@ def normalize(self) -> "PublishedPortModel":
3853
# For SSH-based DOCKER_HOST, local addresses (0.0.0.0, 127.0.0.1, localhost, ::, ::1)
3954
# refer to the remote machine, not the local one.
4055
# Replace them with the actual remote hostname.
56+
# Podman may also return empty string or None for the URL.
4157
ssh_host = get_docker_host_hostname()
42-
if ssh_host and url in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"):
58+
if ssh_host and (not url or url in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1")):
4359
url = ssh_host
4460
# On Windows, 0.0.0.0 is not usable — replace with 127.0.0.1
4561
elif system() == "Windows" and url == "0.0.0.0":
@@ -275,9 +291,8 @@ def docker_compose_command(self) -> list[str]:
275291

276292
@cached_property
277293
def compose_command_property(self) -> list[str]:
278-
docker_compose_cmd = (
279-
[self.docker_command_path, "compose"] if self.docker_command_path else ["docker", "compose"]
280-
)
294+
binary = self.docker_command_path or _default_compose_binary()
295+
docker_compose_cmd = [binary, "compose"]
281296
if self.compose_file_name:
282297
for file in self.compose_file_name:
283298
docker_compose_cmd += ["-f", file]

core/testcontainers/core/docker_client.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,30 @@ def is_ssh_docker_host() -> bool:
366366
return get_docker_host_hostname() is not None
367367

368368

369+
@ft.lru_cache(maxsize=1)
370+
def is_podman() -> bool:
371+
"""Detect whether the configured Docker daemon is actually Podman.
372+
373+
The result is cached for the lifetime of the process: detection requires a
374+
daemon round-trip, and this helper is invoked at test-collection time via
375+
``pytest.mark.skipif`` decorators.
376+
"""
377+
try:
378+
# Use docker.from_env() directly rather than DockerClient() so we avoid
379+
# the constructor's side effects (DOCKER_HOST mutation, registry login).
380+
version = docker.from_env().version()
381+
except Exception as e:
382+
LOGGER.debug(f"is_podman: failed to query daemon version: {e}")
383+
return False
384+
385+
# Prefer the top-level Platform.Name field (matches testcontainers-go).
386+
platform_name = (version.get("Platform") or {}).get("Name", "")
387+
if "podman" in platform_name.lower():
388+
return True
389+
# Fall back to scanning the Components array for older podman versions.
390+
return any("podman" in comp.get("Name", "").lower() for comp in version.get("Components") or [])
391+
392+
369393
def _sanitize_docker_host(docker_host: str) -> str:
370394
"""
371395
Sanitize the DOCKER_HOST value for compatibility with the Docker SDK.

core/tests/test_compose.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@
1010
from pytest_mock import MockerFixture
1111

1212
from testcontainers.compose import DockerCompose, ComposeContainer
13+
from testcontainers.core.docker_client import is_podman
1314
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
1415

1516
FIXTURES = Path(__file__).parent.joinpath("compose_fixtures")
1617

18+
_skip_if_podman_port_range = pytest.mark.skipif(
19+
is_podman(),
20+
reason="Podman does not support published port ranges (e.g. '5000-5999')",
21+
)
22+
1723

1824
def test_compose_no_file_name():
1925
basic = DockerCompose(context=FIXTURES / "basic")
@@ -189,6 +195,7 @@ def test_compose_ports():
189195

190196

191197
# noinspection HttpUrlsUsage
198+
@_skip_if_podman_port_range
192199
def test_compose_multiple_containers_and_ports():
193200
"""test for the logic encapsulated in 'one' function
194201
@@ -286,6 +293,7 @@ def test_exec_in_container():
286293

287294

288295
# noinspection HttpUrlsUsage
296+
@_skip_if_podman_port_range
289297
def test_exec_in_container_multiple():
290298
"""same as above, except we exec into a particular service"""
291299
multiple = DockerCompose(context=FIXTURES / "port_multiple")
@@ -390,6 +398,8 @@ def test_compose_profile_support(profiles: Optional[list[str]], running: list[st
390398
pytest.param("ssh://user@10.0.0.5", "0.0.0.0", "10.0.0.5", id="ssh_replaces_wildcard"),
391399
pytest.param("ssh://user@10.0.0.5", "127.0.0.1", "10.0.0.5", id="ssh_replaces_loopback"),
392400
pytest.param("ssh://user@10.0.0.5", "::", "10.0.0.5", id="ssh_replaces_ipv6_any"),
401+
pytest.param("ssh://user@10.0.0.5", "", "10.0.0.5", id="ssh_replaces_empty"),
402+
pytest.param("ssh://user@10.0.0.5", None, "10.0.0.5", id="ssh_replaces_none"),
393403
pytest.param("tcp://localhost:2375", "0.0.0.0", "0.0.0.0", id="non_ssh_keeps_original"),
394404
],
395405
)
@@ -410,6 +420,33 @@ def test_compose_normalize_rewrites_local_url_for_ssh_docker_host(
410420
assert result.PublishedPort == 9999
411421

412422

423+
@pytest.mark.parametrize(
424+
"docker_on_path, podman_on_path, podman_detected, expected",
425+
[
426+
pytest.param(True, True, True, "docker", id="docker_wins_over_podman"),
427+
pytest.param(False, True, True, "podman", id="podman_when_no_docker_and_podman_daemon"),
428+
pytest.param(False, False, True, "docker", id="fallback_to_docker_when_no_podman_binary"),
429+
],
430+
)
431+
def test_default_compose_binary(
432+
monkeypatch: pytest.MonkeyPatch,
433+
docker_on_path: bool,
434+
podman_on_path: bool,
435+
podman_detected: bool,
436+
expected: str,
437+
) -> None:
438+
from testcontainers.compose import compose as compose_module
439+
440+
paths = {
441+
"docker": "/usr/bin/docker" if docker_on_path else None,
442+
"podman": "/usr/bin/podman" if podman_on_path else None,
443+
}
444+
monkeypatch.setattr("testcontainers.compose.compose.shutil.which", lambda name: paths.get(name))
445+
monkeypatch.setattr(compose_module, "is_podman", lambda: podman_detected)
446+
447+
assert compose_module._default_compose_binary() == expected
448+
449+
413450
def test_container_info():
414451
"""Test get_container_info functionality"""
415452
basic = DockerCompose(context=FIXTURES / "basic")

core/tests/test_core_ports.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,31 @@
44

55
from docker.errors import APIError
66

7+
from testcontainers.core.docker_client import is_podman
8+
79

810
@pytest.mark.parametrize(
911
"container_port, host_port",
1012
[
1113
("8080", "8080"),
12-
("8125/udp", "8125/udp"),
13-
("8092/udp", "8092/udp"),
14-
("9000/tcp", "9000/tcp"),
15-
("8080", "8080/udp"),
14+
pytest.param(
15+
"8125/udp",
16+
"8125/udp",
17+
marks=pytest.mark.skipif(is_podman(), reason="Podman rejects protocol in host_port"),
18+
),
19+
pytest.param(
20+
"8092/udp",
21+
"8092/udp",
22+
marks=pytest.mark.skipif(is_podman(), reason="Podman rejects protocol in host_port"),
23+
),
24+
pytest.param(
25+
"9000/tcp",
26+
"9000/tcp",
27+
marks=pytest.mark.skipif(is_podman(), reason="Podman rejects protocol in host_port"),
28+
),
29+
pytest.param(
30+
"8080", "8080/udp", marks=pytest.mark.skipif(is_podman(), reason="Podman rejects protocol in host_port")
31+
),
1632
(8080, 8080),
1733
(9000, None),
1834
("9009", None),
@@ -42,7 +58,18 @@ def test_docker_container_with_bind_ports(container_port: Union[str, int], host_
4258
expected = {container_port: [{"HostIp": "", "HostPort": host_port}]}
4359

4460
# compare PortBindings to expected output
45-
assert client.containers.get(container_id).attrs["HostConfig"]["PortBindings"] == expected
61+
actual = client.containers.get(container_id).attrs["HostConfig"]["PortBindings"]
62+
if is_podman():
63+
# Normalize Podman differences:
64+
# - HostIp '0.0.0.0' vs '' (both mean all interfaces)
65+
# - Empty host_port: Podman stores the assigned port, Docker stores ''
66+
for bindings in actual.values():
67+
for binding in bindings:
68+
if binding.get("HostIp") == "0.0.0.0":
69+
binding["HostIp"] = ""
70+
if not host_port and binding.get("HostPort", "").isdigit():
71+
binding["HostPort"] = ""
72+
assert actual == expected
4673
container.stop()
4774

4875

core/tests/test_core_registry.py

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@
2222

2323
from testcontainers.registry import DockerRegistryContainer
2424
from testcontainers.core.utils import is_mac
25+
from testcontainers.core.docker_client import is_podman
2526

2627

27-
@pytest.mark.skipif(
28-
is_mac(),
29-
reason="Docker Desktop on macOS does not support insecure private registries without daemon reconfiguration",
30-
)
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",
28+
_skip_insecure_registry = pytest.mark.skipif(
29+
is_mac() or is_podman() or is_ssh_docker_host(),
30+
reason="Insecure HTTP registries are not supported without daemon reconfiguration on macOS, Podman, or SSH-based Docker hosts",
3431
)
32+
33+
34+
@_skip_insecure_registry
3535
def test_missing_on_private_registry(monkeypatch):
3636
username = "user"
3737
password = "pass"
@@ -53,14 +53,7 @@ def test_missing_on_private_registry(monkeypatch):
5353
wait_for_logs(test_container, "Hello from Docker!")
5454

5555

56-
@pytest.mark.skipif(
57-
is_mac(),
58-
reason="Docker Desktop on macOS does not support local insecure registries over HTTP without modifying daemon settings",
59-
)
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-
)
56+
@_skip_insecure_registry
6457
@pytest.mark.parametrize(
6558
"image,tag,username,password,expected_output",
6659
[

core/tests/test_docker_client.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,39 @@ def test_ssh_docker_host(monkeypatch: pytest.MonkeyPatch) -> None:
358358
assert client.host() == "10.0.0.1"
359359

360360

361+
_PODMAN_DAEMON_ERROR = RuntimeError("daemon unreachable")
362+
363+
364+
@pytest.mark.parametrize(
365+
"version, expected",
366+
[
367+
pytest.param({"Platform": {"Name": "Docker Engine - Community"}}, False, id="docker_platform"),
368+
pytest.param({"Platform": {"Name": "Podman Engine"}}, True, id="podman_platform"),
369+
pytest.param({"Platform": {}, "Components": [{"Name": "podman"}]}, True, id="podman_components_fallback"),
370+
pytest.param({}, False, id="empty_version_no_match"),
371+
pytest.param(_PODMAN_DAEMON_ERROR, False, id="daemon_error_swallowed"),
372+
],
373+
)
374+
def test_is_podman(monkeypatch: pytest.MonkeyPatch, version: object, expected: bool) -> None:
375+
from testcontainers.core import docker_client as dc
376+
377+
dc.is_podman.cache_clear()
378+
mock_client = MagicMock()
379+
if isinstance(version, Exception):
380+
monkeypatch.setattr("testcontainers.core.docker_client.docker.from_env", MagicMock(side_effect=version))
381+
else:
382+
mock_client.version.return_value = version
383+
monkeypatch.setattr("testcontainers.core.docker_client.docker.from_env", lambda: mock_client)
384+
try:
385+
# Call twice to also assert the lru_cache only hits the daemon once.
386+
assert dc.is_podman() is expected
387+
assert dc.is_podman() is expected
388+
if not isinstance(version, Exception):
389+
assert mock_client.version.call_count == 1
390+
finally:
391+
dc.is_podman.cache_clear()
392+
393+
361394
def _mock_docker_context(name: str, host: str) -> MagicMock:
362395
context = MagicMock()
363396
context.Name = name

core/tests/test_image.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ def test_docker_image(test_image_tag: Optional[str], test_cleanup: bool, check_f
3030
assert image.get_wrapped_image() is not None
3131
logs = image.get_logs()
3232
assert isinstance(logs, list), "Logs should be a list"
33-
assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"}
34-
assert logs[3] == {"stream": f'Step 2/2 : CMD echo "{random_string}"'}
33+
streams = [entry.get("stream", "").strip() for entry in logs]
34+
assert any(s.upper().startswith("STEP 1/2") and "FROM ALPINE:LATEST" in s.upper() for s in streams)
35+
assert any(s.upper().startswith("STEP 2/2") and random_string in s for s in streams), (
36+
f"Expected step 2 with '{random_string}' in logs: {streams}"
37+
)
3538
with DockerContainer(str(image)) as container:
3639
c_c = container._container
3740
assert c_c

core/tests/test_network.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def test_network_create_errors():
4040
network.create()
4141

4242
assert excinfo.value.response.status_code == HTTPStatus.CONFLICT
43-
excinfo.match(f"network with name {network.name} already exists")
43+
excinfo.match(f"network.*{network.name}.*already exists")
4444
network.remove()
4545

4646

docs/system_requirements/docker.md

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
1-
# General Docker requirements
1+
# Supported Docker environments
2+
3+
## Overview
24

35
Testcontainers requires a Docker-API compatible container runtime.
46
During development, Testcontainers is actively tested against recent versions of Docker on Linux, as well as against Docker Desktop on Mac and Windows.
57
These Docker environments are automatically detected and used by Testcontainers without any additional configuration being necessary.
68

7-
It is possible to configure Testcontainers to work for other Docker setups, such as a remote Docker host or Docker alternatives.
8-
However, these are not actively tested in the main development workflow, so not all Testcontainers features might be available and additional manual configuration might be necessary. Please see the [Docker host detection](../features/configuration.md#docker-host-detection) section for more information.
9+
It is possible to configure Testcontainers to work with alternative container runtimes (see further down for specific runtimes, or [Docker host detection](../features/configuration.md#docker-host-detection) for general configuration mechanisms).
10+
Alternative container runtimes are not actively tested in the main development workflow, so not all Testcontainers features might be available and additional manual configuration might be necessary.
911

1012
If you have further questions about configuration details for your setup or whether it supports running Testcontainers-based tests,
1113
please contact the Testcontainers team and other users from the Testcontainers community on [Slack](https://slack.testcontainers.org/).
14+
15+
## Podman
16+
17+
In order to run testcontainers against [Podman](https://podman.io/), the env var below should be set.
18+
19+
Testcontainers auto-detects Podman from the daemon's `version` response and adapts a few behaviors (compose binary selection, port-binding parsing) accordingly.
20+
21+
**Linux (rootless):**
22+
23+
```bash
24+
systemctl --user start podman.socket
25+
export DOCKER_HOST="unix://${XDG_RUNTIME_DIR}/podman/podman.sock"
26+
```
27+
28+
**Linux (rootful):**
29+
30+
```bash
31+
sudo systemctl start podman.socket
32+
export DOCKER_HOST="unix:///run/podman/podman.sock"
33+
```
34+
35+
**macOS:**
36+
37+
```bash
38+
export DOCKER_HOST="unix://$(podman machine inspect --format '{{.ConnectionInfo.PodmanSocket.Path}}')"
39+
```
40+
41+
You can also persist this in `~/.testcontainers.properties` as `docker.host=...`, or use a Docker context (`docker context use my-podman`).
42+
43+
### Docker Compose with Podman
44+
45+
`DockerCompose` prefers `docker` if it is on `PATH` (e.g. via the `podman-docker` shim). Otherwise, when Podman is detected, it falls back to the `podman` binary. You can always override the binary explicitly:
46+
47+
```python
48+
DockerCompose(".", docker_command_path="podman")
49+
```
50+
51+
Note that Podman does not support host port ranges (`published: "5000-5999"`) in compose files.

docs/system_requirements/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,4 @@ commands = pytest
180180

181181
With any of these, once your environment is set up you can simply `pip install testcontainers` (or use Poetry’s `poetry add --dev testcontainers`) and begin writing your container-backed tests in Python.
182182

183-
See the [General Docker Requirements](docker.md) to continue
183+
See [Supported Docker environments](docker.md) to continue

0 commit comments

Comments
 (0)