Skip to main content

lib/mix/tasks/host_kit.plan.ex

defmodule Mix.Tasks.HostKit.Plan do
  @moduledoc """
  Builds and prints a HostKit plan.

  Prefer `--host NAME config.exs` for remote targets declared with HostKit's
  `host` DSL. Raw SSH flags (`--remote`, `--user`, `--port`, `--identity-file`,
  `--password-env`) are available as an escape hatch.

  Pass `--local` for read-only local inspection.
  """

  use Mix.Task

  alias Mix.Tasks.HostKit.Options

  @shortdoc "Plan HostKit resources"

  @impl true
  def run(args) do
    Mix.Task.run("app.start")

    {opts, positional} =
      OptionParser.parse!(args,
        strict: [
          local: :boolean,
          host: :string,
          remote: :string,
          user: :string,
          port: :integer,
          identity_file: :string,
          password: :string,
          password_env: :string,
          silently_accept_hosts: :boolean,
          sudo: :boolean,
          require: :keep,
          format: :string,
          out: :string,
          service: :keep,
          ignore: :keep,
          package_lock: :string,
          write_package_lock: :string,
          repology_cache: :string,
          repology_cache_ttl: :integer,
          repology_no_cache: :boolean,
          show_graph: :boolean,
          graph_format: :string
        ]
      )

    path = List.first(positional) || "infra/config.exs"
    project = HostKit.load!(path, require: Keyword.get_values(opts, :require))

    Options.with_target_opts(opts, project, fn target_opts ->
      case HostKit.plan(project, plan_opts(opts, target_opts)) do
        {:ok, plan} ->
          maybe_write_artifact(plan, opts, target_opts)
          IO.puts(Mix.Tasks.HostKit.Output.format_plan(plan, opts))

        {:error, %HostKit.Diagnostics{} = diagnostics} ->
          Mix.raise(HostKit.Diagnostics.Format.format(diagnostics))

        {:error, reason} ->
          Mix.raise("HostKit plan failed: #{inspect(reason)}")
      end
    end)
  end

  defp maybe_write_artifact(plan, opts, target_opts) do
    case Keyword.get(opts, :out) do
      nil ->
        :ok

      path ->
        case HostKit.Plan.Artifact.save(path, plan,
               target_metadata: target_metadata(plan, opts, target_opts)
             ) do
          :ok ->
            :ok

          {:error, reason} ->
            Mix.raise("could not write HostKit plan artifact: #{inspect(reason)}")
        end
    end
  end

  defp target_metadata(plan, _opts, target_opts) do
    %{}
    |> put_metadata("kind", target_kind(target_opts))
    |> put_metadata("package_manager", package_manager(plan, target_opts))
    |> put_metadata("package_repo", package_repo(plan, target_opts))
  end

  defp target_kind(target_opts) do
    cond do
      Keyword.has_key?(target_opts, :target) -> "remote"
      Keyword.get(target_opts, :reader) == HostKit.Local -> "local"
      true -> nil
    end
  end

  defp package_manager(plan, target_opts) do
    plan.opts
    |> Keyword.get(:package_manager, Keyword.get(target_opts, :package_manager))
    |> case do
      nil -> nil
      manager -> to_string(manager)
    end
  end

  defp package_repo(plan, target_opts) do
    case Keyword.get(target_opts, :package_repo) do
      repo when is_binary(repo) -> resolved_package_repo(repo)
      _other -> resolved_package_repo(plan)
    end
  end

  defp resolved_package_repo(repo) when is_binary(repo), do: repo

  defp resolved_package_repo(plan) do
    plan.resources
    |> Enum.flat_map(fn
      %HostKit.Resources.Package{meta: %{resolution: %{repo: repo}}} when is_binary(repo) ->
        [repo]

      _resource ->
        []
    end)
    |> Enum.uniq()
    |> case do
      [repo] -> repo
      _other -> nil
    end
  end

  defp put_metadata(metadata, _key, nil), do: metadata
  defp put_metadata(metadata, key, value), do: Map.put(metadata, key, value)

  defp plan_opts(opts, target_opts) do
    target_opts
    |> Options.expand_target_opts()
    |> Keyword.put(:ignore, Options.ignored_resources(opts))
    |> put_present(:services, Options.selected_services(opts))
    |> put_package_lock(opts)
  end

  defp put_package_lock(plan_opts, opts) do
    plan_opts
    |> put_present(:package_lock, Keyword.get(opts, :package_lock))
    |> put_present(:package_lock_write, Keyword.get(opts, :write_package_lock))
    |> Options.put_repology_cache(opts)
  end

  defp put_present(opts, _key, nil), do: opts
  defp put_present(opts, key, value), do: Keyword.put(opts, key, value)
end