# SPDX-License-Identifier: MIT defmodule TestcontainerEx.Docker.Api do @moduledoc """ Internal Docker API client. All functions require a Req connection. """ alias TestcontainerEx.Container.Config alias TestcontainerEx.Util.Constants # ── Container operations ────────────────────────────────────────── def get_container(container_id, conn) when is_binary(container_id) do case get(conn, "/containers/#{container_id}/json") do {:ok, %{status: 200, body: body}} -> {:ok, from_container_inspect(parse_body(body))} {:ok, %{status: status}} -> {:error, {:http_error, status}} {:ok, body} when is_map(body) -> {:error, {:failed_to_get_container, body}} {:error, %Req.Response{status: other}} -> {:error, {:http_error, other}} {:error, reason} -> {:error, reason} end end def get_container_by_hash(hash, conn) do filters = Jason.encode!(%{ "label" => ["#{Constants.container_reuse_hash_label()}=#{hash}"] }) case get(conn, "/containers/json?filters=#{URI.encode_www_form(filters)}") do {:ok, %{status: 200, body: body}} -> case parse_body(body) do [] -> {:error, :no_container} [container | _] -> get_container(container["Id"], conn) _ -> {:error, :no_container} end {:ok, %{body: body}} when is_map(body) -> {:error, {:failed_to_get_container, body}} {:error, %Req.Response{status: other}} -> {:error, {:http_error, other}} {:error, reason} -> {:error, reason} end end def create_container(%Config{} = container, conn) do body = container_create_request(container) query = if container.name, do: "?name=#{URI.encode_www_form(container.name)}", else: "" case post(conn, "/containers/create#{query}", body) do {:ok, %{status: 201, body: body}} -> case parse_body(body) do %{"Id" => id} -> {:ok, id} _ -> {:error, {:failed_to_create_container, body}} end {:ok, %{status: 200, body: body}} -> case parse_body(body) do %{"Id" => id} -> {:ok, id} _ -> {:error, {:failed_to_create_container, body}} end {:ok, %{status: status}} -> {:error, {:http_error, status}} {:ok, %{body: body}} when is_map(body) -> {:error, {:failed_to_create_container, body}} {:error, %Req.Response{status: other}} -> {:error, {:http_error, other}} {:error, reason} -> {:error, reason} end end def start_container(id, conn) when is_binary(id) do case post(conn, "/containers/#{id}/start", nil) do {:ok, %{status: 200}} -> :ok {:ok, %{status: 204}} -> :ok {:ok, %{status: status}} -> {:error, {:http_error, status}} {:ok, %{body: body}} when is_map(body) -> {:error, {:failed_to_start_container, body}} {:error, %Req.Response{status: other}} -> {:error, {:http_error, other}} {:error, reason} -> {:error, reason} end end def stop_container(container_id, conn) when is_binary(container_id) do case delete(conn, "/containers/#{container_id}?force=true") do {:ok, %{status: status}} when status in [200, 204] -> :ok {:ok, %{status: 404}} -> # Container already removed :ok {:ok, %{status: status}} -> {:error, {:http_error, status}} {:error, reason} -> {:error, reason} end end def put_file(container_id, conn, path, file_name, file_contents) do with {:ok, tar} <- create_tar(file_name, file_contents) do put_raw(conn, "/containers/#{container_id}/archive?path=#{URI.encode_www_form(path)}", tar, headers: [{"content-type", "application/x-tar"}] ) end end # ── Image operations ────────────────────────────────────────────── def pull_image(image, conn, opts \\ []) when is_binary(image) do auth = Keyword.get(opts, :auth, nil) headers = if auth, do: [{"x-registry-auth", auth}], else: [] query = "fromImage=#{URI.encode_www_form(image)}" case post(conn, "/images/create?#{query}", nil, headers: headers) do {:ok, %{status: 200}} -> {:ok, nil} # Docker returns NDJSON for image pulls; Req may fail # to decode the streaming body as a single JSON object. A 200 status # still means the pull succeeded. {:error, {Req, :decode, _}} -> {:ok, nil} {:error, %Jason.DecodeError{}} -> {:ok, nil} {:ok, %{status: status}} -> {:error, {:http_error, status}} {:error, %Req.Response{status: other}} -> {:error, {:http_error, other}} {:error, reason} -> {:error, reason} end end def image_exists?(image, conn) when is_binary(image) do case get(conn, "/images/#{image}/json") do {:ok, %{status: 200}} -> {:ok, true} {:ok, %{status: 404}} -> {:ok, false} {:ok, %{status: 500}} -> {:error, {:http_error, 500}} {:ok, %{status: status}} -> {:error, {:http_error, status}} {:error, %Req.Response{status: 404}} -> {:ok, false} {:error, %Req.Response{status: 500}} -> {:error, {:http_error, 500}} {:error, %Req.Response{status: other}} -> {:error, {:http_error, other}} {:error, _reason} -> {:ok, false} end end def delete_image(image, conn) when is_binary(image) do case delete(conn, "/images/#{image}?force=true") do {:ok, %{status: 200}} -> :ok {:ok, %{status: status}} -> {:error, {:http_error, status}} {:error, _} = error -> error end end def tag_image(image, repo, tag, conn) do query = "repo=#{URI.encode_www_form(repo)}&tag=#{URI.encode_www_form(tag)}" case post(conn, "/images/#{image}/tag?#{query}", nil) do {:ok, %{status: 201}} -> {:ok, "#{repo}:#{tag}"} {:ok, %{status: 200}} -> {:ok, "#{repo}:#{tag}"} {:ok, %{status: status}} -> {:error, {:http_error, status}} {:ok, %{body: %{"message" => msg}}} -> {:error, msg} {:error, reason} -> {:error, reason} end end # ── Exec operations ─────────────────────────────────────────────── def inspect_exec(exec_id, conn) do case get(conn, "/exec/#{exec_id}/json") do {:ok, %{status: 200, body: body}} -> parsed = parse_body(body) {:ok, %{running: parsed["Running"], exit_code: parsed["ExitCode"]}} {:ok, %{body: %{"message" => msg}}} -> {:error, msg} {:error, msg} -> {:error, msg} end end def start_exec(container_id, command, conn) do with {:ok, exec_id} <- create_exec(container_id, command, conn), :ok <- do_start_exec(exec_id, conn) do {:ok, exec_id} end end def logs(container_id, conn, opts \\ []) when is_binary(container_id) do stdout? = Keyword.get(opts, :stdout, true) stderr? = Keyword.get(opts, :stderr, true) timestamps? = Keyword.get(opts, :timestamps, false) follow? = Keyword.get(opts, :follow, false) query_params = [ {"stdout", bool_query(stdout?)}, {"stderr", bool_query(stderr?)}, {"timestamps", bool_query(timestamps?)}, {"follow", bool_query(follow?)} ] query_params = opts |> Keyword.take([:tail, :since, :until_time]) |> Enum.reduce(query_params, fn {:tail, value}, acc -> [{"tail", to_string(value)} | acc] {:since, value}, acc -> [{"since", to_string(value)} | acc] {:until_time, value}, acc -> [{"until", to_string(value)} | acc] _, acc -> acc end) query = URI.encode_query(query_params) case get(conn, "/containers/#{container_id}/logs?#{query}") do {:ok, %{status: 200, body: body}} when is_binary(body) -> {:ok, parse_log_body(body, stdout?: stdout?, stderr?: stderr?)} {:ok, %{body: %{"message" => msg}}} -> {:error, msg} {:ok, %{body: body}} -> {:error, {:failed_to_get_container_logs, body}} {:error, %Req.Response{status: other}} -> {:error, {:http_error, other}} {:error, reason} -> {:error, reason} end end def stdout_logs(container_id, conn) do case logs(container_id, conn, stderr: false) do {:ok, %{stdout: stdout}} -> {:ok, stdout} error -> error end end def stderr_logs(container_id, conn) do case logs(container_id, conn, stdout: false) do {:ok, %{stderr: stderr}} -> {:ok, stderr} error -> error end end @doc false def parse_log_body(body, opts \\ []) when is_binary(body) do stdout? = Keyword.get(opts, :stdout?, Keyword.get(opts, :stdout, true)) stderr? = Keyword.get(opts, :stderr?, Keyword.get(opts, :stderr, true)) case decode_log_frames(body) do {:ok, decoded} -> stdout = if(stdout?, do: decoded.stdout, else: "") stderr = if(stderr?, do: decoded.stderr, else: "") %{stdout: stdout, stderr: stderr} :raw -> if stdout?, do: %{stdout: body, stderr: ""}, else: %{stdout: "", stderr: body} end end defp decode_log_frames(body), do: collect_log_frames(body, %{stdout: "", stderr: ""}) defp collect_log_frames(<<>>, acc), do: {:ok, acc} defp collect_log_frames( <>, acc ) when type in [1, 2] do acc = case type do 1 -> %{acc | stdout: acc.stdout <> payload} 2 -> %{acc | stderr: acc.stderr <> payload} end collect_log_frames(rest, acc) end defp collect_log_frames(_body, _acc), do: :raw defp bool_query(true), do: "true" defp bool_query(false), do: "false" # ── Network operations ──────────────────────────────────────────── def get_bridge_gateway(conn) do case get(conn, "/networks/bridge") do {:ok, %{status: 200, body: body}} -> with {:ok, config} <- parse_bridge_gateway_config(body), {:ok, gateway} <- first_gateway(config) do {:ok, gateway} else _ -> {:error, :no_gateway} end {:ok, %{status: status}} -> {:error, {:http_error, status}} {:error, reason} -> {:error, reason} end end defp parse_bridge_gateway_config(body) do case parse_body(body) do %{"IPAM" => %{"Config" => config}} when is_list(config) -> {:ok, config} _ -> :error end end defp first_gateway(config) do config |> Enum.find_value(fn %{"Gateway" => gateway} when is_binary(gateway) and gateway != "" -> {:ok, gateway} _ -> nil end) |> case do nil -> :error gateway -> gateway end end def create_network(name, conn, opts \\ []) when is_binary(name) do body = %{ "Name" => name, "Driver" => Keyword.get(opts, :driver, "bridge"), "CheckDuplicate" => true, "Labels" => Keyword.get(opts, :labels, %{}) } case post(conn, "/networks/create", body) do {:ok, %{status: 201, body: body}} -> case parse_body(body) do %{"Id" => id} -> {:ok, id} _ -> {:error, {:failed_to_create_network, body}} end {:ok, %{status: 200, body: body}} -> case parse_body(body) do %{"Id" => id} -> {:ok, id} _ -> {:error, {:failed_to_create_network, body}} end {:ok, %{status: 409}} -> {:ok, :already_exists} {:ok, %{body: %{"message" => msg}}} -> {:error, {:failed_to_create_network, msg}} {:ok, %{status: status}} -> {:error, {:http_error, status}} {:error, %Req.Response{status: 409}} -> {:ok, :already_exists} {:error, %Req.Response{status: status}} -> {:error, {:http_error, status}} {:error, reason} -> {:error, reason} end end def remove_network(name, conn) when is_binary(name) do case delete(conn, "/networks/#{name}") do {:ok, %{status: 200}} -> :ok {:ok, %{status: 204}} -> :ok {:ok, %{status: 404}} -> {:error, :network_not_found} {:ok, %{body: %{"message" => msg}}} -> {:error, {:failed_to_remove_network, msg}} {:ok, %{status: status}} -> {:error, {:http_error, status}} {:error, %Req.Response{status: 404}} -> {:error, :network_not_found} {:error, %Req.Response{status: status}} -> {:error, {:http_error, status}} {:error, reason} -> {:error, reason} end end def network_exists?(name, conn) when is_binary(name) do case get(conn, "/networks/#{name}") do {:ok, %{status: 200}} -> true _ -> false end end # ── Response mapping ────────────────────────────────────────────── defp from_container_inspect(%{"ExecIDs" => nil} = inspect) do from_container_inspect(%{inspect | "ExecIDs" => []}) end # With Name and full NetworkSettings defp from_container_inspect(%{ "Id" => id, "Image" => image, "Name" => name, "NetworkSettings" => %{"IPAddress" => ip, "Ports" => ports, "Networks" => networks}, "Config" => %{"Env" => env, "Labels" => labels} }) do %Config{ container_id: id, image: image, name: parse_container_name(name), labels: labels, ip_address: resolve_ip(ip, networks), exposed_ports: map_ports(ports), environment: parse_env(env) } end # With Name and NetworkSettings without Networks defp from_container_inspect(%{ "Id" => id, "Image" => image, "Name" => name, "NetworkSettings" => %{"IPAddress" => ip, "Ports" => ports}, "Config" => %{"Env" => env, "Labels" => labels} }) do %Config{ container_id: id, image: image, name: parse_container_name(name), labels: labels, ip_address: ip, exposed_ports: map_ports(ports), environment: parse_env(env) } end # With Name and network_settings as a map (no guaranteed keys) defp from_container_inspect(%{ "Id" => id, "Image" => image, "Name" => name, "NetworkSettings" => network_settings, "Config" => %{"Env" => env, "Labels" => labels} }) do ip = Map.get(network_settings, "IPAddress") ports = Map.get(network_settings, "Ports") || %{} networks = Map.get(network_settings, "Networks") || %{} %Config{ container_id: id, image: image, name: parse_container_name(name), labels: labels, ip_address: resolve_ip(ip, networks), exposed_ports: map_ports(ports), environment: parse_env(env) } end # With Name but no NetworkSettings defp from_container_inspect(%{ "Id" => id, "Image" => image, "Name" => name, "Config" => %{"Env" => env, "Labels" => labels} }) do %Config{ container_id: id, image: image, name: parse_container_name(name), labels: labels, ip_address: nil, exposed_ports: [], environment: parse_env(env) } end # Without Name — fallback clauses for backward compatibility defp from_container_inspect(%{ "Id" => id, "Image" => image, "NetworkSettings" => %{"IPAddress" => ip, "Ports" => ports, "Networks" => networks}, "Config" => %{"Env" => env, "Labels" => labels} }) do %Config{ container_id: id, image: image, labels: labels, ip_address: resolve_ip(ip, networks), exposed_ports: map_ports(ports), environment: parse_env(env) } end defp from_container_inspect(%{ "Id" => id, "Image" => image, "NetworkSettings" => %{"IPAddress" => ip, "Ports" => ports}, "Config" => %{"Env" => env, "Labels" => labels} }) do %Config{ container_id: id, image: image, labels: labels, ip_address: ip, exposed_ports: map_ports(ports), environment: parse_env(env) } end defp from_container_inspect(%{ "Id" => id, "Image" => image, "NetworkSettings" => network_settings, "Config" => %{"Env" => env, "Labels" => labels} }) do ip = Map.get(network_settings, "IPAddress") ports = Map.get(network_settings, "Ports") || %{} networks = Map.get(network_settings, "Networks") || %{} %Config{ container_id: id, image: image, labels: labels, ip_address: resolve_ip(ip, networks), exposed_ports: map_ports(ports), environment: parse_env(env) } end defp from_container_inspect(%{ "Id" => id, "Image" => image, "Config" => %{"Env" => env, "Labels" => labels} }) do %Config{ container_id: id, image: image, labels: labels, ip_address: nil, exposed_ports: [], environment: parse_env(env) } end # Docker returns names with a leading "/" prefix, e.g. "/my-container". # Strip it for consistency. defp parse_container_name("/" <> name), do: name defp parse_container_name(name), do: name # ── Private helpers ─────────────────────────────────────────────── defp container_create_request(%Config{} = cfg) do base_container_request(cfg) |> maybe_put_networking_config(cfg) end defp maybe_put_networking_config(request, %Config{network: nil}), do: request defp maybe_put_networking_config(request, %Config{network: network}) do Map.put(request, "NetworkingConfig", %{"EndpointsConfig" => %{network => %{}}}) end defp base_container_request(%Config{} = cfg) do %{ "Image" => cfg.image, "Cmd" => cfg.cmd, "ExposedPorts" => map_exposed_ports(cfg), "Env" => map_env(cfg.environment), "Labels" => cfg.labels, "Hostname" => cfg.hostname, "HostConfig" => host_config(cfg) } end defp host_config(%Config{} = cfg) do %{ "AutoRemove" => cfg.auto_remove, "PortBindings" => map_port_bindings(cfg), "Privileged" => cfg.privileged, "Binds" => map_binds(cfg), "Mounts" => map_volumes(cfg), "NetworkMode" => cfg.network_mode || cfg.network } end defp map_exposed_ports(%Config{exposed_ports: ports}) do Enum.map(ports, fn {port, _} -> {to_string(port), %{}} end) |> Enum.into(%{}) end defp map_env(nil), do: [] # Convert Config.environment (map) to Docker API format (list of "KEY=STRING" strings) defp map_env(env) when is_map(env) do Enum.map(env, fn {key, value} when is_binary(key) -> "#{key}=#{value}" {key, value} when is_atom(key) -> "#{key}=#{value}" {key, value} -> "#{key}=#{value}" end) end # Parse Docker API response (list of "KEY=STRING" strings) into a keyword list defp parse_env(nil), do: [] defp parse_env(env) when is_list(env) do Enum.map(env, fn str when is_binary(str) -> case String.split(str, "=", parts: 2) do [key, value] -> {String.to_atom(key), value} [key] -> {String.to_atom(key), ""} end {key, value} when is_binary(key) -> {String.to_atom(key), to_string(value)} other -> {to_string(other), ""} end) end defp map_port_bindings(%Config{exposed_ports: ports}) do Enum.map(ports, fn {port, nil} -> {"#{port}/tcp", [%{"HostIp" => "0.0.0.0", "HostPort" => ""}]} {port, host} -> {"#{port}/tcp", [%{"HostIp" => "0.0.0.0", "HostPort" => to_string(host)}]} end) |> Enum.into(%{}) end defp map_binds(%Config{bind_mounts: mounts}) do Enum.map(mounts, &"#{&1.host_src}:#{&1.container_dest}:#{&1.options}") end defp map_volumes(%Config{bind_volumes: volumes}) do Enum.map( volumes, &%{ "Target" => &1.container_dest, "Source" => &1.volume, "Type" => "volume", "ReadOnly" => &1.read_only } ) end defp map_ports(nil), do: [] defp map_ports(ports) do Enum.reduce(ports, [], fn {key, mappings}, acc -> acc ++ Enum.map(mappings || [], fn %{"HostPort" => hp} -> {String.replace(key, "/tcp", "") |> String.to_integer(), String.to_integer(hp)} end) end) end defp resolve_ip(nil, networks), do: get_ip_from_networks(networks) defp resolve_ip("", networks), do: get_ip_from_networks(networks) defp resolve_ip(ip, _) when is_binary(ip) and ip != "", do: ip defp get_ip_from_networks(nil), do: nil defp get_ip_from_networks(networks) when is_map(networks) do Enum.find_value(networks, fn {_name, %{"IPAddress" => ip}} when is_binary(ip) and ip != "" -> ip _ -> nil end) end defp create_tar(file_name, contents) do tar_path = Path.join( System.tmp_dir!(), "#{:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)}-#{file_name}.tar" ) :ok = :erl_tar.create(tar_path, [{String.to_charlist(file_name), contents}], [:compressed]) with {:ok, data} <- File.read(tar_path), :ok <- File.rm(tar_path) do {:ok, data} end end def create_exec(container_id, command, conn) do body = %{"Cmd" => command} case post(conn, "/containers/#{container_id}/exec", body) do {:ok, %{status: status, body: body}} when status in [200, 201] -> case parse_body(body) do %{"Id" => id} -> {:ok, id} _ -> {:error, {:failed_to_create_exec, body}} end {:ok, %{body: %{"message" => msg}}} -> {:error, msg} {:ok, %{status: status}} -> {:error, {:http_error, status}} {:error, msg} -> {:error, msg} end end defp do_start_exec(exec_id, conn) do body = %{"Detach" => true} case post(conn, "/exec/#{exec_id}/start", body) do {:ok, %{status: 200}} -> :ok {:ok, %{status: s}} -> {:error, {:http_error, s}} {:ok, %{body: %{"message" => msg}}} -> {:error, msg} {:error, msg} -> {:error, msg} end end # ── HTTP helpers ────────────────────────────────────────────────── defp get(conn, path) do Req.get(conn, url: path) end defp post(conn, path, body, opts \\ []) do body = encode_body(body) headers = Keyword.get(opts, :headers, []) options = if body == nil do [url: path, headers: headers] else [url: path, body: body, headers: headers ++ [{"content-type", "application/json"}]] end Req.post(conn, options) end defp encode_body(nil), do: nil defp encode_body(body) when is_map(body), do: Jason.encode!(body) defp encode_body(body) when is_list(body), do: Jason.encode!(body) defp encode_body(body), do: body defp put_raw(conn, path, body, opts) do headers = Keyword.get(opts, :headers, []) Req.put(conn, url: path, body: body, headers: headers) end defp delete(conn, path) do Req.delete(conn, url: path) end # ── Body parsing ───────────────────────────────────────────────── defp parse_body(body) when is_binary(body) do case Jason.decode(body) do {:ok, parsed} -> parsed {:error, _} -> body end end defp parse_body(body) when is_map(body), do: body defp parse_body(body) when is_list(body), do: body defp parse_body(_body), do: %{} end