lib/mix/tasks/prom_ex.dashboard.publish.ex

defmodule Mix.Tasks.PromEx.Dashboard.Publish do
  @moduledoc """
  This mix task will publish dashboards to Grafana for a PromEx module. It is
  recommended that you use the functionality that is part of the PromEx supervision
  tree in order to upload dashboards as opposed to this, given that mix may not
  always be available (like in a mix release). This is more so a convenience for
  testing and validating dashboards without starting the whole application.

  The following CLI flags are supported:
  ```md
  -m, --module   The PromEx module which will be used to render the dashboards.
                 This is needed to fetch any relevant assigns from the
                 `c:PromEx.dashboard_assigns/0` callback and to get the Grafana
                 configuration from app config.

  -t, --timeout  The timeout value defines how long the mix task will wait while
                 uploading dashboards.
  ```
  """

  @shortdoc "Upload dashboards to Grafana"

  use Mix.Task

  alias Mix.Shell.IO
  alias PromEx.DashboardUploader

  @impl true
  def run(args) do
    # Compile the project
    Mix.Task.run("compile")

    # Get CLI args and set up uploader
    %{module: prom_ex_module, timeout: timeout} = parse_options(args)
    uploader_process_name = Mix.Tasks.PromEx.Publish.Uploader

    "Elixir.#{prom_ex_module}"
    |> String.to_atom()
    |> Code.ensure_compiled()
    |> case do
      {:module, module} ->
        module

      {:error, reason} ->
        raise "#{prom_ex_module} is not a valid PromEx module because #{inspect(reason)}"
    end
    |> check_grafana_configuration()
    |> start_finch()
    |> upload_dashboards(uploader_process_name, timeout)
  end

  defp parse_options(args) do
    cli_options = [module: :string, timeout: :integer]
    cli_aliases = [m: :module, t: :timeout]

    # Parse out the arguments and put defaults where necessary
    args
    |> OptionParser.parse(aliases: cli_aliases, strict: cli_options)
    |> case do
      {options, _remaining_args, [] = _errors} ->
        Map.new(options)

      {_options, _remaining_args, errors} ->
        raise "Invalid CLI args were provided: #{inspect(errors)}"
    end
    |> Map.put_new(:timeout, 10_000)
    |> Map.put_new_lazy(:module, fn ->
      Mix.Project.config()
      |> Keyword.get(:app)
      |> Atom.to_string()
      |> Macro.camelize()
      |> Kernel.<>(".PromEx")
    end)
  end

  defp check_grafana_configuration(prom_ex_module) do
    if prom_ex_module.init_opts().grafana_config == :disabled do
      raise "#{prom_ex_module} has the Grafana option disabled. Please update your configuration and rerun."
    end

    prom_ex_module
  end

  defp start_finch(prom_ex_module) do
    client_name = Module.concat([prom_ex_module, GrafanaClient])

    Supervisor.start_link(
      [
        {PromEx.GrafanaClient, name: client_name}
      ],
      strategy: :one_for_one
    )

    prom_ex_module
  end

  defp upload_dashboards(prom_ex_module, uploader_process_name, timeout) do
    # We don't want errors in DashboardUploader to kill the mix task
    Process.flag(:trap_exit, true)

    # Start the DashboardUploader
    default_dashboard_opts = [otp_app: prom_ex_module.__otp_app__()]

    {:ok, pid} =
      DashboardUploader.start_link(
        name: uploader_process_name,
        prom_ex_module: prom_ex_module,
        default_dashboard_opts: default_dashboard_opts
      )

    receive do
      {:EXIT, ^pid, :normal} ->
        IO.info("\nPromEx dashboard upload complete! Review the above statuses for each dashboard.")

      {:EXIT, ^pid, error_reason} ->
        IO.error(
          "PromEx was unable to upload your dashboards to Grafana because:\n#{Code.format_string!(inspect(error_reason))}"
        )
    after
      timeout ->
        raise "PromEx timed out trying to upload your dashboards to Grafana"
    end
  end
end