Skip to main content

lib/mix/tasks/parapet.install.ex

defmodule Mix.Tasks.Parapet.Install do
  @moduledoc """
  Installs Parapet into a Phoenix application by scaffolding the host-owned instrumenter
  and wiring it into the endpoint.
  """
  use Igniter.Mix.Task

  alias Igniter.Code.Common
  alias Igniter.Code.Module, as: CodeModule
  alias Igniter.Project.Config
  alias Igniter.Project.Module, as: ProjectModule

  @mailglass_provider Parapet.SLO.MailglassDelivery
  @chimeway_provider Parapet.SLO.ChimewayDelivery
  @core_artifacts [
    "Parapet evidence spine migration",
    "Parapet instrumenter module",
    "Parapet endpoint metrics plug",
    "Parapet deploy hook",
    "Prometheus recording and alert rules"
  ]

  @impl Igniter.Mix.Task
  def info(_argv, _composing_task) do
    %Igniter.Mix.Task.Info{
      schema: [
        with_ui: :boolean,
        skip_ui: :boolean,
        with_mailglass: :boolean,
        with_chimeway: :boolean,
        with_sigra: :boolean,
        with_scoria: :boolean
      ],
      defaults: [
        with_ui: false,
        skip_ui: false,
        with_mailglass: false,
        with_chimeway: false,
        with_sigra: false,
        with_scoria: false
      ],
      composes: [
        "parapet.gen.spine",
        "parapet.gen.prometheus",
        "parapet.gen.ui",
        "parapet.gen.scoria"
      ]
    }
  end

  @impl Igniter.Mix.Task
  def igniter(igniter) do
    app_module = ProjectModule.module_name_prefix(igniter)
    instrumenter_module = Module.concat([app_module, ParapetInstrumenter])
    web_module = Module.concat([inspect(app_module) <> "Web"])
    endpoint_module = Module.concat([web_module, Endpoint])

    with_ui? = igniter.args.options[:with_ui] || false
    skip_ui? = igniter.args.options[:skip_ui] || false
    with_mailglass? = igniter.args.options[:with_mailglass] || false
    with_chimeway? = igniter.args.options[:with_chimeway] || false
    with_sigra? = igniter.args.options[:with_sigra] || false
    with_scoria? = igniter.args.options[:with_scoria] || false
    live_view_available? = Code.ensure_loaded?(Phoenix.LiveView)

    adapters =
      []
      |> maybe_add(with_mailglass?, :mailglass)
      |> maybe_add(with_chimeway?, :chimeway)

    providers =
      []
      |> maybe_add(with_mailglass?, @mailglass_provider)
      |> maybe_add(with_chimeway?, @chimeway_provider)

    igniter
    |> Igniter.compose_task("parapet.gen.spine", [])
    |> write_instrumenter(instrumenter_module, adapters, with_sigra?)
    |> Config.configure("config.exs", :parapet, [:instrumenter], instrumenter_module)
    |> maybe_configure_providers(providers)
    |> update_endpoint(endpoint_module, web_module)
    |> update_deploy_hook()
    |> Igniter.compose_task("parapet.gen.prometheus", [])
    |> maybe_compose_task(with_scoria?, "parapet.gen.scoria")
    |> maybe_install_ui(with_ui?, skip_ui?, live_view_available?)
    |> Igniter.add_notice(
      install_summary_notice(
        adapters: adapters,
        providers: providers,
        with_ui?: with_ui?,
        skip_ui?: skip_ui?,
        live_view_available?: live_view_available?,
        with_scoria?: with_scoria?
      )
    )
  end

  defp write_instrumenter(igniter, instrumenter_module, adapters, with_sigra?) do
    contents = instrumenter_contents(instrumenter_module, adapters, with_sigra?)
    instrumenter_path = ProjectModule.proper_location(igniter, instrumenter_module)

    Igniter.create_or_update_file(
      igniter,
      instrumenter_path,
      contents,
      fn _existing -> contents end
    )
  end

  defp instrumenter_contents(instrumenter_module, adapters, with_sigra?) do
    adapter_code =
      if adapters == [] do
        []
      else
        ["    Parapet.attach(adapters: #{inspect(adapters)})"]
      end

    sigra_code =
      if with_sigra? do
        [
          "    if Code.ensure_loaded?(Parapet.Integrations.Sigra) do",
          "      Parapet.Integrations.Sigra.setup()",
          "    end"
        ]
      else
        []
      end

    setup_lines =
      [adapter_code, sigra_code, ["    Parapet.Metrics.Probe.setup()", "    :ok"]]
      |> List.flatten()
      |> Enum.join("\n")

    """
    defmodule #{inspect(instrumenter_module)} do
      @moduledoc "Host-owned telemetry instrumentation for Parapet."

      def setup do
    #{setup_lines}
      end
    end
    """
  end

  defp maybe_configure_providers(igniter, []), do: igniter

  defp maybe_configure_providers(igniter, providers) do
    Config.configure(
      igniter,
      "config.exs",
      :parapet,
      [:providers],
      providers,
      updater: fn %Sourceror.Zipper{} = zipper ->
        merged =
          zipper
          |> Sourceror.Zipper.node()
          |> Sourceror.to_string()
          |> eval_config_list()
          |> Kernel.++(providers)
          |> Enum.uniq()

        {:ok, Common.replace_code(zipper, inspect(merged))}
      end
    )
  end

  defp eval_config_list(source) do
    case Code.eval_string(source, [], __ENV__) do
      {list, _binding} when is_list(list) -> list
      _ -> []
    end
  rescue
    _ -> []
  end

  defp maybe_install_ui(igniter, false, _skip_ui?, _live_view_available?), do: igniter
  defp maybe_install_ui(igniter, _with_ui?, true, _live_view_available?), do: igniter

  defp maybe_install_ui(igniter, true, false, true) do
    Igniter.compose_task(igniter, "parapet.gen.ui", [])
  end

  defp maybe_install_ui(igniter, true, false, false) do
    Igniter.add_notice(
      igniter,
      "Skipped extras: UI requested but Phoenix LiveView was not detected, so `parapet.gen.ui` was not composed."
    )
  end

  defp maybe_compose_task(igniter, true, task), do: Igniter.compose_task(igniter, task, [])
  defp maybe_compose_task(igniter, false, _task), do: igniter

  defp install_summary_notice(opts) do
    selected_extras =
      []
      |> maybe_add(
        opts[:with_ui?] && opts[:live_view_available?] && !opts[:skip_ui?],
        "UI workbench"
      )
      |> maybe_add(:mailglass in opts[:adapters], "Mailglass adapter")
      |> maybe_add(:chimeway in opts[:adapters], "Chimeway adapter")
      |> maybe_add(opts[:with_scoria?], "Scoria integration")

    skipped_extras =
      []
      |> maybe_add(!opts[:with_ui?], "UI not selected")
      |> maybe_add(opts[:skip_ui?], "UI explicitly skipped")
      |> maybe_add(
        opts[:with_ui?] && !opts[:live_view_available?],
        "UI requested but LiveView is unavailable"
      )
      |> maybe_add(!(:mailglass in opts[:adapters]), "Mailglass adapter not enabled")
      |> maybe_add(!(:chimeway in opts[:adapters]), "Chimeway adapter not enabled")

    provider_line =
      case opts[:providers] do
        [] -> "Host-owned providers: none added"
        providers -> "Host-owned providers: #{Enum.map_join(providers, ", ", &inspect/1)}"
      end

    """
    Parapet install summary

    Generated core artifacts:
    - #{Enum.join(@core_artifacts, "\n- ")}

    Selected extras:
    - #{Enum.join(default_to_none(selected_extras), "\n- ")}

    Skipped extras:
    - #{Enum.join(default_to_none(skipped_extras), "\n- ")}

    #{provider_line}
    Host follow-up:
    - Review the generated instrumenter module and keep `Parapet.attach(adapters: [...])` host-owned.
    - If you enabled the UI, mount it inside an authenticated scope; Parapet does not provide its own auth.
    - Run `mix parapet.doctor` next.
    """
  end

  defp default_to_none([]), do: ["none"]
  defp default_to_none(items), do: items

  defp maybe_add(list, true, value), do: list ++ [value]
  defp maybe_add(list, false, _value), do: list

  defp update_endpoint(igniter, endpoint_module, web_module) do
    ProjectModule.find_and_update_module!(igniter, endpoint_module, fn zipper ->
      has_plug? = Sourceror.to_string(zipper.node) =~ "Parapet.Plug.Metrics"

      if has_plug? do
        {:ok, zipper}
      else
        insert_plug(zipper, web_module)
      end
    end)
  end

  defp insert_plug(zipper, web_module) do
    case find_insertion_point(zipper, web_module) do
      {:ok, insert_zipper} ->
        {:ok, Common.add_code(insert_zipper, "plug Parapet.Plug.Metrics", placement: :after)}

      :error ->
        {:ok, zipper}
    end
  end

  defp find_insertion_point(zipper, web_module) do
    with :error <- CodeModule.move_to_use(zipper, Phoenix.Endpoint),
         :error <- CodeModule.move_to_use(zipper, web_module) do
      CodeModule.move_to_defmodule(zipper)
    end
  end

  defp update_deploy_hook(igniter) do
    app_name = Igniter.Project.Application.app_name(igniter)

    initial_content = """
    #!/bin/sh

    # Emit deploy marker for Parapet
    bin/#{app_name} rpc "Parapet.Deploy.mark(version: \\"$RELEASE_VERSION\\")"
    """

    updater = fn existing_content ->
      if String.contains?(existing_content, "Parapet.Deploy.mark") do
        existing_content
      else
        existing_content <>
          """

          # Emit deploy marker for Parapet
          bin/#{app_name} rpc "Parapet.Deploy.mark(version: \\"$RELEASE_VERSION\\")"
          """
      end
    end

    Igniter.create_or_update_file(
      igniter,
      "rel/hooks/post_start.sh",
      initial_content,
      updater
    )
  end
end