priv/repo/migrations/20260823070000_create_deployment_control_plane.exs

58e6347eeb72 · 10 KB

defmodule OpenAgents.Repo.Migrations.CreateDeploymentControlPlane do
  use Ecto.Migration

  def change do
    create table(:deployment_environments, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :name, :string, null: false, size: 60
      add :kind, :string, null: false
      add :provider, :string, null: false
      add :provider_config, :map, null: false, default: %{}
      add :secret_references, {:array, :string}, null: false, default: []
      add :protection, :map, null: false, default: %{}
      add :retention_days, :integer, null: false, default: 90
      add :created_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)

      timestamps(type: :utc_datetime_usec)
    end

    # An environment name is the identity a request addresses, so one repository
    # cannot hold two `production` environments with different protection.
    create unique_index(:deployment_environments, [:repository_id, :name])

    create constraint(:deployment_environments, :deployment_environments_kind_check,
             check: "kind in ('preview', 'staging', 'production')"
           )

    create constraint(:deployment_environments, :deployment_environments_retention_check,
             check: "retention_days > 0 and retention_days <= 3650"
           )

    create table(:deployment_requests, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :environment_id,
          references(:deployment_environments, type: :binary_id, on_delete: :delete_all),
          null: false

      add :commit_sha, :string, null: false, size: 40
      add :artifact_digest, :string, null: false, size: 100
      add :artifact_created_at, :utc_datetime_usec
      add :source_ref, :string, null: false, size: 255
      add :source_workflow, :string, size: 120
      add :principal_type, :string, null: false
      add :requested_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
      add :requested_by_grant_id, :binary_id
      add :idempotency_key, :string, null: false, size: 255
      add :request_digest, :string, null: false, size: 64
      add :input_digest, :string, null: false, size: 64
      add :requested_at, :utc_datetime_usec, null: false

      timestamps(type: :utc_datetime_usec)
    end

    # Idempotency is scoped to the environment the key was spent on: replaying a
    # key with the same payload returns the original request, and replaying it
    # with different bytes is a conflict rather than a second deployment.
    create unique_index(
             :deployment_requests,
             [:repository_id, :environment_id, :idempotency_key],
             name: :deployment_requests_idempotency_index
           )

    create index(:deployment_requests, [:environment_id, :inserted_at])

    create constraint(:deployment_requests, :deployment_requests_principal_check,
             check: "principal_type in ('user', 'workflow', 'operator')"
           )

    create constraint(:deployment_requests, :deployment_requests_commit_check,
             check: "commit_sha ~ '^[0-9a-f]{40}$'"
           )

    create constraint(:deployment_requests, :deployment_requests_digest_check,
             check: "request_digest ~ '^[0-9a-f]{64}$' and input_digest ~ '^[0-9a-f]{64}$'"
           )

    create table(:deployment_runs, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :environment_id,
          references(:deployment_environments, type: :binary_id, on_delete: :delete_all),
          null: false

      add :deployment_request_id,
          references(:deployment_requests, type: :binary_id, on_delete: :delete_all),
          null: false

      add :input_digest, :string, null: false, size: 64
      add :state, :string, null: false
      add :result_reason, :string, size: 80
      add :provider, :string, null: false
      add :provider_receipt, :map, null: false, default: %{}
      add :policy_explanation, {:array, :map}, null: false, default: []
      add :attempt_count, :integer, null: false, default: 0
      add :lease_owner, :string, size: 120
      add :lease_expires_at, :utc_datetime_usec
      add :cancel_requested_at, :utc_datetime_usec

      add :cancel_requested_by_user_id,
          references(:users, type: :binary_id, on_delete: :nilify_all)

      add :started_at, :utc_datetime_usec
      add :finished_at, :utc_datetime_usec
      add :superseded_by_run_id, :binary_id

      timestamps(type: :utc_datetime_usec)
    end

    # One admitted run per request. A retried worker resumes the existing run
    # rather than starting a second execution of the same immutable input.
    create unique_index(:deployment_runs, [:deployment_request_id])
    create index(:deployment_runs, [:environment_id, :state])
    create index(:deployment_runs, [:state, :lease_expires_at])
    create index(:deployment_runs, [:repository_id, :inserted_at])

    create constraint(:deployment_runs, :deployment_runs_state_check,
             check:
               "state in ('requested', 'checking', 'waiting_for_approval', 'queued', 'deploying', 'succeeded', 'failed', 'cancelled', 'superseded')"
           )

    create table(:deployment_approvals, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :deployment_run_id,
          references(:deployment_runs, type: :binary_id, on_delete: :delete_all),
          null: false

      add :approver_user_id, references(:users, type: :binary_id, on_delete: :restrict),
        null: false

      add :decision, :string, null: false
      add :rule, :string, null: false, size: 80
      add :request_digest, :string, null: false, size: 64
      add :comment, :string, size: 500
      add :decided_at, :utc_datetime_usec, null: false

      timestamps(type: :utc_datetime_usec)
    end

    # One decision per approver per run, so a single approver cannot satisfy a
    # two-approval policy by voting twice.
    create unique_index(:deployment_approvals, [:deployment_run_id, :approver_user_id])

    create constraint(:deployment_approvals, :deployment_approvals_decision_check,
             check: "decision in ('approved', 'rejected')"
           )

    create table(:deployment_check_results, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :name, :string, null: false, size: 120
      add :commit_sha, :string, null: false, size: 40
      add :artifact_digest, :string, null: false, size: 100
      add :status, :string, null: false
      add :evidence_url, :string, size: 500
      add :evidence_digest, :string, size: 64
      add :valid_until, :utc_datetime_usec
      add :published_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
      add :published_by_grant_id, :binary_id

      timestamps(type: :utc_datetime_usec)
    end

    # A check result is identified by the exact bytes it examined. Publishing
    # the same check name for a different artifact adds a row instead of
    # relabelling the old evidence.
    create unique_index(
             :deployment_check_results,
             [:repository_id, :name, :commit_sha, :artifact_digest],
             name: :deployment_check_results_identity_index
           )

    create constraint(:deployment_check_results, :deployment_check_results_status_check,
             check: "status in ('pending', 'succeeded', 'failed')"
           )

    create constraint(:deployment_check_results, :deployment_check_results_commit_check,
             check: "commit_sha ~ '^[0-9a-f]{40}$'"
           )

    create table(:deployment_events, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :deployment_run_id,
          references(:deployment_runs, type: :binary_id, on_delete: :delete_all),
          null: false

      add :sequence, :integer, null: false
      add :type, :string, null: false, size: 60
      add :from_state, :string, size: 30
      add :to_state, :string, size: 30
      add :detail, :map, null: false, default: %{}
      add :actor_type, :string, null: false, size: 20
      add :actor_id, :string, size: 64
      add :occurred_at, :utc_datetime_usec, null: false

      # Append-only: an event has an insertion time and no update time, because
      # nothing may rewrite a transition after the fact.
      timestamps(type: :utc_datetime_usec, updated_at: false)
    end

    create unique_index(:deployment_events, [:deployment_run_id, :sequence])
    create index(:deployment_events, [:repository_id, :inserted_at])

    create table(:deployment_workflow_grants, primary_key: false) do
      add :id, :binary_id, primary_key: true

      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
        null: false

      add :environment_id,
          references(:deployment_environments, type: :binary_id, on_delete: :delete_all)

      add :token_digest, :string, null: false, size: 64
      add :audience, :string, null: false, size: 120
      add :source_ref, :string, null: false, size: 255
      add :source_workflow, :string, null: false, size: 120
      add :workflow_run_id, :string, null: false, size: 64
      add :scopes, {:array, :string}, null: false, default: []
      add :expires_at, :utc_datetime_usec, null: false
      add :revoked_at, :utc_datetime_usec
      add :created_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)

      timestamps(type: :utc_datetime_usec)
    end

    create unique_index(:deployment_workflow_grants, [:token_digest])
    create index(:deployment_workflow_grants, [:repository_id, :expires_at])
  end
end