lib/nomad_crd/template_crd.ex

defmodule NomadCrd.TemplateCrd do
  use GenServer

  defmodule State do
    defstruct [
      :backend,
      :rendering,
      :diff_engine,
      :template,
      :delta,
      :job_filter_fn
    ]

    @type t :: %__MODULE__{
            :backend => NomadCrd.Backend,
            :rendering => NomadCrd.TemplateRender,
            :diff_engine => NomadCrd.DiffEngines.TemplateDiff,
            :template => NomadCrd.Template,
            :delta => %{optional(any) => {NomadClient.Model.Job.t(), map()}},
            :job_filter_fn => (NomadClient.Model.Job.t() -> true | false) | nil
          }
  end

  @spec start_link(keyword) :: :ignore | {:error, any} | {:ok, pid}
  def start_link(opts) when is_list(opts) do
    state = %State{
      backend: Keyword.fetch!(opts, :backend),
      rendering: Keyword.get(opts, :rendering, NomadCrd.TemplateRender),
      diff_engine: Keyword.get(opts, :diff_engine, NomadCrd.DiffEngines.TemplateDiff),
      template: Keyword.fetch!(opts, :template),
      job_filter_fn: Keyword.get(opts, :job_filter_fn, nil)
    }

    name = Keyword.get(opts, :name, state.template)

    GenServer.start_link(__MODULE__, state, name: name)
  end

  def create(pid, variables) do
    GenServer.call(pid, {:create, variables}, 60_000)
  end

  def get_diff(pid) do
    GenServer.call(pid, :check_diff)
  end

  def delete(pid, id) do
    GenServer.call(pid, {:delete, id})
  end

  def update_all(pid) do
    GenServer.call(pid, {:update, :all}, 60_000)
  end

  def get_jobs(pid) do
    GenServer.call(pid, :get_jobs)
  end

  # Server (callbacks)

  def init(%State{} = state) do
    {:ok, state}
  end

  def handle_call({:create, variables}, _from, %State{} = state) do
    job_def = state.rendering.render(state.template, variables)
    response = state.backend.create_job(job_def)

    {:reply, response, state}
  end

  def handle_call({:delete, id}, _from, %State{} = state) do
    response = state.backend.delete_job(id)

    {:reply, response, state}
  end

  # ------------------------[EVERY HANDLE CALL DOWN HERE REQUIRES A DELTA]------------------------
  def handle_call(action, from, %State{delta: nil} = state) do
    state = load_delta(state)

    handle_call(action, from, state)
  end

  def handle_call(:check_diff, _from, %State{delta: delta} = state) do
    diff =
      delta
      |> Enum.map(fn {id, {_job, diff}} ->
        {id, diff}
      end)
      |> Enum.reject(fn {_id, diff} -> %{} === diff end)
      |> Map.new()

    {:reply, diff, state}
  end

  def handle_call(:get_jobs, _from, %State{delta: delta} = state) do
    jobs = Enum.map(delta, fn {_id, {job, _delta}} -> job end)

    {:reply, jobs, state}
  end

  def handle_call({:update, :all}, _from, %State{delta: delta} = state) do
    task_results =
      delta
      |> Task.async_stream(
        fn
          {_id, {job, diff}} when diff === %{} ->
            # noop for empty delta
            {:ok, job}

          {_id, {job, diff}} ->
            job_id = Map.get(job, :ID)
            job_update = state.diff_engine.patch(job, diff)
            state.backend.update_job(job_id, job_update)
        end,
        timeout: 60_000
      )
      |> Enum.to_list()

    # TODO: Let's just be honest ... this is kind of lazy and garabage, sorry future me.
    # TODO: Do propper error handling.
    status =
      task_results
      |> Enum.all?(fn
        {:ok, {:ok, _job}} -> true
        _ -> false
      end)
      |> if do
        :ok
      else
        :error
      end

    {:reply, status, invalidate_delta(state)}
  end

  def handle_info(:reload_delta, %State{} = state) do
    {:noreply, load_delta(state)}
  end

  defp load_delta(%State{} = state) do
    alias NomadClient.Model.Job

    {:ok, jobs} = get_suitable_jobs(state)

    delta =
      jobs
      |> Enum.map(fn %Job{} = job ->
        id = Map.get(job, :ID)

        diff = state.diff_engine.diff(job, state.template)

        {id, {job, diff}}
      end)
      |> Map.new()

    %{state | delta: delta}
  end

  defp get_suitable_jobs(%State{job_filter_fn: nil} = state), do: state.backend.get_jobs()

  defp get_suitable_jobs(%State{job_filter_fn: filter_fn} = state) do
    {:ok, jobs} = state.backend.get_jobs()
    jobs = Enum.filter(jobs, filter_fn)
    {:ok, jobs}
  end

  defp invalidate_delta(%State{} = state), do: %{state | delta: nil}
end