Skip to main content

lib/mix/tasks/host_kit.down.ex

defmodule Mix.Tasks.HostKit.Down do
  @moduledoc """
  Builds a down/rollback plan from an existing HostKit plan artifact.

  Rollback is just another plan: inspect the generated down plan, save it if you
  want, then apply it with `mix host_kit.apply --plan down.plan.json --confirm`.
  """

  use Mix.Task

  alias Mix.Tasks.HostKit.Options

  @shortdoc "Build a HostKit down plan"

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

    {opts, positional} =
      OptionParser.parse!(args,
        strict: [
          plan: :string,
          out: :string,
          format: :string,
          only: :keep,
          except: :keep,
          last: :boolean,
          run: :string,
          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,
          runs_root: :string
        ]
      )

    project = load_project(opts, positional)

    Options.with_target_opts(opts, project, fn target_opts ->
      plan = load_up_plan!(opts, positional, target_opts)

      {:ok, down_plan} = HostKit.down(plan, down_opts(opts))
      maybe_write_artifact(down_plan, opts)
      IO.puts(format_down_plan(down_plan, opts))
    end)
  end

  defp load_project(opts, positional) do
    if Keyword.has_key?(opts, :host) do
      path = List.first(positional) || "infra/config.exs"
      HostKit.load!(path, require: Keyword.get_values(opts, :require))
    end
  end

  defp load_up_plan!(opts, positional, target_opts) do
    cond do
      Keyword.get(opts, :last, false) ->
        {path, record} = tracked_plan_artifact!(:latest, opts, target_opts)
        path |> load_plan_artifact!(target_opts) |> HostKit.RunRecord.apply_backups(record)

      run_id = Keyword.get(opts, :run) ->
        {path, record} = tracked_plan_artifact!(run_id, opts, target_opts)
        path |> load_plan_artifact!(target_opts) |> HostKit.RunRecord.apply_backups(record)

      true ->
        path =
          Keyword.get(opts, :plan) || List.first(positional) ||
            Mix.raise("expected a plan artifact")

        load_plan_artifact!(path, target_opts)
    end
  end

  defp load_plan_artifact!(path, target_opts) do
    case HostKit.Plan.Artifact.load(path, Options.expand_target_opts(target_opts)) do
      {:ok, plan} -> plan
      {:error, reason} -> Mix.raise("could not load HostKit plan artifact: #{inspect(reason)}")
    end
  end

  defp tracked_plan_artifact!(selector, opts, target_opts) do
    run_opts =
      target_opts
      |> Options.expand_target_opts()
      |> put_present(:hostkit_runs_root, Keyword.get(opts, :runs_root))

    case load_run_record(selector, run_opts) do
      {:ok, %{artifacts: %{"up_plan" => path}} = record} when is_binary(path) ->
        {path, record}

      {:ok, record} ->
        Mix.raise("HostKit run #{inspect(record.id)} does not reference an up plan artifact")

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

  defp load_run_record(:latest, run_opts), do: HostKit.RunRecord.latest(run_opts)
  defp load_run_record(id, run_opts), do: HostKit.RunRecord.load(id, run_opts)

  defp down_opts(opts) do
    []
    |> put_filter(:only, Keyword.get_values(opts, :only))
    |> put_filter(:except, Keyword.get_values(opts, :except))
  end

  defp put_filter(opts, _key, []), do: opts

  defp put_filter(opts, key, values),
    do: Keyword.put(opts, key, Enum.map(values, &parse_resource_id/1))

  defp parse_resource_id(resource) do
    case String.split(resource, ":", parts: 2) do
      [type, name] -> {resource_type(type), name}
      _ -> Mix.raise("invalid resource id #{inspect(resource)}, expected type:name")
    end
  end

  defp resource_type(type) do
    String.to_existing_atom(type)
  rescue
    ArgumentError -> Mix.raise("unknown resource type: #{inspect(type)}")
  end

  defp format_down_plan(plan, opts) do
    case Keyword.get(opts, :format, "text") do
      "text" ->
        [format_down_report(plan), "\n", Mix.Tasks.HostKit.Output.format_plan(plan, opts)]
        |> IO.iodata_to_binary()

      _format ->
        Mix.Tasks.HostKit.Output.format_plan(plan, opts)
    end
  end

  defp format_down_report(plan) do
    report = HostKit.Plan.Summary.down_report(plan)

    [
      "Down plan: ",
      to_string(report.reversible_changes),
      " reversible, ",
      to_string(report.noop_changes),
      " explicit no-op, ",
      to_string(report.skipped_changes),
      " skipped of ",
      to_string(report.source_changes),
      " original changes (",
      to_string(report.reversible_percent),
      "% covered)",
      "\nSkipped by type: ",
      Mix.Tasks.HostKit.Output.format_counts(report.skipped_by_type),
      "\nSkipped by reason: ",
      Mix.Tasks.HostKit.Output.format_counts(report.skipped_by_reason)
    ]
  end

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

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

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

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