Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/testcontainers/core/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,13 @@ def waiting_for(self, strategy: WaitStrategy) -> Self:
return self

def start(self) -> Self:
if not c.ryuk_disabled and self.image != c.ryuk_image:
# self.image carries the configured hub_image_name_prefix (see __init__), but c.ryuk_image
# never does, so comparing the two directly here would never recognize Ryuk's own container
# once a prefix is configured (e.g. TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX pointing at a
# private registry mirror). That would make every Ryuk container think it isn't Ryuk and
# try to spin up its own Reaper, which spins up another Ryuk container, recursing until
# Python raises RecursionError (see #1085). Apply the same prefix to both sides instead.
if not c.ryuk_disabled and self.image != c.hub_image_name_prefix + c.ryuk_image:
logger.debug("Creating Ryuk container")
Reaper.get_instance()
logger.info("Pulling image %s", self.image)
Expand Down
72 changes: 71 additions & 1 deletion tests/core/test_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest

from testcontainers.core.config import ConnectionMode, testcontainers_config
from testcontainers.core.container import DockerContainer
from testcontainers.core.container import DockerContainer, Reaper
from testcontainers.core.docker_client import DockerClient

FAKE_ID = "ABC123"
Expand All @@ -17,6 +17,10 @@ def __init__(self) -> None:
def id(self) -> str:
return FAKE_ID

@property
def short_id(self) -> str:
return FAKE_ID[:12]


@pytest.fixture
def container(monkeypatch: pytest.MonkeyPatch) -> DockerContainer:
Expand Down Expand Up @@ -125,6 +129,72 @@ def test_image_no_prefix_applied_when_empty(monkeypatch: pytest.MonkeyPatch) ->
assert container.image == "nginx:latest"


class _FakeDockerClient:
"""Stand-in for DockerClient that never touches a real Docker daemon."""

def create(self, *args, **kwargs) -> FakeContainer:
return FakeContainer()

def start(self, container: FakeContainer) -> None:
pass


def _make_and_start_container(image: str, monkeypatch: pytest.MonkeyPatch) -> None:
"""Construct a DockerContainer for `image` and drive start() to completion, without touching
a real Docker daemon. Patches the DockerClient the container module resolves at construction
time, since DockerContainer.__init__ eagerly builds one before a test gets a chance to swap it.
"""
monkeypatch.setattr("testcontainers.core.container.DockerClient", lambda **kwargs: _FakeDockerClient())
DockerContainer(image).start()


def test_start_does_not_recreate_reaper_for_ryuk_container_with_hub_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
"""Regression test for #1085.

Once TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX is configured, self.image carries that prefix (see
__init__) but c.ryuk_image never does. Starting Ryuk's own container must still recognize
itself as Ryuk and skip requesting another Reaper - otherwise Reaper._create_instance() ->
DockerContainer(c.ryuk_image).start() -> Reaper.get_instance() recurses without end.
"""
monkeypatch.setattr(testcontainers_config, "hub_image_name_prefix", "myregistry.example.com/")
monkeypatch.setattr(testcontainers_config, "ryuk_disabled", False)

reaper_calls = 0

def fake_get_instance() -> None:
nonlocal reaper_calls
reaper_calls += 1

monkeypatch.setattr(Reaper, "get_instance", staticmethod(fake_get_instance))

# Mirrors exactly how Reaper._create_instance() builds Ryuk's own container.
_make_and_start_container(testcontainers_config.ryuk_image, monkeypatch)

assert reaper_calls == 0, "starting Ryuk's own container must not request another Reaper"


def test_start_still_creates_reaper_for_regular_container_with_hub_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
"""Companion to the regression test above: the recursion guard must not become a no-op.

A regular (non-Ryuk) container started with a hub_image_name_prefix configured should still
trigger Reaper.get_instance() as before.
"""
monkeypatch.setattr(testcontainers_config, "hub_image_name_prefix", "myregistry.example.com/")
monkeypatch.setattr(testcontainers_config, "ryuk_disabled", False)

reaper_calls = 0

def fake_get_instance() -> None:
nonlocal reaper_calls
reaper_calls += 1

monkeypatch.setattr(Reaper, "get_instance", staticmethod(fake_get_instance))

_make_and_start_container("nginx:latest", monkeypatch)

assert reaper_calls == 1, "starting a regular container should still request the Reaper"


def test_container_info():
"""Test get_container_info functionality with a real container."""
with DockerContainer("alpine:latest").with_command("sleep 30") as container:
Expand Down