lib/runbox/slave.ex

defmodule Runbox.Slave do
  @moduledoc group: :internal
  @moduledoc """
  Utilities for starting a slave node with a scenario release and communicating
  with the node.
  """

  alias Runbox.Utils.Path, as: PathUtils

  @doc """
  Starts a slave node.

  The `release_dir` is a directory with scenario release.

  If `app_env` is set, given app environment variables are set on slave.
  """
  @spec start(String.t(), [{Application.app(), [{Application.key(), Application.value()}]}]) ::
          {:ok, pid(), node()} | {:error, term()}
  def start(release_dir, app_env \\ []) do
    host = slave_host() |> to_charlist()
    slave_name = UUID.uuid4() |> to_charlist()

    args =
      [
        ["-setcookie", "#{Node.get_cookie()}"],
        ["-boot", boot_file(release_dir)],
        ["-boot_var", "RELEASE_LIB", release_lib(release_dir)],
        ["-config", config_file(release_dir)],
        ["-mode", "interactive"],
        ["-runbox", "mode", "slave"],
        "-hidden",
        erl_opt_runbox_scenario_config_dir(),
        erl_opt_logger_console(),
        erl_opt_runbox_altworx_root(),
        # Limit number of concurrent ports/sockets
        # https://www.erlang.org/doc/apps/erts/erl_cmd.html#max_ports
        ["+Q", "65536"]
      ]
      |> List.flatten()
      |> Enum.map(&to_charlist/1)

    opts = %{name: slave_name, host: host, args: args}

    case :peer.start_link(opts) do
      {:ok, pid, slave} ->
        with :ok <- check_runbox_started(slave),
             :ok <- check_runbox_scenario_app_configured(slave),
             :ok <- put_app_env(slave, app_env) do
          {:ok, pid, slave}
        else
          {:error, _} = error ->
            :ok = :peer.stop(pid)
            error
        end

      {:error, _} = error ->
        error
    end
  end

  @doc """
  Spawns a slave and executes the `fun`.

  `fun` receives the slave node as a parameter. The slave is terminated after
  the `fun` finishes.
  """
  @spec with_slave(String.t(), (node() -> result)) :: {:ok, result} | {:error, term}
        when result: any()
  def with_slave(release_dir, fun) do
    with {:ok, pid, slave} <- start(release_dir) do
      try do
        {:ok, fun.(slave)}
      after
        :ok = :peer.stop(pid)
      end
    end
  end

  @doc """
  Calls a function on the slave.

  Calls MFA on the `slave`. See `:erpc.call/4` for details.
  """
  @spec call(node(), module(), atom(), list()) :: term()
  def call(slave, m, f, a) do
    :erpc.call(slave, m, f, a)
  end

  defp check_runbox_started(slave) do
    apps = call(slave, Application, :started_applications, [])

    if Enum.any?(apps, &match?({:runbox, _, _}, &1)) do
      :ok
    else
      {:error, :runbox_not_running_on_slave}
    end
  end

  defp check_runbox_scenario_app_configured(slave) do
    case call(slave, Application, :fetch_env, [:runbox, :scenario_app]) do
      {:ok, _} -> :ok
      :error -> {:error, {:runbox_misconfigured, "Missing app config key :scenario_app"}}
    end
  end

  defp put_app_env(slave, app_env) do
    if app_env == [], do: :ok, else: call(slave, Application, :put_all_env, [app_env])
  end

  defp release_lib(release_dir) do
    Path.join([release_dir, "lib"])
  end

  defp boot_file(release_dir) do
    Path.join([release_dir, "releases", release_version(release_dir), "start"])
  end

  defp config_file(release_dir) do
    Path.join([release_dir, "releases", release_version(release_dir), "sys"])
  end

  defp release_version(release_dir) do
    data_file = Path.join([release_dir, "releases", "start_erl.data"])

    [_, release_version] =
      data_file
      |> File.read!()
      |> String.split(" ")

    release_version
  end

  defp slave_host, do: this_host()

  # The name of the host has to be derived from the node name. If taken from
  # `:inet.gethostname`, it does not work inside docker.
  defp this_host do
    [_name, host] =
      node()
      |> Atom.to_string()
      |> String.split("@")

    host
  end

  defp erl_opt_runbox_scenario_config_dir do
    scenario_config_dir = Application.fetch_env!(:runbox, :scenario_config_dir)
    ["-runbox", "scenario_config_dir", erl_format_term(scenario_config_dir)]
  end

  defp erl_opt_logger_console do
    case Application.fetch_env(:logger, :default_formatter) do
      {:ok, conf} -> ["-logger", "default_formatter", erl_format_term(conf)]
      :error -> []
    end
  end

  defp erl_opt_runbox_altworx_root do
    env_var = PathUtils.altworx_root_env_var()
    altworx_root = PathUtils.get_altworx_root()
    ["-runbox", erl_format_term(env_var), erl_format_term(altworx_root)]
  end

  defp erl_format_term(term) do
    IO.chardata_to_string(:io_lib.format("~tw", [term]))
  end
end