lib/openagents/deployments/providers/fake.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgents.Deployments.Providers.Fake do
  @moduledoc """
  A provider that deploys nothing and records what it was asked to do.

  The fake provider is the first delivery phase's whole execution surface. It
  exists so the contract — admission, leases, idempotency by run id, uncertain
  results, cancellation, secret boundaries — can be proved before any real
  infrastructure is involved, and so those proofs keep running afterwards.

  It is idempotent by run id: a second `deploy/1` for a run it already deployed
  returns the original receipt instead of a second deployment. State lives in an
  ETS table owned by a supervised process, so a worker crash does not lose the
  record of what the provider already did.

  Behavior is chosen per run through `program/2`, which is how a test asks for a
  failure, a timeout, or an uncertain result without a real outage.
  """

  @behaviour OpenAgents.Deployments.Provider

  use GenServer

  alias OpenAgents.Deployments.Execution

  @table __MODULE__.Deployments
  @programs __MODULE__.Programs

  @doc "Start the fake provider's durable-enough bookkeeping."
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(options), do: GenServer.start_link(__MODULE__, options, name: __MODULE__)

  @impl GenServer
  def init(_options) do
    :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
    :ets.new(@programs, [:named_table, :public, :set, read_concurrency: true])
    {:ok, %{}}
  end

  @impl OpenAgents.Deployments.Provider
  def deploy(%Execution{} = execution) do
    ensure_tables()

    case :ets.lookup(@table, execution.run_id) do
      [{_run_id, receipt}] ->
        {:ok, Map.put(receipt, "replayed", true)}

      [] ->
        execute(execution, program(execution.run_id))
    end
  end

  @impl OpenAgents.Deployments.Provider
  def cancel(%Execution{} = execution) do
    ensure_tables()
    :ets.insert(@programs, {execution.run_id, :cancelled})
    :ok
  end

  @impl OpenAgents.Deployments.Provider
  def required_secret_references(provider_config) when is_map(provider_config) do
    case Map.get(provider_config, "secret_reference") do
      reference when is_binary(reference) -> [reference]
      _absent -> []
    end
  end

  @doc """
  Program the outcome of one run's next deployment.

  `:succeed` is the default. `:fail` returns a definitive error, `:uncertain`
  returns an unknown outcome, and `:hang` returns an uncertain result after the
  execution deadline passes, which is how a timeout looks from here.
  """
  @spec program(String.t(), :succeed | :fail | :uncertain | :hang) :: :ok
  def program(run_id, outcome)
      when is_binary(run_id) and outcome in [:succeed, :fail, :uncertain, :hang] do
    ensure_tables()
    :ets.insert(@programs, {run_id, outcome})
    :ok
  end

  @doc "Whether this run was asked to stop."
  @spec cancelled?(String.t()) :: boolean()
  def cancelled?(run_id) when is_binary(run_id) do
    ensure_tables()
    :ets.lookup(@programs, run_id) == [{run_id, :cancelled}]
  end

  @doc "The receipt this provider already issued for a run, if any."
  @spec receipt(String.t()) :: {:ok, map()} | :error
  def receipt(run_id) when is_binary(run_id) do
    ensure_tables()

    case :ets.lookup(@table, run_id) do
      [{_run_id, receipt}] -> {:ok, receipt}
      [] -> :error
    end
  end

  @doc """
  Forget one run's recorded deployment and program.

  Bookkeeping is keyed by run id, which is unique, so nothing here clears every
  run: a concurrent test's programmed outcome must survive another test's setup.
  """
  @spec forget(String.t()) :: :ok
  def forget(run_id) when is_binary(run_id) do
    ensure_tables()
    :ets.delete(@table, run_id)
    :ets.delete(@programs, run_id)
    :ok
  end

  defp execute(%Execution{}, :fail), do: {:error, :provider_rejected}

  defp execute(%Execution{} = execution, :uncertain) do
    {:uncertain, %{"run_id" => execution.run_id, "detail" => "provider did not confirm"}}
  end

  defp execute(%Execution{} = execution, :hang) do
    {:uncertain, %{"run_id" => execution.run_id, "detail" => "provider timed out"}}
  end

  defp execute(%Execution{}, :cancelled), do: {:error, :cancelled}

  defp execute(%Execution{} = execution, :succeed) do
    receipt = %{
      "run_id" => execution.run_id,
      "commit_sha" => execution.commit_sha,
      "artifact_digest" => execution.artifact_digest,
      "environment" => execution.environment,
      "secret_references_resolved" =>
        execution.secrets |> Map.keys() |> Enum.sort() |> Enum.join(",")
    }

    :ets.insert(@table, {execution.run_id, receipt})
    {:ok, receipt}
  end

  defp program(run_id) do
    case :ets.lookup(@programs, run_id) do
      [{_run_id, outcome}] -> outcome
      [] -> :succeed
    end
  end

  # The provider is used from request-path tests and from workers, so the tables
  # are created on demand rather than requiring a supervised owner everywhere.
  defp ensure_tables do
    for table <- [@table, @programs] do
      if :ets.whereis(table) == :undefined do
        :ets.new(table, [:named_table, :public, :set, read_concurrency: true])
      end
    end

    :ok
  rescue
    ArgumentError -> :ok
  end
end