Skip to content

Commit 9b13057

Browse files
jarlahclaude
andcommitted
Add Docker Compose support
Introduces Docker Compose file support via CLI subprocess, enabling multi-service test environments from docker-compose.yml files. New modules: - DockerCompose: builder struct for compose configuration - Compose.Cli: subprocess wrapper for docker compose commands - Compose.ComposeService: lightweight service representation - Compose.ComposeEnvironment: started state with service accessors Integration with existing GenServer (start_compose/stop_compose) and ExUnit (compose/3 macro) following established patterns. Supports per-service wait strategies by bridging to existing WaitStrategy protocol. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b7ee130 commit 9b13057

10 files changed

Lines changed: 1116 additions & 1 deletion

lib/compose/cli.ex

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
defmodule Testcontainers.Compose.Cli do
2+
@moduledoc """
3+
Subprocess wrapper for Docker Compose CLI interaction.
4+
"""
5+
6+
require Logger
7+
8+
alias Testcontainers.DockerCompose
9+
10+
@doc """
11+
Runs `docker compose up -d --wait` with the given compose configuration.
12+
"""
13+
def up(%DockerCompose{} = compose) do
14+
args = build_up_args(compose)
15+
16+
case execute(compose, args) do
17+
{_output, 0} -> :ok
18+
{output, exit_code} -> {:error, {:compose_up_failed, exit_code, output}}
19+
end
20+
end
21+
22+
@doc """
23+
Runs `docker compose down` with the given compose configuration.
24+
"""
25+
def down(%DockerCompose{} = compose) do
26+
args = build_down_args(compose)
27+
28+
case execute(compose, args) do
29+
{_output, 0} -> :ok
30+
{output, exit_code} -> {:error, {:compose_down_failed, exit_code, output}}
31+
end
32+
end
33+
34+
@doc """
35+
Runs `docker compose ps --format=json` and parses the output into a list of maps.
36+
"""
37+
def ps(%DockerCompose{} = compose) do
38+
args = build_ps_args(compose)
39+
40+
case execute(compose, args) do
41+
{output, 0} -> {:ok, parse_ps_output(output)}
42+
{output, exit_code} -> {:error, {:compose_ps_failed, exit_code, output}}
43+
end
44+
end
45+
46+
@doc """
47+
Runs `docker compose pull` with the given compose configuration.
48+
"""
49+
def pull(%DockerCompose{} = compose) do
50+
args = build_pull_args(compose)
51+
52+
case execute(compose, args) do
53+
{_output, 0} -> :ok
54+
{output, exit_code} -> {:error, {:compose_pull_failed, exit_code, output}}
55+
end
56+
end
57+
58+
@doc """
59+
Runs `docker compose logs <service>` and returns the output.
60+
"""
61+
def logs(%DockerCompose{} = compose, service_name) when is_binary(service_name) do
62+
args = build_logs_args(compose, service_name)
63+
64+
case execute(compose, args) do
65+
{output, 0} -> {:ok, output}
66+
{output, exit_code} -> {:error, {:compose_logs_failed, exit_code, output}}
67+
end
68+
end
69+
70+
# Command building functions - public for testability
71+
72+
@doc """
73+
Builds the argument list for `docker compose up`.
74+
"""
75+
def build_up_args(%DockerCompose{} = compose) do
76+
base_args(compose) ++ ["up", "-d", "--wait"] ++ build_args(compose) ++ compose.services
77+
end
78+
79+
@doc """
80+
Builds the argument list for `docker compose down`.
81+
"""
82+
def build_down_args(%DockerCompose{} = compose) do
83+
args = base_args(compose) ++ ["down"]
84+
85+
if compose.remove_volumes do
86+
args ++ ["-v"]
87+
else
88+
args
89+
end
90+
end
91+
92+
@doc """
93+
Builds the argument list for `docker compose ps`.
94+
"""
95+
def build_ps_args(%DockerCompose{} = compose) do
96+
base_args(compose) ++ ["ps", "--format=json"]
97+
end
98+
99+
@doc """
100+
Builds the argument list for `docker compose pull`.
101+
"""
102+
def build_pull_args(%DockerCompose{} = compose) do
103+
base_args(compose) ++ ["pull"]
104+
end
105+
106+
@doc """
107+
Builds the argument list for `docker compose logs`.
108+
"""
109+
def build_logs_args(%DockerCompose{} = compose, service_name) do
110+
base_args(compose) ++ ["logs", service_name]
111+
end
112+
113+
@doc """
114+
Parses the JSON output from `docker compose ps`.
115+
116+
Each line is a separate JSON object with fields like Service, ID, State, Publishers.
117+
"""
118+
def parse_ps_output(output) when is_binary(output) do
119+
output
120+
|> String.trim()
121+
|> String.split("\n", trim: true)
122+
|> Enum.flat_map(fn line ->
123+
case Jason.decode(line) do
124+
{:ok, %{} = parsed} ->
125+
[parsed]
126+
127+
{:ok, list} when is_list(list) ->
128+
list
129+
130+
{:error, _} ->
131+
[]
132+
end
133+
end)
134+
end
135+
136+
@doc """
137+
Parses the Publishers field from a `docker compose ps` JSON entry
138+
into a list of `{container_port, host_port}` tuples.
139+
"""
140+
def parse_publishers(nil), do: []
141+
def parse_publishers([]), do: []
142+
143+
def parse_publishers(publishers) when is_list(publishers) do
144+
publishers
145+
|> Enum.filter(fn pub ->
146+
published = Map.get(pub, "PublishedPort", 0)
147+
published != 0
148+
end)
149+
|> Enum.map(fn pub ->
150+
target = Map.get(pub, "TargetPort", 0)
151+
published = Map.get(pub, "PublishedPort", 0)
152+
{target, published}
153+
end)
154+
|> Enum.uniq()
155+
end
156+
157+
# Private functions
158+
159+
defp base_args(%DockerCompose{} = compose) do
160+
args = ["compose"]
161+
162+
args =
163+
if compose.project_name do
164+
args ++ ["-p", compose.project_name]
165+
else
166+
args
167+
end
168+
169+
args =
170+
Enum.reduce(compose.compose_files, args, fn file, acc ->
171+
acc ++ ["-f", file]
172+
end)
173+
174+
Enum.reduce(compose.profiles, args, fn profile, acc ->
175+
acc ++ ["--profile", profile]
176+
end)
177+
end
178+
179+
defp build_args(%DockerCompose{} = compose) do
180+
args = []
181+
182+
args =
183+
if compose.build do
184+
args ++ ["--build"]
185+
else
186+
args
187+
end
188+
189+
case compose.pull do
190+
:always -> args ++ ["--pull", "always"]
191+
:never -> args ++ ["--pull", "never"]
192+
:missing -> args
193+
end
194+
end
195+
196+
defp execute(%DockerCompose{} = compose, args) do
197+
dir = resolve_directory(compose.filepath)
198+
env_vars = Enum.map(compose.env, fn {k, v} -> {to_string(k), to_string(v)} end)
199+
200+
Logger.debug("Running: docker #{Enum.join(args, " ")} in #{dir}")
201+
202+
System.cmd("docker", args, cd: dir, env: env_vars, stderr_to_stdout: true)
203+
end
204+
205+
defp resolve_directory(filepath) do
206+
if File.dir?(filepath) do
207+
filepath
208+
else
209+
Path.dirname(filepath)
210+
end
211+
end
212+
end

lib/compose/compose_environment.ex

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule Testcontainers.Compose.ComposeEnvironment do
2+
@moduledoc """
3+
Represents the started state of a Docker Compose environment.
4+
"""
5+
6+
alias Testcontainers.Compose.ComposeService
7+
8+
defstruct [:compose, :project_name, :docker_host, services: %{}]
9+
10+
@doc """
11+
Returns the service struct for the given service name.
12+
"""
13+
def get_service(%__MODULE__{} = env, service_name) when is_binary(service_name) do
14+
Map.get(env.services, service_name)
15+
end
16+
17+
@doc """
18+
Returns the docker host for a service.
19+
"""
20+
def get_service_host(%__MODULE__{} = env, _service_name) do
21+
env.docker_host
22+
end
23+
24+
@doc """
25+
Returns the mapped host port for a service and container port.
26+
"""
27+
def get_service_port(%__MODULE__{} = env, service_name, port)
28+
when is_binary(service_name) and is_integer(port) do
29+
case get_service(env, service_name) do
30+
nil -> nil
31+
service -> ComposeService.mapped_port(service, port)
32+
end
33+
end
34+
end

lib/compose/compose_service.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule Testcontainers.Compose.ComposeService do
2+
@moduledoc """
3+
A lightweight struct representing a service within a Docker Compose environment.
4+
"""
5+
6+
defstruct [
7+
:service_name,
8+
:container_id,
9+
:state,
10+
exposed_ports: []
11+
]
12+
13+
@doc """
14+
Returns the mapped host port for the given container port.
15+
"""
16+
def mapped_port(%__MODULE__{} = service, port) when is_integer(port) do
17+
service.exposed_ports
18+
|> Enum.find_value(nil, fn
19+
{^port, host_port} -> host_port
20+
_ -> nil
21+
end)
22+
end
23+
end

lib/compose/docker_compose.ex

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
defmodule Testcontainers.DockerCompose do
2+
@moduledoc """
3+
A struct with builder functions for creating a Docker Compose configuration.
4+
"""
5+
6+
defstruct [
7+
:filepath,
8+
compose_files: [],
9+
project_name: nil,
10+
env: %{},
11+
wait_strategies: %{},
12+
wait_timeout: 120_000,
13+
pull: :missing,
14+
services: [],
15+
build: false,
16+
profiles: [],
17+
remove_volumes: true
18+
]
19+
20+
@doc """
21+
Creates a new DockerCompose configuration.
22+
23+
The `filepath` can be a path to a directory containing a docker-compose.yml file,
24+
or a path to a specific compose file.
25+
"""
26+
def new(filepath) when is_binary(filepath) do
27+
%__MODULE__{
28+
filepath: filepath,
29+
project_name: generate_project_name()
30+
}
31+
end
32+
33+
@doc """
34+
Sets an environment variable for the compose environment.
35+
"""
36+
def with_env(%__MODULE__{} = config, key, value)
37+
when (is_binary(key) or is_atom(key)) and is_binary(value) do
38+
%__MODULE__{config | env: Map.put(config.env, to_string(key), value)}
39+
end
40+
41+
@doc """
42+
Sets a wait strategy for a specific service.
43+
"""
44+
def with_wait_strategy(%__MODULE__{} = config, service_name, wait_strategy)
45+
when is_binary(service_name) and is_struct(wait_strategy) do
46+
strategies = Map.get(config.wait_strategies, service_name, [])
47+
48+
%__MODULE__{
49+
config
50+
| wait_strategies:
51+
Map.put(config.wait_strategies, service_name, [wait_strategy | strategies])
52+
}
53+
end
54+
55+
@doc """
56+
Sets the specific services to start. If empty, all services are started.
57+
"""
58+
def with_services(%__MODULE__{} = config, services) when is_list(services) do
59+
%__MODULE__{config | services: services}
60+
end
61+
62+
@doc """
63+
Sets whether to build images before starting containers.
64+
"""
65+
def with_build(%__MODULE__{} = config, build) when is_boolean(build) do
66+
%__MODULE__{config | build: build}
67+
end
68+
69+
@doc """
70+
Adds a profile to enable when starting compose.
71+
"""
72+
def with_profile(%__MODULE__{} = config, profile) when is_binary(profile) do
73+
%__MODULE__{config | profiles: [profile | config.profiles]}
74+
end
75+
76+
@doc """
77+
Sets the pull policy for compose services.
78+
"""
79+
def with_pull(%__MODULE__{} = config, pull) when pull in [:always, :missing, :never] do
80+
%__MODULE__{config | pull: pull}
81+
end
82+
83+
@doc """
84+
Sets whether to remove volumes when stopping compose.
85+
"""
86+
def with_remove_volumes(%__MODULE__{} = config, remove_volumes)
87+
when is_boolean(remove_volumes) do
88+
%__MODULE__{config | remove_volumes: remove_volumes}
89+
end
90+
91+
@doc """
92+
Sets the wait timeout in milliseconds.
93+
"""
94+
def with_wait_timeout(%__MODULE__{} = config, timeout)
95+
when is_integer(timeout) and timeout > 0 do
96+
%__MODULE__{config | wait_timeout: timeout}
97+
end
98+
99+
@doc """
100+
Sets the project name for the compose environment.
101+
"""
102+
def with_project_name(%__MODULE__{} = config, project_name) when is_binary(project_name) do
103+
%__MODULE__{config | project_name: project_name}
104+
end
105+
106+
@doc """
107+
Adds additional compose files to use with the -f flag.
108+
"""
109+
def with_compose_file(%__MODULE__{} = config, file) when is_binary(file) do
110+
%__MODULE__{config | compose_files: config.compose_files ++ [file]}
111+
end
112+
113+
defp generate_project_name do
114+
hex =
115+
:crypto.strong_rand_bytes(8)
116+
|> Base.encode16(case: :lower)
117+
|> binary_part(0, 12)
118+
119+
"tc-#{hex}"
120+
end
121+
end

0 commit comments

Comments
 (0)