lib/runbox/slave.ex

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

  @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, 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()}",
        ~s(-boot "#{boot_file(release_dir)}"),
        ~s(-boot_var RELEASE_LIB "#{release_lib(release_dir)}"),
        ~s(-config "#{config_file(release_dir)}"),
        "-mode interactive",
        "-runbox mode slave",
        erl_opt_toolbox_scenario_config_dir(),
        erl_opt_logger_console()
      ]
      |> Enum.join(" ")
      |> to_charlist()

    link_to = self()

    # The progname has to be stated explicitly, otherwise it can be wrongly set
    # to the name of the release script.
    progname = 'erl'

    case :slave.start(host, slave_name, args, link_to, progname) do
      {:ok, slave} ->
        with :ok <- check_runbox_started(slave),
             :ok <- check_runbox_same_version(slave),
             :ok <- check_runbox_scenario_app_configured(slave),
             :ok <- put_app_env(slave, app_env) do
          {:ok, slave}
        else
          {:error, _} = error ->
            :ok = :slave.stop(slave)
            error
        end

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

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

  `fun` recives 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, slave} <- start(release_dir) do
      try do
        {:ok, fun.(slave)}
      after
        :ok = :slave.stop(slave)
      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_same_version(slave) do
    {:ok, runbox_master} = :application.get_key(:runbox, :vsn)
    {:ok, runbox_slave} = call(slave, :application, :get_key, [:runbox, :vsn])
    runbox_master_vsn = Version.parse!(to_string(runbox_master))
    runbox_slave_vsn = Version.parse!(to_string(runbox_slave))

    if runbox_master_vsn.major == runbox_slave_vsn.major do
      :ok
    else
      {:error, {:runbox_version_mismatch_on_slave, %{master: runbox_master, slave: runbox_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_toolbox_scenario_config_dir do
    scenario_config_dir = Application.fetch_env!(:toolbox, :scenario_config_dir)
    ~s(-toolbox scenario_config_dir "#{erl_format_term(scenario_config_dir)}")
  end

  defp erl_opt_logger_console do
    case Application.fetch_env(:logger, :console) do
      {:ok, conf} -> ~s(-logger console "#{erl_format_term(conf)}")
      :error -> ""
    end
  end

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