Skip to content

Commit 83950fa

Browse files
authored
Merge branch 'main' into ports_refactor
2 parents 5a591bd + 82a2e7b commit 83950fa

File tree

18 files changed

+656
-75
lines changed

18 files changed

+656
-75
lines changed

Dockerfile

Lines changed: 21 additions & 8 deletions
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

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,6 @@ coverage: ## Target to combine and report coverage.
3131
lint: ## Lint all files in the project, which we also run in pre-commit
3232
poetry run pre-commit run -a
3333

34-
image: ## Make the docker image for dind tests
35-
poetry export -f requirements.txt -o build/requirements.txt
36-
docker build --build-arg PYTHON_VERSION=${PYTHON_VERSION} -t ${IMAGE} .
37-
38-
DOCKER_RUN = docker run --rm -v /var/run/docker.sock:/var/run/docker.sock
39-
40-
tests-dind: ${TESTS_DIND} ## Run the tests in docker containers to test `dind`
41-
${TESTS_DIND}: %/tests-dind: image
42-
${DOCKER_RUN} ${IMAGE} \
43-
bash -c "make $*/tests"
44-
4534
docs: ## Build the docs for the project
4635
poetry run sphinx-build -nW . docs/_build
4736

core/testcontainers/core/config.py

Lines changed: 34 additions & 0 deletions
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

Lines changed: 18 additions & 33 deletions
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:
@@ -157,38 +157,23 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
157157
self.stop()
158158

159159
def get_container_host_ip(self) -> str:
160-
# infer from docker host
161-
host = self.get_docker_client().host()
162-
if not host:
163-
return "localhost"
164-
# see https://github.com/testcontainers/testcontainers-python/issues/415
165-
if host == "localnpipe" and system() == "Windows":
166-
return "localhost"
167-
168-
# # check testcontainers itself runs inside docker container
169-
# if inside_container() and not os.getenv("DOCKER_HOST") and not host.startswith("http://"):
170-
# # If newly spawned container's gateway IP address from the docker
171-
# # "bridge" network is equal to detected host address, we should use
172-
# # container IP address, otherwise fall back to detected host
173-
# # address. Even it's inside container, we need to double check,
174-
# # because docker host might be set to docker:dind, usually in CI/CD environment
175-
# gateway_ip = self.get_docker_client().gateway_ip(self._container.id)
176-
177-
# if gateway_ip == host:
178-
# return self.get_docker_client().bridge_ip(self._container.id)
179-
# return gateway_ip
180-
return host
160+
connection_mode: ConnectionMode
161+
connection_mode = self.get_docker_client().get_connection_mode()
162+
if connection_mode == ConnectionMode.docker_host:
163+
return self.get_docker_client().host()
164+
elif connection_mode == ConnectionMode.gateway_ip:
165+
return self.get_docker_client().gateway_ip(self._container.id)
166+
elif connection_mode == ConnectionMode.bridge_ip:
167+
return self.get_docker_client().bridge_ip(self._container.id)
168+
else:
169+
# ensure that we covered all possible connection_modes
170+
assert_never(connection_mode)
181171

182172
@wait_container_is_ready()
183-
def get_exposed_port(self, port: int) -> str:
184-
mapped_port = self.get_docker_client().port(self._container.id, port)
185-
if inside_container():
186-
gateway_ip = self.get_docker_client().gateway_ip(self._container.id)
187-
host = self.get_docker_client().host()
188-
189-
if gateway_ip == host:
190-
return port
191-
return mapped_port
173+
def get_exposed_port(self, port: int) -> int:
174+
if self.get_docker_client().get_connection_mode().use_mapped_port:
175+
return self.get_docker_client().port(self._container.id, port)
176+
return port
192177

193178
def with_command(self, command: str) -> Self:
194179
self._command = command

core/testcontainers/core/docker_client.py

Lines changed: 45 additions & 8 deletions
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

Lines changed: 19 additions & 1 deletion
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/testcontainers/core/waiting_utils.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def wait_for(condition: Callable[..., bool]) -> bool:
7777
return condition()
7878

7979

80+
_NOT_EXITED_STATUSES = {"running", "created"}
81+
82+
8083
def wait_for_logs(
8184
container: "DockerContainer",
8285
predicate: Union[Callable, str],
@@ -103,11 +106,13 @@ def wait_for_logs(
103106
"""
104107
if isinstance(predicate, str):
105108
predicate = re.compile(predicate, re.MULTILINE).search
109+
wrapped = container.get_wrapped_container()
106110
start = time.time()
107111
while True:
108112
duration = time.time() - start
109-
stdout = container.get_logs()[0].decode()
110-
stderr = container.get_logs()[1].decode()
113+
stdout, stderr = container.get_logs()
114+
stdout = stdout.decode()
115+
stderr = stderr.decode()
111116
predicate_result = (
112117
predicate(stdout) or predicate(stderr)
113118
if predicate_streams_and is False
@@ -118,6 +123,8 @@ def wait_for_logs(
118123
return duration
119124
if duration > timeout:
120125
raise TimeoutError(f"Container did not emit logs satisfying predicate in {timeout:.3f} " "seconds")
121-
if raise_on_exit and container.get_wrapped_container().status != "running":
122-
raise RuntimeError("Container exited before emitting logs satisfying predicate")
126+
if raise_on_exit:
127+
wrapped.reload()
128+
if wrapped.status not in _NOT_EXITED_STATUSES:
129+
raise RuntimeError("Container exited before emitting logs satisfying predicate")
123130
time.sleep(interval)

0 commit comments

Comments
 (0)