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
24 changes: 16 additions & 8 deletions src/testcontainers/core/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,16 +475,24 @@ def get_instance(cls) -> "Reaper":

@classmethod
def delete_instance(cls) -> None:
if Reaper._socket is not None:
Reaper._socket.close()
container = Reaper._container
try:
if Reaper._socket is not None:
Reaper._socket.close()

if container is not None and container._container is not None:
try:
container.stop()
except docker.errors.NotFound:
pass
except APIError as e:
# Ryuk runs with auto_remove, so if it died Docker may already be removing it (409).
if e.status_code != 409:
raise
finally:
# Reset whatever stop() did, so get_instance() creates a new reaper instead of returning a dead one.
Reaper._socket = None

if Reaper._container is not None and Reaper._container._container is not None:
with contextlib.suppress(docker.errors.NotFound):
Reaper._container.stop()
Reaper._container = None

if Reaper._instance is not None:
Reaper._instance = None

@classmethod
Expand Down
60 changes: 59 additions & 1 deletion tests/core/test_ryuk.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest
from docker import DockerClient
from docker.errors import NotFound
from docker.errors import APIError, NotFound

from testcontainers.core.config import testcontainers_config
from testcontainers.core.container import DockerContainer, Reaper
Expand Down Expand Up @@ -95,3 +95,61 @@ def test_ryuk_is_reused_in_same_process():
with DockerContainer("hello-world") as container:
wait_for_logs(container, "Hello from Docker!")
assert reaper_instance is Reaper._instance


class _FakeResponse:
def __init__(self, status_code: int) -> None:
self.status_code = status_code
self.reason = "Conflict" if status_code == 409 else "Server Error"
self.url = "http+docker://localhost/containers/ryuk"


class _DeadReaperContainer:
"""Stands in for a ryuk container that died and that Docker is already auto-removing."""

def __init__(self, status_code: int) -> None:
self._container = object()
self._status_code = status_code

def stop(self) -> None:
raise APIError("removal of container is already in progress", response=_FakeResponse(self._status_code))


class _FakeSocket:
closed = False

def close(self) -> None:
self.closed = True


def test_delete_instance_treats_removal_in_progress_as_gone(monkeypatch: pytest.MonkeyPatch):
# https://github.com/testcontainers/testcontainers-python/issues/1125
dead = Reaper()
sock = _FakeSocket()
monkeypatch.setattr(Reaper, "_instance", dead)
monkeypatch.setattr(Reaper, "_container", _DeadReaperContainer(409))
monkeypatch.setattr(Reaper, "_socket", sock)

Reaper.delete_instance()

assert sock.closed
assert Reaper._socket is None
assert Reaper._container is None
assert Reaper._instance is None

fresh = Reaper()
monkeypatch.setattr(Reaper, "_create_instance", classmethod(lambda cls: fresh))
assert Reaper.get_instance() is fresh


def test_delete_instance_resets_its_state_when_stop_fails(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(Reaper, "_instance", Reaper())
monkeypatch.setattr(Reaper, "_container", _DeadReaperContainer(500))
monkeypatch.setattr(Reaper, "_socket", _FakeSocket())

with pytest.raises(APIError):
Reaper.delete_instance()

assert Reaper._socket is None
assert Reaper._container is None
assert Reaper._instance is None