Skip to content

Commit

Permalink
propose compose implementation re: #306, #358
Browse files Browse the repository at this point in the history
  • Loading branch information
alexanderankin committed Feb 20, 2024
1 parent 6bad02a commit b8dc0cf
Show file tree
Hide file tree
Showing 6 changed files with 298 additions and 36 deletions.
2 changes: 1 addition & 1 deletion core/testcontainers/compose/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from testcontainers.compose.compose import (
ContainerIsNotRunning,
PortIsNotExposed,
NoSuchPortExposed,
PublishedPort,
ComposeContainer,
DockerCompose
Expand Down
136 changes: 104 additions & 32 deletions core/testcontainers/compose/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from functools import cached_property
from json import loads
from os import PathLike
from typing import List, Optional, Tuple, Union
from re import split
from typing import Callable, List, Optional, Tuple, Type, TypeVar, Union

from typing import TypeVar, Type
from testcontainers.core.exceptions import NoSuchPortExposed, ContainerIsNotRunning

_IPT = TypeVar('_IPT')

Expand All @@ -20,14 +21,6 @@ def _ignore_properties(cls: Type[_IPT], dict_: any) -> _IPT:
return cls(**filtered)


class ContainerIsNotRunning(RuntimeError):
pass


class PortIsNotExposed(RuntimeError):
pass


@dataclass
class PublishedPort:
"""
Expand All @@ -40,6 +33,16 @@ class PublishedPort:
Protocol: Optional[str] = None


OT = TypeVar('OT')


def one(array: List[OT], exception: Callable[[], Exception]) -> OT:
if len(array) != 1:
e = exception()
raise e
return array[0]


@dataclass
class ComposeContainer:
"""
Expand All @@ -63,9 +66,6 @@ def __post_init__(self):
_ignore_properties(PublishedPort, p) for p in self.Publishers
]

# TODO: you can ask testcontainers.core.docker_client.DockerClient.get_container(self, id: str)
# to get you a testcontainer instance which then can stop/restart the instance individually

def get_publisher(
self,
by_port: Optional[int] = None,
Expand All @@ -84,9 +84,17 @@ def get_publisher(
if item.URL == by_host
]
if len(remaining_publishers) == 0:
raise PortIsNotExposed(
raise NoSuchPortExposed(
f"Could not find publisher for for service {self.Service}")
return remaining_publishers[0]
return one(
remaining_publishers,
lambda: NoSuchPortExposed(
'get_publisher failed because there is '
f'not exactly 1 publisher for service {self.Service}'
f' when filtering by_port={by_port}, by_host={by_host}'
f' (but {len(remaining_publishers)})'
)
)


@dataclass
Expand All @@ -113,6 +121,8 @@ class DockerCompose:
to pass to docker compose.
services:
The list of services to use from this DockerCompose.
client_args:
arguments to pass to docker.from_env()
Example:
Expand Down Expand Up @@ -155,14 +165,17 @@ def __enter__(self) -> "DockerCompose":
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.stop()

@cached_property
def docker_compose_command(self) -> List[str]:
"""
Returns command parts used for the docker compose commands
Returns:
cmd: Docker compose command parts.
"""
return self.compose_command_property

@cached_property
def compose_command_property(self) -> List[str]:
docker_compose_cmd = ['docker', 'compose']
if self.compose_file_name:
for file in self.compose_file_name:
Expand All @@ -175,7 +188,7 @@ def start(self) -> None:
"""
Starts the docker compose environment.
"""
base_cmd = self.docker_compose_command or []
base_cmd = self.compose_command_property or []

# pull means running a separate command before starting
if self.pull:
Expand Down Expand Up @@ -203,7 +216,7 @@ def stop(self, down=True) -> None:
"""
Stops the docker compose environment.
"""
down_cmd = self.docker_compose_command[:]
down_cmd = self.compose_command_property[:]
if down:
down_cmd += ['down', '--volumes']
else:
Expand All @@ -220,7 +233,7 @@ def get_logs(self, *services: str) -> Tuple[str, str]:
stdout: Standard output stream.
stderr: Standard error stream.
"""
logs_cmd = self.docker_compose_command + ["logs", *services]
logs_cmd = self.compose_command_property + ["logs", *services]

result = subprocess.run(
logs_cmd,
Expand All @@ -240,23 +253,28 @@ def get_containers(self, include_all=False) -> List[ComposeContainer]:
"""

cmd = self.docker_compose_command + ["ps", "--format", "json"]
cmd = self.compose_command_property + ["ps", "--format", "json"]
if include_all:
cmd += ["-a"]
result = subprocess.run(cmd, cwd=self.context, check=True, stdout=subprocess.PIPE)
stdout = result.stdout.decode("utf-8")
if not stdout:
return []
json_object = loads(stdout)

if not isinstance(json_object, list):
return [_ignore_properties(ComposeContainer, json_object)]
stdout = split(r'\r?\n', result.stdout.decode("utf-8"))

return [
_ignore_properties(ComposeContainer, item) for item in json_object
_ignore_properties(ComposeContainer, loads(line)) for line in stdout if line
]

def get_container(self, service_name: str, include_all=False) -> ComposeContainer:
def get_container(
self,
service_name: Optional[str] = None,
include_all: bool = False
) -> ComposeContainer:
if not service_name:
c = self.get_containers(include_all=include_all)
return one(c, lambda: ContainerIsNotRunning(
'get_container failed because no service_name given '
f'and there is not exactly 1 container (but {len(c)})'
))

matching_containers = [
item for item in self.get_containers(include_all=include_all)
if item.Service == service_name
Expand All @@ -270,8 +288,8 @@ def get_container(self, service_name: str, include_all=False) -> ComposeContaine

def exec_in_container(
self,
service_name: str,
command: List[str]
command: List[str],
service_name: Optional[str] = None,
) -> Tuple[str, str, int]:
"""
Executes a command in the container of one of the services.
Expand All @@ -288,7 +306,9 @@ def exec_in_container(
stderr: Standard error stream.
exit_code: The command's exit code.
"""
exec_cmd = self.docker_compose_command + ['exec', '-T', service_name] + command
if not service_name:
service_name = self.get_container().Service
exec_cmd = self.compose_command_property + ['exec', '-T', service_name] + command
result = subprocess.run(
exec_cmd,
cwd=self.context,
Expand All @@ -307,3 +327,55 @@ def _call_command(self,
context: Optional[str] = None) -> None:
context = context or self.context
subprocess.call(cmd, cwd=context)

def get_service_port(
self,
service_name: Optional[str] = None,
port: Optional[int] = None
):
"""
Returns the mapped port for one of the services.
Parameters
----------
service_name: str
Name of the docker compose service
port: int
The internal port to get the mapping for
Returns
-------
str:
The mapped port on the host
"""
return self.get_container(service_name).get_publisher(by_port=port).PublishedPort

def get_service_host(
self,
service_name: Optional[str] = None,
port: Optional[int] = None
):
"""
Returns the host for one of the services.
Parameters
----------
service_name: str
Name of the docker compose service
port: int
The internal port to get the host for
Returns
-------
str:
The hostname for the service
"""
return self.get_container(service_name).get_publisher(by_port=port).URL

def get_service_host_and_port(
self,
service_name: Optional[str] = None,
port: Optional[int] = None
):
publisher = self.get_container(service_name).get_publisher(by_port=port)
return publisher.URL, publisher.PublishedPort
4 changes: 4 additions & 0 deletions core/testcontainers/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,9 @@ class ContainerStartException(RuntimeError):
pass


class ContainerIsNotRunning(RuntimeError):
pass


class NoSuchPortExposed(RuntimeError):
pass
28 changes: 28 additions & 0 deletions core/tests/compose_fixtures/port_multiple/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
version: '3.0'

services:
alpine:
image: nginx:alpine-slim
init: true
ports:
- '81'
- '82'
- target: 80
host_ip: 127.0.0.1
protocol: tcp
command:
- sh
- -c
- '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;"'

alpine2:
image: nginx:alpine-slim
init: true
ports:
- target: 80
host_ip: 127.0.0.1
protocol: tcp
command:
- sh
- -c
- '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;"'
14 changes: 14 additions & 0 deletions core/tests/compose_fixtures/port_single/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: '3.0'

services:
alpine:
image: nginx:alpine-slim
init: true
ports:
- target: 80
host_ip: 127.0.0.1
protocol: tcp
command:
- sh
- -c
- 'nginx -g "daemon off;"'
Loading

0 comments on commit b8dc0cf

Please sign in to comment.