Skip to content

Commit 5356caf

Browse files
authoredMar 6, 2024··
feat(compose)!: implement compose v2 with improved typing (#426)
relates to #306, #358
1 parent 87b5873 commit 5356caf

File tree

7 files changed

+713
-0
lines changed

7 files changed

+713
-0
lines changed
 
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# flake8: noqa
2+
from testcontainers.compose.compose import (
3+
ContainerIsNotRunning,
4+
NoSuchPortExposed,
5+
PublishedPort,
6+
ComposeContainer,
7+
DockerCompose,
8+
)
+406
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,406 @@
1+
import subprocess
2+
from dataclasses import dataclass, field, fields
3+
from functools import cached_property
4+
from json import loads
5+
from os import PathLike
6+
from re import split
7+
from typing import Callable, Literal, Optional, TypeVar, Union
8+
from urllib.error import HTTPError, URLError
9+
from urllib.request import urlopen
10+
11+
from testcontainers.core.exceptions import ContainerIsNotRunning, NoSuchPortExposed
12+
from testcontainers.core.waiting_utils import wait_container_is_ready
13+
14+
_IPT = TypeVar("_IPT")
15+
16+
17+
def _ignore_properties(cls: type[_IPT], dict_: any) -> _IPT:
18+
"""omits extra fields like @JsonIgnoreProperties(ignoreUnknown = true)
19+
20+
https://gist.github.com/alexanderankin/2a4549ac03554a31bef6eaaf2eaf7fd5"""
21+
if isinstance(dict_, cls):
22+
return dict_
23+
class_fields = {f.name for f in fields(cls)}
24+
filtered = {k: v for k, v in dict_.items() if k in class_fields}
25+
return cls(**filtered)
26+
27+
28+
@dataclass
29+
class PublishedPort:
30+
"""
31+
Class that represents the response we get from compose when inquiring status
32+
via `DockerCompose.get_running_containers()`.
33+
"""
34+
35+
URL: Optional[str] = None
36+
TargetPort: Optional[str] = None
37+
PublishedPort: Optional[str] = None
38+
Protocol: Optional[str] = None
39+
40+
41+
OT = TypeVar("OT")
42+
43+
44+
def get_only_element_or_raise(array: list[OT], exception: Callable[[], Exception]) -> OT:
45+
if len(array) != 1:
46+
e = exception()
47+
raise e
48+
return array[0]
49+
50+
51+
@dataclass
52+
class ComposeContainer:
53+
"""
54+
A container class that represents a container managed by compose.
55+
It is not a true testcontainers.core.container.DockerContainer,
56+
but you can use the id with DockerClient to get that one too.
57+
"""
58+
59+
ID: Optional[str] = None
60+
Name: Optional[str] = None
61+
Command: Optional[str] = None
62+
Project: Optional[str] = None
63+
Service: Optional[str] = None
64+
State: Optional[str] = None
65+
Health: Optional[str] = None
66+
ExitCode: Optional[str] = None
67+
Publishers: list[PublishedPort] = field(default_factory=list)
68+
69+
def __post_init__(self):
70+
if self.Publishers:
71+
self.Publishers = [_ignore_properties(PublishedPort, p) for p in self.Publishers]
72+
73+
def get_publisher(
74+
self,
75+
by_port: Optional[int] = None,
76+
by_host: Optional[str] = None,
77+
prefer_ip_version: Literal["IPV4", "IPv6"] = "IPv4",
78+
) -> PublishedPort:
79+
remaining_publishers = self.Publishers
80+
81+
remaining_publishers = [r for r in remaining_publishers if self._matches_protocol(prefer_ip_version, r)]
82+
83+
if by_port:
84+
remaining_publishers = [item for item in remaining_publishers if by_port == item.TargetPort]
85+
if by_host:
86+
remaining_publishers = [item for item in remaining_publishers if by_host == item.URL]
87+
if len(remaining_publishers) == 0:
88+
raise NoSuchPortExposed(f"Could not find publisher for for service {self.Service}")
89+
return get_only_element_or_raise(
90+
remaining_publishers,
91+
lambda: NoSuchPortExposed(
92+
"get_publisher failed because there is "
93+
f"not exactly 1 publisher for service {self.Service}"
94+
f" when filtering by_port={by_port}, by_host={by_host}"
95+
f" (but {len(remaining_publishers)})"
96+
),
97+
)
98+
99+
@staticmethod
100+
def _matches_protocol(prefer_ip_version, r):
101+
return (":" in r.URL) is (prefer_ip_version == "IPv6")
102+
103+
104+
@dataclass
105+
class DockerCompose:
106+
"""
107+
Manage docker compose environments.
108+
109+
Args:
110+
context:
111+
The docker context. It corresponds to the directory containing
112+
the docker compose configuration file.
113+
compose_file_name:
114+
Optional. File name of the docker compose configuration file.
115+
If specified, you need to also specify the overrides if any.
116+
pull:
117+
Pull images before launching environment.
118+
build:
119+
Run `docker compose build` before running the environment.
120+
wait:
121+
Wait for the services to be healthy
122+
(as per healthcheck definitions in the docker compose configuration)
123+
env_file:
124+
Path to an '.env' file containing environment variables
125+
to pass to docker compose.
126+
services:
127+
The list of services to use from this DockerCompose.
128+
client_args:
129+
arguments to pass to docker.from_env()
130+
131+
Example:
132+
133+
This example spins up chrome and firefox containers using docker compose.
134+
135+
.. doctest::
136+
137+
>>> from testcontainers.compose import DockerCompose
138+
139+
>>> compose = DockerCompose("compose/tests", compose_file_name="docker-compose-4.yml",
140+
... pull=True)
141+
>>> with compose:
142+
... stdout, stderr = compose.get_logs()
143+
>>> b"Hello from Docker!" in stdout
144+
True
145+
146+
.. code-block:: yaml
147+
148+
services:
149+
hello-world:
150+
image: "hello-world"
151+
"""
152+
153+
context: Union[str, PathLike]
154+
compose_file_name: Optional[Union[str, list[str]]] = None
155+
pull: bool = False
156+
build: bool = False
157+
wait: bool = True
158+
env_file: Optional[str] = None
159+
services: Optional[list[str]] = None
160+
161+
def __post_init__(self):
162+
if isinstance(self.compose_file_name, str):
163+
self.compose_file_name = [self.compose_file_name]
164+
165+
def __enter__(self) -> "DockerCompose":
166+
self.start()
167+
return self
168+
169+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
170+
self.stop()
171+
172+
def docker_compose_command(self) -> list[str]:
173+
"""
174+
Returns command parts used for the docker compose commands
175+
176+
Returns:
177+
cmd: Docker compose command parts.
178+
"""
179+
return self.compose_command_property
180+
181+
@cached_property
182+
def compose_command_property(self) -> list[str]:
183+
docker_compose_cmd = ["docker", "compose"]
184+
if self.compose_file_name:
185+
for file in self.compose_file_name:
186+
docker_compose_cmd += ["-f", file]
187+
if self.env_file:
188+
docker_compose_cmd += ["--env-file", self.env_file]
189+
return docker_compose_cmd
190+
191+
def start(self) -> None:
192+
"""
193+
Starts the docker compose environment.
194+
"""
195+
base_cmd = self.compose_command_property or []
196+
197+
# pull means running a separate command before starting
198+
if self.pull:
199+
pull_cmd = [*base_cmd, "pull"]
200+
self._call_command(cmd=pull_cmd)
201+
202+
up_cmd = [*base_cmd, "up"]
203+
204+
# build means modifying the up command
205+
if self.build:
206+
up_cmd.append("--build")
207+
208+
if self.wait:
209+
up_cmd.append("--wait")
210+
else:
211+
# we run in detached mode instead of blocking
212+
up_cmd.append("--detach")
213+
214+
if self.services:
215+
up_cmd.extend(self.services)
216+
217+
self._call_command(cmd=up_cmd)
218+
219+
def stop(self, down=True) -> None:
220+
"""
221+
Stops the docker compose environment.
222+
"""
223+
down_cmd = self.compose_command_property[:]
224+
if down:
225+
down_cmd += ["down", "--volumes"]
226+
else:
227+
down_cmd += ["stop"]
228+
self._call_command(cmd=down_cmd)
229+
230+
def get_logs(self, *services: str) -> tuple[str, str]:
231+
"""
232+
Returns all log output from stdout and stderr of a specific container.
233+
234+
:param services: which services to get the logs for (or omit, for all)
235+
236+
Returns:
237+
stdout: Standard output stream.
238+
stderr: Standard error stream.
239+
"""
240+
logs_cmd = [*self.compose_command_property, "logs", *services]
241+
242+
result = subprocess.run(
243+
logs_cmd,
244+
cwd=self.context,
245+
capture_output=True,
246+
)
247+
return result.stdout.decode("utf-8"), result.stderr.decode("utf-8")
248+
249+
def get_containers(self, include_all=False) -> list[ComposeContainer]:
250+
"""
251+
Fetch information about running containers via `docker compose ps --format json`.
252+
Available only in V2 of compose.
253+
254+
Returns:
255+
The list of running containers.
256+
257+
"""
258+
259+
cmd = [*self.compose_command_property, "ps", "--format", "json"]
260+
if include_all:
261+
cmd = [*cmd, "-a"]
262+
result = subprocess.run(cmd, cwd=self.context, check=True, stdout=subprocess.PIPE)
263+
stdout = split(r"\r?\n", result.stdout.decode("utf-8"))
264+
265+
containers = []
266+
# one line per service in docker 25, single array for docker 24.0.2
267+
for line in stdout:
268+
if not line:
269+
continue
270+
data = loads(line)
271+
if isinstance(data, list):
272+
containers += [_ignore_properties(ComposeContainer, d) for d in data]
273+
else:
274+
containers.append(_ignore_properties(ComposeContainer, data))
275+
276+
return containers
277+
278+
def get_container(
279+
self,
280+
service_name: Optional[str] = None,
281+
include_all: bool = False,
282+
) -> ComposeContainer:
283+
if not service_name:
284+
containers = self.get_containers(include_all=include_all)
285+
return get_only_element_or_raise(
286+
containers,
287+
lambda: ContainerIsNotRunning(
288+
"get_container failed because no service_name given "
289+
f"and there is not exactly 1 container (but {len(containers)})"
290+
),
291+
)
292+
293+
matching_containers = [
294+
item for item in self.get_containers(include_all=include_all) if item.Service == service_name
295+
]
296+
297+
if not matching_containers:
298+
raise ContainerIsNotRunning(f"{service_name} is not running in the compose context")
299+
300+
return matching_containers[0]
301+
302+
def exec_in_container(
303+
self,
304+
command: list[str],
305+
service_name: Optional[str] = None,
306+
) -> tuple[str, str, int]:
307+
"""
308+
Executes a command in the container of one of the services.
309+
310+
Args:
311+
service_name: Name of the docker compose service to run the command in.
312+
command: Command to execute.
313+
314+
:param service_name: specify the service name
315+
:param command: the command to run in the container
316+
317+
Returns:
318+
stdout: Standard output stream.
319+
stderr: Standard error stream.
320+
exit_code: The command's exit code.
321+
"""
322+
if not service_name:
323+
service_name = self.get_container().Service
324+
exec_cmd = [*self.compose_command_property, "exec", "-T", service_name, *command]
325+
result = subprocess.run(
326+
exec_cmd,
327+
cwd=self.context,
328+
capture_output=True,
329+
check=True,
330+
)
331+
332+
return (result.stdout.decode("utf-8"), result.stderr.decode("utf-8"), result.returncode)
333+
334+
def _call_command(
335+
self,
336+
cmd: Union[str, list[str]],
337+
context: Optional[str] = None,
338+
) -> None:
339+
context = context or self.context
340+
subprocess.call(cmd, cwd=context)
341+
342+
def get_service_port(
343+
self,
344+
service_name: Optional[str] = None,
345+
port: Optional[int] = None,
346+
):
347+
"""
348+
Returns the mapped port for one of the services.
349+
350+
Parameters
351+
----------
352+
service_name: str
353+
Name of the docker compose service
354+
port: int
355+
The internal port to get the mapping for
356+
357+
Returns
358+
-------
359+
str:
360+
The mapped port on the host
361+
"""
362+
return self.get_container(service_name).get_publisher(by_port=port).PublishedPort
363+
364+
def get_service_host(
365+
self,
366+
service_name: Optional[str] = None,
367+
port: Optional[int] = None,
368+
):
369+
"""
370+
Returns the host for one of the services.
371+
372+
Parameters
373+
----------
374+
service_name: str
375+
Name of the docker compose service
376+
port: int
377+
The internal port to get the host for
378+
379+
Returns
380+
-------
381+
str:
382+
The hostname for the service
383+
"""
384+
return self.get_container(service_name).get_publisher(by_port=port).URL
385+
386+
def get_service_host_and_port(
387+
self,
388+
service_name: Optional[str] = None,
389+
port: Optional[int] = None,
390+
):
391+
publisher = self.get_container(service_name).get_publisher(by_port=port)
392+
return publisher.URL, publisher.PublishedPort
393+
394+
@wait_container_is_ready(HTTPError, URLError)
395+
def wait_for(self, url: str) -> "DockerCompose":
396+
"""
397+
Waits for a response from a given URL. This is typically used to block until a service in
398+
the environment has started and is responding. Note that it does not assert any sort of
399+
return code, only check that the connection was successful.
400+
401+
Args:
402+
url: URL from one of the services in the environment to use to wait on.
403+
"""
404+
with urlopen(url) as response:
405+
response.read()
406+
return self

‎core/testcontainers/core/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,9 @@ class ContainerStartException(RuntimeError):
1616
pass
1717

1818

19+
class ContainerIsNotRunning(RuntimeError):
20+
pass
21+
22+
1923
class NoSuchPortExposed(RuntimeError):
2024
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: '3.0'
2+
3+
services:
4+
alpine:
5+
image: alpine:latest
6+
init: true
7+
command:
8+
- sh
9+
- -c
10+
- 'while true; do sleep 0.1 ; date -Ins; done'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
version: '3.0'
2+
3+
services:
4+
alpine:
5+
image: nginx:alpine-slim
6+
init: true
7+
ports:
8+
- '81'
9+
- '82'
10+
- target: 80
11+
host_ip: 127.0.0.1
12+
protocol: tcp
13+
command:
14+
- sh
15+
- -c
16+
- 'd=/etc/nginx/conf.d; echo "server { listen 81; location / { return 202; } }" > $$d/81.conf && echo "server { listen 82; location / { return 204; } }" > $$d/82.conf && nginx -g "daemon off;"'
17+
18+
alpine2:
19+
image: nginx:alpine-slim
20+
init: true
21+
ports:
22+
- target: 80
23+
host_ip: 127.0.0.1
24+
protocol: tcp
25+
command:
26+
- sh
27+
- -c
28+
- 'd=/etc/nginx/conf.d; echo "server { listen 81; location / { return 202; } }" > $$d/81.conf && echo "server { listen 82; location / { return 204; } }" > $$d/82.conf && nginx -g "daemon off;"'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
version: '3.0'
2+
3+
services:
4+
alpine:
5+
image: nginx:alpine-slim
6+
init: true
7+
ports:
8+
- target: 80
9+
host_ip: 127.0.0.1
10+
protocol: tcp
11+
command:
12+
- sh
13+
- -c
14+
- 'nginx -g "daemon off;"'

‎core/tests/test_compose.py

+243
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
from pathlib import Path
2+
from re import split
3+
from time import sleep
4+
from typing import Union
5+
from urllib.request import urlopen, Request
6+
7+
import pytest
8+
9+
from testcontainers.compose import DockerCompose, ContainerIsNotRunning, NoSuchPortExposed
10+
11+
FIXTURES = Path(__file__).parent.joinpath("compose_fixtures")
12+
13+
14+
def test_compose_no_file_name():
15+
basic = DockerCompose(context=FIXTURES / "basic")
16+
assert basic.compose_file_name is None
17+
18+
19+
def test_compose_str_file_name():
20+
basic = DockerCompose(context=FIXTURES / "basic", compose_file_name="docker-compose.yaml")
21+
assert basic.compose_file_name == ["docker-compose.yaml"]
22+
23+
24+
def test_compose_list_file_name():
25+
basic = DockerCompose(context=FIXTURES / "basic", compose_file_name=["docker-compose.yaml"])
26+
assert basic.compose_file_name == ["docker-compose.yaml"]
27+
28+
29+
def test_compose_stop():
30+
basic = DockerCompose(context=FIXTURES / "basic")
31+
basic.stop()
32+
33+
34+
def test_compose_start_stop():
35+
basic = DockerCompose(context=FIXTURES / "basic")
36+
basic.start()
37+
basic.stop()
38+
39+
40+
def test_compose():
41+
"""stream-of-consciousness e2e test"""
42+
basic = DockerCompose(context=FIXTURES / "basic")
43+
try:
44+
# first it does not exist
45+
containers = basic.get_containers(include_all=True)
46+
assert len(containers) == 0
47+
48+
# then we create it and it exists
49+
basic.start()
50+
containers = basic.get_containers(include_all=True)
51+
assert len(containers) == 1
52+
containers = basic.get_containers()
53+
assert len(containers) == 1
54+
55+
# test that get_container returns the same object, value assertions, etc
56+
from_all = containers[0]
57+
assert from_all.State == "running"
58+
assert from_all.Service == "alpine"
59+
60+
by_name = basic.get_container("alpine")
61+
62+
assert by_name.Name == from_all.Name
63+
assert by_name.Service == from_all.Service
64+
assert by_name.State == from_all.State
65+
assert by_name.ID == from_all.ID
66+
67+
assert by_name.ExitCode == 0
68+
69+
# what if you want to get logs after it crashes:
70+
basic.stop(down=False)
71+
72+
with pytest.raises(ContainerIsNotRunning):
73+
assert basic.get_container("alpine") is None
74+
75+
# what it looks like after it exits
76+
stopped = basic.get_container("alpine", include_all=True)
77+
assert stopped.State == "exited"
78+
finally:
79+
basic.stop()
80+
81+
82+
def test_compose_logs():
83+
basic = DockerCompose(context=FIXTURES / "basic")
84+
with basic:
85+
sleep(1) # generate some logs every 200ms
86+
stdout, stderr = basic.get_logs()
87+
container = basic.get_container()
88+
89+
assert not stderr
90+
assert stdout
91+
lines = split(r"\r?\n", stdout)
92+
93+
assert len(lines) > 5 # actually 10
94+
for line in lines[1:]:
95+
# either the line is blank or the first column (|-separated) contains the service name
96+
# this is a safe way to split the string
97+
# docker changes the prefix between versions 24 and 25
98+
assert not line or container.Service in next(iter(line.split("|")), None)
99+
100+
101+
# noinspection HttpUrlsUsage
102+
def test_compose_ports():
103+
# fairly straight forward - can we get the right port to request it
104+
single = DockerCompose(context=FIXTURES / "port_single")
105+
with single:
106+
host, port = single.get_service_host_and_port()
107+
endpoint = f"http://{host}:{port}"
108+
single.wait_for(endpoint)
109+
code, response = fetch(Request(method="GET", url=endpoint))
110+
assert code == 200
111+
assert "<h1>" in response
112+
113+
114+
# noinspection HttpUrlsUsage
115+
def test_compose_multiple_containers_and_ports():
116+
"""test for the logic encapsulated in 'one' function
117+
118+
assert correctness of multiple logic
119+
"""
120+
multiple = DockerCompose(context=FIXTURES / "port_multiple")
121+
with multiple:
122+
with pytest.raises(ContainerIsNotRunning) as e:
123+
multiple.get_container()
124+
e.match("get_container failed")
125+
e.match("not exactly 1 container")
126+
127+
assert multiple.get_container("alpine")
128+
assert multiple.get_container("alpine2")
129+
130+
a2p = multiple.get_service_port("alpine2")
131+
assert a2p > 0 # > 1024
132+
133+
with pytest.raises(NoSuchPortExposed) as e:
134+
multiple.get_service_port("alpine")
135+
e.match("not exactly 1")
136+
with pytest.raises(NoSuchPortExposed) as e:
137+
multiple.get_container("alpine").get_publisher(by_host="example.com")
138+
e.match("not exactly 1")
139+
with pytest.raises(NoSuchPortExposed) as e:
140+
multiple.get_container("alpine").get_publisher(by_host="localhost")
141+
e.match("not exactly 1")
142+
143+
try:
144+
# this fails when ipv6 is enabled and docker is forwarding for both 4 + 6
145+
multiple.get_container(service_name="alpine").get_publisher(by_port=81, prefer_ip_version="IPv6")
146+
except: # noqa
147+
pass
148+
149+
ports = [
150+
(
151+
80,
152+
multiple.get_service_host(service_name="alpine", port=80),
153+
multiple.get_service_port(service_name="alpine", port=80),
154+
),
155+
(
156+
81,
157+
multiple.get_service_host(service_name="alpine", port=81),
158+
multiple.get_service_port(service_name="alpine", port=81),
159+
),
160+
(
161+
82,
162+
multiple.get_service_host(service_name="alpine", port=82),
163+
multiple.get_service_port(service_name="alpine", port=82),
164+
),
165+
]
166+
167+
# test correctness of port lookup
168+
for target, host, mapped in ports:
169+
assert mapped, f"we have a mapped port for target port {target}"
170+
url = f"http://{host}:{mapped}"
171+
code, body = fetch(Request(method="GET", url=url))
172+
173+
expected_code = {
174+
80: 200,
175+
81: 202,
176+
82: 204,
177+
}.get(code, None)
178+
179+
if not expected_code:
180+
continue
181+
182+
message = f"response '{body}' ({code}) from url {url} should have code {expected_code}"
183+
assert code == expected_code, message
184+
185+
186+
# noinspection HttpUrlsUsage
187+
def test_exec_in_container():
188+
"""we test that we can manipulate a container via exec"""
189+
single = DockerCompose(context=FIXTURES / "port_single")
190+
with single:
191+
url = f"http://{single.get_service_host()}:{single.get_service_port()}"
192+
single.wait_for(url)
193+
194+
# unchanged
195+
code, body = fetch(url)
196+
assert code == 200
197+
assert "test_exec_in_container" not in body
198+
199+
# change it
200+
single.exec_in_container(
201+
command=["sh", "-c", 'echo "test_exec_in_container" > /usr/share/nginx/html/index.html']
202+
)
203+
204+
# and it is changed
205+
code, body = fetch(url)
206+
assert code == 200
207+
assert "test_exec_in_container" in body
208+
209+
210+
# noinspection HttpUrlsUsage
211+
def test_exec_in_container_multiple():
212+
"""same as above, except we exec into a particular service"""
213+
multiple = DockerCompose(context=FIXTURES / "port_multiple")
214+
with multiple:
215+
sn = "alpine2" # service name
216+
host, port = multiple.get_service_host_and_port(service_name=sn)
217+
url = f"http://{host}:{port}"
218+
multiple.wait_for(url)
219+
220+
# unchanged
221+
code, body = fetch(url)
222+
assert code == 200
223+
assert "test_exec_in_container" not in body
224+
225+
# change it
226+
multiple.exec_in_container(
227+
command=["sh", "-c", 'echo "test_exec_in_container" > /usr/share/nginx/html/index.html'], service_name=sn
228+
)
229+
230+
# and it is changed
231+
code, body = fetch(url)
232+
assert code == 200
233+
assert "test_exec_in_container" in body
234+
235+
236+
def fetch(req: Union[Request, str]):
237+
if isinstance(req, str):
238+
req = Request(method="GET", url=req)
239+
with urlopen(req) as res:
240+
body = res.read().decode("utf-8")
241+
if 200 < res.getcode() >= 400:
242+
raise Exception(f"HTTP Error: {res.getcode()} - {res.reason}: {body}")
243+
return res.getcode(), body

0 commit comments

Comments
 (0)
Please sign in to comment.