Skip to content

fix(core): DinD issues #141, #329 #368

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Mar 20, 2024
54 changes: 51 additions & 3 deletions core/testcontainers/core/docker_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
# License for the specific language governing permissions and limitations
# under the License.
import functools as ft
import ipaddress
import os
import urllib
import urllib.parse
from os.path import exists
from pathlib import Path
from typing import Optional, Union
Expand All @@ -34,7 +36,7 @@ class DockerClient:
"""

def __init__(self, **kwargs) -> None:
docker_host = read_tc_properties().get("tc.host")
docker_host = get_docker_host()

if docker_host:
LOGGER.info(f"using host {docker_host}")
Expand All @@ -57,6 +59,12 @@ def run(
remove: bool = False,
**kwargs,
) -> Container:
# If the user has specified a network, we'll assume the user knows best
if "network" not in kwargs and not get_docker_host():
# Otherwise we'll try to find the docker host for dind usage.
host_network = self.find_host_network()
if host_network:
kwargs["network"] = host_network
container = self.client.containers.run(
image,
command=command,
Expand All @@ -71,6 +79,30 @@ def run(
)
return container

def find_host_network(self) -> Optional[str]:
"""
Try to find the docker host network.

:return: The network name if found, None if not set.
"""
# 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.
try:
docker_host = ipaddress.IPv4Address(self.host())
# 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:
for config in network.attrs["IPAM"]["Config"]:
try:
subnet = ipaddress.IPv4Network(config["Subnet"])
except ipaddress.AddressValueError:
continue
if docker_host in subnet:
return network.name
except ipaddress.AddressValueError:
pass
return None

def port(self, container_id: str, port: int) -> int:
"""
Lookup the public-facing port that is NAT-ed to :code:`port`.
Expand All @@ -94,14 +126,26 @@ def bridge_ip(self, container_id: str) -> str:
Get the bridge ip address for a container.
"""
container = self.get_container(container_id)
return container["NetworkSettings"]["Networks"]["bridge"]["IPAddress"]
network_name = self.network_name(container_id)
return container["NetworkSettings"]["Networks"][network_name]["IPAddress"]

def network_name(self, container_id: str) -> str:
"""
Get the name of the network this container runs on
"""
container = self.get_container(container_id)
name = container["HostConfig"]["NetworkMode"]
if name == "default":
return "bridge"
return name

def gateway_ip(self, container_id: str) -> str:
"""
Get the gateway ip address for a container.
"""
container = self.get_container(container_id)
return container["NetworkSettings"]["Networks"]["bridge"]["Gateway"]
network_name = self.network_name(container_id)
return container["NetworkSettings"]["Networks"][network_name]["Gateway"]

def host(self) -> str:
"""
Expand Down Expand Up @@ -145,3 +189,7 @@ def read_tc_properties() -> dict[str, str]:
tuples = [line.split("=") for line in contents.readlines() if "=" in line]
settings = {**settings, **{item[0].strip(): item[1].strip() for item in tuples}}
return settings


def get_docker_host() -> Optional[str]:
return read_tc_properties().get("tc.host") or os.getenv("DOCKER_HOST")
63 changes: 56 additions & 7 deletions core/tests/test_docker_in_docker.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import pytest

import time
import socket
from testcontainers.core.container import DockerContainer
from testcontainers.core.docker_client import DockerClient
from testcontainers.core.waiting_utils import wait_for_logs


@pytest.mark.xfail(reason="https://github.com/docker/docker-py/issues/2717")
def _wait_for_dind_return_ip(client, dind):
# get ip address for DOCKER_HOST
# avoiding DockerContainer class here to prevent code changes affecting the test
docker_host_ip = client.bridge_ip(dind.id)
# Wait for startup
timeout = 10
start_wait = time.perf_counter()
while True:
try:
with socket.create_connection((docker_host_ip, 2375), timeout=timeout):
break
except ConnectionRefusedError:
if time.perf_counter() - start_wait > timeout:
raise RuntimeError("Docker in docker took longer than 10 seconds to start")
time.sleep(0.01)
return docker_host_ip


def test_wait_for_logs_docker_in_docker():
# real dind isn't possible (AFAIK) in CI
# forwarding the socket to a container port is at least somewhat the same
Expand All @@ -18,21 +35,53 @@ def test_wait_for_logs_docker_in_docker():
)

not_really_dind.start()
docker_host_ip = _wait_for_dind_return_ip(client, not_really_dind)
docker_host = f"tcp://{docker_host_ip}:2375"

# get ip address for DOCKER_HOST
# avoiding DockerContainer class here to prevent code changes affecting the test
specs = client.get_container(not_really_dind.id)
docker_host_ip = specs["NetworkSettings"]["Networks"]["bridge"]["IPAddress"]
with DockerContainer(
image="hello-world",
docker_client_kw={"environment": {"DOCKER_HOST": docker_host, "DOCKER_CERT_PATH": "", "DOCKER_TLS_VERIFY": ""}},
) as container:
assert container.get_container_host_ip() == docker_host_ip
wait_for_logs(container, "Hello from Docker!")
stdout, stderr = container.get_logs()
assert stdout, "There should be something on stdout"

not_really_dind.stop()
not_really_dind.remove()


def test_dind_inherits_network():
client = DockerClient()
try:
custom_network = client.client.networks.create("custom_network", driver="bridge", check_duplicate=True)
except Exception:
custom_network = client.client.networks.list(names=["custom_network"])[0]
not_really_dind = client.run(
image="alpine/socat",
command="tcp-listen:2375,fork,reuseaddr unix-connect:/var/run/docker.sock",
volumes={"/var/run/docker.sock": {"bind": "/var/run/docker.sock"}},
detach=True,
)

not_really_dind.start()

docker_host_ip = _wait_for_dind_return_ip(client, not_really_dind)
docker_host = f"tcp://{docker_host_ip}:2375"

with DockerContainer(
image="hello-world",
docker_client_kw={"environment": {"DOCKER_HOST": docker_host, "DOCKER_CERT_PATH": "", "DOCKER_TLS_VERIFY": ""}},
) as container:
assert container.get_container_host_ip() == docker_host_ip
# Check the gateways are the same, so they can talk to each other
assert container.get_docker_client().gateway_ip(container.get_wrapped_container().id) == client.gateway_ip(
not_really_dind.id
)
wait_for_logs(container, "Hello from Docker!")
stdout, stderr = container.get_logs()
assert stdout, "There should be something on stdout"

not_really_dind.stop()
not_really_dind.remove()
custom_network.remove()