Skip to content

feat : Add support for depends_on functionality #728

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
85 changes: 83 additions & 2 deletions core/testcontainers/core/container.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import contextlib
import time
from socket import socket
from typing import TYPE_CHECKING, Optional, Union

import docker.errors
from docker import version
from docker.errors import NotFound
from docker.types import EndpointConfig
from typing_extensions import Self, assert_never

Expand Down Expand Up @@ -44,6 +46,7 @@ def __init__(
self.env = {}
self.ports = {}
self.volumes = {}
self._dependencies = []
self.image = image
self._docker = DockerClient(**(docker_client_kw or {}))
self._container = None
Expand Down Expand Up @@ -83,14 +86,63 @@ def maybe_emulate_amd64(self) -> Self:
return self.with_kwargs(platform="linux/amd64")
return self

def depends_on(self, dependencies: Union["DockerContainer", list["DockerContainer"]]) -> "DockerContainer":
"""
Specify dependencies for this container.

Args:
dependencies (Union[DockerContainer, list[DockerContainer]]): One or multiple Docker container instances
this container depends on.

Returns:
DockerContainer: The current instance, for chaining.
"""
if isinstance(dependencies, DockerContainer):
self._dependencies.append(dependencies)
elif isinstance(dependencies, list):
self._dependencies.extend(dependencies)
else:
raise TypeError("dependencies must be a DockerContainer or list of DockerContainer instances")
return self

def _start_dependencies(self) -> bool:
"""
Start all dependencies recursively, ensuring each dependency's dependencies are also resolved.
If a dependency fails to start, stop all previously started dependencies and raise the exception.
"""
started_dependencies = []
for dependency in self._dependencies:
if not dependency._container:
try:
dependency._start_dependencies()
dependency.start()
started_dependencies.append(dependency)

if not dependency.wait_until_running():
raise ContainerStartException(f"Dependency {dependency.image} did not reach 'running' state.")

except Exception as e:
# Clean up all dependencies started before the failure
for dep in started_dependencies:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't seem like it work as the recursive call would create its own started_dependencies?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing this out! The current code creates a new started_dependencies list on each recursive call, which prevents complete cleanup if a nested dependency fails. I’ll update the code to pass started_dependencies across recursive calls to ensure full cleanup.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexanderankin I have made the needed changes such that the _start_dependencies function passes the started_dependencies across the recursive function calls to maintain a consistent state. I have also added a new check for circular dependencies which is called while adding dependencies in depends_on function. The tests have also been updated to be more comprehensive. I will be happy to make changes in the future if any of the core parts change just tag me in that case.

try:
logger.debug("Stopping previously started dependency container: %s", dep.image)
dep.stop()
logger.debug("Dependency container %s stopped successfully", dep.image)
except Exception as stop_error:
logger.error("Failed to stop dependency container %s: %s", dep.image, str(stop_error))
# Raise the exception after cleaning up
raise e
return True

def start(self) -> Self:
if not c.ryuk_disabled and self.image != c.ryuk_image:
logger.debug("Creating Ryuk container")
Reaper.get_instance()
logger.info("Pulling image %s", self.image)
docker_client = self.get_docker_client()
self._configure()

self._start_dependencies()

network_kwargs = (
{
"network": self._network.name,
Expand All @@ -102,6 +154,7 @@ def start(self) -> Self:
else {}
)

logger.info("Pulling image %s", self.image)
self._container = docker_client.run(
self.image,
command=self._command,
Expand All @@ -119,7 +172,11 @@ def start(self) -> Self:

def stop(self, force=True, delete_volume=True) -> None:
if self._container:
self._container.remove(force=force, v=delete_volume)
try:
self._container.remove(force=force, v=delete_volume)
except NotFound:
logger.warning("Container not found when attempting to stop.")
self._container = None
self.get_docker_client().client.close()

def __enter__(self) -> Self:
Expand All @@ -128,6 +185,30 @@ def __enter__(self) -> Self:
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.stop()

def wait_until_running(self, timeout: int = 30) -> bool:
"""
Wait until the container is in the 'running' state, up to a specified timeout.

Args:
timeout (int): Maximum time to wait in seconds.

Returns:
bool: True if the container is running, False if the timeout is reached.
"""
start_time = time.time()
while time.time() - start_time < timeout:
self.get_wrapped_container().reload()
if self._container and self._container.status == "running":
logger.info(f"Container {self.image} reached 'running' state.")
return True
elif self._container:
logger.debug(f"Container {self.image} state: {self._container.status}")
else:
logger.debug(f"Container {self.image} is not initialized yet.")
time.sleep(0.5)
logger.error(f"Container {self.image} did not reach 'running' state within {timeout} seconds.")
return False

def get_container_host_ip(self) -> str:
connection_mode: ConnectionMode
connection_mode = self.get_docker_client().get_connection_mode()
Expand Down
76 changes: 76 additions & 0 deletions core/tests/test_container_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import pytest
from docker.errors import APIError, ImageNotFound
from testcontainers.core.container import DockerContainer


def test_single_dependency_starts() -> None:
container = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
dependency_container = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
container.depends_on(dependency_container)

container.start()

assert dependency_container.wait_until_running(), "Dependency did not reach running state"
assert container.wait_until_running(), "Container did not reach running state"

container.stop()
dependency_container.stop()


def test_multiple_dependencies_start() -> None:
container = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
dependency1 = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
dependency2 = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
container.depends_on([dependency1, dependency2])

dependency1.start()
dependency2.start()
assert dependency1.wait_until_running(), "Dependency 1 did not reach running state"
assert dependency2.wait_until_running(), "Dependency 2 did not reach running state"

container.start()
assert container.wait_until_running(), "Container did not reach running state"

container.stop()
dependency1.stop()
dependency2.stop()


def test_dependency_failure() -> None:
container = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
failing_dependency = DockerContainer("nonexistent-image")
container.depends_on(failing_dependency)

with pytest.raises((APIError, ImageNotFound)):
container.start()

assert container._container is None


def test_all_dependencies_fail() -> None:
container = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
failing_dependency1 = DockerContainer("nonexistent-image1")
failing_dependency2 = DockerContainer("nonexistent-image2")
container.depends_on([failing_dependency1, failing_dependency2])

with pytest.raises((APIError, ImageNotFound)):
container.start()

assert container._container is None
assert failing_dependency1._container is None
assert failing_dependency2._container is None


def test_dependency_cleanup_on_partial_failure() -> None:
container = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
dependency1 = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
failing_dependency = DockerContainer("nonexistent-image3")

container.depends_on([dependency1, failing_dependency])

with pytest.raises(Exception):
container.start()

assert dependency1._container is None, "dependency1 was not cleaned up properly"
assert failing_dependency._container is None, "failing_dependency was not cleaned up properly"
assert container._container is None, "container was not cleaned up properly"