Skip to main content

lib/container/toxiproxy_container.ex

# SPDX-License-Identifier: MIT
defmodule TestcontainerEx.ToxiproxyContainer do
  @moduledoc """
  Provides functionality for creating and managing Toxiproxy container configurations.
  """

  alias TestcontainerEx.Container.Builder
  alias TestcontainerEx.Container.Config
  alias TestcontainerEx.HttpWaitStrategy
  alias TestcontainerEx.ToxiproxyContainer

  use TestcontainerEx.ContainerConfig

  @default_image "ghcr.io/shopify/toxiproxy"
  @default_tag "2.9.0"
  @default_image_with_tag "#{@default_image}:#{@default_tag}"
  @control_port 8474
  @first_proxy_port 8666
  @proxy_port_count 31
  @default_wait_timeout 60_000
  @max_retries 3
  @retry_delay_ms 500

  @type t :: %__MODULE__{}

  @enforce_keys [:image, :wait_timeout]
  defstruct [:image, :wait_timeout, :name, check_image: @default_image, reuse: false]

  def new, do: %__MODULE__{image: @default_image_with_tag, wait_timeout: @default_wait_timeout}
  def with_image(%__MODULE__{} = c, image) when is_binary(image), do: %{c | image: image}
  def with_wait_timeout(%__MODULE__{} = c, t) when is_integer(t), do: %{c | wait_timeout: t}

  @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

  def default_image, do: @default_image_with_tag
  def control_port, do: @control_port
  def first_proxy_port, do: @first_proxy_port
  def proxy_port_count, do: @proxy_port_count

  def mapped_control_port(%Config{} = container),
    do: TestcontainerEx.get_port(container, @control_port)

  def api_url(%Config{} = container) do
    host = TestcontainerEx.get_host(container)
    port = mapped_control_port(container)
    "http://#{host}:#{port}"
  end

  def configure_toxiproxy_ex(%Config{} = container) do
    Application.put_env(:toxiproxy_ex, :host, api_url(container))
    :ok
  end

  def create_proxy(%Config{} = container, name, upstream, opts \\ []) do
    listen_port = Keyword.get(opts, :listen_port, @first_proxy_port)
    host = TestcontainerEx.get_host(container)
    api_port = mapped_control_port(container)
    :inets.start()

    url = ~c"http://#{host}:#{api_port}/proxies"
    body = Jason.encode!(%{name: name, listen: "0.0.0.0:#{listen_port}", upstream: upstream})
    headers = [{~c"content-type", ~c"application/json"}]

    case httpc_request_with_retry(:post, {url, headers, ~c"application/json", body}) do
      {:ok, {{_, code, _}, _, _}} when code in [200, 201] ->
        {:ok, TestcontainerEx.get_port(container, listen_port)}

      {:ok, {{_, 409, _}, _, _}} ->
        {:ok, TestcontainerEx.get_port(container, listen_port)}

      {:ok, {{_, code, _}, _, response_body}} ->
        {:error, {:http_error, code, response_body}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  def create_proxy_for_container(
        %Config{} = toxiproxy,
        name,
        %Config{} = target,
        target_port,
        opts \\ []
      ) do
    upstream = "#{target.ip_address}:#{target_port}"
    create_proxy(toxiproxy, name, upstream, opts)
  end

  def delete_proxy(%Config{} = container, name) do
    host = TestcontainerEx.get_host(container)
    api_port = mapped_control_port(container)
    :inets.start()
    url = ~c"http://#{host}:#{api_port}/proxies/#{name}"

    case httpc_request_with_retry(:delete, {url, []}) do
      {:ok, {{_, 204, _}, _, _}} -> :ok
      {:ok, {{_, 404, _}, _, _}} -> {:error, :not_found}
      {:ok, {{_, code, _}, _, body}} -> {:error, {:http_error, code, body}}
      {:error, reason} -> {:error, reason}
    end
  end

  def reset(%Config{} = container) do
    host = TestcontainerEx.get_host(container)
    api_port = mapped_control_port(container)
    :inets.start()
    url = ~c"http://#{host}:#{api_port}/reset"

    case httpc_request_with_retry(:post, {url, [], ~c"application/json", "{}"}) do
      {:ok, {{_, 204, _}, _, _}} -> :ok
      {:ok, {{_, code, _}, _, body}} -> {:error, {:http_error, code, body}}
      {:error, reason} -> {:error, reason}
    end
  end

  def list_proxies(%Config{} = container) do
    host = TestcontainerEx.get_host(container)
    api_port = mapped_control_port(container)
    :inets.start()
    url = ~c"http://#{host}:#{api_port}/proxies"

    case httpc_request_with_retry(:get, {url, []}) do
      {:ok, {{_, 200, _}, _, body}} -> {:ok, Jason.decode!(to_string(body))}
      {:ok, {{_, code, _}, _, body}} -> {:error, {:http_error, code, body}}
      {:error, reason} -> {:error, reason}
    end
  end

  defp httpc_request_with_retry(method, request, retries_left \\ @max_retries) do
    http_opts = [timeout: 5_000, connect_timeout: 5_000]
    result = :httpc.request(method, request, http_opts, [])

    case result do
      {:error, reason} when retries_left > 0 ->
        retryable =
          case reason do
            :socket_closed_remotely -> true
            {:failed_connect, _} -> true
            :econnrefused -> true
            _ -> false
          end

        if retryable do
          Process.sleep(@retry_delay_ms)
          httpc_request_with_retry(method, request, retries_left - 1)
        else
          result
        end

      _ ->
        result
    end
  end

  defimpl Builder do
    @impl true
    def build(%ToxiproxyContainer{} = config) do
      proxy_ports =
        Enum.to_list(
          ToxiproxyContainer.first_proxy_port()..(ToxiproxyContainer.first_proxy_port() +
                                                    ToxiproxyContainer.proxy_port_count() - 1)
        )

      all_ports = [ToxiproxyContainer.control_port() | proxy_ports]

      Config.new(config.image)
      |> Config.with_exposed_ports(all_ports)
      |> Config.with_waiting_strategy(
        HttpWaitStrategy.new("/version", ToxiproxyContainer.control_port(),
          timeout: config.wait_timeout
        )
      )
      |> Config.with_reuse(config.reuse)
      |> then(fn cfg ->
        if config.name, do: Config.with_name(cfg, config.name), else: cfg
      end)
    end

    @impl true
    def after_start(_config, _container, _conn), do: :ok
  end
end