lib/runbox/scenario_release.ex

defmodule Runbox.ScenarioRelease do
  @moduledoc group: :internal
  @moduledoc """
  Module for inspecting a scenario release.
  """

  alias Runbox.Scenario
  alias Runbox.ScenarioTemplate
  alias Runbox.Slave

  defmodule SlaveFunc do
    require Logger

    @moduledoc group: :internal
    @moduledoc """
    Functions which are called on the scenario slave via RPC from the master
    node.
    """

    alias Runbox.Scenario.TemplateInspector
    alias Runbox.Scenario.Type

    @doc """
    Retrieves information about scenarios in the release.

    Scenarios with invalid manifests are filtered out. However, scenarios with colliding module
    names are not.
    """
    @spec release_info([module] | nil) :: [Scenario.t()]
    def release_info(modules \\ nil) do
      modules_to_scenarios(modules || release_modules())
    end

    @doc """
    Returns list of IDs of scenarios in the release.

    Scenarios with invalid manifests or mutually colliding manifest module names are filtered out.
    """
    @spec scenarios :: [scenario_id :: String.t()]
    def scenarios do
      release_modules()
      |> Enum.filter(&Scenario.manifest_module?/1)
      |> resolve_prefix_conflicts()
      |> Enum.flat_map(fn manifest_mod ->
        with {:ok, manifest} <- scenario_manifest_info(manifest_mod),
             :ok <- validate_scenario_type(manifest.type, manifest_mod) do
          [manifest.id]
        else
          :error ->
            []
        end
      end)
    end

    @doc """
    Returns all modules of the scenario app.
    """
    @spec release_modules() :: [module()]
    def release_modules do
      scenario_app = Application.fetch_env!(:runbox, :scenario_app)

      case :application.get_key(scenario_app, :modules) do
        {:ok, modules} -> modules
        :undefined -> []
      end
    end

    defp resolve_prefix_conflicts(manifest_mods) do
      conflicting =
        Enum.reduce(manifest_mods, MapSet.new(), &find_conflicting(&1, &2, manifest_mods))

      Enum.filter(manifest_mods, fn mod -> mod not in conflicting end)
    end

    defp find_conflicting(module, conflicting, others) do
      if module in conflicting do
        conflicting
      else
        case Enum.filter(others, &prefix_of?(module, &1)) do
          [] -> conflicting
          new_conflicts -> MapSet.union(conflicting, MapSet.new([module | new_conflicts]))
        end
      end
    end

    defp prefix_of?(module, other) do
      prefix = Module.split(other)
      module_path = Module.split(module)

      if List.starts_with?(module_path, prefix) and module != other do
        Logger.warning(
          "Module #{other} is prefix of module #{module}, which could " <>
            "lead to conflicts when detecting templates - they will be excluded"
        )

        true
      else
        false
      end
    end

    defp modules_to_scenarios(modules) do
      modules
      |> Enum.filter(&Scenario.manifest_module?/1)
      |> Enum.flat_map(fn manifest_mod ->
        with {:ok, manifest} <- scenario_manifest_info(manifest_mod),
             :ok <- validate_scenario_type(manifest.type, manifest_mod) do
          opts = scenario_opts(manifest_mod)
          templates = scenario_templates(modules, manifest_mod, manifest)

          [
            %Scenario{
              manifest: manifest,
              opts: opts,
              templates: templates
            }
          ]
        else
          :error ->
            []
        end
      end)
    end

    defp scenario_templates(modules, manifest_mod, manifest) do
      template_modules = Enum.filter(modules, &Scenario.template_module?(&1, manifest_mod))
      %{template_inspector: inspector_mod} = Scenario.get_impl_for(manifest.type)

      Enum.flat_map(template_modules, fn mod ->
        if valid_template?(inspector_mod, mod) do
          info = template_info(inspector_mod, mod)
          [%ScenarioTemplate{module: mod, info: info}]
        else
          []
        end
      end)
    end

    defp validate_scenario_type(type, manifest_mod) do
      if type in Type.all_types() do
        :ok
      else
        Logger.error(
          "Ignoring manifest module #{inspect(manifest_mod)} because it has invalid type '#{type}'"
        )

        :error
      end
    end

    defp scenario_manifest_info(manifest_mod) do
      {:ok, manifest_mod.get_info()}
    catch
      kind, value ->
        Logger.error(
          "Error reading info of manifest #{inspect(manifest_mod)}: #{inspect({kind, value})}"
        )

        Logger.error("Ignoring manifest module #{inspect(manifest_mod)}")

        :error
    end

    defp scenario_opts(manifest_mod) do
      if Code.ensure_loaded?(manifest_mod) && function_exported?(manifest_mod, :on_start, 0) do
        %{on_start: {manifest_mod, :on_start, []}}
      else
        %{}
      end
    end

    defp valid_template?(inspector_mod, template_mod) do
      TemplateInspector.valid_template?(inspector_mod, template_mod)
    end

    defp template_info(inspector_mod, template_mod) do
      TemplateInspector.template_info(inspector_mod, template_mod)
    end
  end

  @doc """
  Retrieves information about scenarios in a release.
  """
  @spec release_info(String.t()) :: {:ok, [Scenario.t()]} | {:error, term()}
  def release_info(release_dir) do
    Slave.with_slave(release_dir, fn slave ->
      Slave.call(slave, SlaveFunc, :release_info, [])
    end)
  end
end