From 85a66667c23d76e87aecc6761bbb01429adb3dee Mon Sep 17 00:00:00 2001 From: Kound Date: Wed, 23 Oct 2024 19:12:06 +0200 Subject: [PATCH] fix(core): running testcontainer inside container (#714) closes #475 One step closer to solve #517 Replaces #622 According to the [data collected](https://github.com/testcontainers/testcontainers-python/issues/475#issuecomment-2407250970) 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. --- Dockerfile | 29 +++- Makefile | 1 - core/testcontainers/core/config.py | 34 ++++ core/testcontainers/core/container.py | 51 +++--- core/testcontainers/core/docker_client.py | 53 ++++++- core/testcontainers/core/utils.py | 20 ++- core/tests/conftest.py | 29 ++++ core/tests/test_config.py | 26 +++- core/tests/test_container.py | 77 ++++++++++ core/tests/test_docker_client.py | 179 +++++++++++++++++++++- core/tests/test_docker_in_docker.py | 158 ++++++++++++++++++- core/tests/test_ryuk.py | 9 +- core/tests/test_utils.py | 24 +++ modules/mysql/tests/test_mysql.py | 1 + modules/postgres/tests/test_postgres.py | 1 + pyproject.toml | 3 + 16 files changed, 639 insertions(+), 56 deletions(-) create mode 100644 core/tests/test_container.py diff --git a/Dockerfile b/Dockerfile index c86c9e2d..865771fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,29 @@ -ARG PYTHON_VERSION -FROM python:${version}-slim-bookworm +ARG PYTHON_VERSION=3.10 +FROM python:${PYTHON_VERSION}-slim-bookworm + +ENV POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=1 \ + POETRY_VIRTUALENVS_CREATE=1 \ + POETRY_CACHE_DIR=/tmp/poetry_cache WORKDIR /workspace RUN pip install --upgrade pip \ && apt-get update \ - && apt-get install -y \ - freetds-dev \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y freetds-dev \ + && apt-get install -y make \ + # no real need for keeping this image small at the moment + && :; # rm -rf /var/lib/apt/lists/* + +# install poetry +RUN bash -c 'python -m venv /opt/poetry-venv && source $_/bin/activate && pip install poetry && ln -s $(which poetry) /usr/bin' -# install requirements we exported from poetry -COPY build/requirements.txt requirements.txt -RUN pip install -r requirements.txt +# install dependencies with poetry +COPY pyproject.toml . +COPY poetry.lock . +RUN poetry install --all-extras --with dev --no-root # copy project source COPY . . + +# install project with poetry +RUN poetry install --all-extras --with dev diff --git a/Makefile b/Makefile index 9a4fd6f9..93f14f79 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,6 @@ lint: ## Lint all files in the project, which we also run in pre-commit poetry run pre-commit run -a image: ## Make the docker image for dind tests - poetry export -f requirements.txt -o build/requirements.txt docker build --build-arg PYTHON_VERSION=${PYTHON_VERSION} -t ${IMAGE} . DOCKER_RUN = docker run --rm -v /var/run/docker.sock:/var/run/docker.sock diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index 142dbc19..110a441e 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -1,10 +1,29 @@ from dataclasses import dataclass, field +from enum import Enum from logging import warning from os import environ from os.path import exists from pathlib import Path from typing import Optional, Union + +class ConnectionMode(Enum): + bridge_ip = "bridge_ip" + gateway_ip = "gateway_ip" + docker_host = "docker_host" + + @property + def use_mapped_port(self) -> bool: + """ + Return true if we need to use mapped port for this connection + + This is true for everything but bridge mode. + """ + if self == self.bridge_ip: + return False + return True + + MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120)) SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1)) TIMEOUT = MAX_TRIES * SLEEP_TIME @@ -20,6 +39,19 @@ TC_GLOBAL = Path.home() / TC_FILE +def get_user_overwritten_connection_mode() -> Optional[ConnectionMode]: + """ + Return the user overwritten connection mode. + """ + connection_mode: str | None = environ.get("TESTCONTAINERS_CONNECTION_MODE") + if connection_mode: + try: + return ConnectionMode(connection_mode) + except ValueError as e: + raise ValueError(f"Error parsing TESTCONTAINERS_CONNECTION_MODE: {e}") from e + return None + + def read_tc_properties() -> dict[str, str]: """ Read the .testcontainers.properties for settings. (see the Java implementation for details) @@ -54,6 +86,8 @@ class TestcontainersConfiguration: tc_properties: dict[str, str] = field(default_factory=read_tc_properties) _docker_auth_config: Optional[str] = field(default_factory=lambda: environ.get("DOCKER_AUTH_CONFIG")) tc_host_override: Optional[str] = TC_HOST_OVERRIDE + connection_mode_override: Optional[ConnectionMode] = None + """ https://github.com/testcontainers/testcontainers-go/blob/dd76d1e39c654433a3d80429690d07abcec04424/docker.go#L644 if os env TC_HOST is set, use it diff --git a/core/testcontainers/core/container.py b/core/testcontainers/core/container.py index 9211b13b..90967e95 100644 --- a/core/testcontainers/core/container.py +++ b/core/testcontainers/core/container.py @@ -1,19 +1,19 @@ import contextlib -from platform import system from socket import socket from typing import TYPE_CHECKING, Optional, Union import docker.errors from docker import version from docker.types import EndpointConfig -from typing_extensions import Self +from typing_extensions import Self, assert_never +from testcontainers.core.config import ConnectionMode from testcontainers.core.config import testcontainers_config as c from testcontainers.core.docker_client import DockerClient from testcontainers.core.exceptions import ContainerStartException from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID from testcontainers.core.network import Network -from testcontainers.core.utils import inside_container, is_arm, setup_logger +from testcontainers.core.utils import is_arm, setup_logger from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs if TYPE_CHECKING: @@ -129,38 +129,23 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.stop() def get_container_host_ip(self) -> str: - # infer from docker host - host = self.get_docker_client().host() - if not host: - return "localhost" - # see https://github.com/testcontainers/testcontainers-python/issues/415 - if host == "localnpipe" and system() == "Windows": - return "localhost" - - # # check testcontainers itself runs inside docker container - # if inside_container() and not os.getenv("DOCKER_HOST") and not host.startswith("http://"): - # # If newly spawned container's gateway IP address from the docker - # # "bridge" network is equal to detected host address, we should use - # # container IP address, otherwise fall back to detected host - # # address. Even it's inside container, we need to double check, - # # because docker host might be set to docker:dind, usually in CI/CD environment - # gateway_ip = self.get_docker_client().gateway_ip(self._container.id) - - # if gateway_ip == host: - # return self.get_docker_client().bridge_ip(self._container.id) - # return gateway_ip - return host + connection_mode: ConnectionMode + connection_mode = self.get_docker_client().get_connection_mode() + if connection_mode == ConnectionMode.docker_host: + return self.get_docker_client().host() + elif connection_mode == ConnectionMode.gateway_ip: + return self.get_docker_client().gateway_ip(self._container.id) + elif connection_mode == ConnectionMode.bridge_ip: + return self.get_docker_client().bridge_ip(self._container.id) + else: + # ensure that we covered all possible connection_modes + assert_never(connection_mode) @wait_container_is_ready() - def get_exposed_port(self, port: int) -> str: - mapped_port = self.get_docker_client().port(self._container.id, port) - if inside_container(): - gateway_ip = self.get_docker_client().gateway_ip(self._container.id) - host = self.get_docker_client().host() - - if gateway_ip == host: - return port - return mapped_port + def get_exposed_port(self, port: int) -> int: + if self.get_docker_client().get_connection_mode().use_mapped_port: + return self.get_docker_client().port(self._container.id, port) + return port def with_command(self, command: str) -> Self: self._command = command diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py index c540b9f7..83127884 100644 --- a/core/testcontainers/core/docker_client.py +++ b/core/testcontainers/core/docker_client.py @@ -10,10 +10,12 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +import contextlib import functools as ft import importlib.metadata import ipaddress import os +import socket import urllib import urllib.parse from collections.abc import Iterable @@ -24,12 +26,13 @@ from docker.models.images import Image, ImageCollection from typing_extensions import ParamSpec +from testcontainers.core import utils from testcontainers.core.auth import DockerAuthInfo, parse_docker_auth_config +from testcontainers.core.config import ConnectionMode from testcontainers.core.config import testcontainers_config as c from testcontainers.core.labels import SESSION_ID, create_labels -from testcontainers.core.utils import default_gateway_ip, inside_container, setup_logger -LOGGER = setup_logger(__name__) +LOGGER = utils.setup_logger(__name__) _P = ParamSpec("_P") _T = TypeVar("_T") @@ -127,8 +130,18 @@ def find_host_network(self) -> Optional[str]: """ # If we're docker in docker running on a custom network, we need to inherit the # network settings, so we can access the resulting container. + + # first to try to find the network the container runs in, if we can determine + container_id = utils.get_running_in_container_id() + if container_id: + with contextlib.suppress(Exception): + return self.network_name(container_id) + + # if this results nothing, try to determine the network based on the + # docker_host try: - docker_host = ipaddress.IPv4Address(self.host()) + host_ip = socket.gethostbyname(self.host()) + docker_host = ipaddress.IPv4Address(host_ip) # See if we can find the host on our networks for network in self.client.networks.list(filters={"type": "custom"}): if "IPAM" in network.attrs: @@ -139,7 +152,7 @@ def find_host_network(self) -> Optional[str]: continue if docker_host in subnet: return network.name - except ipaddress.AddressValueError: + except (ipaddress.AddressValueError, OSError): pass return None @@ -187,6 +200,28 @@ def gateway_ip(self, container_id: str) -> str: network_name = self.network_name(container_id) return container["NetworkSettings"]["Networks"][network_name]["Gateway"] + def get_connection_mode(self) -> ConnectionMode: + """ + Determine the connection mode. + + See https://github.com/testcontainers/testcontainers-python/issues/475#issuecomment-2407250970 + """ + if c.connection_mode_override: + return c.connection_mode_override + localhosts = {"localhost", "127.0.0.1", "::1"} + if not utils.inside_container() or self.host() not in localhosts: + # if running not inside a container or with a non-local docker client, + # connect ot the docker host per default + return ConnectionMode.docker_host + elif self.find_host_network(): + # a host network could be determined, indicator for DooD, + # so we should connect to the bridge_ip as the container we run in + # and the one we started are connected to the same network + # that might have no access to either docker_host or the gateway + return ConnectionMode.bridge_ip + # default for DinD + return ConnectionMode.gateway_ip + def host(self) -> str: """ Get the hostname or ip address of the docker host. @@ -196,13 +231,15 @@ def host(self) -> str: return host try: url = urllib.parse.urlparse(self.client.api.base_url) - except ValueError: return "localhost" - if "http" in url.scheme or "tcp" in url.scheme: + if "http" in url.scheme or "tcp" in url.scheme and url.hostname: + # see https://github.com/testcontainers/testcontainers-python/issues/415 + if url.hostname == "localnpipe" and utils.is_windows(): + return "localhost" return url.hostname - if inside_container() and ("unix" in url.scheme or "npipe" in url.scheme): - ip_address = default_gateway_ip() + if utils.inside_container() and ("unix" in url.scheme or "npipe" in url.scheme): + ip_address = utils.default_gateway_ip() if ip_address: return ip_address return "localhost" diff --git a/core/testcontainers/core/utils.py b/core/testcontainers/core/utils.py index 4a7bec4b..6c613614 100644 --- a/core/testcontainers/core/utils.py +++ b/core/testcontainers/core/utils.py @@ -3,7 +3,8 @@ import platform import subprocess import sys -from typing import Any, Optional +from pathlib import Path +from typing import Any, Final, Optional LINUX = "linux" MAC = "mac" @@ -80,3 +81,20 @@ def raise_for_deprecated_parameter(kwargs: dict[Any, Any], name: str, replacemen if kwargs.pop(name, None): raise ValueError(f"Use `{replacement}` instead of `{name}`") return kwargs + + +CGROUP_FILE: Final[Path] = Path("/proc/self/cgroup") + + +def get_running_in_container_id() -> Optional[str]: + """ + Get the id of the currently running container + """ + if not CGROUP_FILE.is_file(): + return None + cgroup = CGROUP_FILE.read_text() + for line in cgroup.splitlines(keepends=False): + path = line.rpartition(":")[2] + if path.startswith("/docker"): + return path.removeprefix("/docker/") + return None diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 4f69565f..725df8e6 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -1,6 +1,35 @@ +from pathlib import Path + import pytest from typing import Callable +import subprocess from testcontainers.core.container import DockerClient +import sys + +PROJECT_DIR = Path(__file__).parent.parent.parent.resolve() + + +def pytest_configure(config: pytest.Config) -> None: + """ + Add configuration for custom pytest markers. + """ + config.addinivalue_line( + "markers", + "inside_docker_check: test used to validate DinD/DooD are working as expected", + ) + + +@pytest.fixture(scope="session") +def python_testcontainer_image() -> str: + """Build an image with test containers python for DinD and DooD tests""" + py_version = ".".join(map(str, sys.version_info[:2])) + image_name = f"testcontainers-python:{py_version}" + subprocess.run( + [*("docker", "build"), *("--build-arg", f"PYTHON_VERSION={py_version}"), *("-t", image_name), "."], + cwd=PROJECT_DIR, + check=True, + ) + return image_name @pytest.fixture diff --git a/core/tests/test_config.py b/core/tests/test_config.py index a6597fd4..8be68cc7 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -1,4 +1,11 @@ -from testcontainers.core.config import TestcontainersConfiguration as TCC, TC_FILE +import pytest + +from testcontainers.core.config import ( + TestcontainersConfiguration as TCC, + TC_FILE, + get_user_overwritten_connection_mode, + ConnectionMode, +) from pytest import MonkeyPatch, mark, LogCaptureFixture @@ -60,3 +67,20 @@ def test_timeout() -> None: config.max_tries = 2 config.sleep_time = 3 assert config.timeout == 6 + + +def test_invalid_connection_mode(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TESTCONTAINERS_CONNECTION_MODE", "FOOBAR") + with pytest.raises(ValueError, match="Error parsing TESTCONTAINERS_CONNECTION_MODE.*FOOBAR.*"): + get_user_overwritten_connection_mode() + + +@pytest.mark.parametrize("mode, use_mapped", (("bridge_ip", False), ("gateway_ip", True), ("docker_host", True))) +def test_valid_connection_mode(monkeypatch: pytest.MonkeyPatch, mode: str, use_mapped: bool) -> None: + monkeypatch.setenv("TESTCONTAINERS_CONNECTION_MODE", mode) + assert get_user_overwritten_connection_mode().use_mapped_port is use_mapped + + +def test_no_connection_mode_given(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TESTCONTAINERS_CONNECTION_MODE", raising=False) + assert get_user_overwritten_connection_mode() is None diff --git a/core/tests/test_container.py b/core/tests/test_container.py new file mode 100644 index 00000000..e1e7cff7 --- /dev/null +++ b/core/tests/test_container.py @@ -0,0 +1,77 @@ +import pytest + +from testcontainers.core.container import DockerContainer +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.config import ConnectionMode + +FAKE_ID = "ABC123" + + +class FakeContainer: + @property + def id(self) -> str: + return FAKE_ID + + +@pytest.fixture +def container(monkeypatch: pytest.MonkeyPatch) -> DockerContainer: + """ + Fake initialized container + """ + client = DockerClient() + container = DockerContainer("foobar") + monkeypatch.setattr(container, "_docker", client) + monkeypatch.setattr(container, "_container", FakeContainer()) + + return container + + +@pytest.mark.parametrize("mode", ["docker_host", "gateway_ip", "bridge_ip"]) +def test_get_container_host_ip(container: DockerContainer, monkeypatch: pytest.MonkeyPatch, mode: str) -> None: + """ + Depending on the connection mode the correct function is executed to the host_ip + """ + connection_mode = ConnectionMode(mode) + + def result_fake(result: str, require_container_id): + def fake_for_mode(*container_id: str): + if require_container_id: + assert len(container_id) == 1 + assert container_id[0] == FAKE_ID + else: + assert len(container_id) == 0 + return result + + return fake_for_mode + + client = container._docker + + monkeypatch.setattr(client, "get_connection_mode", lambda: connection_mode) + monkeypatch.setattr(client, "gateway_ip", result_fake("gateway_ip", True)) + monkeypatch.setattr(client, "bridge_ip", result_fake("bridge_ip", True)) + monkeypatch.setattr(client, "host", result_fake("docker_host", False)) + + assert container.get_container_host_ip() == mode + + +@pytest.mark.parametrize("mode", [ConnectionMode.gateway_ip, ConnectionMode.docker_host]) +def test_get_exposed_port_mapped( + container: DockerContainer, monkeypatch: pytest.MonkeyPatch, mode: ConnectionMode +) -> None: + def fake_mapped(container_id: int, port: int) -> int: + assert container_id == FAKE_ID + assert port == 8080 + return 45678 + + client = container._docker + monkeypatch.setattr(client, "port", fake_mapped) + monkeypatch.setattr(client, "get_connection_mode", lambda: mode) + + assert container.get_exposed_port(8080) == 45678 + + +def test_get_exposed_port_original(container: DockerContainer, monkeypatch: pytest.MonkeyPatch) -> None: + client = container._docker + monkeypatch.setattr(client, "get_connection_mode", lambda: ConnectionMode.bridge_ip) + + assert container.get_exposed_port(8080) == 8080 diff --git a/core/tests/test_docker_client.py b/core/tests/test_docker_client.py index 6bfe388d..1de293ef 100644 --- a/core/tests/test_docker_client.py +++ b/core/tests/test_docker_client.py @@ -1,19 +1,24 @@ import os import json from collections import namedtuple +from typing import Any from unittest import mock from unittest.mock import MagicMock, patch import docker +import pytest -from testcontainers.core.config import testcontainers_config as c +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.auth import parse_docker_auth_config from testcontainers.core.image import DockerImage +from testcontainers.core import utils from pytest import mark +from docker.models.networks import Network + def test_docker_client_from_env(): test_kwargs = {"test_kw": "test_value"} @@ -116,3 +121,175 @@ def test_image_docker_client_kw(): DockerImage(name="", path="", docker_client_kw=test_kwargs) mock_docker.from_env.assert_called_with(**test_kwargs) + + +def test_host_prefer_host_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(c, "tc_host_override", "my_docker_host") + assert DockerClient().host() == "my_docker_host" + + +@pytest.mark.parametrize( + "base_url, expected", + [ + pytest.param("http://[-", "localhost", id="invalid_url"), + pytest.param("http+docker://localhost", "localhost", id="docker_socket"), + pytest.param("http://localnpipe", "localhost", id="docker_socket_windows"), + pytest.param("http://some_host", "some_host", id="other_host"), + pytest.param("unix://something", "1.2.3.4", id="inside_container_socket"), + ], +) +def test_host(monkeypatch: pytest.MonkeyPatch, base_url: str, expected: str) -> None: + client = DockerClient() + monkeypatch.setattr(client.client.api, "base_url", base_url) + monkeypatch.setattr(c, "tc_host_override", None) + # overwrite some utils in order to test all branches of host + monkeypatch.setattr(utils, "is_windows", lambda: True) + monkeypatch.setattr(utils, "inside_container", lambda: True) + monkeypatch.setattr(utils, "default_gateway_ip", lambda: "1.2.3.4") + + assert client.host() == expected + + +def test_get_connection_mode_overwritten(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(c, "connection_mode_override", ConnectionMode.gateway_ip) + assert DockerClient().get_connection_mode() == ConnectionMode.gateway_ip + + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "::1"]) +def test_get_connection_mode_localhost_inside_container(monkeypatch: pytest.MonkeyPatch, host: str) -> None: + """ + If docker host is localhost and we are inside a container prefer gateway_ip + """ + client = DockerClient() + monkeypatch.setattr(c, "connection_mode_override", None) + monkeypatch.setattr(client, "host", lambda: host) + monkeypatch.setattr(client, "find_host_network", lambda: None) + monkeypatch.setattr(utils, "inside_container", lambda: True) + assert client.get_connection_mode() == ConnectionMode.gateway_ip + + +def test_get_connection_mode_remote_docker_host(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Use docker_host inside container if remote docker host is given + """ + client = DockerClient() + monkeypatch.setattr(c, "connection_mode_override", None) + monkeypatch.setattr(client, "host", lambda: "remote.docker.host") + monkeypatch.setattr(client, "find_host_network", lambda: None) + monkeypatch.setattr(utils, "inside_container", lambda: True) + assert client.get_connection_mode() == ConnectionMode.docker_host + + +def test_get_connection_mode_dood(monkeypatch: pytest.MonkeyPatch) -> None: + """ + For docker out of docker (docker socket mount), we expect to be able + to find a host network. + + In this case we should use the bridge ip as we can't expect + that either docker_host nor gateway_ip of the container are actually + reachable from within this network. + + This is the case for instance if using Gitlab CIs `FF_NETWORK_PER_BUILD` flag + """ + client = DockerClient() + monkeypatch.setattr(c, "connection_mode_override", None) + monkeypatch.setattr(client, "host", lambda: "localhost") + monkeypatch.setattr(client, "find_host_network", lambda: "new_bridge_network") + monkeypatch.setattr(utils, "inside_container", lambda: True) + assert client.get_connection_mode() == ConnectionMode.bridge_ip + + +def test_find_host_network_invalid_url(monkeypatch: pytest.MonkeyPatch) -> None: + """ + If the hostname can't be resolved just return None + """ + client = DockerClient() + monkeypatch.setattr(client, "host", lambda: "this does not exists") + assert client.find_host_network() is None + + +def test_find_host_network_found_by_docker_host(monkeypatch: pytest.MonkeyPatch) -> None: + client = DockerClient() + monkeypatch.setattr(client, "host", lambda: "172.22.0.1") + + networks = [ + # a network without IPAM + {"Name": "host"}, + # network with invalid subnet + { + "Name": "invalid", + "IPAM": {"Config": [{"Gateway": "172.22.0.1", "Subnet": "invalid subnet"}]}, + }, + { + "Attachable": False, + "ConfigFrom": {"Network": ""}, + "ConfigOnly": False, + "Containers": {}, + "Created": "2024-10-11T16:08:36.005642863Z", + "Driver": "bridge", + "EnableIPv6": False, + "Name": "runner-346da30e-2641-1-8365005", + "IPAM": { + "Config": [{"Gateway": "172.22.0.1", "Subnet": "172.22.0.0/16"}], + "Driver": "default", + "Options": None, + }, + }, + ] + + class FakeNetworks: + def list(self, filters: dict[str, str]) -> list[Network]: + assert filters == {"type": "custom"} + return [Network(network) for network in networks] + + class FakeClient: + @property + def networks(self): + return FakeNetworks() + + monkeypatch.setattr(client, "client", FakeClient()) + + assert client.find_host_network() == "runner-346da30e-2641-1-8365005" + + +def test_find_host_network_found_by_running_id(monkeypatch: pytest.MonkeyPatch) -> None: + client = DockerClient() + fake_id = "abcde1234" + + def network_name(container_id: str) -> str: + assert container_id == fake_id + return "FAKE_NETWORK" + + monkeypatch.setattr(utils, "get_running_in_container_id", lambda: fake_id) + monkeypatch.setattr(client, "network_name", network_name) + + assert client.find_host_network() == "FAKE_NETWORK" + + +def test_run_uses_found_network(monkeypatch: pytest.MonkeyPatch) -> None: + """ + If a host network is found, use it + """ + + client = DockerClient() + + class ContainerRunFake: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def run(self, image: str, **kwargs: Any) -> str: + self.calls.append(kwargs) + return "CONTAINER" + + class FakeClient: + def __init__(self) -> None: + self.containers = ContainerRunFake() + + fake_client = FakeClient() + + monkeypatch.setattr(client, "find_host_network", lambda: "new_bridge_network") + monkeypatch.setattr(client, "client", fake_client) + + assert client.run("test") == "CONTAINER" + + assert fake_client.containers.calls[0]["network"] == "new_bridge_network" diff --git a/core/tests/test_docker_in_docker.py b/core/tests/test_docker_in_docker.py index 6a424884..b07f80e9 100644 --- a/core/tests/test_docker_in_docker.py +++ b/core/tests/test_docker_in_docker.py @@ -1,7 +1,20 @@ +import contextlib +import json +import os import time import socket +from pathlib import Path +from typing import Final, Any + +import pytest + +from testcontainers.core import utils +from testcontainers.core.config import testcontainers_config as tcc +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 +from testcontainers.core.docker_client import DockerClient, LOGGER +from testcontainers.core.utils import inside_container from testcontainers.core.waiting_utils import wait_for_logs @@ -85,3 +98,146 @@ def test_dind_inherits_network(): not_really_dind.stop() not_really_dind.remove() custom_network.remove() + + +@contextlib.contextmanager +def print_surround_header(what: str, header_len: int = 80) -> None: + """ + Helper to visually mark a block with headers + """ + start = f"# Beginning of {what}" + end = f"# End of {what}" + + print("\n") + print("#" * header_len) + print(start + " " * (header_len - len(start) - 1) + "#") + print("#" * header_len) + print("\n") + + yield + + print("\n") + print("#" * header_len) + print(end + " " * (header_len - len(end) - 1) + "#") + print("#" * header_len) + print("\n") + + +EXPECTED_NETWORK_VAR: Final[str] = "TCC_EXPECTED_NETWORK" + + +def get_docker_info() -> dict[str, Any]: + client = DockerClient().client + + # Get Docker version info + version_info = client.version() + + # Get Docker system info + system_info = client.info() + + # Get container inspections + containers = client.containers.list(all=True) # List all containers (running or not) + container_inspections = {container.name: container.attrs for container in containers} + + # Return as a dictionary + return {"version_info": version_info, "system_info": system_info, "container_inspections": container_inspections} + + +# see https://forums.docker.com/t/get-a-containers-full-id-from-inside-of-itself +@pytest.mark.xfail(reason="Does not work in rootles 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") +def test_find_host_network_in_dood() -> None: + """ + Check that the correct host network is found for DooD + """ + LOGGER.info(f"Running container id={utils.get_running_in_container_id()}") + # Get some debug information in the hope this helps to find + LOGGER.info(f"hostname: {socket.gethostname()}") + LOGGER.info(f"docker info: {json.dumps(get_docker_info(), indent=2)}") + assert DockerClient().find_host_network() == os.environ[EXPECTED_NETWORK_VAR] + + +@pytest.mark.skipif(not Path(tcc.ryuk_docker_socket).exists(), reason="No docker socket available") +def test_dood(python_testcontainer_image: str) -> None: + """ + Run tests marked as inside_docker_check inside docker out of docker + """ + + docker_sock = tcc.ryuk_docker_socket + with Network() as network: + with ( + DockerContainer( + image=python_testcontainer_image, + ) + .with_command("poetry run pytest -m inside_docker_check") + .with_volume_mapping(docker_sock, docker_sock, "rw") + # test also that the correct network was found + # but only do this if not already inside a container + # as there for some reason this doesn't work + .with_env(EXPECTED_NETWORK_VAR, "" if inside_container() else network.name) + .with_env("RYUK_RECONNECTION_TIMEOUT", "1s") + .with_network(network) + ) as container: + status = container.get_wrapped_container().wait() + stdout, stderr = container.get_logs() + # ensure ryuk removed the containers created inside container + # because they are bound our network the deletion of the network + # would fail otherwise + time.sleep(1.1) + + # Show what was done inside test + with print_surround_header("test_dood results"): + print(stdout.decode("utf-8", errors="replace")) + print(stderr.decode("utf-8", errors="replace")) + assert status["StatusCode"] == 0 + + +def test_dind(python_testcontainer_image: str, tmp_path: Path) -> None: + """ + Run selected tests in Docker in Docker + """ + cert_dir = tmp_path / "certs" + dind_name = f"docker_{SESSION_ID}" + with Network() as network: + with ( + DockerContainer(image="docker:dind", privileged=True) + .with_name(dind_name) + .with_volume_mapping(str(cert_dir), "/certs", "rw") + .with_env("DOCKER_TLS_CERTDIR", "/certs/docker") + .with_env("DOCKER_TLS_VERIFY", "1") + .with_network(network) + .with_network_aliases("docker") + ) as dind_container: + wait_for_logs(dind_container, "API listen on") + client_dir = cert_dir / "docker" / "client" + ca_file = client_dir / "ca.pem" + assert ca_file.is_file() + try: + with ( + DockerContainer(image=python_testcontainer_image) + .with_command("poetry run pytest -m inside_docker_check") + .with_volume_mapping(str(cert_dir), "/certs") + # for some reason the docker client does not respect + # DOCKER_TLS_CERTDIR and looks in /root/.docker instead + .with_volume_mapping(str(client_dir), "/root/.docker") + .with_env("DOCKER_TLS_CERTDIR", "/certs/docker/client") + .with_env("DOCKER_TLS_VERIFY", "1") + # docker port is 2376 for https, 2375 for http + .with_env("DOCKER_HOST", "tcp://docker:2376") + .with_network(network) + ) as test_container: + status = test_container.get_wrapped_container().wait() + stdout, stderr = test_container.get_logs() + finally: + # ensure the certs are deleted from inside the container + # as they might be owned by root it otherwise could lead to problems + # with pytest cleanup + dind_container.exec("rm -rf /certs/docker") + dind_container.exec("chmod -R a+rwX /certs") + + # Show what was done inside test + with print_surround_header("test_dood results"): + print(stdout.decode("utf-8", errors="replace")) + print(stderr.decode("utf-8", errors="replace")) + assert status["StatusCode"] == 0 diff --git a/core/tests/test_ryuk.py b/core/tests/test_ryuk.py index e081d2c0..5d6b208a 100644 --- a/core/tests/test_ryuk.py +++ b/core/tests/test_ryuk.py @@ -11,11 +11,14 @@ from testcontainers.core.waiting_utils import wait_for_logs +@pytest.mark.inside_docker_check def test_wait_for_reaper(monkeypatch: MonkeyPatch): Reaper.delete_instance() monkeypatch.setattr(testcontainers_config, "ryuk_reconnection_timeout", "0.1s") - docker_client = DockerClient() - container = DockerContainer("hello-world").start() + container = DockerContainer("hello-world") + container.start() + + docker_client = container.get_docker_client().client container_id = container.get_wrapped_container().short_id reaper_id = Reaper._container.get_wrapped_container().short_id @@ -38,6 +41,7 @@ def test_wait_for_reaper(monkeypatch: MonkeyPatch): Reaper.delete_instance() +@pytest.mark.inside_docker_check def test_container_without_ryuk(monkeypatch: MonkeyPatch): Reaper.delete_instance() monkeypatch.setattr(testcontainers_config, "ryuk_disabled", True) @@ -46,6 +50,7 @@ def test_container_without_ryuk(monkeypatch: MonkeyPatch): assert Reaper._instance is None +@pytest.mark.inside_docker_check def test_ryuk_is_reused_in_same_process(): with DockerContainer("hello-world") as container: wait_for_logs(container, "Hello from Docker!") diff --git a/core/tests/test_utils.py b/core/tests/test_utils.py index 01134327..1923483e 100644 --- a/core/tests/test_utils.py +++ b/core/tests/test_utils.py @@ -1,3 +1,6 @@ +from pathlib import Path + +import pytest from pytest import MonkeyPatch, raises, mark from testcontainers.core import utils @@ -51,3 +54,24 @@ def test_raise_for_deprecated_parameters() -> None: result = utils.raise_for_deprecated_parameter(kwargs, current, replacement) assert str(e.value) == "Parameter 'deprecated' is deprecated and should be replaced by 'replacement'." assert result == {} + + +@pytest.fixture +def fake_cgroup(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + target = tmp_path / "cgroup" + monkeypatch.setattr(utils, "CGROUP_FILE", target) + return target + + +def test_get_running_container_id_empty_or_missing(fake_cgroup: Path) -> None: + # non existing does not fail but is only none + assert utils.get_running_in_container_id() is None + fake_cgroup.write_text("12:devices:/system.slice/sshd.service\n" "13:cpuset:\n") + # missing docker does also not fail + assert utils.get_running_in_container_id() is None + + +def test_get_running_container_id(fake_cgroup: Path) -> None: + container_id = "b78eebb08f89158ed6e2ed2fe" + fake_cgroup.write_text(f"13:cpuset:/docker/{container_id}") + assert utils.get_running_in_container_id() == container_id diff --git a/modules/mysql/tests/test_mysql.py b/modules/mysql/tests/test_mysql.py index 323c3532..af0b4491 100644 --- a/modules/mysql/tests/test_mysql.py +++ b/modules/mysql/tests/test_mysql.py @@ -9,6 +9,7 @@ from testcontainers.mysql import MySqlContainer +@pytest.mark.inside_docker_check def test_docker_run_mysql(): config = MySqlContainer("mysql:8.3.0") with config as mysql: diff --git a/modules/postgres/tests/test_postgres.py b/modules/postgres/tests/test_postgres.py index 6bbd0ba7..c2e7d982 100644 --- a/modules/postgres/tests/test_postgres.py +++ b/modules/postgres/tests/test_postgres.py @@ -28,6 +28,7 @@ def fail(*args, **kwargs): assert status == 0 +@pytest.mark.inside_docker_check def test_docker_run_postgres_with_sqlalchemy(): postgres_container = PostgresContainer("postgres:9.5") with postgres_container as postgres: diff --git a/pyproject.toml b/pyproject.toml index 257c0798..bd4e7ed0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -201,6 +201,9 @@ line-length = 120 addopts = "--tb=short --strict-markers" log_cli = true log_cli_level = "INFO" +markers = [ + "inside_docker_check: mark test to be used to validate DinD/DooD is working as expected" +] [tool.coverage.run] branch = true