# SPDX-License-Identifier: MIT
defmodule TestcontainerEx.ElixirContainer do
@moduledoc """
Provides functionality for creating and managing Elixir/Erlang container
configurations — useful for testing distributed Erlang, remote deployment,
clustering, and running Elixir releases inside containers.
## Basic usage
{:ok, container} = TestcontainerEx.start_container(ElixirContainer.new())
## With a specific Elixir version
config =
ElixirContainer.new()
|> ElixirContainer.with_image("elixir:1.17-otp-27")
{:ok, container} = TestcontainerEx.start_container(config)
## With Erlang distribution enabled
config =
ElixirContainer.new()
|> ElixirContainer.with_cookie("my-secret-cookie")
|> ElixirContainer.with_node_name("app@192.168.1.100")
|> ElixirContainer.with_distribution_port(9100)
{:ok, container} = TestcontainerEx.start_container(config)
## Mount a local Mix project
config =
ElixirContainer.new()
|> ElixirContainer.with_project("/path/to/my_app")
{:ok, container} = TestcontainerEx.start_container(config)
## Connect from your host machine
After the container starts, the Erlang distribution port and EPMD port are
exposed. From your local IEx session:
Node.connect(:"app@<host>")
Node.list() # => [:"app@<host>"]
## Copy a module into a running container and call it remotely
ElixirContainer.copy_module(container, MyModule, conn)
:rpc.call(:"app@<host>", MyModule, :hello, [])
## Run a release with remote shell
config =
ElixirContainer.new()
|> ElixirContainer.with_image("my_app:latest")
|> ElixirContainer.with_release("my_app")
|> ElixirContainer.with_cookie("release-cookie")
{:ok, container} = TestcontainerEx.start_container(config)
"""
alias TestcontainerEx.{
Container.Builder,
Container.Config,
ElixirContainer,
Engine,
LogWaitStrategy,
PortWaitStrategy
}
use TestcontainerEx.ContainerConfig
@default_image "elixir"
@default_tag "latest"
@default_image_with_tag "#{@default_image}:#{@default_tag}"
@default_epmd_port 4369
@default_dist_port 9100
@default_wait_timeout 120_000
@type t :: %__MODULE__{}
@enforce_keys [:image, :wait_timeout]
defstruct [
:image,
:wait_timeout,
:cookie,
:node_name,
:dist_port,
:project_path,
:release_name,
:release_args,
:vm_args,
:env_vars,
:cmd,
:working_dir,
:name,
check_image: @default_image,
reuse: false
]
# ── Constructor ────────────────────────────────────────────────────
@doc """
Creates a new `ElixirContainer` with default configuration.
Defaults:
- Image: `elixir:latest`
- Wait timeout: 120s
- No distribution (cookie/node_name not set)
- No project mounted
"""
@spec new() :: t()
def new,
do: %__MODULE__{
image: @default_image_with_tag,
wait_timeout: @default_wait_timeout,
cookie: nil,
node_name: nil,
dist_port: nil,
project_path: nil,
release_name: nil,
release_args: [],
vm_args: [],
env_vars: %{},
cmd: nil,
working_dir: nil
}
# ── Setters ────────────────────────────────────────────────────────
@doc """
Sets the container image (e.g. `"elixir:1.17-otp-27"` or a custom image
with Elixir/OTP pre-installed).
"""
@spec with_image(t(), String.t()) :: t()
def with_image(%__MODULE__{} = config, image) when is_binary(image) do
%{config | image: image}
end
@doc """
Sets the wait timeout in milliseconds.
"""
@spec with_wait_timeout(t(), pos_integer()) :: t()
def with_wait_timeout(%__MODULE__{} = config, timeout)
when is_integer(timeout) and timeout > 0 do
%{config | wait_timeout: timeout}
end
@doc """
Sets the Erlang distribution cookie.
Required for node-to-node communication. Both nodes must use the same cookie.
config
|> ElixirContainer.with_cookie("my-secret-cookie")
"""
@spec with_cookie(t(), String.t()) :: t()
def with_cookie(%__MODULE__{} = config, cookie) when is_binary(cookie) do
%{config | cookie: cookie}
end
@doc """
Sets the Erlang node name for distribution.
Use long names (`--name`) for cross-machine connections, short names
(`--sname`) for same-network connections.
config
|> ElixirContainer.with_node_name("app@192.168.1.100")
When set, EPMD port 4369 and the distribution port are automatically exposed.
"""
@spec with_node_name(t(), String.t()) :: t()
def with_node_name(%__MODULE__{} = config, node_name) when is_binary(node_name) do
%{config | node_name: node_name}
end
@doc """
Sets the fixed Erlang distribution port (default: 9100).
Erlang uses a random port by default, which is difficult with containers.
Setting a fixed port makes it possible to expose it through Docker.
config
|> ElixirContainer.with_distribution_port(9100)
"""
@spec with_distribution_port(t(), pos_integer()) :: t()
def with_distribution_port(%__MODULE__{} = config, port) when is_integer(port) and port > 0 do
%{config | dist_port: port}
end
@doc """
Bind-mounts a local Mix project directory into the container.
The project is mounted at `/app` inside the container. Useful for running
tests, starting a remote IEx session, or deploying code.
config
|> ElixirContainer.with_project("/path/to/my_app")
"""
@spec with_project(t(), String.t()) :: t()
def with_project(%__MODULE__{} = config, path) when is_binary(path) do
%{config | project_path: path, working_dir: "/app"}
end
@doc """
Configures the container to run a release.
When set, the container starts the release instead of an IEx session.
The release binary is expected to be at `/app/bin/<release_name>`.
config
|> ElixirContainer.with_release("my_app")
|> ElixirContainer.with_release_args(["start"])
"""
@spec with_release(t(), String.t()) :: t()
def with_release(%__MODULE__{} = config, name) when is_binary(name) do
%{config | release_name: name}
end
@doc """
Sets the arguments passed to the release binary (default: `["start"]`).
config
|> ElixirContainer.with_release_args(["start_iex"])
"""
@spec with_release_args(t(), [String.t()]) :: t()
def with_release_args(%__MODULE__{} = config, args) when is_list(args) do
%{config | release_args: args}
end
@doc """
Sets additional `vm.args` entries for the Erlang VM.
These are appended to the generated vm.args file inside the container.
Useful for tuning distribution, heart, or other kernel settings.
config
|> ElixirContainer.with_vm_args([
"-kernel inet_dist_listen_min 9100",
"-kernel inet_dist_listen_max 9100"
])
"""
@spec with_vm_args(t(), [String.t()]) :: t()
def with_vm_args(%__MODULE__{} = config, args) when is_list(args) do
%{config | vm_args: args}
end
@doc """
Sets environment variables inside the container.
These are merged with any distribution-related env vars set automatically.
config
|> ElixirContainer.with_env_vars(%{
"MIX_ENV" => "prod",
"DATABASE_URL" => "postgres://..."
})
"""
@spec with_env_vars(t(), map()) :: t()
def with_env_vars(%__MODULE__{} = config, vars) when is_map(vars) do
%{config | env_vars: Map.merge(config.env_vars, vars)}
end
@doc """
Sets a custom command to run inside the container.
Overrides the default IEx or release command. Useful for running
custom scripts or one-off commands.
config
|> ElixirContainer.with_cmd(["mix", "test"])
"""
@spec with_cmd(t(), [String.t()]) :: t()
def with_cmd(%__MODULE__{} = config, cmd) when is_list(cmd) do
%{config | cmd: cmd}
end
@doc """
Sets the container name.
"""
@spec with_name(t(), String.t()) :: t()
def with_name(%__MODULE__{} = config, name) when is_binary(name) do
%__MODULE__{config | name: name}
end
# ── Accessors ──────────────────────────────────────────────────────
@doc "Returns the default image name without tag."
@spec default_image() :: String.t()
def default_image, do: @default_image
@doc "Returns the default EPMD port (4369)."
@spec epmd_port() :: 4369
def epmd_port, do: @default_epmd_port
@doc "Returns the default distribution port (9100)."
@spec default_dist_port() :: 9100
def default_dist_port, do: @default_dist_port
@doc """
Returns the mapped EPMD port on the host machine.
"""
@spec mapped_epmd_port(Config.t()) :: integer() | nil
def mapped_epmd_port(%Config{} = container),
do: TestcontainerEx.get_port(container, @default_epmd_port)
@doc """
Returns the mapped distribution port on the host machine.
"""
@spec mapped_dist_port(Config.t()) :: integer() | nil
def mapped_dist_port(%Config{} = container) do
dist_port = container.environment[:ELIXIR_DIST_PORT] || "#{@default_dist_port}"
TestcontainerEx.get_port(container, String.to_integer(dist_port))
end
@doc """
Returns the full node name for connecting from the host.
Returns `nil` if no node_name is configured.
node_str = ElixirContainer.connection_node(container)
# => "app@localhost:9100"
Node.connect(String.to_atom(node_str))
"""
@spec connection_node(Config.t()) :: String.t() | nil
def connection_node(%Config{} = container) do
case container.environment["RELEASE_NODE"] do
nil ->
nil
node_name ->
host = TestcontainerEx.get_host(container)
node_part = node_name |> String.split("@") |> List.first()
dist_port = mapped_dist_port(container)
"#{node_part}@#{host}:#{dist_port}"
end
end
# ── Remote operations ──────────────────────────────────────────────
@doc """
Connects from the host to the container's Erlang node.
Requires the container to have been started with `with_node_name/2`
and `with_cookie/2`.
{:ok, container} = TestcontainerEx.start_container(
ElixirContainer.new()
|> ElixirContainer.with_cookie("secret")
|> ElixirContainer.with_node_name("app@192.168.1.100")
)
ElixirContainer.connect(container, conn)
# => true
"""
@spec connect(Config.t(), Req.Request.t()) :: boolean()
def connect(%Config{} = container, _conn) do
case connection_node(container) do
nil -> false
node_str -> Node.connect(String.to_atom(node_str))
end
end
@doc """
Copies an Elixir module's source file into the container.
The module is compiled inside the container and can then be called
via `:rpc.call/4`.
ElixirContainer.copy_module(container, MyApp.Helper, conn)
:rpc.call(:"app@localhost:9100", MyApp.Helper, :hello, [])
# => :world
The source file is expected to be at `lib/<module_path>.ex` relative
to the project root, or at the given `source_path`.
"""
@spec copy_module(Config.t(), module(), Req.Request.t(), String.t() | nil) ::
:ok | {:error, term()}
def copy_module(%Config{} = container, module, conn, source_path \\ nil) do
source_path = source_path || default_source_path(module)
case File.read(source_path) do
{:ok, contents} ->
Engine.Api.put_file(
container.container_id,
conn,
"/app/lib",
"#{module_path(module)}.ex",
contents
)
{:error, reason} ->
{:error, {:file_not_found, source_path, reason}}
end
end
@doc """
Evaluates code inside the container's Erlang node via RPC.
ElixirContainer.remote_eval(container, conn, "IO.puts(:hello)")
# => "hello"
"""
@spec remote_eval(Config.t(), Req.Request.t(), String.t()) ::
{:ok, term()} | {:error, term()}
def remote_eval(%Config{} = container, conn, code) do
case connection_node(container) do
nil ->
{:error, :no_node_name}
node_str ->
_node = String.to_atom(node_str)
case Engine.Api.start_exec(
container.container_id,
[
"iex",
"--eval",
code
],
conn
) do
{:ok, exec_id} ->
wait_for_exec(exec_id, container, conn)
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Runs a Mix task inside the container.
ElixirContainer.run_mix(container, conn, "test")
ElixirContainer.run_mix(container, conn, "ecto.migrate")
"""
@spec run_mix(Config.t(), Req.Request.t(), String.t()) :: :ok | {:error, term()}
def run_mix(%Config{} = container, conn, task) do
case Engine.Api.start_exec(container.container_id, ["mix", task], conn) do
{:ok, exec_id} ->
wait_for_exec(exec_id, container, conn)
{:error, reason} ->
{:error, reason}
end
end
# ── Builder protocol ───────────────────────────────────────────────
defimpl Builder do
@impl true
@spec build(ElixirContainer.t()) :: Config.t()
def build(%ElixirContainer{} = config) do
# Build the base container config
container =
config.image
|> Config.new()
|> maybe_with_distribution(config)
|> maybe_with_project(config)
|> maybe_with_env_vars(config)
|> maybe_with_working_dir(config)
|> maybe_with_cmd(config)
|> Config.with_check_image(config.check_image)
|> Config.with_reuse(config.reuse)
|> then(fn cfg ->
if config.name, do: Config.with_name(cfg, config.name), else: cfg
end)
# Choose wait strategy based on configuration
container =
if config.release_name do
# For releases, wait for the application to be started
container
|> Config.with_waiting_strategy(
LogWaitStrategy.new(
~r/.*Running.*with.*/,
config.wait_timeout,
1000
)
)
else
# For IEx sessions, wait for the IEx prompt
container
|> Config.with_waiting_strategy(
LogWaitStrategy.new(
~r/.*iex\(\d+\)>.*/,
config.wait_timeout,
1000
)
)
end
# Add port wait strategy for distribution if configured
container =
if config.node_name && config.dist_port do
Config.with_waiting_strategy(
container,
PortWaitStrategy.new("localhost", config.dist_port, config.wait_timeout, 1000)
)
else
container
end
Config.valid_image!(container)
end
@impl true
def after_start(_config, _container, _conn), do: :ok
# ── Distribution setup ────────────────────────────────────────────
defp maybe_with_distribution(container, %{node_name: nil}), do: container
defp maybe_with_distribution(container, config) do
dist_port = config.dist_port || ElixirContainer.default_dist_port()
container
# Expose EPMD and distribution ports
|> Config.with_exposed_ports([ElixirContainer.epmd_port(), dist_port])
# Set distribution environment variables
|> Config.with_environment("RELEASE_DISTRIBUTION", "name")
|> Config.with_environment("RELEASE_NODE", config.node_name)
|> Config.with_environment("RELEASE_COOKIE", config.cookie || "default-cookie")
|> Config.with_environment("ELIXIR_DIST_PORT", to_string(dist_port))
end
# ── Project mount ─────────────────────────────────────────────────
defp maybe_with_project(container, %{project_path: nil}), do: container
defp maybe_with_project(container, %{project_path: path}) do
Config.with_bind_mount(container, path, "/app", "rw")
end
# ── Environment variables ─────────────────────────────────────────
defp maybe_with_env_vars(container, %{env_vars: vars}) when map_size(vars) == 0, do: container
defp maybe_with_env_vars(container, %{env_vars: vars}) do
Enum.reduce(vars, container, fn {key, value}, acc ->
Config.with_environment(acc, key, value)
end)
end
# ── Working directory ────────────────────────────────────────────
defp maybe_with_working_dir(container, %{working_dir: nil}), do: container
defp maybe_with_working_dir(container, %{working_dir: dir} = config) do
Config.with_cmd(container, ["sh", "-c", "cd #{dir} && #{default_cmd(config)}"])
end
# ── Command ──────────────────────────────────────────────────────
defp maybe_with_cmd(container, %{cmd: nil}), do: container
defp maybe_with_cmd(container, %{cmd: cmd}) do
Config.with_cmd(container, cmd)
end
# ── Default command ──────────────────────────────────────────────
defp default_cmd(%{release_name: name, release_args: args}) when not is_nil(name) do
"/app/bin/#{name} #{Enum.join(args, " ")}"
end
defp default_cmd(%{node_name: nil}) do
"iex"
end
defp default_cmd(%{node_name: node_name, dist_port: dist_port, vm_args: vm_args}) do
dist_port_str =
if dist_port,
do: to_string(dist_port),
else: to_string(ElixirContainer.default_dist_port())
vm_args_str = if vm_args == [], do: "", else: Enum.join(vm_args, " ")
"iex --name #{node_name} --cookie #{System.get_env("RELEASE_COOKIE", "default-cookie")} -kernel inet_dist_listen_min #{dist_port_str} -kernel inet_dist_listen_max #{dist_port_str} #{vm_args_str}"
end
end
# ── Private helpers ───────────────────────────────────────────────
defp default_source_path(module) do
module_str = Atom.to_string(module)
path_parts = String.split(module_str, ".")
file_name = List.last(path_parts) |> Macro.underscore()
dir_path = Enum.drop(path_parts, -1) |> Enum.map(&Macro.underscore/1)
Path.join(["lib" | dir_path] ++ ["#{file_name}.ex"])
end
defp module_path(module) do
module_str = Atom.to_string(module)
path_parts = String.split(module_str, ".")
file_name = List.last(path_parts) |> Macro.underscore()
dir_path = Enum.drop(path_parts, -1) |> Enum.map(&Macro.underscore/1)
Path.join(dir_path ++ [file_name])
end
defp wait_for_exec(exec_id, container, conn) do
case Engine.Api.inspect_exec(exec_id, conn) do
{:ok, %{running: true}} ->
Process.sleep(100)
wait_for_exec(exec_id, container, conn)
{:ok, %{running: false, exit_code: 0}} ->
:ok
{:ok, %{running: false, exit_code: code}} ->
{:error, {:exec_failed, code}}
{:error, reason} ->
{:error, reason}
end
end
end