Skip to content

Commit 85a6666

Browse files
authored
fix(core): running testcontainer inside container (#714)
closes #475 One step closer to solve #517 Replaces #622 According to the [data collected](#475 (comment)) this should fix issues running testcontainers inside a container in almost all cases. There is still an issue when running it within docker desktop, that is probably easily solved which checking for the existence of `host.docker.internal`. But I have to recollect the data to ensure this, so this will be added at later point in time, as with with setting `-e TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal` an easy workaround exists as well.
1 parent f958cf9 commit 85a6666

16 files changed

+639
-56
lines changed

Dockerfile

+21-8
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
1-
ARG PYTHON_VERSION
2-
FROM python:${version}-slim-bookworm
1+
ARG PYTHON_VERSION=3.10
2+
FROM python:${PYTHON_VERSION}-slim-bookworm
3+
4+
ENV POETRY_NO_INTERACTION=1 \
5+
POETRY_VIRTUALENVS_IN_PROJECT=1 \
6+
POETRY_VIRTUALENVS_CREATE=1 \
7+
POETRY_CACHE_DIR=/tmp/poetry_cache
38

49
WORKDIR /workspace
510
RUN pip install --upgrade pip \
611
&& apt-get update \
7-
&& apt-get install -y \
8-
freetds-dev \
9-
&& rm -rf /var/lib/apt/lists/*
12+
&& apt-get install -y freetds-dev \
13+
&& apt-get install -y make \
14+
# no real need for keeping this image small at the moment
15+
&& :; # rm -rf /var/lib/apt/lists/*
16+
17+
# install poetry
18+
RUN bash -c 'python -m venv /opt/poetry-venv && source $_/bin/activate && pip install poetry && ln -s $(which poetry) /usr/bin'
1019

11-
# install requirements we exported from poetry
12-
COPY build/requirements.txt requirements.txt
13-
RUN pip install -r requirements.txt
20+
# install dependencies with poetry
21+
COPY pyproject.toml .
22+
COPY poetry.lock .
23+
RUN poetry install --all-extras --with dev --no-root
1424

1525
# copy project source
1626
COPY . .
27+
28+
# install project with poetry
29+
RUN poetry install --all-extras --with dev

Makefile

-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ lint: ## Lint all files in the project, which we also run in pre-commit
3232
poetry run pre-commit run -a
3333

3434
image: ## Make the docker image for dind tests
35-
poetry export -f requirements.txt -o build/requirements.txt
3635
docker build --build-arg PYTHON_VERSION=${PYTHON_VERSION} -t ${IMAGE} .
3736

3837
DOCKER_RUN = docker run --rm -v /var/run/docker.sock:/var/run/docker.sock

core/testcontainers/core/config.py

+34
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
11
from dataclasses import dataclass, field
2+
from enum import Enum
23
from logging import warning
34
from os import environ
45
from os.path import exists
56
from pathlib import Path
67
from typing import Optional, Union
78

9+
10+
class ConnectionMode(Enum):
11+
bridge_ip = "bridge_ip"
12+
gateway_ip = "gateway_ip"
13+
docker_host = "docker_host"
14+
15+
@property
16+
def use_mapped_port(self) -> bool:
17+
"""
18+
Return true if we need to use mapped port for this connection
19+
20+
This is true for everything but bridge mode.
21+
"""
22+
if self == self.bridge_ip:
23+
return False
24+
return True
25+
26+
827
MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120))
928
SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1))
1029
TIMEOUT = MAX_TRIES * SLEEP_TIME
@@ -20,6 +39,19 @@
2039
TC_GLOBAL = Path.home() / TC_FILE
2140

2241

42+
def get_user_overwritten_connection_mode() -> Optional[ConnectionMode]:
43+
"""
44+
Return the user overwritten connection mode.
45+
"""
46+
connection_mode: str | None = environ.get("TESTCONTAINERS_CONNECTION_MODE")
47+
if connection_mode:
48+
try:
49+
return ConnectionMode(connection_mode)
50+
except ValueError as e:
51+
raise ValueError(f"Error parsing TESTCONTAINERS_CONNECTION_MODE: {e}") from e
52+
return None
53+
54+
2355
def read_tc_properties() -> dict[str, str]:
2456
"""
2557
Read the .testcontainers.properties for settings. (see the Java implementation for details)
@@ -54,6 +86,8 @@ class TestcontainersConfiguration:
5486
tc_properties: dict[str, str] = field(default_factory=read_tc_properties)
5587
_docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG"))
5688
tc_host_override: Optional[str] = TC_HOST_OVERRIDE
89+
connection_mode_override: Optional[ConnectionMode] = None
90+
5791
"""
5892
https://github.com/testcontainers/testcontainers-go/blob/dd76d1e39c654433a3d80429690d07abcec04424/docker.go#L644
5993
if os env TC_HOST is set, use it

core/testcontainers/core/container.py

+18-33
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import contextlib
2-
from platform import system
32
from socket import socket
43
from typing import TYPE_CHECKING, Optional, Union
54

65
import docker.errors
76
from docker import version
87
from docker.types import EndpointConfig
9-
from typing_extensions import Self
8+
from typing_extensions import Self, assert_never
109

10+
from testcontainers.core.config import ConnectionMode
1111
from testcontainers.core.config import testcontainers_config as c
1212
from testcontainers.core.docker_client import DockerClient
1313
from testcontainers.core.exceptions import ContainerStartException
1414
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
1515
from testcontainers.core.network import Network
16-
from testcontainers.core.utils import inside_container, is_arm, setup_logger
16+
from testcontainers.core.utils import is_arm, setup_logger
1717
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
1818

1919
if TYPE_CHECKING:
@@ -129,38 +129,23 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
129129
self.stop()
130130

131131
def get_container_host_ip(self) -> str:
132-
# infer from docker host
133-
host = self.get_docker_client().host()
134-
if not host:
135-
return "localhost"
136-
# see https://github.com/testcontainers/testcontainers-python/issues/415
137-
if host == "localnpipe" and system() == "Windows":
138-
return "localhost"
139-
140-
# # check testcontainers itself runs inside docker container
141-
# if inside_container() and not os.getenv("DOCKER_HOST") and not host.startswith("http://"):
142-
# # If newly spawned container's gateway IP address from the docker
143-
# # "bridge" network is equal to detected host address, we should use
144-
# # container IP address, otherwise fall back to detected host
145-
# # address. Even it's inside container, we need to double check,
146-
# # because docker host might be set to docker:dind, usually in CI/CD environment
147-
# gateway_ip = self.get_docker_client().gateway_ip(self._container.id)
148-
149-
# if gateway_ip == host:
150-
# return self.get_docker_client().bridge_ip(self._container.id)
151-
# return gateway_ip
152-
return host
132+
connection_mode: ConnectionMode
133+
connection_mode = self.get_docker_client().get_connection_mode()
134+
if connection_mode == ConnectionMode.docker_host:
135+
return self.get_docker_client().host()
136+
elif connection_mode == ConnectionMode.gateway_ip:
137+
return self.get_docker_client().gateway_ip(self._container.id)
138+
elif connection_mode == ConnectionMode.bridge_ip:
139+
return self.get_docker_client().bridge_ip(self._container.id)
140+
else:
141+
# ensure that we covered all possible connection_modes
142+
assert_never(connection_mode)
153143

154144
@wait_container_is_ready()
155-
def get_exposed_port(self, port: int) -> str:
156-
mapped_port = self.get_docker_client().port(self._container.id, port)
157-
if inside_container():
158-
gateway_ip = self.get_docker_client().gateway_ip(self._container.id)
159-
host = self.get_docker_client().host()
160-
161-
if gateway_ip == host:
162-
return port
163-
return mapped_port
145+
def get_exposed_port(self, port: int) -> int:
146+
if self.get_docker_client().get_connection_mode().use_mapped_port:
147+
return self.get_docker_client().port(self._container.id, port)
148+
return port
164149

165150
def with_command(self, command: str) -> Self:
166151
self._command = command

core/testcontainers/core/docker_client.py

+45-8
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1111
# License for the specific language governing permissions and limitations
1212
# under the License.
13+
import contextlib
1314
import functools as ft
1415
import importlib.metadata
1516
import ipaddress
1617
import os
18+
import socket
1719
import urllib
1820
import urllib.parse
1921
from collections.abc import Iterable
@@ -24,12 +26,13 @@
2426
from docker.models.images import Image, ImageCollection
2527
from typing_extensions import ParamSpec
2628

29+
from testcontainers.core import utils
2730
from testcontainers.core.auth import DockerAuthInfo, parse_docker_auth_config
31+
from testcontainers.core.config import ConnectionMode
2832
from testcontainers.core.config import testcontainers_config as c
2933
from testcontainers.core.labels import SESSION_ID, create_labels
30-
from testcontainers.core.utils import default_gateway_ip, inside_container, setup_logger
3134

32-
LOGGER = setup_logger(__name__)
35+
LOGGER = utils.setup_logger(__name__)
3336

3437
_P = ParamSpec("_P")
3538
_T = TypeVar("_T")
@@ -127,8 +130,18 @@ def find_host_network(self) -> Optional[str]:
127130
"""
128131
# If we're docker in docker running on a custom network, we need to inherit the
129132
# network settings, so we can access the resulting container.
133+
134+
# first to try to find the network the container runs in, if we can determine
135+
container_id = utils.get_running_in_container_id()
136+
if container_id:
137+
with contextlib.suppress(Exception):
138+
return self.network_name(container_id)
139+
140+
# if this results nothing, try to determine the network based on the
141+
# docker_host
130142
try:
131-
docker_host = ipaddress.IPv4Address(self.host())
143+
host_ip = socket.gethostbyname(self.host())
144+
docker_host = ipaddress.IPv4Address(host_ip)
132145
# See if we can find the host on our networks
133146
for network in self.client.networks.list(filters={"type": "custom"}):
134147
if "IPAM" in network.attrs:
@@ -139,7 +152,7 @@ def find_host_network(self) -> Optional[str]:
139152
continue
140153
if docker_host in subnet:
141154
return network.name
142-
except ipaddress.AddressValueError:
155+
except (ipaddress.AddressValueError, OSError):
143156
pass
144157
return None
145158

@@ -187,6 +200,28 @@ def gateway_ip(self, container_id: str) -> str:
187200
network_name = self.network_name(container_id)
188201
return container["NetworkSettings"]["Networks"][network_name]["Gateway"]
189202

203+
def get_connection_mode(self) -> ConnectionMode:
204+
"""
205+
Determine the connection mode.
206+
207+
See https://github.com/testcontainers/testcontainers-python/issues/475#issuecomment-2407250970
208+
"""
209+
if c.connection_mode_override:
210+
return c.connection_mode_override
211+
localhosts = {"localhost", "127.0.0.1", "::1"}
212+
if not utils.inside_container() or self.host() not in localhosts:
213+
# if running not inside a container or with a non-local docker client,
214+
# connect ot the docker host per default
215+
return ConnectionMode.docker_host
216+
elif self.find_host_network():
217+
# a host network could be determined, indicator for DooD,
218+
# so we should connect to the bridge_ip as the container we run in
219+
# and the one we started are connected to the same network
220+
# that might have no access to either docker_host or the gateway
221+
return ConnectionMode.bridge_ip
222+
# default for DinD
223+
return ConnectionMode.gateway_ip
224+
190225
def host(self) -> str:
191226
"""
192227
Get the hostname or ip address of the docker host.
@@ -196,13 +231,15 @@ def host(self) -> str:
196231
return host
197232
try:
198233
url = urllib.parse.urlparse(self.client.api.base_url)
199-
200234
except ValueError:
201235
return "localhost"
202-
if "http" in url.scheme or "tcp" in url.scheme:
236+
if "http" in url.scheme or "tcp" in url.scheme and url.hostname:
237+
# see https://github.com/testcontainers/testcontainers-python/issues/415
238+
if url.hostname == "localnpipe" and utils.is_windows():
239+
return "localhost"
203240
return url.hostname
204-
if inside_container() and ("unix" in url.scheme or "npipe" in url.scheme):
205-
ip_address = default_gateway_ip()
241+
if utils.inside_container() and ("unix" in url.scheme or "npipe" in url.scheme):
242+
ip_address = utils.default_gateway_ip()
206243
if ip_address:
207244
return ip_address
208245
return "localhost"

core/testcontainers/core/utils.py

+19-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import platform
44
import subprocess
55
import sys
6-
from typing import Any, Optional
6+
from pathlib import Path
7+
from typing import Any, Final, Optional
78

89
LINUX = "linux"
910
MAC = "mac"
@@ -80,3 +81,20 @@ def raise_for_deprecated_parameter(kwargs: dict[Any, Any], name: str, replacemen
8081
if kwargs.pop(name, None):
8182
raise ValueError(f"Use `{replacement}` instead of `{name}`")
8283
return kwargs
84+
85+
86+
CGROUP_FILE: Final[Path] = Path("/proc/self/cgroup")
87+
88+
89+
def get_running_in_container_id() -> Optional[str]:
90+
"""
91+
Get the id of the currently running container
92+
"""
93+
if not CGROUP_FILE.is_file():
94+
return None
95+
cgroup = CGROUP_FILE.read_text()
96+
for line in cgroup.splitlines(keepends=False):
97+
path = line.rpartition(":")[2]
98+
if path.startswith("/docker"):
99+
return path.removeprefix("/docker/")
100+
return None

core/tests/conftest.py

+29
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
1+
from pathlib import Path
2+
13
import pytest
24
from typing import Callable
5+
import subprocess
36
from testcontainers.core.container import DockerClient
7+
import sys
8+
9+
PROJECT_DIR = Path(__file__).parent.parent.parent.resolve()
10+
11+
12+
def pytest_configure(config: pytest.Config) -> None:
13+
"""
14+
Add configuration for custom pytest markers.
15+
"""
16+
config.addinivalue_line(
17+
"markers",
18+
"inside_docker_check: test used to validate DinD/DooD are working as expected",
19+
)
20+
21+
22+
@pytest.fixture(scope="session")
23+
def python_testcontainer_image() -> str:
24+
"""Build an image with test containers python for DinD and DooD tests"""
25+
py_version = ".".join(map(str, sys.version_info[:2]))
26+
image_name = f"testcontainers-python:{py_version}"
27+
subprocess.run(
28+
[*("docker", "build"), *("--build-arg", f"PYTHON_VERSION={py_version}"), *("-t", image_name), "."],
29+
cwd=PROJECT_DIR,
30+
check=True,
31+
)
32+
return image_name
433

534

635
@pytest.fixture

core/tests/test_config.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
from testcontainers.core.config import TestcontainersConfiguration as TCC, TC_FILE
1+
import pytest
2+
3+
from testcontainers.core.config import (
4+
TestcontainersConfiguration as TCC,
5+
TC_FILE,
6+
get_user_overwritten_connection_mode,
7+
ConnectionMode,
8+
)
29

310
from pytest import MonkeyPatch, mark, LogCaptureFixture
411

@@ -60,3 +67,20 @@ def test_timeout() -> None:
6067
config.max_tries = 2
6168
config.sleep_time = 3
6269
assert config.timeout == 6
70+
71+
72+
def test_invalid_connection_mode(monkeypatch: pytest.MonkeyPatch) -> None:
73+
monkeypatch.setenv("TESTCONTAINERS_CONNECTION_MODE", "FOOBAR")
74+
with pytest.raises(ValueError, match="Error parsing TESTCONTAINERS_CONNECTION_MODE.*FOOBAR.*"):
75+
get_user_overwritten_connection_mode()
76+
77+
78+
@pytest.mark.parametrize("mode, use_mapped", (("bridge_ip", False), ("gateway_ip", True), ("docker_host", True)))
79+
def test_valid_connection_mode(monkeypatch: pytest.MonkeyPatch, mode: str, use_mapped: bool) -> None:
80+
monkeypatch.setenv("TESTCONTAINERS_CONNECTION_MODE", mode)
81+
assert get_user_overwritten_connection_mode().use_mapped_port is use_mapped
82+
83+
84+
def test_no_connection_mode_given(monkeypatch: pytest.MonkeyPatch) -> None:
85+
monkeypatch.delenv("TESTCONTAINERS_CONNECTION_MODE", raising=False)
86+
assert get_user_overwritten_connection_mode() is None

0 commit comments

Comments
 (0)