Packages

A typed Elixir wrapper for the Docker CLI with struct-based commands and pipeline composition

Current section

Files

Jump to
docker_wrapper lib docker supervised.ex
Raw

lib/docker/supervised.ex

defmodule Docker.Supervised do
@moduledoc """
A GenServer wrapping the full container lifecycle under OTP supervision.
This is a BEAM-native extension not present in the Rust crate. It starts
a Docker container in detached mode, monitors its health, and cleans up
on termination.
## Examples
# Start a supervised Redis container
{:ok, pid} = Docker.Supervised.start_link(
Docker.Commands.Run.new("redis:7")
|> Docker.Commands.Run.name("my-redis")
|> Docker.Commands.Run.port(6379, 6379),
health_interval: 5_000,
rm_on_terminate: true
)
Docker.Supervised.container_id(pid) #=> "abc123..."
Docker.Supervised.status(pid) #=> :running
Docker.Supervised.healthy?(pid) #=> :healthy
# Under a supervisor
children = [
{Docker.Supervised, {run_cmd, name: MyApp.Redis, health_interval: 3_000}}
]
Supervisor.start_link(children, strategy: :one_for_one)
## Options
* `:name` - GenServer registration name
* `:health_check` - whether to poll container health (default: `true`)
* `:health_interval` - milliseconds between health checks (default: `5_000`)
* `:rm_on_terminate` - force-remove container on GenServer terminate (default: `false`)
* `:config` - `Docker.Config` to use for all commands (default: `Docker.Config.new()`)
"""
use GenServer
alias Docker.Commands
defstruct [:run_cmd, :container_id, :status, :health, :opts, :config]
@type t :: %__MODULE__{
run_cmd: Commands.Run.t(),
container_id: String.t() | nil,
status: :running | :stopped | :failed,
health: :starting | :healthy | :unhealthy | :unknown | :not_found,
opts: keyword(),
config: Docker.Config.t()
}
# -- Client API --
@doc """
Starts a supervised container.
Accepts a `Docker.Commands.Run` struct and options. The container is
automatically started in detached mode.
"""
@spec start_link(Commands.Run.t(), keyword()) :: GenServer.on_start()
def start_link(%Commands.Run{} = run_cmd, opts \\ []) do
{gen_opts, opts} = Keyword.split(opts, [:name])
GenServer.start_link(__MODULE__, {run_cmd, opts}, gen_opts)
end
@doc "Returns the container ID."
@spec container_id(GenServer.server()) :: String.t() | nil
def container_id(pid), do: GenServer.call(pid, :container_id)
@doc "Returns the container status."
@spec status(GenServer.server()) :: :running | :stopped | :failed
def status(pid), do: GenServer.call(pid, :status)
@doc "Returns the container health status."
@spec healthy?(GenServer.server()) :: atom()
def healthy?(pid), do: GenServer.call(pid, :healthy?)
@doc "Stops the container gracefully."
@spec stop_container(GenServer.server(), timeout()) :: :ok
def stop_container(pid, timeout \\ 30_000) do
GenServer.call(pid, :stop_container, timeout)
end
@doc """
Returns a child spec for use in supervisors.
Expects `{run_cmd, opts}` as the argument.
"""
def child_spec({%Commands.Run{} = run_cmd, opts}) do
name = Keyword.get(opts, :name)
%{
id: name || __MODULE__,
start: {__MODULE__, :start_link, [run_cmd, opts]},
restart: :transient,
shutdown: 30_000
}
end
# -- Server Callbacks --
@impl true
def init({run_cmd, opts}) do
config = Keyword.get(opts, :config, Docker.Config.new())
run_cmd = Commands.Run.detach(run_cmd)
case Docker.Command.run(Commands.Run, run_cmd, config) do
{:ok, %Docker.ContainerId{full: container_id}} ->
health_interval = Keyword.get(opts, :health_interval, 5_000)
if Keyword.get(opts, :health_check, true) do
Process.send_after(self(), :health_check, health_interval)
end
{:ok,
%__MODULE__{
run_cmd: run_cmd,
container_id: container_id,
status: :running,
health: :starting,
opts: opts,
config: config
}}
{:error, reason} ->
{:stop, reason}
end
end
@impl true
def handle_call(:container_id, _from, state) do
{:reply, state.container_id, state}
end
def handle_call(:status, _from, state) do
{:reply, state.status, state}
end
def handle_call(:healthy?, _from, state) do
{:reply, state.health, state}
end
def handle_call(:stop_container, _from, state) do
stop_cmd = Commands.Stop.new(state.container_id)
Docker.Command.run(Commands.Stop, stop_cmd, state.config)
{:reply, :ok, %{state | status: :stopped}}
end
@impl true
def handle_info(:health_check, state) do
health_interval = Keyword.get(state.opts, :health_interval, 5_000)
new_health = check_container_health(state.container_id, state.config)
if new_health not in [:not_found, :stopped] do
Process.send_after(self(), :health_check, health_interval)
end
{:noreply, %{state | health: new_health}}
end
@impl true
def terminate(_reason, %{container_id: id, status: :running} = state) when id != nil do
stop_cmd = Commands.Stop.new(id)
Docker.Command.run(Commands.Stop, stop_cmd, state.config)
if Keyword.get(state.opts, :rm_on_terminate, false) do
rm_cmd = Commands.Rm.new(id) |> Commands.Rm.force()
Docker.Command.run(Commands.Rm, rm_cmd, state.config)
end
:ok
end
def terminate(_, _), do: :ok
# -- Private --
defp check_container_health(container_id, config) do
inspect_cmd = Commands.Inspect.new(container_id)
case Docker.Command.run(Commands.Inspect, inspect_cmd, config) do
{:ok, [%{"State" => %{"Status" => "running", "Health" => %{"Status" => health}}}]} ->
health_atom(health)
{:ok, [%{"State" => %{"Status" => "running"}}]} ->
:healthy
{:ok, [%{"State" => %{"Status" => "exited"}}]} ->
:stopped
{:ok, [%{"State" => %{"Status" => status}}]} ->
{:unhealthy, status}
{:ok, []} ->
:not_found
{:error, _} ->
:unknown
end
end
defp health_atom("healthy"), do: :healthy
defp health_atom("starting"), do: :starting
defp health_atom("unhealthy"), do: :unhealthy
defp health_atom(other), do: {:unhealthy, other}
end