lib/reaper.ex

defmodule Testcontainers.Reaper do
  use Supervisor

  def start_link(opts \\ []) do
    Supervisor.start_link(__MODULE__, :ok, opts)
  end

  @impl true
  def init(:ok) do
    Process.flag(:trap_exit, true)

    children = [
      {Testcontainers.ReaperWorker, []}
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

defmodule Testcontainers.ReaperWorker do
  use GenServer

  require Logger

  alias Testcontainers.Docker
  alias Testcontainers.Container

  @ryuk_image "testcontainers/ryuk:0.5.1"
  @ryuk_port 8080

  def start_link(_) do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def register(filter) do
    if Process.whereis(__MODULE__) do
      GenServer.cast(__MODULE__, {:register, filter})
    else
      Logger.warning("""
      Reaper is not running! Ensure that Testcontainers.Reaper
      is started in your test_helper.exs.
      e.g.,
        Testcontainers.Reaper.start_link()
      """)
    end
  end

  @impl true
  def init(_) do
    Process.flag(:trap_exit, true)

    with {:ok, container} <- create_ryuk_container(),
         {:ok, socket} <- create_ryuk_socket(container) do
      Logger.info("Reaper initialized with containerId #{container.container_id}")
      {:ok, %{socket: socket, container: container}}
    end
  end

  @impl true
  def handle_cast({:register, filter}, %{socket: socket} = state) do
    case register(socket, filter) do
      :ok ->
        {:noreply, state}

      {:error, _reason} ->
        {:stop, :error_reason, state}
    end
  end

  defp register(socket, {filter_key, filter_value}) do
    :gen_tcp.send(
      socket,
      "#{:uri_string.quote(filter_key)}=#{:uri_string.quote(filter_value)}" <> "\n"
    )

    case :gen_tcp.recv(socket, 0, 1_000) do
      {:ok, "ACK\n"} ->
        :ok

      {:error, reason} ->
        Logger.warning("Error receiving data: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp create_ryuk_container do
    %Container{image: @ryuk_image}
    |> Container.with_exposed_port(@ryuk_port)
    |> Container.with_environment("RYUK_PORT", "#{@ryuk_port}")
    |> Container.with_environment("RYUK_CONNECTION_TIMEOUT", "120s")
    |> Container.with_bind_mount("/var/run/docker.sock", "/var/run/docker.sock", "rw")
    |> Docker.Api.run(reap: false)
  end

  defp create_ryuk_socket(%Container{} = container) do
    host_port = Container.mapped_port(container, @ryuk_port)

    :gen_tcp.connect(~c"localhost", host_port, [
      :binary,
      active: false,
      packet: :line
    ])
  end
end