Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 16 additions & 14 deletions examples/phoenix_project/mix.lock

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions examples/phoenix_project/test/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
services:
postgres:
image: postgres:16-alpine
ports:
- "5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: hello_compose_test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 1s
timeout: 3s
retries: 10

redis:
image: redis:7-alpine
ports:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 1s
timeout: 3s
retries: 10
146 changes: 146 additions & 0 deletions examples/phoenix_project/test/hello/docker_compose_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
defmodule Hello.DockerComposeTest do
use ExUnit.Case, async: false

alias Testcontainers.DockerCompose
alias Testcontainers.Compose.ComposeEnvironment

@compose_path Path.expand("../docker-compose.yml", __DIR__)

describe "multi-service compose" do
setup do
compose = DockerCompose.new(@compose_path)
{:ok, env} = Testcontainers.start_compose(compose)
on_exit(fn -> Testcontainers.stop_compose(env) end)
%{env: env}
end

test "starts both postgres and redis", %{env: env} do
assert %ComposeEnvironment{} = env

# Verify postgres
pg_service = ComposeEnvironment.get_service(env, "postgres")
assert pg_service.service_name == "postgres"
assert pg_service.state == "running"

pg_host = ComposeEnvironment.get_service_host(env, "postgres")
pg_port = ComposeEnvironment.get_service_port(env, "postgres", 5432)

{:ok, pid} =
Postgrex.start_link(
hostname: pg_host,
port: pg_port,
username: "postgres",
password: "postgres",
database: "hello_compose_test"
)

assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", [])
GenServer.stop(pid)

# Verify redis
redis_service = ComposeEnvironment.get_service(env, "redis")
assert redis_service.service_name == "redis"
assert redis_service.state == "running"

redis_host = ComposeEnvironment.get_service_host(env, "redis")
redis_port = ComposeEnvironment.get_service_port(env, "redis", 6379)

{:ok, conn} = :gen_tcp.connect(~c"#{redis_host}", redis_port, [:binary, active: false], 5000)
:gen_tcp.send(conn, "PING\r\n")
{:ok, response} = :gen_tcp.recv(conn, 0, 5000)
assert response =~ "PONG"
:gen_tcp.close(conn)
end
end
end

defmodule Hello.DockerComposeSharedTest do
use ExUnit.Case, async: false

import Testcontainers.ExUnit

alias Testcontainers.DockerCompose
alias Testcontainers.Compose.ComposeEnvironment

@compose_path Path.expand("../docker-compose.yml", __DIR__)

compose :env, DockerCompose.new(@compose_path), shared: true

test "can connect to postgres (shared)", %{env: env} do
assert %ComposeEnvironment{} = env

host = ComposeEnvironment.get_service_host(env, "postgres")
port = ComposeEnvironment.get_service_port(env, "postgres", 5432)

{:ok, pid} =
Postgrex.start_link(
hostname: host,
port: port,
username: "postgres",
password: "postgres",
database: "hello_compose_test"
)

assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", [])
GenServer.stop(pid)
end

test "can connect to redis (shared)", %{env: env} do
host = ComposeEnvironment.get_service_host(env, "redis")
port = ComposeEnvironment.get_service_port(env, "redis", 6379)

{:ok, conn} = :gen_tcp.connect(~c"#{host}", port, [:binary, active: false], 5000)
:gen_tcp.send(conn, "PING\r\n")
{:ok, response} = :gen_tcp.recv(conn, 0, 5000)
assert response =~ "PONG"
:gen_tcp.close(conn)
end

test "shared env has same project name across tests", %{env: env} do
assert is_binary(env.project_name)
assert String.starts_with?(env.project_name, "tc-")
end
end

defmodule Hello.DockerComposePerTestTest do
use ExUnit.Case, async: false

import Testcontainers.ExUnit

alias Testcontainers.DockerCompose
alias Testcontainers.Compose.ComposeEnvironment

@compose_path Path.expand("../docker-compose.yml", __DIR__)

compose :env, DockerCompose.new(@compose_path), shared: false

test "can connect to postgres (per-test)", %{env: env} do
assert %ComposeEnvironment{} = env

host = ComposeEnvironment.get_service_host(env, "postgres")
port = ComposeEnvironment.get_service_port(env, "postgres", 5432)

{:ok, pid} =
Postgrex.start_link(
hostname: host,
port: port,
username: "postgres",
password: "postgres",
database: "hello_compose_test"
)

assert {:ok, %Postgrex.Result{num_rows: 1}} = Postgrex.query(pid, "SELECT 1", [])
GenServer.stop(pid)
end

test "can connect to redis (per-test)", %{env: env} do
host = ComposeEnvironment.get_service_host(env, "redis")
port = ComposeEnvironment.get_service_port(env, "redis", 6379)

{:ok, conn} = :gen_tcp.connect(~c"#{host}", port, [:binary, active: false], 5000)
:gen_tcp.send(conn, "PING\r\n")
{:ok, response} = :gen_tcp.recv(conn, 0, 5000)
assert response =~ "PONG"
:gen_tcp.close(conn)
end
end
212 changes: 212 additions & 0 deletions lib/compose/cli.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
defmodule Testcontainers.Compose.Cli do
@moduledoc """
Subprocess wrapper for Docker Compose CLI interaction.
"""

require Logger

alias Testcontainers.DockerCompose

@doc """
Runs `docker compose up -d --wait` with the given compose configuration.
"""
def up(%DockerCompose{} = compose) do
args = build_up_args(compose)

case execute(compose, args) do
{_output, 0} -> :ok
{output, exit_code} -> {:error, {:compose_up_failed, exit_code, output}}
end
end

@doc """
Runs `docker compose down` with the given compose configuration.
"""
def down(%DockerCompose{} = compose) do
args = build_down_args(compose)

case execute(compose, args) do
{_output, 0} -> :ok
{output, exit_code} -> {:error, {:compose_down_failed, exit_code, output}}
end
end

@doc """
Runs `docker compose ps --format=json` and parses the output into a list of maps.
"""
def ps(%DockerCompose{} = compose) do
args = build_ps_args(compose)

case execute(compose, args) do
{output, 0} -> {:ok, parse_ps_output(output)}
{output, exit_code} -> {:error, {:compose_ps_failed, exit_code, output}}
end
end

@doc """
Runs `docker compose pull` with the given compose configuration.
"""
def pull(%DockerCompose{} = compose) do
args = build_pull_args(compose)

case execute(compose, args) do
{_output, 0} -> :ok
{output, exit_code} -> {:error, {:compose_pull_failed, exit_code, output}}
end
end

@doc """
Runs `docker compose logs <service>` and returns the output.
"""
def logs(%DockerCompose{} = compose, service_name) when is_binary(service_name) do
args = build_logs_args(compose, service_name)

case execute(compose, args) do
{output, 0} -> {:ok, output}
{output, exit_code} -> {:error, {:compose_logs_failed, exit_code, output}}
end
end

# Command building functions - public for testability

@doc """
Builds the argument list for `docker compose up`.
"""
def build_up_args(%DockerCompose{} = compose) do
base_args(compose) ++ ["up", "-d", "--wait"] ++ build_args(compose) ++ compose.services
end

@doc """
Builds the argument list for `docker compose down`.
"""
def build_down_args(%DockerCompose{} = compose) do
args = base_args(compose) ++ ["down"]

if compose.remove_volumes do
args ++ ["-v"]
else
args
end
end

@doc """
Builds the argument list for `docker compose ps`.
"""
def build_ps_args(%DockerCompose{} = compose) do
base_args(compose) ++ ["ps", "--format=json"]
end

@doc """
Builds the argument list for `docker compose pull`.
"""
def build_pull_args(%DockerCompose{} = compose) do
base_args(compose) ++ ["pull"]
end

@doc """
Builds the argument list for `docker compose logs`.
"""
def build_logs_args(%DockerCompose{} = compose, service_name) do
base_args(compose) ++ ["logs", service_name]
end

@doc """
Parses the JSON output from `docker compose ps`.

Each line is a separate JSON object with fields like Service, ID, State, Publishers.
"""
def parse_ps_output(output) when is_binary(output) do
output
|> String.trim()
|> String.split("\n", trim: true)
|> Enum.flat_map(fn line ->
case Jason.decode(line) do
{:ok, %{} = parsed} ->
[parsed]

{:ok, list} when is_list(list) ->
list

{:error, _} ->
[]
end
end)
end

@doc """
Parses the Publishers field from a `docker compose ps` JSON entry
into a list of `{container_port, host_port}` tuples.
"""
def parse_publishers(nil), do: []
def parse_publishers([]), do: []

def parse_publishers(publishers) when is_list(publishers) do
publishers
|> Enum.filter(fn pub ->
published = Map.get(pub, "PublishedPort", 0)
published != 0
end)
|> Enum.map(fn pub ->
target = Map.get(pub, "TargetPort", 0)
published = Map.get(pub, "PublishedPort", 0)
{target, published}
end)
|> Enum.uniq()
end

# Private functions

defp base_args(%DockerCompose{} = compose) do
args = ["compose"]

args =
if compose.project_name do
args ++ ["-p", compose.project_name]
else
args
end

args =
Enum.reduce(compose.compose_files, args, fn file, acc ->
acc ++ ["-f", file]
end)

Enum.reduce(compose.profiles, args, fn profile, acc ->
acc ++ ["--profile", profile]
end)
end

defp build_args(%DockerCompose{} = compose) do
args = []

args =
if compose.build do
args ++ ["--build"]
else
args
end

case compose.pull do
:always -> args ++ ["--pull", "always"]
:never -> args ++ ["--pull", "never"]
:missing -> args
end
end

defp execute(%DockerCompose{} = compose, args) do
dir = resolve_directory(compose.filepath)
env_vars = Enum.map(compose.env, fn {k, v} -> {to_string(k), to_string(v)} end)

Logger.debug("Running: docker #{Enum.join(args, " ")} in #{dir}")

System.cmd("docker", args, cd: dir, env: env_vars, stderr_to_stdout: true)
end

defp resolve_directory(filepath) do
if File.dir?(filepath) do
filepath
else
Path.dirname(filepath)
end
end
end
Loading
Loading