Skip to content

Commit 029423a

Browse files
committed
recover compose container
1 parent 386521f commit 029423a

9 files changed

+384
-0
lines changed

compose/README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.. autoclass:: testcontainers.compose.DockerCompose

compose/setup.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from setuptools import setup, find_namespace_packages
2+
3+
description = "Docker Compose component of testcontainers-python."
4+
5+
setup(
6+
name="testcontainers-compose",
7+
version="0.0.1rc1",
8+
packages=find_namespace_packages(),
9+
description=description,
10+
long_description=description,
11+
long_description_content_type="text/x-rst",
12+
url="https://github.com/testcontainers/testcontainers-python",
13+
install_requires=[
14+
"testcontainers-core",
15+
],
16+
python_requires=">=3.7",
17+
)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import subprocess
2+
from typing import Iterable, List, Optional, Tuple, Union
3+
4+
import requests
5+
6+
from testcontainers.core.exceptions import NoSuchPortExposed
7+
from testcontainers.core.waiting_utils import wait_container_is_ready
8+
9+
10+
class DockerCompose:
11+
"""
12+
Manage docker compose environments.
13+
14+
Args:
15+
filepath: Relative directory containing the docker compose configuration file.
16+
compose_file_name: File name of the docker compose configuration file.
17+
pull: Pull images before launching environment.
18+
build: Build images referenced in the configuration file.
19+
env_file: Path to an env file containing environment variables to pass to docker compose.
20+
services: List of services to start.
21+
22+
Example:
23+
24+
This example spins up chrome and firefox containers using docker compose.
25+
26+
.. doctest::
27+
28+
>>> from testcontainers.compose import DockerCompose
29+
30+
>>> compose = DockerCompose("compose/tests", compose_file_name="docker-compose-4.yml",
31+
... pull=True)
32+
>>> with compose:
33+
... stdout, stderr = compose.get_logs()
34+
>>> b"Hello from Docker!" in stdout
35+
True
36+
37+
.. code-block:: yaml
38+
39+
services:
40+
hello-world:
41+
image: "hello-world"
42+
"""
43+
44+
def __init__(
45+
self,
46+
filepath: str,
47+
compose_file_name: Union[str, Iterable] = "docker-compose.yml",
48+
pull: bool = False,
49+
build: bool = False,
50+
env_file: Optional[str] = None,
51+
services: Optional[List[str]] = None,
52+
) -> None:
53+
self.filepath = filepath
54+
self.compose_file_names = (
55+
[compose_file_name]
56+
if isinstance(compose_file_name, str)
57+
else list(compose_file_name)
58+
)
59+
self.pull = pull
60+
self.build = build
61+
self.env_file = env_file
62+
self.services = services
63+
64+
def __enter__(self) -> "DockerCompose":
65+
self.start()
66+
return self
67+
68+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
69+
self.stop()
70+
71+
def docker_compose_command(self) -> List[str]:
72+
"""
73+
Returns command parts used for the docker compose commands
74+
75+
Returns:
76+
cmd: Docker compose command parts.
77+
"""
78+
docker_compose_cmd = ["docker-compose"]
79+
for file in self.compose_file_names:
80+
docker_compose_cmd += ["-f", file]
81+
if self.env_file:
82+
docker_compose_cmd += ["--env-file", self.env_file]
83+
return docker_compose_cmd
84+
85+
def start(self) -> None:
86+
"""
87+
Starts the docker compose environment.
88+
"""
89+
if self.pull:
90+
pull_cmd = self.docker_compose_command() + ["pull"]
91+
self._call_command(cmd=pull_cmd)
92+
93+
up_cmd = self.docker_compose_command() + ["up", "-d"]
94+
if self.build:
95+
up_cmd.append("--build")
96+
if self.services:
97+
up_cmd.extend(self.services)
98+
99+
self._call_command(cmd=up_cmd)
100+
101+
def stop(self) -> None:
102+
"""
103+
Stops the docker compose environment.
104+
"""
105+
down_cmd = self.docker_compose_command() + ["down", "-v"]
106+
self._call_command(cmd=down_cmd)
107+
108+
def get_logs(self) -> Tuple[bytes, bytes]:
109+
"""
110+
Returns all log output from stdout and stderr
111+
112+
Returns:
113+
stdout: Standard output stream.
114+
stderr: Standard error stream.
115+
"""
116+
logs_cmd = self.docker_compose_command() + ["logs"]
117+
result = subprocess.run(
118+
logs_cmd,
119+
cwd=self.filepath,
120+
stdout=subprocess.PIPE,
121+
stderr=subprocess.PIPE,
122+
)
123+
return result.stdout, result.stderr
124+
125+
def exec_in_container(
126+
self, service_name: str, command: List[str]
127+
) -> Tuple[str, str, int]:
128+
"""
129+
Executes a command in the container of one of the services.
130+
131+
Args:
132+
service_name: Name of the docker compose service to run the command in.
133+
command: Command to execute.
134+
135+
Returns:
136+
stdout: Standard output stream.
137+
stderr: Standard error stream.
138+
"""
139+
exec_cmd = (
140+
self.docker_compose_command() + ["exec", "-T", service_name] + command
141+
)
142+
result = subprocess.run(
143+
exec_cmd,
144+
cwd=self.filepath,
145+
stdout=subprocess.PIPE,
146+
stderr=subprocess.PIPE,
147+
)
148+
return (
149+
result.stdout.decode("utf-8"),
150+
result.stderr.decode("utf-8"),
151+
result.returncode,
152+
)
153+
154+
def get_service_port(self, service_name: str, port: int) -> int:
155+
"""
156+
Returns the mapped port for one of the services.
157+
158+
Args:
159+
service_name: Name of the docker compose service.
160+
port: Internal port to get the mapping for.
161+
162+
Returns:
163+
mapped_port: Mapped port on the host.
164+
"""
165+
return self._get_service_info(service_name, port)[1]
166+
167+
def get_service_host(self, service_name: str, port: int) -> str:
168+
"""
169+
Returns the host for one of the services.
170+
171+
Args:
172+
service_name: Name of the docker compose service.
173+
port: Internal port to get the mapping for.
174+
175+
Returns:
176+
host: Hostname for the service.
177+
"""
178+
return self._get_service_info(service_name, port)[0]
179+
180+
def _get_service_info(self, service: str, port: int) -> List[str]:
181+
port_cmd = self.docker_compose_command() + ["port", service, str(port)]
182+
output = subprocess.check_output(port_cmd, cwd=self.filepath).decode("utf-8")
183+
result = str(output).rstrip().split(":")
184+
if len(result) != 2 or not all(result):
185+
raise NoSuchPortExposed(f"port {port} is not exposed for service {service}")
186+
return result
187+
188+
def _call_command(
189+
self, cmd: Union[str, List[str]], filepath: Optional[str] = None
190+
) -> None:
191+
if filepath is None:
192+
filepath = self.filepath
193+
subprocess.call(cmd, cwd=filepath)
194+
195+
@wait_container_is_ready(requests.exceptions.ConnectionError)
196+
def wait_for(self, url: str) -> "DockerCompose":
197+
"""
198+
Waits for a response from a given URL. This is typically used to block until a service in
199+
the environment has started and is responding. Note that it does not assert any sort of
200+
return code, only check that the connection was successful.
201+
202+
Args:
203+
url: URL from one of the services in the environment to use to wait on.
204+
"""
205+
requests.get(url)
206+
return self

compose/tests/.env.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
TAG_TEST_ASSERT_KEY="test_has_passed"

compose/tests/docker-compose-2.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
services:
2+
alpine:
3+
image: alpine
4+
command: sleep 3600
5+
ports:
6+
- "3306:3306"

compose/tests/docker-compose-3.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
services:
2+
alpine:
3+
image: alpine
4+
command: sleep 3600
5+
ports:
6+
- "3306:3306"
7+
environment:
8+
TEST_ASSERT_KEY: ${TAG_TEST_ASSERT_KEY}

compose/tests/docker-compose-4.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
services:
2+
hello-world:
3+
image: "hello-world"

compose/tests/docker-compose.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
services:
2+
hub:
3+
image: selenium/hub
4+
ports:
5+
- "4444:4444"
6+
firefox:
7+
image: selenium/node-firefox
8+
links:
9+
- hub
10+
expose:
11+
- "5555"
12+
chrome:
13+
image: selenium/node-chrome
14+
links:
15+
- hub
16+
expose:
17+
- "5555"

compose/tests/test_docker_compose.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import os
2+
from unittest.mock import patch
3+
4+
import pytest
5+
6+
from testcontainers.compose import DockerCompose
7+
from testcontainers.core.docker_client import DockerClient
8+
from testcontainers.core.exceptions import NoSuchPortExposed
9+
from testcontainers.core.waiting_utils import wait_for_logs
10+
11+
ROOT = os.path.dirname(__file__)
12+
13+
14+
def test_can_spawn_service_via_compose():
15+
with DockerCompose(ROOT) as compose:
16+
host = compose.get_service_host("hub", 4444)
17+
port = compose.get_service_port("hub", 4444)
18+
assert host == "0.0.0.0"
19+
assert port == "4444"
20+
21+
22+
def test_can_pull_images_before_spawning_service_via_compose():
23+
with DockerCompose(ROOT, pull=True) as compose:
24+
host = compose.get_service_host("hub", 4444)
25+
port = compose.get_service_port("hub", 4444)
26+
assert host == "0.0.0.0"
27+
assert port == "4444"
28+
29+
30+
def test_can_build_images_before_spawning_service_via_compose():
31+
with patch.object(DockerCompose, "_call_command") as call_mock:
32+
with DockerCompose(ROOT, build=True) as compose:
33+
...
34+
35+
assert compose.build
36+
docker_compose_cmd = call_mock.call_args_list[0][1]["cmd"]
37+
assert "docker-compose" in docker_compose_cmd
38+
assert "up" in docker_compose_cmd
39+
assert "--build" in docker_compose_cmd
40+
41+
42+
def test_can_specify_services():
43+
with patch.object(DockerCompose, "_call_command") as call_mock:
44+
with DockerCompose(ROOT, services=["hub", "firefox"]) as compose:
45+
...
46+
47+
assert compose.services
48+
docker_compose_cmd = call_mock.call_args_list[0][1]["cmd"]
49+
services_at_the_end = docker_compose_cmd[-2:]
50+
assert "firefox" in services_at_the_end
51+
assert "hub" in services_at_the_end
52+
assert "chrome" not in docker_compose_cmd
53+
54+
55+
@pytest.mark.parametrize("should_run_hub", [
56+
[True],
57+
[False],
58+
])
59+
def test_can_run_specific_services(should_run_hub: bool):
60+
# compose V2 will improve this test by being able to assert that "firefox" also started/exited
61+
services = ["firefox"]
62+
if should_run_hub:
63+
services.append("hub")
64+
65+
with DockerCompose(ROOT, services=services) as compose:
66+
if should_run_hub:
67+
assert compose.get_service_host("hub", 4444)
68+
assert compose.get_service_port("hub", 4444)
69+
else:
70+
with pytest.raises(NoSuchPortExposed):
71+
assert compose.get_service_host("hub", 4444)
72+
73+
74+
def test_can_throw_exception_if_no_port_exposed():
75+
with DockerCompose(ROOT) as compose:
76+
with pytest.raises(NoSuchPortExposed):
77+
compose.get_service_host("hub", 5555)
78+
79+
80+
def test_compose_wait_for_container_ready():
81+
with DockerCompose(ROOT) as compose:
82+
docker = DockerClient()
83+
compose.wait_for("http://%s:4444/wd/hub" % docker.host())
84+
85+
86+
def test_compose_can_wait_for_logs():
87+
with DockerCompose(filepath=ROOT, compose_file_name="docker-compose-4.yml") as compose:
88+
wait_for_logs(compose, "Hello from Docker!")
89+
90+
91+
def test_can_parse_multiple_compose_files():
92+
with DockerCompose(filepath=ROOT,
93+
compose_file_name=["docker-compose.yml", "docker-compose-2.yml"]) as compose:
94+
host = compose.get_service_host("alpine", 3306)
95+
port = compose.get_service_port("alpine", 3306)
96+
assert host == "0.0.0.0"
97+
assert port == "3306"
98+
99+
host = compose.get_service_host("hub", 4444)
100+
port = compose.get_service_port("hub", 4444)
101+
assert host == "0.0.0.0"
102+
assert port == "4444"
103+
104+
105+
def test_can_get_logs():
106+
with DockerCompose(ROOT) as compose:
107+
docker = DockerClient()
108+
compose.wait_for("http://%s:4444/wd/hub" % docker.host())
109+
stdout, stderr = compose.get_logs()
110+
assert stdout, 'There should be something on stdout'
111+
112+
113+
def test_can_pass_env_params_by_env_file():
114+
with DockerCompose(ROOT, compose_file_name='docker-compose-3.yml',
115+
env_file='.env.test') as compose:
116+
stdout, *_ = compose.exec_in_container("alpine", ["printenv"])
117+
assert stdout.splitlines()[0], 'test_has_passed'
118+
119+
120+
def test_can_exec_commands():
121+
with DockerCompose(ROOT) as compose:
122+
result = compose.exec_in_container('hub', ['echo', 'my_test'])
123+
assert result[0] == 'my_test\n', "The echo should be successful"
124+
assert result[1] == '', "stderr should be empty"
125+
assert result[2] == 0, 'The exit code should be successful'

0 commit comments

Comments
 (0)