lib/docker/api.ex

# SPDX-License-Identifier: MIT
defmodule Testcontainers.Docker.Api do
  alias Testcontainers.WaitStrategy
  alias DockerEngineAPI.Model.ContainerCreateRequest
  alias DockerEngineAPI.Api
  alias Testcontainers.Container
  alias Testcontainers.ReaperWorker
  alias Testcontainers.Docker.Connection

  def run(%Container{} = container_config, options \\ []) do
    on_exit = Keyword.get(options, :on_exit, nil)
    wait_strategies = container_config.wait_strategies || []
    create_request = container_create_request(container_config)

    with :ok <- pull_image(create_request."Image", recv_timeout: 60_000),
         {:ok, id} <- create_container(create_request, recv_timeout: 2000),
         :ok <- start_container(id, recv_timeout: 300_000),
         :ok <- if(on_exit, do: on_exit.(fn -> stop_container(id) end), else: :ok),
         :ok <- reap_container(id),
         :ok <- wait_for_container(id, wait_strategies) do
      get_container(id)
    end
  end

  def get_container(container_id, options \\ [])
      when is_binary(container_id) do
    conn = Connection.get_connection(options)

    case Api.Container.container_inspect(conn, container_id) do
      {:error, %Tesla.Env{status: other}} ->
        {:error, {:http_error, other}}

      {:ok, %DockerEngineAPI.Model.ErrorResponse{} = error} ->
        {:error, {:failed_to_get_container, error}}

      {:ok, response} ->
        {:ok, from(response)}
    end
  end

  defp wait_for_container(id, wait_strategies) when is_binary(id) do
    Enum.reduce(wait_strategies, :ok, fn
      wait_strategy, :ok ->
        WaitStrategy.wait_until_container_is_ready(wait_strategy, id)

      _, error ->
        error
    end)
  end

  defp pull_image(image, options) when is_binary(image) do
    conn = Connection.get_connection(options)

    case Api.Image.image_create(conn, fromImage: image) do
      {:ok, %Tesla.Env{status: 200}} ->
        :ok

      {:error, %Tesla.Env{status: other}} ->
        {:error, {:http_error, other}}

      {:ok, %DockerEngineAPI.Model.ErrorResponse{} = error} ->
        {:error, {:failed_to_pull_image, error}}
    end
  end

  defp create_container(%ContainerCreateRequest{} = config, options) do
    conn = Connection.get_connection(options)

    case Api.Container.container_create(conn, config) do
      {:error, %Tesla.Env{status: other}} ->
        {:error, {:http_error, other}}

      {:ok, %{Id: id}} ->
        {:ok, id}

      {:ok, %DockerEngineAPI.Model.ErrorResponse{} = error} ->
        {:error, {:failed_to_create_container, error}}
    end
  end

  defp start_container(id, options) when is_binary(id) do
    conn = Connection.get_connection(options)

    case Api.Container.container_start(conn, id) do
      {:ok, %Tesla.Env{status: 204}} ->
        :ok

      {:error, %Tesla.Env{status: other}} ->
        {:error, {:http_error, other}}

      {:ok, %DockerEngineAPI.Model.ErrorResponse{} = error} ->
        {:error, {:failed_to_start_container, error}}
    end
  end

  defp container_create_request(%Container{} = container_config) do
    %ContainerCreateRequest{
      Image: container_config.image,
      Cmd: container_config.cmd,
      ExposedPorts: map_exposed_ports(container_config),
      Env: map_env(container_config),
      Labels: container_config.labels,
      HostConfig: %{
        AutoRemove: container_config.auto_remove,
        PortBindings: map_port_bindings(container_config),
        Privileged: container_config.privileged,
        Binds: map_binds(container_config)
      }
    }
  end

  defp map_exposed_ports(%Container{} = container_config) do
    container_config.exposed_ports
    |> Enum.map(fn
      {container_port, _host_port} -> {container_port, %{}}
      port -> {port, %{}}
    end)
    |> Enum.into(%{})
  end

  defp map_env(%Container{} = container_config) do
    container_config.environment
    |> Enum.map(fn {key, value} -> "#{key}=#{value}" end)
  end

  defp map_port_bindings(%Container{} = container_config) do
    container_config.exposed_ports
    |> Enum.map(fn
      {container_port, host_port} ->
        {container_port, [%{"HostIp" => "0.0.0.0", "HostPort" => to_string(host_port)}]}

      port ->
        {port, [%{"HostIp" => "0.0.0.0", "HostPort" => ""}]}
    end)
    |> Enum.into(%{})
  end

  defp map_binds(%Container{} = container_config) do
    container_config.bind_mounts
    |> Enum.map(fn volume_binding ->
      "#{volume_binding.host_src}:#{volume_binding.container_dest}:#{volume_binding.options}"
    end)
  end

  defp stop_container(container_id, options \\ []) when is_binary(container_id) do
    conn = Connection.get_connection(options)

    with {:ok, _} <- Api.Container.container_kill(conn, container_id),
         {:ok, _} <- Api.Container.container_delete(conn, container_id) do
      :ok
    end
  end

  defp reap_container(container_id) when is_binary(container_id) do
    ReaperWorker.register({"id", container_id})
  end

  defp from(
         %DockerEngineAPI.Model.ContainerInspectResponse{
           Id: container_id,
           Image: image,
           NetworkSettings: %{Ports: ports}
         } = res
       ) do
    ports =
      Enum.reduce(ports || [], [], fn {key, ports}, acc ->
        acc ++
          Enum.map(ports || [], fn %{"HostIp" => host_ip, "HostPort" => host_port} ->
            %{exposed_port: key, host_ip: host_ip, host_port: host_port |> String.to_integer()}
          end)
      end)

    environment =
      Enum.reduce(res."Config"."Env" || [], %{}, fn env, acc ->
        tokens = String.split(env, "=")
        Map.merge(acc, %{"#{List.first(tokens)}": List.last(tokens)})
      end)

    %Container{
      container_id: container_id,
      image: image,
      exposed_ports: ports,
      environment: environment
    }
  end
end