Skip to main content

lib/host_kit/run_record.ex

defmodule HostKit.RunRecord do
  @moduledoc "Minimal host-side record of an applied HostKit plan."

  use JSONCodec, fast_path: :json

  alias HostKit.{Conventions, Plan, Resource, Runner}

  @version 1

  defmodule Change do
    @moduledoc "One compact change entry in a HostKit run record."

    use JSONCodec, fast_path: :json

    @type t :: %__MODULE__{
            resource_id: term(),
            action: String.t(),
            status: String.t(),
            reason: term()
          }

    defstruct resource_id: nil,
              action: nil,
              status: nil,
              reason: nil
  end

  @type t :: %__MODULE__{
          version: pos_integer(),
          id: String.t(),
          project: String.t(),
          direction: String.t(),
          applied_at: String.t(),
          changes: [Change.t()],
          artifacts: %{String.t() => String.t()},
          backups: %{String.t() => String.t()}
        }

  defstruct version: @version,
            id: nil,
            project: nil,
            direction: nil,
            applied_at: nil,
            changes: [],
            artifacts: %{},
            backups: %{}

  codec(:version, transform: :validate_version!)
  codec(:changes, type: {:list, Change})

  @spec write(Plan.t(), [HostKit.Apply.result()], keyword()) :: :ok | {:error, term()}
  def write(%Plan{} = plan, results, opts) do
    if Keyword.get(opts, :track, false) do
      id = run_id(plan)
      path = record_path(plan, opts, id)

      with {:ok, artifacts} <- write_artifacts(plan, id, opts),
           {:ok, backups} <- write_backups(plan, results, id, opts),
           :ok <- Runner.mkdir_p(runner(opts), Path.dirname(path), opts) do
        content =
          plan
          |> record(results, id, artifacts, backups)
          |> dump()
          |> Jason.encode_to_iodata!(pretty: true)

        Runner.write_file(runner(opts), path, content, opts)
      end
    else
      :ok
    end
  end

  @spec list(keyword()) :: {:ok, [t()]} | {:error, term()}
  def list(opts \\ []) do
    case list_files(opts) do
      {:ok, files} -> {:ok, files |> load_records(opts) |> sort_records()}
      {:error, reason} -> {:error, reason}
    end
  end

  @spec prune(keyword(), keyword()) :: {:ok, [t()]} | {:error, term()}
  def prune(run_opts \\ [], opts) do
    keep = Keyword.get(opts, :keep, 20)

    with {:ok, records} <- list(run_opts) do
      {kept, pruned} = Enum.split(records, keep)
      _kept = kept

      case prune_records(pruned, run_opts) do
        :ok -> {:ok, pruned}
        {:error, reason} -> {:error, reason}
      end
    end
  end

  @spec latest(keyword()) :: {:ok, t()} | {:error, term()}
  def latest(opts \\ []) do
    case list(opts) do
      {:ok, [record | _]} -> {:ok, record}
      {:ok, []} -> {:error, :no_run_records}
      {:error, reason} -> {:error, reason}
    end
  end

  @spec load(String.t(), keyword()) :: {:ok, t()} | {:error, term()}
  def load(id_or_file, opts \\ []) do
    id_or_file
    |> run_path(opts)
    |> read_text(opts)
    |> case do
      {:ok, content} -> decode(content)
      {:error, reason} -> {:error, reason}
    end
  end

  @spec apply_backups(Plan.t(), t()) :: Plan.t()
  def apply_backups(%Plan{} = plan, %__MODULE__{} = record) do
    backups = record.backups || %{}
    changes = Enum.map(plan.changes, &apply_change_backup(&1, backups))
    %Plan{plan | changes: changes}
  end

  @spec runs_root(Plan.t() | nil, keyword()) :: String.t()
  def runs_root(plan \\ nil, opts) do
    opts
    |> Keyword.get(:hostkit_runs_root)
    |> case do
      nil -> plan |> plan_project() |> project_conventions() |> Conventions.runs_root()
      root -> root
    end
  end

  defp apply_change_backup(
         %HostKit.Change{resource_id: resource_id, before: before} = change,
         backups
       ) do
    case Map.get(backups, inspect(resource_id)) do
      path when is_binary(path) -> %HostKit.Change{change | before: put_backup_ref(before, path)}
      _missing -> change
    end
  end

  defp put_backup_ref(%HostKit.Resources.File{} = file, path),
    do: %HostKit.Resources.File{file | content: %HostKit.BackupRef{path: path}}

  defp put_backup_ref(%{meta: meta} = resource, path),
    do: %{resource | meta: Map.put(meta, :content, %HostKit.BackupRef{path: path})}

  defp put_backup_ref(resource, _path), do: resource

  def validate_version!(@version), do: @version

  def validate_version!(version) do
    raise JSONCodec.Error,
      path: [:version],
      expected: @version,
      got: version,
      reason: :unsupported_run_record_version
  end

  defp load_records(files, opts) do
    Enum.flat_map(files, fn path ->
      case load(Path.basename(path), opts) do
        {:ok, record} -> [record]
        {:error, _reason} -> []
      end
    end)
  end

  defp sort_records(records), do: Enum.sort_by(records, &(&1.applied_at || ""), :desc)

  defp prune_records([], _opts), do: :ok

  defp prune_records([record | rest], opts) do
    with :ok <- prune_record(record, opts) do
      prune_records(rest, opts)
    end
  end

  defp prune_record(%__MODULE__{} = record, opts) do
    paths = [run_path(record.id, opts) | prune_paths(record)]

    Enum.reduce_while(paths, :ok, fn path, :ok ->
      case rm_rf(path, opts) do
        :ok -> {:cont, :ok}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp prune_paths(record) do
    []
    |> Kernel.++(payload_dirs(record.artifacts || %{}))
    |> Kernel.++(payload_dirs(record.backups || %{}))
    |> Enum.uniq()
  end

  defp payload_dirs(paths) do
    Enum.map(paths, fn {_key, path} -> Path.dirname(path) end)
  end

  defp rm_rf(path, opts) do
    if runner(opts) == HostKit.Runner.Local and not Keyword.get(opts, :sudo, false) do
      path
      |> File.rm_rf()
      |> case do
        {:ok, _files} -> :ok
        {:error, reason, file} -> {:error, {:rm_rf_failed, file, reason}}
      end
    else
      rm_rf_with_runner(path, opts)
    end
  end

  defp rm_rf_with_runner(path, opts) do
    {command, args} =
      if Keyword.get(opts, :sudo, false),
        do: {"sudo", ["rm", "-rf", path]},
        else: {"rm", ["-rf", path]}

    case Runner.cmd(runner(opts), command, args, Keyword.merge(opts, stderr_to_stdout: true)) do
      {_output, 0} -> :ok
      {output, status} -> {:error, {:command_failed, command, args, status, output}}
    end
  end

  defp list_files(opts) do
    root = runs_root(nil, opts)

    case runner(opts) do
      HostKit.Runner.Local ->
        {:ok, Path.wildcard(Path.join(root, "*.json"))}

      runner ->
        script = "ls -1 #{HostKit.Shell.escape(root)}/*.json 2>/dev/null || true"

        case Runner.cmd(runner, "sh", ["-c", script], stderr_to_stdout: true) do
          {output, 0} -> {:ok, output |> String.split("\n", trim: true)}
          {output, status} -> {:error, {:command_failed, "ls", status, output}}
        end
    end
  end

  defp read_text(path, opts), do: HostKit.Runner.Files.read_file(path, opts)

  defp run_path(id_or_file, opts) do
    if String.ends_with?(id_or_file, ".json") and String.contains?(id_or_file, "/") do
      id_or_file
    else
      file =
        if String.ends_with?(id_or_file, ".json"), do: id_or_file, else: id_or_file <> ".json"

      Path.join(runs_root(nil, opts), file)
    end
  end

  defp record(%Plan{} = plan, results, id, artifacts, backups) do
    %__MODULE__{
      id: id,
      project: project_name(plan.project),
      direction: plan.opts |> Keyword.get(:direction, :up) |> to_string(),
      applied_at: DateTime.utc_now() |> DateTime.to_iso8601(),
      changes: Enum.map(results, &change_record/1),
      artifacts: artifacts,
      backups: backups
    }
  end

  defp write_artifacts(plan, id, opts) do
    artifacts_root = Path.join([runs_root(plan, opts), "artifacts", id])

    %{}
    |> maybe_write_artifact("up_plan", Keyword.get(opts, :up_plan_artifact), artifacts_root, opts)
    |> maybe_write_artifact(
      "down_plan",
      Keyword.get(opts, :down_plan_artifact),
      artifacts_root,
      opts
    )
  end

  defp maybe_write_artifact({:error, reason}, _key, _source, _root, _opts), do: {:error, reason}

  defp maybe_write_artifact({:ok, artifacts}, key, source, root, opts),
    do: maybe_write_artifact(artifacts, key, source, root, opts)

  defp maybe_write_artifact(artifacts, _key, nil, _root, _opts), do: {:ok, artifacts}

  defp maybe_write_artifact(artifacts, key, source, root, opts) do
    target = Path.join(root, Path.basename(source))

    with {:ok, content} <- File.read(source),
         :ok <- HostKit.Runner.Files.mkdir_p(root, opts),
         :ok <- HostKit.Runner.Files.write_file(target, content, opts) do
      {:ok, Map.put(artifacts, key, target)}
    end
  end

  defp write_backups(plan, results, id, opts) do
    root = Path.join([backups_root(plan, opts), id])

    Enum.reduce_while(results, {:ok, %{}}, &write_backup_step(&1, &2, root, opts))
  end

  defp write_backup_step(result, {:ok, backups}, root, opts) do
    case backup_content(result) do
      {:ok, resource_id, content} ->
        write_backup_content(backups, resource_id, content, root, opts)

      :skip ->
        {:cont, {:ok, backups}}
    end
  end

  defp write_backup_content(backups, resource_id, content, root, opts) do
    path = Path.join(root, backup_filename(resource_id))

    case write_backup(path, content, opts) do
      :ok -> {:cont, {:ok, Map.put(backups, inspect(resource_id), path)}}
      {:error, reason} -> {:halt, {:error, reason}}
    end
  end

  defp backup_content(%{change: %{before: nil}}), do: :skip

  defp backup_content(%{
         change: %{resource_id: resource_id, before: %HostKit.Resources.File{content: content}}
       })
       when is_binary(content), do: {:ok, resource_id, content}

  defp backup_content(%{
         change: %{resource_id: resource_id, before: %{meta: %{content: content}}}
       })
       when is_binary(content), do: {:ok, resource_id, content}

  defp backup_content(_result), do: :skip

  defp write_backup(path, content, opts) do
    with :ok <- HostKit.Runner.Files.mkdir_p(Path.dirname(path), opts) do
      HostKit.Runner.Files.write_file(path, content, opts)
    end
  end

  defp backup_filename(resource_id) do
    resource_id
    |> inspect()
    |> String.downcase()
    |> String.replace(~r/[^a-z0-9_.-]+/, "-")
    |> String.trim("-.")
    |> Kernel.<>(".bak")
  end

  defp backups_root(plan, opts) do
    opts
    |> Keyword.get(:hostkit_backups_root)
    |> case do
      nil -> plan |> plan_project() |> project_conventions() |> Conventions.backups_root()
      root -> root
    end
  end

  defp change_record(%{change: change, status: status}) do
    %Change{
      resource_id: Resource.dump(change.resource_id),
      action: to_string(change.action),
      status: to_string(status),
      reason: Resource.dump(change.reason)
    }
  end

  defp record_path(plan, opts, id), do: Path.join(runs_root(plan, opts), id <> ".json")

  defp run_id(plan) do
    stamp = DateTime.utc_now() |> Calendar.strftime("%Y%m%d-%H%M%S")
    project = if plan.project && plan.project.name, do: to_string(plan.project.name), else: "plan"
    direction = plan.opts |> Keyword.get(:direction, :up) |> to_string()
    "#{stamp}-#{project}-#{direction}"
  end

  defp project_name(project), do: to_string(project.name)

  defp plan_project(%Plan{project: project}), do: project
  defp plan_project(nil), do: nil
  defp project_conventions(nil), do: Conventions.new()
  defp project_conventions(%{conventions: conventions}), do: Conventions.new(conventions)
  defp runner(opts), do: Keyword.get(opts, :runner, HostKit.Runner.Local)
end