lib/openagents_web/controllers/deployment_json.ex

58e6347eeb72 · 5 KB

defmodule OpenAgentsWeb.DeploymentJson do
  @moduledoc """
  Stable JSON for the deployment control plane.

  Two properties matter more than shape here. Nothing rendered carries a secret
  value: environments expose secret *references*, runs expose a sanitized
  provider receipt, and events expose bounded detail. And every state, reason,
  and policy explanation a client needs in order to know why a deployment is
  waiting is present, because a control plane that says only "failed" forces its
  users to guess.
  """

  alias OpenAgents.Deployments.Approval
  alias OpenAgents.Deployments.CheckResult
  alias OpenAgents.Deployments.Environment
  alias OpenAgents.Deployments.Event
  alias OpenAgents.Deployments.Request
  alias OpenAgents.Deployments.Run
  alias OpenAgents.Deployments.WorkflowGrant

  @doc "Render one environment, including its protection requirements."
  @spec environment(Environment.t()) :: map()
  def environment(%Environment{} = environment) do
    %{
      "name" => environment.name,
      "kind" => environment.kind,
      "provider" => environment.provider,
      "provider_config" => environment.provider_config,
      "secret_references" => environment.secret_references,
      "retention_days" => environment.retention_days,
      "protection" => protection(environment)
    }
  end

  @doc "Render an environment's protection requirements on their own."
  @spec protection(Environment.t()) :: map()
  def protection(%Environment{protection: nil}), do: %{}

  def protection(%Environment{protection: protection}) do
    %{
      "required_checks" => protection.required_checks,
      "required_approvals" => protection.required_approvals,
      "separation_of_duties" => protection.separation_of_duties,
      "approver_roles" => protection.approver_roles,
      "allowed_branches" => protection.allowed_branches,
      "allowed_tags" => protection.allowed_tags,
      "allowed_workflows" => protection.allowed_workflows,
      "window" => %{
        "weekdays" => protection.window_weekdays,
        "start_minute" => protection.window_start_minute,
        "end_minute" => protection.window_end_minute
      },
      "frozen" => protection.frozen,
      "freeze_reason" => protection.freeze_reason,
      "concurrency" => protection.concurrency,
      "maximum_artifact_age_seconds" => protection.maximum_artifact_age_seconds,
      "check_validity_seconds" => protection.check_validity_seconds
    }
  end

  @doc "Render one run, with the request it executes when that is loaded."
  @spec run(Run.t()) :: map()
  def run(%Run{} = run) do
    %{
      "id" => run.id,
      "state" => run.state,
      "result_reason" => run.result_reason,
      "provider" => run.provider,
      "provider_receipt" => run.provider_receipt,
      "policy_explanation" => run.policy_explanation,
      "attempt_count" => run.attempt_count,
      "input_digest" => run.input_digest,
      "cancel_requested" => not is_nil(run.cancel_requested_at),
      "superseded_by_run_id" => run.superseded_by_run_id,
      "started_at" => run.started_at,
      "finished_at" => run.finished_at,
      "created_at" => run.inserted_at,
      "environment" => environment_name(run),
      "request" => request(run)
    }
  end

  @doc "Render one recorded approval decision."
  @spec approval(Approval.t()) :: map()
  def approval(%Approval{} = approval) do
    %{
      "decision" => approval.decision,
      "rule" => approval.rule,
      "request_digest" => approval.request_digest,
      "comment" => approval.comment,
      "decided_at" => approval.decided_at
    }
  end

  @doc "Render one published check result."
  @spec check_result(CheckResult.t()) :: map()
  def check_result(%CheckResult{} = result) do
    %{
      "name" => result.name,
      "commit_sha" => result.commit_sha,
      "artifact_digest" => result.artifact_digest,
      "status" => result.status,
      "evidence_url" => result.evidence_url,
      "evidence_digest" => result.evidence_digest,
      "valid_until" => result.valid_until,
      "published_at" => result.updated_at
    }
  end

  @doc "Render one append-only run event."
  @spec event(Event.t()) :: map()
  def event(%Event{} = event) do
    %{
      "sequence" => event.sequence,
      "type" => event.type,
      "from_state" => event.from_state,
      "to_state" => event.to_state,
      "detail" => event.detail,
      "actor_type" => event.actor_type,
      "occurred_at" => event.occurred_at
    }
  end

  @doc """
  Render a workflow grant and its one-time token.

  The plaintext appears in this response and nowhere else, because only its
  digest is stored.
  """
  @spec workflow_grant(WorkflowGrant.t(), String.t()) :: map()
  def workflow_grant(%WorkflowGrant{} = grant, plaintext) do
    %{
      "id" => grant.id,
      "token" => plaintext,
      "audience" => grant.audience,
      "scopes" => grant.scopes,
      "source_ref" => grant.source_ref,
      "source_workflow" => grant.source_workflow,
      "workflow_run_id" => grant.workflow_run_id,
      "expires_at" => grant.expires_at
    }
  end

  defp request(%Run{deployment_request: %Request{} = request}) do
    %{
      "commit_sha" => request.commit_sha,
      "artifact_digest" => request.artifact_digest,
      "artifact_created_at" => request.artifact_created_at,
      "source_ref" => request.source_ref,
      "source_workflow" => request.source_workflow,
      "principal_type" => request.principal_type,
      "idempotency_key" => request.idempotency_key,
      "request_digest" => request.request_digest,
      "requested_at" => request.requested_at
    }
  end

  defp request(%Run{}), do: nil

  defp environment_name(%Run{environment: %Environment{name: name}}), do: name
  defp environment_name(%Run{}), do: nil
end