Skip to content

Commit c61dcbf

Browse files
jarlahclaude
andauthored
Add Docker Compose support (#247)
* 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> * Add Docker Compose integration test to phoenix example project Adds a test that verifies Docker Compose can start a PostgreSQL service and that it is reachable via Postgrex from the phoenix example project. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add multi-service compose and compose/3 macro tests - Multi-service compose file with PostgreSQL and Redis - Test compose/3 macro with shared: true (setup_all) - Test compose/3 macro with shared: false (per-test setup) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b7ee130 commit c61dcbf

File tree

13 files changed

+1302
-15
lines changed

13 files changed

+1302
-15
lines changed

examples/phoenix_project/mix.lock

Lines changed: 16 additions & 14 deletions
Large diffs are not rendered by default.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
services:
2+
postgres:
3+
image: postgres:16-alpine
4+
ports:
5+
- "5432"
6+
environment:
7+
POSTGRES_USER: postgres
8+
POSTGRES_PASSWORD: postgres
9+
POSTGRES_DB: hello_compose_test
10+
healthcheck:
11+
test: ["CMD-SHELL", "pg_isready -U postgres"]
12+
interval: 1s
13+
timeout: 3s
14+
retries: 10
15+
16+
redis:
17+
image: redis:7-alpine
18+
ports:
19+
- "6379"
20+
healthcheck:
21+
test: ["CMD", "redis-cli", "ping"]
22+
interval: 1s
23+
timeout: 3s
24+
retries: 10
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
defmodule Hello.DockerComposeTest do
2+
use ExUnit.Case, async: false
3+
4+
alias Testcontainers.DockerCompose
5+
alias Testcontainers.Compose.ComposeEnvironment
6+
7+
@compose_path Path.expand("../docker-compose.yml", __DIR__)
8+
9+
describe "multi-service compose" do
10+
setup do
11+
compose = DockerCompose.new(@compose_path)
12+
{:ok, env} = Testcontainers.start_compose(compose)
13+
on_exit(fn -> Testcontainers.stop_compose(env) end)
14+
%{env: env}
15+
end
16+
17+
test "starts both postgres and redis", %{env: env} do
18+
assert %ComposeEnvironment{} = env
19+
20+
# Verify postgres
21+
pg_service = ComposeEnvironment.get_service(env, "postgres")
22+
assert pg_service.service_name == "postgres"
23+
assert pg_service.state == "running"
24+
25+
pg_host = ComposeEnvironment.get_service_host(env, "postgres")
26+
pg_port = ComposeEnvironment.get_service_port(env, "postgres", 5432)
27+
28+
{:ok, pid} =
29+
Postgrex.start_link(
30+
hostname: pg_host,
31+
port: pg_port,
32+
username: "postgres",
33+
password: "postgres",
34+
database: "hello_compose_test"
35+
)
36+
37+
assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", [])
38+
GenServer.stop(pid)
39+
40+
# Verify redis
41+
redis_service = ComposeEnvironment.get_service(env, "redis")
42+
assert redis_service.service_name == "redis"
43+
assert redis_service.state == "running"
44+
45+
redis_host = ComposeEnvironment.get_service_host(env, "redis")
46+
redis_port = ComposeEnvironment.get_service_port(env, "redis", 6379)
47+
48+
{:ok, conn} = :gen_tcp.connect(~c"#{redis_host}", redis_port, [:binary, active: false], 5000)
49+
:gen_tcp.send(conn, "PING\r\n")
50+
{:ok, response} = :gen_tcp.recv(conn, 0, 5000)
51+
assert response =~ "PONG"
52+
:gen_tcp.close(conn)
53+
end
54+
end
55+
end
56+
57+
defmodule Hello.DockerComposeSharedTest do
58+
use ExUnit.Case, async: false
59+
60+
import Testcontainers.ExUnit
61+
62+
alias Testcontainers.DockerCompose
63+
alias Testcontainers.Compose.ComposeEnvironment
64+
65+
@compose_path Path.expand("../docker-compose.yml", __DIR__)
66+
67+
compose :env, DockerCompose.new(@compose_path), shared: true
68+
69+
test "can connect to postgres (shared)", %{env: env} do
70+
assert %ComposeEnvironment{} = env
71+
72+
host = ComposeEnvironment.get_service_host(env, "postgres")
73+
port = ComposeEnvironment.get_service_port(env, "postgres", 5432)
74+
75+
{:ok, pid} =
76+
Postgrex.start_link(
77+
hostname: host,
78+
port: port,
79+
username: "postgres",
80+
password: "postgres",
81+
database: "hello_compose_test"
82+
)
83+
84+
assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", [])
85+
GenServer.stop(pid)
86+
end
87+
88+
test "can connect to redis (shared)", %{env: env} do
89+
host = ComposeEnvironment.get_service_host(env, "redis")
90+
port = ComposeEnvironment.get_service_port(env, "redis", 6379)
91+
92+
{:ok, conn} = :gen_tcp.connect(~c"#{host}", port, [:binary, active: false], 5000)
93+
:gen_tcp.send(conn, "PING\r\n")
94+
{:ok, response} = :gen_tcp.recv(conn, 0, 5000)
95+
assert response =~ "PONG"
96+
:gen_tcp.close(conn)
97+
end
98+
99+
test "shared env has same project name across tests", %{env: env} do
100+
assert is_binary(env.project_name)
101+
assert String.starts_with?(env.project_name, "tc-")
102+
end
103+
end
104+
105+
defmodule Hello.DockerComposePerTestTest do
106+
use ExUnit.Case, async: false
107+
108+
import Testcontainers.ExUnit
109+
110+
alias Testcontainers.DockerCompose
111+
alias Testcontainers.Compose.ComposeEnvironment
112+
113+
@compose_path Path.expand("../docker-compose.yml", __DIR__)
114+
115+
compose :env, DockerCompose.new(@compose_path), shared: false
116+
117+
test "can connect to postgres (per-test)", %{env: env} do
118+
assert %ComposeEnvironment{} = env
119+
120+
host = ComposeEnvironment.get_service_host(env, "postgres")
121+
port = ComposeEnvironment.get_service_port(env, "postgres", 5432)
122+
123+
{:ok, pid} =
124+
Postgrex.start_link(
125+
hostname: host,
126+
port: port,
127+
username: "postgres",
128+
password: "postgres",
129+
database: "hello_compose_test"
130+
)
131+
132+
assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", [])
133+
GenServer.stop(pid)
134+
end
135+
136+
test "can connect to redis (per-test)", %{env: env} do
137+
host = ComposeEnvironment.get_service_host(env, "redis")
138+
port = ComposeEnvironment.get_service_port(env, "redis", 6379)
139+
140+
{:ok, conn} = :gen_tcp.connect(~c"#{host}", port, [:binary, active: false], 5000)
141+
:gen_tcp.send(conn, "PING\r\n")
142+
{:ok, response} = :gen_tcp.recv(conn, 0, 5000)
143+
assert response =~ "PONG"
144+
:gen_tcp.close(conn)
145+
end
146+
end

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

0 commit comments

Comments
 (0)