Skip to content
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

feat : Add support for depends_on functionality #728

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
135 changes: 133 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,87 @@ 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")

# Check for any circular dependencies before starting
self.check_for_circular_dependencies()

return self

def _start_dependencies(self, started_dependencies=None) -> 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.
"""
if started_dependencies is None:
started_dependencies = []

for dependency in self._dependencies:
if not dependency._container:
try:
container_name = dependency._name if dependency._name else dependency.image
logger.info(f"Starting dependency container: {container_name}")

# Start sub-dependencies recursively
dependency._start_dependencies(started_dependencies)

# Start the actual dependency
dependency.start()
started_dependencies.append(dependency)

logger.info(
f"Dependency container started: {container_name}, "
f"ID: {dependency._container.short_id}, Name: {dependency._container.name}"
)

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

except Exception as e:
logger.error(f"Failed to start dependency {container_name}: {e}")
logger.info("Cleaning up previously started dependencies...")

# Clean up all previously started dependencies
for dep in started_dependencies:
dep_name = dep._name if dep._name else dep.image
try:
dep.stop()
logger.info(
f"Successfully stopped dependency container: {dep_name}, "
f"ID: {dep._container.short_id}"
)
except Exception as stop_error:
logger.error(f"Error stopping dependency container {dep_name}: {stop_error}")

# Re-raise the original exception after cleanup
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 +178,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 +196,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 +209,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 Expand Up @@ -176,6 +281,32 @@ def exec(self, command: Union[str, list[str]]) -> tuple[int, bytes]:
raise ContainerStartException("Container should be started before executing a command")
return self._container.exec_run(command)

def check_for_circular_dependencies(self) -> None:
"""
Check for circular dependencies before starting containers.

Raises:
ContainerStartException: If a circular dependency is detected.
"""
visited = set()
current_path = set()

def dfs(container: "DockerContainer"):
if container in current_path:
raise ContainerStartException(f"Circular dependency detected for container: {container.image}")
if container in visited:
return

current_path.add(container)
visited.add(container)

for dependency in container._dependencies:
dfs(dependency)

current_path.remove(container)

dfs(self)

def _configure(self) -> None:
# placeholder if subclasses want to define this and use the default start method
pass
Expand Down
181 changes: 181 additions & 0 deletions core/tests/test_container_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import pytest
from docker.errors import APIError, ImageNotFound
from testcontainers.core.container import DockerContainer
from testcontainers.core.exceptions import ContainerStartException


def test_single_dependency_starts() -> None:
"""
Test that a container with a single dependency starts correctly.
"""
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:
"""
Test that a container with multiple dependencies starts correctly.
"""
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])

container.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"
assert container.wait_until_running(), "Container did not reach running state"

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


def test_dependency_failure() -> None:
"""
Test that the container fails to start if a dependency fails to start.
"""
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, "Container should not start if dependency fails"


def test_all_dependencies_fail() -> None:
"""
Test that the container fails to start if all dependencies fail.
"""
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:
"""
Test that all started dependencies are stopped if one of them fails.
"""
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"


def test_circular_dependency_detection() -> None:
"""
Test that adding a circular dependency raises a ContainerStartException.
"""
container_a = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
container_b = DockerContainer("alpine:latest").with_command("tail -f /dev/null")

# Add dependency from A to B
container_a.depends_on(container_b)

with pytest.raises(ContainerStartException, match="Circular dependency detected"):
container_b.depends_on(container_a)


def test_multi_level_circular_dependency_detection() -> None:
"""
Test that a multi-level circular dependency raises a ContainerStartException.
"""
container_a = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
container_b = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
container_c = DockerContainer("alpine:latest").with_command("tail -f /dev/null")

# Step 1: A depends on B
container_a.depends_on(container_b)

# Step 2: B depends on C
container_b.depends_on(container_c)

# Step 3: Adding the circular dependency: C depends on A
with pytest.raises(ContainerStartException, match="Circular dependency detected"):
container_c.depends_on(container_a)


def test_complex_dependency_graph() -> None:
container_a = DockerContainer("alpine:latest").with_name("container_a").with_command("tail -f /dev/null")
container_b = DockerContainer("alpine:latest").with_name("container_b").with_command("tail -f /dev/null")
container_c = DockerContainer("alpine:latest").with_name("container_c").with_command("tail -f /dev/null")
container_d = DockerContainer("alpine:latest").with_name("container_d").with_command("tail -f /dev/null")
container_e = DockerContainer("alpine:latest").with_name("container_e").with_command("tail -f /dev/null")

# Dependency graph:
# A -> [B, C]
# B -> D
# C -> E
container_a.depends_on([container_b, container_c])
container_b.depends_on(container_d)
container_c.depends_on(container_e)

try:
container_a.start()
except Exception as e:
raise e

assert container_a.wait_until_running(), "Container A did not reach running state"
assert container_b.wait_until_running(), "Container B did not reach running state"
assert container_c.wait_until_running(), "Container C did not reach running state"
assert container_d.wait_until_running(), "Container D did not reach running state"
assert container_e.wait_until_running(), "Container E did not reach running state"

# Cleanup
container_a.stop()
container_b.stop()
container_c.stop()
container_d.stop()
container_e.stop()


def test_dependency_cleanup_on_complex_failure() -> None:
"""
Test that all dependencies are cleaned up in a complex graph if one fails.
"""
container_a = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
container_b = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
container_c = DockerContainer("alpine:latest").with_command("tail -f /dev/null")
failing_container = DockerContainer("nonexistent-image")

# Dependency graph:
# A -> [B, C]
# C -> Failing
container_a.depends_on([container_b, container_c])
container_c.depends_on(failing_container)

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

assert container_b._container is None, "Container B was not cleaned up properly"
assert container_c._container is None, "Container C was not cleaned up properly"
assert failing_container._container is None, "Failing container was not cleaned up properly"
assert container_a._container is None, "Container A was not cleaned up properly"