Add a policy-controlled deployment automation control plane

6fb4d010a502 · Devin AI · · parent 32e1a2ef3f62

Add a policy-controlled deployment automation control plane

Deployment intent is recorded, evaluated against repository and environment
policy, and executed only as an immutable admitted run. Tenant deployment
authority (deployments:write) stays separate from operator forge promotion
authority (deployments:promote), workflow grants cannot widen repository,
environment, commit, or audience authority, and provider uncertainty,
timeouts, and exceptions never yield success.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/runtime.exs
  • added docs/deployment-control-plane.md
  • modified docs/taxonomy.md
  • modified lib/openagents/api_tokens.ex
  • added lib/openagents/deployments.ex
  • added lib/openagents/deployments/approval.ex
  • added lib/openagents/deployments/authority.ex
  • added lib/openagents/deployments/check_result.ex
  • added lib/openagents/deployments/environment.ex
  • added lib/openagents/deployments/event.ex
  • added lib/openagents/deployments/execution.ex
  • added lib/openagents/deployments/lifecycle.ex
  • added lib/openagents/deployments/policy.ex
  • added lib/openagents/deployments/principal.ex
  • added lib/openagents/deployments/protection.ex
  • added lib/openagents/deployments/provider.ex
  • added lib/openagents/deployments/providers/fake.ex
  • added lib/openagents/deployments/request.ex
  • added lib/openagents/deployments/run.ex
  • added lib/openagents/deployments/secret_resolver.ex
  • added lib/openagents/deployments/secret_resolver/environment.ex
  • added lib/openagents/deployments/worker.ex
  • added lib/openagents/deployments/workflow_grant.ex
  • modified lib/openagents/repositories/repository.ex
  • modified lib/openagents/runtime_config.ex
  • modified lib/openagents/runtime_supervisor.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/deployment_controller.ex
  • added lib/openagents_web/controllers/deployment_json.ex
  • added lib/openagents_web/plugs/deployment_principal.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823070000_create_deployment_control_plane.exs
  • added priv/repo/migrations/20260823071500_allow_deployments_write_api_token_scope.exs
  • added test/openagents/deployments/lifecycle_test.exs
  • added test/openagents/deployments/policy_test.exs
  • added test/openagents/deployments_test.exs
  • modified test/openagents/runtime_config_test.exs
  • added test/openagents_web/controllers/deployment_controller_test.exs
  • modified test/support/conn_case.ex
  • added test/support/fixtures/deployments_fixtures.ex

Diff

44 files changed, +6248 -3

INVARIANTS.md modified +103

@@ -1236,6 +1236,104 @@ Evidence: `OpenAgents.AcceptedOutcome`,

1236 1236
`docs/accepted-outcome-contract.md`, and
1237 1237
`test/openagents/accepted_outcome_test.exs`.
1238 1238
1239
## Tenant deployment control plane
1240
1241
### DEPLOYPLANE-001 — A deployment intent carries no authority
1242
1243
Status: Current
1244
1245
A deployment request states what a tenant wants deployed. Every authority
1246
decision comes from durable records instead: repository membership rechecked at
1247
each sensitive transition, the environment's protection policy, and published
1248
evidence. A request names a full 40-character commit SHA and a `sha256:`
1249
artifact digest; a branch or tag is provenance, never something resolved later.
1250
1251
The plane authenticates a human holding `deployments:write` or a short-lived
1252
workflow grant. `forge:write` is not deployment authority, and no route in this
1253
plane reaches the operator fleet-promotion surface behind
1254
`deployments:promote`. A private repository is readable only by a member, and
1255
cross-repository reads, approvals, cancellations, and provider bindings are
1256
denied.
1257
1258
Evidence: `OpenAgents.Deployments`, `OpenAgents.Deployments.Authority`,
1259
`OpenAgents.Deployments.Principal`, `OpenAgentsWeb.DeploymentController`,
1260
`OpenAgentsWeb.ApiRouteAuthority`, `test/openagents/deployments_test.exs`, and
1261
`test/openagents_web/controllers/deployment_controller_test.exs`.
1262
1263
### DEPLOYPLANE-002 — A workflow grant binds to exactly one context
1264
1265
Status: Current
1266
1267
A grant issued to a workflow is single-context and short-lived: it binds one
1268
repository, one environment where applicable, one source ref, one source
1269
workflow, and one workflow run ID, with a clamped lifetime. Presenting a grant
1270
cannot widen repository, environment, commit, artifact, or audience authority,
1271
and a workflow principal can never approve a request. Revocation takes effect
1272
before the next sensitive transition.
1273
1274
Evidence: `OpenAgents.Deployments.WorkflowGrant`,
1275
`OpenAgents.Deployments.Authority`, and `test/openagents/deployments_test.exs`.
1276
1277
### DEPLOYPLANE-003 — Policy admits a run on exact bytes, with a durable explanation
1278
1279
Status: Current
1280
1281
`OpenAgents.Deployments.Policy` evaluates allowed branches, allowed tags,
1282
allowed source workflows, freeze, deployment window, artifact age, required
1283
checks, and required approvals, and persists an explanation for every rule it
1284
evaluated. A required check satisfies a requirement only when it names the same
1285
commit SHA and the same artifact digest and is younger than the environment's
1286
validity limit, so a green result cannot be replayed onto different bytes. A
1287
missing required check leaves the request pending rather than admitting it.
1288
Approvals honor separation of duties: a requester cannot approve its own
1289
request. Preview environments may supersede an in-flight request; production
1290
never supersedes implicitly.
1291
1292
Evidence: `OpenAgents.Deployments.Policy`,
1293
`OpenAgents.Deployments.Protection`,
1294
`test/openagents/deployments/policy_test.exs`, and
1295
`test/openagents/deployments_test.exs`.
1296
1297
### DEPLOYPLANE-004 — One lifecycle defines every legal transition
1298
1299
Status: Current
1300
1301
`OpenAgents.Deployments.Lifecycle` is the only definition of legal deployment
1302
states and transitions, and transitions are enforced transactionally against
1303
the durable row. Terminal states have no successors, a run cannot skip
1304
`deploying` on its way to `succeeded`, and a run already `deploying` cannot be
1305
superseded. Every transition appends a sequenced deployment event whose payload
1306
is bounded and redacted, so event polling, subscriptions, and receipts cannot
1307
disclose a secret.
1308
1309
Evidence: `OpenAgents.Deployments.Lifecycle`, `OpenAgents.Deployments.Event`,
1310
`test/openagents/deployments/lifecycle_test.exs`, and
1311
`test/openagents/deployments_test.exs`.
1312
1313
### DEPLOYPLANE-005 — Only an admitted, immutable execution reaches a provider, and uncertainty fails
1314
1315
Status: Current
1316
1317
A provider receives an immutable execution object built after admission. It
1318
never receives caller credentials, its idempotency is keyed by run ID, and
1319
secrets are resolved at execution time only for its own bound environment as
1320
references rather than stored values. A provider failure, exception, exit,
1321
timeout, or unknown result terminalizes the run as failed; only an explicit
1322
provider success produces a success receipt.
1323
1324
`OpenAgents.Deployments.Worker` claims queued runs under renewable leases,
1325
re-evaluates policy and membership before handing work to the provider,
1326
observes cancellation during execution, and reconciles runs whose lease expired
1327
after a crash. The worker starts only when the `deployment_control_plane`
1328
feature is enabled, validated by `OpenAgents.RuntimeConfig`; the API surface
1329
records and evaluates runs regardless.
1330
1331
Evidence: `OpenAgents.Deployments.Execution`,
1332
`OpenAgents.Deployments.Provider`, `OpenAgents.Deployments.Providers.Fake`,
1333
`OpenAgents.Deployments.SecretResolver`, `OpenAgents.Deployments.Worker`,
1334
`OpenAgents.RuntimeSupervisor`, `test/openagents/deployments_test.exs`, and
1335
`test/openagents/runtime_config_test.exs`.
1336
1239 1337
## Interface and release
1240 1338
1241 1339
### VOICE-001 — Spoken identity is admitted before media

@@ -2187,6 +2285,11 @@ contract; the invariant prose above defines the assertion, not the filename.

2187 2285
| SELF-EDIT-001 | `test/openagents/tools/repository_mutation_tools_test.exs`, `test/openagents/coding_job_test.exs` |
2188 2286
| SCV-001 | `test/openagents/scv/deployments_test.exs` |
2189 2287
| OUTCOME-001 | `test/openagents/accepted_outcome_test.exs` |
2288
| DEPLOYPLANE-001 | `test/openagents/deployments_test.exs`, `test/openagents_web/controllers/deployment_controller_test.exs`, `test/openagents_web/api_route_authority_test.exs` |
2289
| DEPLOYPLANE-002 | `test/openagents/deployments_test.exs` |
2290
| DEPLOYPLANE-003 | `test/openagents/deployments/policy_test.exs`, `test/openagents/deployments_test.exs` |
2291
| DEPLOYPLANE-004 | `test/openagents/deployments/lifecycle_test.exs`, `test/openagents/deployments_test.exs` |
2292
| DEPLOYPLANE-005 | `test/openagents/deployments_test.exs`, `test/openagents/runtime_config_test.exs` |
2190 2293
| VOICE-001 | `test/openagents/voice/config_test.exs` |
2191 2294
| VOICE-002 | `test/openagents_web/controllers/voice_call_controller_test.exs` |
2192 2295
| VOICE-003 | `test/openagents/voice_test.exs`, `test/openagents/voice_sessions_test.exs` |
config/config.exs modified +1

@@ -217,6 +217,7 @@ config :openagents,

217 217
  voice_retention_worker_enabled: false,
218 218
  turn_recovery_enabled: false,
219 219
  voice_retention_enabled: false,
220
  deployment_control_plane_enabled: false,
220 221
  ra_enabled: false,
221 222
  ra_data_dir: "/var/lib/openagents/ra",
222 223
  ra_expected_size: 3,
config/runtime.exs modified +2

@@ -194,6 +194,7 @@ if config_env() == :prod and runtime_role == :web do

194 194
  incident_fixer_enabled = feature.("INCIDENT_FIXER")
195 195
  turn_recovery_enabled = feature.("TURN_RECOVERY")
196 196
  voice_retention_enabled = feature.("VOICE_RETENTION")
197
  deployment_control_plane_enabled = feature.("DEPLOYMENT_CONTROL_PLANE")
197 198
  forge_enabled = feature.("FORGE")
198 199
  forge_deploy_enabled = feature.("FORGE_DEPLOY")
199 200
  boot_convergence_enabled = feature.("BOOT_CONVERGENCE")

@@ -427,6 +428,7 @@ if config_env() == :prod and runtime_role == :web do

427 428
    conversation_reset_enabled: conversation_reset_enabled,
428 429
    incident_fixer_enabled: incident_fixer_enabled,
429 430
    turn_recovery_enabled: turn_recovery_enabled,
431
    deployment_control_plane_enabled: deployment_control_plane_enabled,
430 432
    github_oauth_scopes: github_oauth_scopes,
431 433
    openai_api_key: required_text.("OPENAI_API_KEY"),
432 434
    openrouter_api_key: optional_text.("OPENROUTER_API_KEY"),
docs/deployment-control-plane.md added +147

@@ -0,0 +1,147 @@

1
# Deployment control plane
2
3
How a deployment intent becomes an executed run, and what stops it.
4
5
This plane serves tenants: a repository deploys its own code to its own
6
environments. It is not the forge fleet-promotion surface. Promoting the
7
OpenAgents release itself remains operator-only behind `deployments:promote`,
8
and nothing described here can reach it.
9
10
## The contract
11
12
A deployment request states an intent. It never states authority. The control
13
plane derives every authority decision from durable records: repository
14
membership, the environment's protection policy, published check results bound
15
to exact bytes, and recorded approvals. A caller cannot widen its own
16
authority by asserting a field.
17
18
Four rules hold everywhere in this plane:
19
20
1. **Identity is exact.** A request names a full 40-character commit SHA and a
21
   `sha256:`-prefixed artifact digest. A branch or tag is recorded as
22
   provenance, never resolved later.
23
2. **Admission precedes execution.** A provider receives an immutable
24
   execution object built after policy admitted the run. It never receives
25
   caller credentials, and it resolves secrets only for its own bound
26
   environment.
27
3. **Uncertainty is not success.** A provider failure, exception, exit,
28
   timeout, or unknown result terminalizes the run as failed. Only an explicit
29
   provider success produces a success receipt.
30
4. **History is append-only.** Every state change writes a sequenced
31
   deployment event with a redacted, bounded payload.
32
33
## Objects
34
35
| Object | What it holds |
36
| --- | --- |
37
| Environment | The provider binding, secret *references*, and the protection policy for one target such as `preview` or `production`. |
38
| Request | The intent: commit SHA, artifact digest, source provenance, creator principal, idempotency key, and input digest. |
39
| Run | The execution of one admitted request: state, lease, attempt count, provider receipt. |
40
| Check result | A trusted workflow's verdict, bound to an exact commit SHA and artifact digest. |
41
| Approval | One principal's decision on one request, with a reason. |
42
| Workflow grant | A short-lived, single-context credential issued to a workflow. |
43
| Event | Append-only evidence of one transition. |
44
45
An environment stores the *name* of a secret, never its value, so no read path
46
can disclose one.
47
48
## States
49
50
```text
51
requested ─▶ checking ─▶ waiting_for_approval ─▶ queued ─▶ deploying ─▶ succeeded
52
                                                                    └─▶ failed
53
     └────────────────────── cancelled / superseded ──────────────────────┘
54
```
55
56
`OpenAgents.Deployments.Lifecycle` is the only place that defines legal
57
transitions, and `OpenAgents.Deployments.transition/4` enforces them inside a
58
transaction against the durable row. Terminal states have no successors, a run
59
cannot skip `deploying` to reach `succeeded`, and a run that is already
60
`deploying` cannot be superseded.
61
62
## Principals
63
64
`OpenAgents.Deployments.Principal` distinguishes a human, a workflow, an
65
approver, a provider, and a platform operator. The differences are
66
enforcement, not labels:
67
68
- A human request requires writable repository membership, rechecked at
69
  sensitive transitions rather than only at creation.
70
- A workflow request requires a grant whose repository, environment, source
71
  ref, source workflow, and workflow run ID all match the request. A grant
72
  binds to one context and cannot widen it.
73
- A workflow principal cannot approve, and a requester cannot approve its own
74
  request when the policy requires separation of duties.
75
- Operator recovery authority (lease reconciliation) is separate from tenant
76
  deployment authority.
77
78
Cross-repository reads, approvals, cancellations, and provider bindings are
79
denied. A public repository is readable by visibility; a private repository
80
requires membership.
81
82
## Policy
83
84
`OpenAgents.Deployments.Policy` evaluates one environment's protection
85
document against one request and returns `:satisfied`, `:pending`, or
86
`:blocked` with a durable explanation for every rule it evaluated. The rules
87
are allowed branches, allowed tags, allowed source workflows, freeze, the
88
deployment window, artifact age, required checks, and required approvals.
89
90
Required checks match exact bytes. A check published for the same commit but a
91
different artifact digest, or a check older than the environment's validity
92
limit, does not satisfy a requirement — which is what prevents replaying a
93
stale green build onto different bytes. A missing required check leaves the run
94
pending rather than admitting it.
95
96
Per-environment concurrency is `queue`, `cancel`, `reject`, or `supersede`. A
97
preview environment can supersede an in-flight request; production never
98
supersedes implicitly.
99
100
## Execution
101
102
`OpenAgents.Deployments.Worker` claims a queued run under a lease, renews it
103
while deploying, and reconciles runs whose lease expired after a crash. Before
104
handing work to the provider it rebuilds the execution object and re-evaluates
105
policy, so a revoked membership or a freeze declared after queueing stops the
106
run. Cancellation requested during provider execution is observed before the
107
run terminalizes.
108
109
Provider idempotency is keyed by run ID, so a retried attempt cannot deploy
110
twice. `OpenAgents.Deployments.Providers.Fake` implements the provider
111
contract for tests and for the contract-first phase, before real
112
infrastructure exists.
113
114
The worker starts only when `DEPLOYMENT_CONTROL_PLANE` is enabled, validated
115
by `OpenAgents.RuntimeConfig`. The API surface does not depend on that flag: a
116
host that cannot execute runs still records and evaluates them.
117
118
## API
119
120
The routes live under `/api/v3/repos/:owner/:repo` and require
121
`deployments:write` or a workflow grant. `forge:write` is not deployment
122
authority.
123
124
| Route | Purpose |
125
| --- | --- |
126
| `GET deployment-environments` | List environments. |
127
| `PUT deployment-environments/:name` | Create or update an environment and its policy. |
128
| `GET deployment-environments/:name/protection` | Read protection requirements. |
129
| `POST deployments` | Request a deployment. |
130
| `GET deployments` | List runs, bounded and paginated. |
131
| `GET deployments/:id` | Read one run. |
132
| `POST deployments/:id/cancel` | Cancel, optionally under a state precondition. |
133
| `POST deployments/:id/approvals` | Approve or reject. |
134
| `GET deployments/:id/approvals` | List decisions. |
135
| `GET deployments/:id/events` | Poll append-only events. |
136
| `POST deployment-checks` | Publish a trusted check result. |
137
| `POST deployment-workflow-grants` | Issue a workflow grant. |
138
| `DELETE deployment-workflow-grants/:id` | Revoke a workflow grant. |
139
140
Errors use one stable JSON shape with a typed code. Writes accept an
141
idempotency key: replaying the same key and the same input digest returns the
142
original run, and reusing the key with different bytes is a conflict.
143
144
## Related contracts
145
146
- `INVARIANTS.md`, `DEPLOYPLANE-001` through `DEPLOYPLANE-005`.
147
- `docs/taxonomy.md` for the difference between this plane and forge promotion.
docs/taxonomy.md modified +13

@@ -112,6 +112,19 @@ messages and tool steps underneath them.

112 112
policy, lifecycle, budgets, and receipts. It is not a container and not a
113 113
model.
114 114
115
**Deployment control plane** — the tenant-facing plane where a repository
116
deploys its own commits to its own environments: environments, requests, runs,
117
checks, approvals, workflow grants, and append-only deployment events, backed by
118
`OpenAgents.Deployments` and served at `/api/v3` under `deployments:write`. It
119
is not the forge deployment plane. Promoting the OpenAgents release itself stays
120
operator-only behind `deployments:promote`, and no tenant route reaches it.
121
`docs/deployment-control-plane.md` describes the contract.
122
123
**Deployment request and deployment run** — a request is the recorded intent
124
(exact commit SHA, exact artifact digest, provenance, creator principal); a run
125
is the execution of one admitted request. A request that policy never admits has
126
no run.
127
115 128
**Work job (`work_jobs`)** — a durable, budgeted delegated job row started by
116 129
`deep_work.v1`. Delegation, not execution.
117 130
lib/openagents/api_tokens.ex modified +6 -1

@@ -9,7 +9,12 @@ defmodule OpenAgents.ApiTokens do

9 9
  alias OpenAgents.Repo
10 10
11 11
  @prefix "oa_pat_"
12
  @allowed_scopes ["chat:account", "forge:write"]
12
  # `deployments:write` speaks to the deployment control plane. It is deliberately
13
  # not `forge:write`, and deliberately not the operator-only
14
  # `deployments:promote` fleet scope: holding it lets a caller address the
15
  # deployment API, while repository membership and environment policy still
16
  # decide what it may deploy.
17
  @allowed_scopes ["chat:account", "forge:write", "deployments:write"]
13 18
  @maximum_lifetime_days 90
14 19
15 20
  @spec create(User.t(), map()) ::
lib/openagents/deployments.ex added +1360

@@ -0,0 +1,1360 @@

1
defmodule OpenAgents.Deployments do
2
  @moduledoc """
3
  The deployment control plane: one place that decides whether exact bytes may
4
  reach a repository's environment, and records why.
5
6
  ## What this context owns
7
8
    * Repository-scoped environments and their protection policy.
9
    * Durable deployment requests: an intent for one exact commit and artifact.
10
    * Durable runs: the admitted execution of one request, with a lifecycle no
11
      caller can shortcut.
12
    * Approvals, check results, and an append-only event stream.
13
14
  ## The rules that hold everywhere
15
16
    * Authority comes from the principal, never from the request body. See
17
      `OpenAgents.Deployments.Authority`.
18
    * Every transition is checked against `OpenAgents.Deployments.Lifecycle` and
19
      written transactionally with its event, so a run's state and its history
20
      cannot disagree.
21
    * Policy is re-evaluated, and authority re-checked, at sensitive transitions.
22
      Membership is revoked and environments freeze between `requested` and
23
      `deploying`.
24
    * A request is idempotent on `{repository, environment, idempotency_key}`.
25
      Replaying a key with the same bytes returns the original run; replaying it
26
      with different bytes is a conflict, not a second deployment.
27
    * Durable records hold secret *references*. Values are resolved at execution
28
      time, for the bound provider and environment, and never stored.
29
    * Platform-operator authority recovers the control plane. It does not deploy
30
      tenant code, and `deployments:promote` — the Forge fleet promotion scope —
31
      is never exposed to a repository workflow.
32
  """
33
34
  import Ecto.Query
35
36
  alias Ecto.Multi
37
  alias OpenAgents.Accounts.User
38
  alias OpenAgents.Deployments.Approval
39
  alias OpenAgents.Deployments.Authority
40
  alias OpenAgents.Deployments.CheckResult
41
  alias OpenAgents.Deployments.Environment
42
  alias OpenAgents.Deployments.Event
43
  alias OpenAgents.Deployments.Lifecycle
44
  alias OpenAgents.Deployments.Policy
45
  alias OpenAgents.Deployments.Principal
46
  alias OpenAgents.Deployments.Protection
47
  alias OpenAgents.Deployments.Provider
48
  alias OpenAgents.Deployments.Request
49
  alias OpenAgents.Deployments.Run
50
  alias OpenAgents.Deployments.WorkflowGrant
51
  alias OpenAgents.Repo
52
  alias OpenAgents.Repositories.Repository
53
54
  @default_limit 25
55
  @maximum_limit 100
56
  @default_lease_seconds 300
57
58
  @type error ::
59
          {:forbidden, Authority.reason()}
60
          | :environment_not_found
61
          | :run_not_found
62
          | :request_not_found
63
          | :unknown_provider
64
          | :unknown_commit
65
          | :idempotency_conflict
66
          | :precondition_failed
67
          | {:illegal_transition, String.t(), String.t()}
68
          | {:policy_denied, String.t()}
69
          | Ecto.Changeset.t()
70
71
  # ---------------------------------------------------------------------------
72
  # Environments
73
  # ---------------------------------------------------------------------------
74
75
  @doc """
76
  Define or replace one environment's provider binding and protection policy.
77
78
  Only a repository member with write authority can define an environment, and
79
  the provider must be one the platform configures: a tenant that could name an
80
  arbitrary module would choose which code the control plane runs.
81
  """
82
  @spec put_environment(Repository.t(), Principal.t(), map()) ::
83
          {:ok, Environment.t()} | {:error, error()}
84
  def put_environment(%Repository{} = repository, %Principal{} = principal, attrs) do
85
    attrs = normalize(attrs)
86
87
    with :ok <- authorize_write(principal, repository),
88
         {:ok, _module} <- Provider.fetch(attrs["provider"] || "") do
89
      name = attrs["name"]
90
91
      environment =
92
        case fetch_environment_record(repository, name) do
93
          {:ok, existing} -> existing
94
          {:error, :environment_not_found} -> %Environment{repository_id: repository.id}
95
        end
96
97
      environment
98
      |> Environment.changeset(Map.put_new(attrs, "protection", %{}))
99
      |> put_creator(principal, environment)
100
      |> Repo.insert_or_update()
101
    end
102
  end
103
104
  @doc "List one repository's environments, with their protection requirements."
105
  @spec list_environments(Repository.t(), Principal.t()) ::
106
          {:ok, [Environment.t()]} | {:error, error()}
107
  def list_environments(%Repository{} = repository, %Principal{} = principal) do
108
    with :ok <- Authority.authorize_read(principal, repository) do
109
      {:ok,
110
       Repo.all(
111
         from environment in Environment,
112
           where: environment.repository_id == ^repository.id,
113
           order_by: [asc: environment.name]
114
       )}
115
    end
116
  end
117
118
  @doc "Fetch one environment by name, enforcing repository visibility."
119
  @spec fetch_environment(Repository.t(), Principal.t(), String.t()) ::
120
          {:ok, Environment.t()} | {:error, error()}
121
  def fetch_environment(%Repository{} = repository, %Principal{} = principal, name) do
122
    with :ok <- Authority.authorize_read(principal, repository) do
123
      fetch_environment_record(repository, name)
124
    end
125
  end
126
127
  # ---------------------------------------------------------------------------
128
  # Requests and runs
129
  # ---------------------------------------------------------------------------
130
131
  @doc """
132
  Record a deployment intent and admit it as far as policy allows.
133
134
  The request names exact bytes: a full commit sha and an artifact digest. The
135
  commit is verified against the repository's own git storage through
136
  `:commit_store`, so a request cannot deploy a sha the repository never
137
  received.
138
139
  On success the run holds the furthest state policy admits: `queued` when
140
  nothing is outstanding, `checking` while required checks are missing,
141
  `waiting_for_approval` while approvals are. A policy denial is durable too: the
142
  run reaches `failed` with an explanation, because a deployment that was refused
143
  and left no record is indistinguishable from one that was never asked for.
144
  """
145
  @spec request_deployment(Repository.t(), Principal.t(), map(), keyword()) ::
146
          {:ok, Run.t()} | {:error, error()}
147
  def request_deployment(
148
        %Repository{} = repository,
149
        %Principal{} = principal,
150
        attrs,
151
        options \\ []
152
      ) do
153
    attrs = normalize(attrs)
154
    commit_store = Keyword.get_lazy(options, :commit_store, &default_commit_store/0)
155
156
    with {:ok, environment} <- fetch_environment_record(repository, attrs["environment"]),
157
         {:ok, principal_type} <- Principal.request_principal_type(principal),
158
         intent = intent(attrs),
159
         :ok <- Authority.authorize_request(principal, repository, environment, intent),
160
         {:ok, provider} <- Provider.fetch(environment.provider),
161
         :ok <- verify_commit(repository, attrs["commit_sha"], commit_store),
162
         {:ok, request} <-
163
           insert_request(repository, environment, principal, principal_type, attrs) do
164
      case Repo.preload(request, :run) do
165
        %Request{run: %Run{} = run} -> {:ok, run}
166
        %Request{} -> admit(repository, environment, request, principal, provider)
167
      end
168
    end
169
  end
170
171
  @doc """
172
  Fetch one run by id, enforcing repository visibility and boundary.
173
174
  A valid run id from another repository is a `cross_repository` denial rather
175
  than a successful read.
176
  """
177
  @spec fetch_run(Repository.t(), Principal.t(), String.t()) ::
178
          {:ok, Run.t()} | {:error, error()}
179
  def fetch_run(%Repository{} = repository, %Principal{} = principal, id) do
180
    with :ok <- Authority.authorize_read(principal, repository),
181
         {:ok, run} <- fetch_run_record(id),
182
         :ok <- Authority.check_repository(repository, run) do
183
      {:ok, Repo.preload(run, [:deployment_request, :environment])}
184
    end
185
  end
186
187
  @doc """
188
  List a repository's runs, newest first, with bounded keyset pagination.
189
190
  `:limit` is clamped to #{@maximum_limit}, and `:cursor` is the id of the last
191
  run a caller has already seen.
192
  """
193
  @spec list_runs(Repository.t(), Principal.t(), keyword()) ::
194
          {:ok, [Run.t()]} | {:error, error()}
195
  def list_runs(%Repository{} = repository, %Principal{} = principal, options \\ []) do
196
    with :ok <- Authority.authorize_read(principal, repository) do
197
      query =
198
        from run in Run,
199
          where: run.repository_id == ^repository.id,
200
          order_by: [desc: run.inserted_at, desc: run.id],
201
          limit: ^limit(options),
202
          preload: [:deployment_request, :environment]
203
204
      query =
205
        case cursor_run(repository, options[:cursor]) do
206
          nil ->
207
            query
208
209
          %Run{} = cursor ->
210
            from run in query,
211
              where:
212
                run.inserted_at < ^cursor.inserted_at or
213
                  (run.inserted_at == ^cursor.inserted_at and run.id < ^cursor.id)
214
        end
215
216
      query =
217
        case options[:environment_id] do
218
          nil -> query
219
          environment_id -> from run in query, where: run.environment_id == ^environment_id
220
        end
221
222
      query =
223
        case options[:state] do
224
          nil -> query
225
          state -> from run in query, where: run.state == ^state
226
        end
227
228
      {:ok, Repo.all(query)}
229
    end
230
  end
231
232
  @doc """
233
  Request cancellation of a run.
234
235
  Cancellation is bounded, not immediate. A run that has not reached the provider
236
  cancels at once. A run already `deploying` records the request, and the worker
237
  that holds the lease drives it to a terminal state itself, so the control plane
238
  never claims a deployment stopped because someone asked.
239
  """
240
  @spec cancel_run(Repository.t(), Principal.t(), String.t(), keyword()) ::
241
          {:ok, Run.t()} | {:error, error()}
242
  def cancel_run(%Repository{} = repository, %Principal{} = principal, id, options \\ []) do
243
    with {:ok, run} <- fetch_run_record(id),
244
         :ok <- Authority.check_repository(repository, run),
245
         {:ok, request} <- fetch_request_record(run.deployment_request_id),
246
         :ok <- Authority.authorize_cancel(principal, repository, run, request),
247
         :ok <- precondition(run, options) do
248
      cancel = fn ->
249
        run
250
        |> Ecto.Changeset.change(%{
251
          cancel_requested_at: DateTime.utc_now(),
252
          cancel_requested_by_user_id: cancel_requester(principal)
253
        })
254
        |> Repo.update()
255
      end
256
257
      cond do
258
        Run.terminal?(run) ->
259
          {:error, {:illegal_transition, run.state, "cancelled"}}
260
261
        run.state == "deploying" ->
262
          with {:ok, run} <- cancel.() do
263
            {:ok, _event} = append_event(run, "cancellation_requested", principal, %{})
264
            {:ok, run}
265
          end
266
267
        true ->
268
          with {:ok, run} <- cancel.() do
269
            transition(run, "cancelled", principal, reason: "cancelled_by_request")
270
          end
271
      end
272
    end
273
  end
274
275
  # ---------------------------------------------------------------------------
276
  # Approvals
277
  # ---------------------------------------------------------------------------
278
279
  @doc """
280
  Record an approval decision and re-evaluate the run.
281
282
  The decision is bound to the request digest it was made against, so it cannot
283
  carry onto different bytes. The approver must hold a role the environment
284
  admits and, under separation of duties, must not be the requester.
285
  """
286
  @spec decide_run(Repository.t(), Principal.t(), String.t(), String.t(), map()) ::
287
          {:ok, Run.t()} | {:error, error()}
288
  def decide_run(%Repository{} = repository, %Principal{} = principal, id, decision, attrs \\ %{})
289
      when decision in ~w(approved rejected) do
290
    attrs = normalize(attrs)
291
292
    with {:ok, run} <- fetch_run_record(id),
293
         :ok <- Authority.check_repository(repository, run),
294
         {:ok, request} <- fetch_request_record(run.deployment_request_id),
295
         {:ok, environment} <- fetch_environment_by_id(repository, run.environment_id),
296
         :ok <- Authority.authorize_approval(principal, repository, environment, request),
297
         :ok <- approvable(run) do
298
      %Approval{
299
        repository_id: repository.id,
300
        deployment_run_id: run.id,
301
        approver_user_id: principal.user.id,
302
        rule: "required_approvals",
303
        request_digest: request.request_digest,
304
        decided_at: DateTime.utc_now()
305
      }
306
      |> Approval.changeset(Map.put(attrs, "decision", decision))
307
      |> Repo.insert()
308
      |> case do
309
        {:ok, _approval} ->
310
          {:ok, _event} =
311
            append_event(run, "approval_recorded", principal, %{"decision" => decision})
312
313
          reevaluate(repository, environment, request, run, principal)
314
315
        {:error, changeset} ->
316
          {:error, changeset}
317
      end
318
    end
319
  end
320
321
  @doc "List the decisions recorded against one run."
322
  @spec list_approvals(Repository.t(), Principal.t(), Run.t()) ::
323
          {:ok, [Approval.t()]} | {:error, error()}
324
  def list_approvals(%Repository{} = repository, %Principal{} = principal, %Run{} = run) do
325
    with :ok <- Authority.authorize_read(principal, repository),
326
         :ok <- Authority.check_repository(repository, run) do
327
      {:ok,
328
       Repo.all(
329
         from approval in Approval,
330
           where: approval.deployment_run_id == ^run.id,
331
           order_by: [asc: approval.decided_at]
332
       )}
333
    end
334
  end
335
336
  # ---------------------------------------------------------------------------
337
  # Check results
338
  # ---------------------------------------------------------------------------
339
340
  @doc """
341
  Publish a check result for exact bytes, from a trusted identity.
342
343
  Identity is `{repository, name, commit, artifact}`. Publishing the same name
344
  for different bytes writes a new row, so a green result can never be replayed
345
  onto an artifact it did not examine. Republishing the same bytes updates that
346
  one row, which is how a `pending` check becomes `succeeded`.
347
348
  Every run in `checking` for those bytes is re-evaluated afterwards, so evidence
349
  arriving is what moves a run forward.
350
  """
351
  @spec publish_check_result(Repository.t(), Principal.t(), map()) ::
352
          {:ok, CheckResult.t()} | {:error, error()}
353
  def publish_check_result(%Repository{} = repository, %Principal{} = principal, attrs) do
354
    attrs = normalize(attrs)
355
356
    with :ok <- Authority.authorize_publish_check(principal, repository, intent(attrs)) do
357
      existing =
358
        Repo.one(
359
          from result in CheckResult,
360
            where:
361
              result.repository_id == ^repository.id and result.name == ^to_string(attrs["name"]) and
362
                result.commit_sha == ^String.downcase(to_string(attrs["commit_sha"])) and
363
                result.artifact_digest == ^to_string(attrs["artifact_digest"])
364
        ) || %CheckResult{repository_id: repository.id}
365
366
      existing
367
      |> CheckResult.changeset(attrs)
368
      |> put_publisher(principal)
369
      |> Repo.insert_or_update()
370
      |> case do
371
        {:ok, result} ->
372
          reevaluate_checking_runs(repository, result, principal)
373
          {:ok, result}
374
375
        {:error, changeset} ->
376
          {:error, changeset}
377
      end
378
    end
379
  end
380
381
  @doc "List the check results published for one repository's exact bytes."
382
  @spec list_check_results(Repository.t(), Principal.t(), String.t(), String.t()) ::
383
          {:ok, [CheckResult.t()]} | {:error, error()}
384
  def list_check_results(
385
        %Repository{} = repository,
386
        %Principal{} = principal,
387
        commit_sha,
388
        artifact
389
      ) do
390
    with :ok <- Authority.authorize_read(principal, repository) do
391
      {:ok, check_results(repository, commit_sha, artifact)}
392
    end
393
  end
394
395
  # ---------------------------------------------------------------------------
396
  # Events
397
  # ---------------------------------------------------------------------------
398
399
  @doc """
400
  List a run's events after a cursor, for polling or streaming.
401
402
  The sequence is per-run and monotonic, so a reader that remembers the last
403
  sequence it saw can resume without gaps or duplicates.
404
  """
405
  @spec list_events(Repository.t(), Principal.t(), Run.t(), keyword()) ::
406
          {:ok, [Event.t()]} | {:error, error()}
407
  def list_events(
408
        %Repository{} = repository,
409
        %Principal{} = principal,
410
        %Run{} = run,
411
        options \\ []
412
      ) do
413
    with :ok <- Authority.authorize_read(principal, repository),
414
         :ok <- Authority.check_repository(repository, run) do
415
      after_sequence = Keyword.get(options, :after_sequence, 0)
416
417
      {:ok,
418
       Repo.all(
419
         from event in Event,
420
           where: event.deployment_run_id == ^run.id and event.sequence > ^after_sequence,
421
           order_by: [asc: event.sequence],
422
           limit: ^limit(options)
423
       )}
424
    end
425
  end
426
427
  @doc "The topic a subscriber follows for one run's committed events."
428
  @spec run_topic(Run.t() | String.t()) :: String.t()
429
  def run_topic(%Run{id: id}), do: run_topic(id)
430
  def run_topic(id) when is_binary(id), do: "deployments:run:" <> id
431
432
  @doc """
433
  Subscribe to one run's events.
434
435
  Broadcasts happen after the transaction commits, so a subscriber never sees a
436
  transition that later disappears.
437
  """
438
  @spec subscribe(Run.t() | String.t()) :: :ok | {:error, term()}
439
  def subscribe(run), do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, run_topic(run))
440
441
  # ---------------------------------------------------------------------------
442
  # Workflow grants
443
  # ---------------------------------------------------------------------------
444
445
  @doc """
446
  Issue a short-lived workflow grant bound to one run of one workflow.
447
448
  The plaintext token is returned once and never stored. The grant carries the
449
  repository, the optional environment, the source ref, the workflow, and the
450
  workflow run it was issued for; authorization compares a request against those
451
  bound values rather than against anything the caller sends.
452
  """
453
  @spec issue_workflow_grant(Repository.t(), Principal.t(), map()) ::
454
          {:ok, {WorkflowGrant.t(), String.t()}} | {:error, error()}
455
  def issue_workflow_grant(%Repository{} = repository, %Principal{} = principal, attrs) do
456
    attrs = normalize(attrs)
457
458
    with :ok <- authorize_write(principal, repository),
459
         {:ok, environment_id} <- grant_environment_id(repository, attrs["environment"]) do
460
      plaintext = "oa_wfg_" <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
461
      lifetime = grant_lifetime(attrs["lifetime_seconds"])
462
463
      %WorkflowGrant{
464
        repository_id: repository.id,
465
        environment_id: environment_id,
466
        token_digest: digest(plaintext),
467
        expires_at: DateTime.add(DateTime.utc_now(), lifetime, :second),
468
        created_by_user_id: principal.user.id
469
      }
470
      |> WorkflowGrant.changeset(attrs)
471
      |> Repo.insert()
472
      |> case do
473
        {:ok, grant} -> {:ok, {grant, plaintext}}
474
        {:error, changeset} -> {:error, changeset}
475
      end
476
    end
477
  end
478
479
  @doc """
480
  Authenticate a workflow grant token into a principal.
481
482
  Only a live, unrevoked grant authenticates, and only the digest is compared, so
483
  a database read cannot recover a usable token.
484
  """
485
  @spec authenticate_workflow_grant(String.t()) ::
486
          {:ok, Principal.t()} | {:error, :invalid_grant}
487
  def authenticate_workflow_grant("oa_wfg_" <> _rest = plaintext)
488
      when byte_size(plaintext) < 160 do
489
    grant = Repo.one(from g in WorkflowGrant, where: g.token_digest == ^digest(plaintext))
490
491
    if grant && WorkflowGrant.usable?(grant, DateTime.utc_now()) do
492
      {:ok, Principal.workflow(grant)}
493
    else
494
      {:error, :invalid_grant}
495
    end
496
  end
497
498
  def authenticate_workflow_grant(_plaintext), do: {:error, :invalid_grant}
499
500
  @doc "Revoke a grant before it expires."
501
  @spec revoke_workflow_grant(Repository.t(), Principal.t(), String.t()) ::
502
          {:ok, WorkflowGrant.t()} | {:error, error()}
503
  def revoke_workflow_grant(%Repository{} = repository, %Principal{} = principal, id) do
504
    with :ok <- authorize_write(principal, repository),
505
         %WorkflowGrant{} = grant <- Repo.get(WorkflowGrant, id),
506
         :ok <- Authority.check_repository(repository, grant) do
507
      grant
508
      |> Ecto.Changeset.change(%{revoked_at: DateTime.utc_now()})
509
      |> Repo.update()
510
    else
511
      nil -> {:error, :run_not_found}
512
      {:error, reason} -> {:error, reason}
513
    end
514
  end
515
516
  # ---------------------------------------------------------------------------
517
  # Worker surface
518
  # ---------------------------------------------------------------------------
519
520
  @doc """
521
  Claim the next queued run for execution, taking a lease.
522
523
  The claim is a single conditional update, so two workers racing for one run
524
  produce one winner and one `nil`: this is what keeps a provider from executing
525
  the same run twice.
526
  """
527
  @spec claim_run(String.t(), keyword()) :: {:ok, Run.t()} | :empty
528
  def claim_run(worker, options \\ []) when is_binary(worker) do
529
    lease_seconds = Keyword.get(options, :lease_seconds, @default_lease_seconds)
530
    now = Keyword.get(options, :now, DateTime.utc_now())
531
    expires_at = DateTime.add(now, lease_seconds, :second)
532
533
    candidate =
534
      Repo.one(
535
        from run in Run,
536
          where: run.state == "queued",
537
          where: is_nil(run.lease_expires_at) or run.lease_expires_at < ^now,
538
          order_by: [asc: run.inserted_at],
539
          limit: 1,
540
          select: run.id
541
      )
542
543
    case candidate && claim(candidate, worker, expires_at, now) do
544
      nil ->
545
        :empty
546
547
      {1, [%Run{} = run]} ->
548
        {:ok, _event} =
549
          append_event(run, "lease_acquired", Principal.system(worker), %{"worker" => worker})
550
551
        {:ok, run}
552
553
      {0, _runs} ->
554
        :empty
555
    end
556
  end
557
558
  @doc """
559
  Extend the lease a worker holds on a run.
560
561
  A worker that cannot renew has lost the run, and must stop rather than keep
562
  deploying: the renewal returning `:error` is the signal.
563
  """
564
  @spec renew_lease(Run.t(), String.t(), keyword()) :: {:ok, Run.t()} | :error
565
  def renew_lease(%Run{} = run, worker, options \\ []) do
566
    lease_seconds = Keyword.get(options, :lease_seconds, @default_lease_seconds)
567
    expires_at = DateTime.add(DateTime.utc_now(), lease_seconds, :second)
568
569
    {count, runs} =
570
      Repo.update_all(
571
        from(r in Run,
572
          where: r.id == ^run.id and r.lease_owner == ^worker and r.state == ^run.state,
573
          select: r
574
        ),
575
        set: [lease_expires_at: expires_at, updated_at: DateTime.utc_now()]
576
      )
577
578
    case {count, runs} do
579
      {1, [%Run{} = renewed]} -> {:ok, renewed}
580
      _lost -> :error
581
    end
582
  end
583
584
  @doc """
585
  Reclaim runs whose worker died holding a lease.
586
587
  Recovery is deliberately narrow. A run that had not reached the provider goes
588
  back to `queued` to be executed by another worker. A run that was already
589
  `deploying` does *not*: the control plane does not know what the provider did,
590
  so the run fails with an explicitly uncertain reason. Guessing `succeeded`
591
  reports a deployment that may not exist, and guessing a clean retry can deploy
592
  twice.
593
  """
594
  @spec reconcile_leases(Principal.t(), keyword()) ::
595
          {:ok, %{requeued: integer(), uncertain: integer()}} | {:error, error()}
596
  def reconcile_leases(%Principal{} = principal, options \\ []) do
597
    with :ok <- Authority.authorize_recovery(principal) do
598
      now = Keyword.get(options, :now, DateTime.utc_now())
599
600
      requeued = release_expired(now, principal)
601
      uncertain = fail_uncertain(now, principal)
602
603
      {:ok, %{requeued: requeued, uncertain: uncertain}}
604
    end
605
  end
606
607
  @doc """
608
  Record the provider's outcome for a run the worker holds.
609
610
  Only three outcomes exist, and the third is why this function is narrow:
611
  `{:uncertain, receipt}` writes `failed` with the reason
612
  `provider_result_uncertain` rather than a success receipt. The control plane
613
  never upgrades "I don't know" to "it worked".
614
  """
615
  @spec finish_run(Run.t(), String.t(), Provider.result(), keyword()) ::
616
          {:ok, Run.t()} | {:error, error()}
617
  def finish_run(%Run{} = run, worker, result, options \\ []) do
618
    principal = Principal.system(worker)
619
    secret_values = Keyword.get(options, :secret_values, [])
620
621
    # A cancellation requested while the provider was running is honored here,
622
    # after the provider reported, so the run's terminal state reflects what the
623
    # provider actually did rather than what the canceller hoped.
624
    cancelled? = not is_nil(run.cancel_requested_at)
625
626
    case result do
627
      {:ok, receipt} when cancelled? ->
628
        transition(run, "cancelled", principal,
629
          reason: "cancelled_after_provider_success",
630
          receipt: receipt,
631
          secret_values: secret_values,
632
          finished: true
633
        )
634
635
      {:ok, receipt} ->
636
        transition(run, "succeeded", principal,
637
          receipt: receipt,
638
          secret_values: secret_values,
639
          finished: true
640
        )
641
642
      {:error, reason} when cancelled? ->
643
        transition(run, "cancelled", principal,
644
          reason: bounded_reason(reason),
645
          finished: true
646
        )
647
648
      {:error, reason} ->
649
        transition(run, "failed", principal,
650
          reason: bounded_reason(reason),
651
          finished: true
652
        )
653
654
      {:uncertain, receipt} ->
655
        transition(run, "failed", principal,
656
          reason: "provider_result_uncertain",
657
          receipt: receipt,
658
          secret_values: secret_values,
659
          finished: true
660
        )
661
    end
662
  end
663
664
  @doc """
665
  Move a run to `state`, transactionally, with its event.
666
667
  This is the only writer of `Run.state`. It checks the transition against
668
  `OpenAgents.Deployments.Lifecycle`, compares the state it read against the row
669
  it updates so a concurrent transition cannot be lost, and appends the event in
670
  the same transaction. A state without a matching event, or an event without a
671
  state change, would each make the history a guess.
672
  """
673
  @spec transition(Run.t(), String.t(), Principal.t(), keyword()) ::
674
          {:ok, Run.t()} | {:error, error()}
675
  def transition(%Run{} = run, state, %Principal{} = principal, options \\ []) do
676
    with :ok <- Lifecycle.check(run.state, state) do
677
      now = DateTime.utc_now()
678
      finished = Keyword.get(options, :finished, state in Run.terminal_states())
679
680
      changes =
681
        %{state: state, updated_at: now}
682
        |> maybe_put(:result_reason, Keyword.get(options, :reason))
683
        |> maybe_put(
684
          :provider_receipt,
685
          case Keyword.get(options, :receipt) do
686
            nil ->
687
              nil
688
689
            receipt ->
690
              Provider.sanitize_receipt(receipt, Keyword.get(options, :secret_values, []))
691
          end
692
        )
693
        |> maybe_put(:policy_explanation, Keyword.get(options, :explanation))
694
        |> maybe_put(:superseded_by_run_id, Keyword.get(options, :superseded_by_run_id))
695
        |> maybe_put(:started_at, if(state == "deploying", do: now))
696
        |> maybe_put(:finished_at, if(finished, do: now))
697
        |> maybe_put(:lease_owner, if(finished, do: :nil_value))
698
        |> maybe_put(:lease_expires_at, if(finished, do: :nil_value))
699
700
      Multi.new()
701
      |> Multi.run(:run, fn repo, _changes ->
702
        {count, runs} =
703
          repo.update_all(
704
            from(r in Run, where: r.id == ^run.id and r.state == ^run.state, select: r),
705
            set: Enum.map(changes, fn {key, value} -> {key, denil(value)} end)
706
          )
707
708
        case {count, runs} do
709
          {1, [%Run{} = updated]} -> {:ok, updated}
710
          _stale -> {:error, :precondition_failed}
711
        end
712
      end)
713
      |> Multi.run(:event, fn repo, %{run: updated} ->
714
        insert_event(
715
          repo,
716
          updated,
717
          Keyword.get(options, :event_type, "state_changed"),
718
          principal,
719
          Map.merge(
720
            %{"reason" => Keyword.get(options, :reason)},
721
            Keyword.get(options, :detail, %{})
722
          ),
723
          from: run.state,
724
          to: state
725
        )
726
      end)
727
      |> Repo.transaction()
728
      |> case do
729
        {:ok, %{run: updated, event: event}} ->
730
          broadcast(updated, event)
731
          {:ok, updated}
732
733
        {:error, _step, reason, _changes} ->
734
          {:error, reason}
735
      end
736
    end
737
  end
738
739
  @doc """
740
  Re-evaluate policy for a run that is waiting, re-checking authority first.
741
742
  Called when evidence arrives, when a decision is recorded, and before a worker
743
  hands a run to a provider. A run admitted an hour ago is not admitted now if
744
  the environment froze, the window closed, or the requester lost membership.
745
  """
746
  @spec reevaluate(Repository.t(), Environment.t(), Request.t(), Run.t(), Principal.t()) ::
747
          {:ok, Run.t()} | {:error, error()}
748
  def reevaluate(
749
        %Repository{} = repository,
750
        %Environment{} = environment,
751
        %Request{} = request,
752
        %Run{} = run,
753
        %Principal{} = principal
754
      ) do
755
    approvals =
756
      Repo.all(from approval in Approval, where: approval.deployment_run_id == ^run.id)
757
758
    checks = check_results(repository, request.commit_sha, request.artifact_digest)
759
760
    case Policy.evaluate(environment, request, checks, approvals, DateTime.utc_now()) do
761
      {:admit, state, explanation} ->
762
        if state == run.state do
763
          {:ok, store_explanation(run, explanation)}
764
        else
765
          transition(run, state, principal,
766
            explanation: explanation,
767
            event_type: "policy_evaluated"
768
          )
769
        end
770
771
      {:deny, reason, explanation} ->
772
        transition(run, "failed", principal,
773
          reason: reason,
774
          explanation: explanation,
775
          event_type: "policy_denied"
776
        )
777
    end
778
  end
779
780
  @doc """
781
  Re-check the authority behind a request at a sensitive transition.
782
783
  Membership is revoked, roles change, and grants expire between `requested` and
784
  `deploying`. The check reruns against the durable request rather than the
785
  original credential, because the original credential is long gone by then.
786
  """
787
  @spec recheck_request_authority(Repository.t(), Environment.t(), Request.t()) ::
788
          :ok | {:error, error()}
789
  def recheck_request_authority(
790
        %Repository{} = repository,
791
        %Environment{} = environment,
792
        %Request{} = request
793
      ) do
794
    case request.principal_type do
795
      "user" ->
796
        case Repo.get(User, request.requested_by_user_id) do
797
          %User{} = user ->
798
            Authority.authorize_request(Principal.user(user), repository, environment, %{})
799
800
          nil ->
801
            {:error, {:forbidden, :not_a_member}}
802
        end
803
804
      "workflow" ->
805
        case Repo.get(WorkflowGrant, request.requested_by_grant_id) do
806
          %WorkflowGrant{} = grant ->
807
            Authority.authorize_request(
808
              Principal.workflow(grant),
809
              repository,
810
              environment,
811
              %{}
812
            )
813
814
          nil ->
815
            {:error, {:forbidden, :grant_expired}}
816
        end
817
818
      _other ->
819
        {:error, {:forbidden, :operator_is_not_tenant}}
820
    end
821
  end
822
823
  @doc "Build the execution a provider receives, resolving secrets for this attempt."
824
  @spec build_execution(Repository.t(), Environment.t(), Request.t(), Run.t(), keyword()) ::
825
          {:ok, OpenAgents.Deployments.Execution.t()} | {:error, error()}
826
  def build_execution(
827
        %Repository{} = repository,
828
        %Environment{} = environment,
829
        %Request{} = request,
830
        %Run{} = run,
831
        options \\ []
832
      ) do
833
    with {:ok, module} <- Provider.fetch(environment.provider),
834
         references = module.required_secret_references(environment.provider_config),
835
         {:ok, secrets} <- OpenAgents.Deployments.SecretResolver.resolve(environment, references) do
836
      {:ok,
837
       %OpenAgents.Deployments.Execution{
838
         run_id: run.id,
839
         repository: repository.owner <> "/" <> repository.name,
840
         environment: environment.name,
841
         commit_sha: request.commit_sha,
842
         artifact_digest: request.artifact_digest,
843
         input_digest: run.input_digest,
844
         attempt: run.attempt_count + 1,
845
         deadline:
846
           DateTime.add(DateTime.utc_now(), Keyword.get(options, :timeout_seconds, 600), :second),
847
         provider_config: environment.provider_config,
848
         secrets: secrets
849
       }}
850
    end
851
  end
852
853
  @doc "Load the repository, environment, and request a run belongs to."
854
  @spec load_run_context(Run.t()) ::
855
          {:ok, {Repository.t(), Environment.t(), Request.t()}} | {:error, error()}
856
  def load_run_context(%Run{} = run) do
857
    with %Repository{} = repository <- Repo.get(Repository, run.repository_id),
858
         %Environment{} = environment <- Repo.get(Environment, run.environment_id),
859
         {:ok, request} <- fetch_request_record(run.deployment_request_id) do
860
      {:ok, {repository, environment, request}}
861
    else
862
      nil -> {:error, :run_not_found}
863
      {:error, reason} -> {:error, reason}
864
    end
865
  end
866
867
  # ---------------------------------------------------------------------------
868
  # Admission
869
  # ---------------------------------------------------------------------------
870
871
  defp admit(repository, environment, request, principal, _provider) do
872
    protection = environment.protection || %Protection{}
873
874
    with {:ok, run} <- insert_run(repository, environment, request),
875
         {:ok, run} <- apply_concurrency(repository, environment, protection, run, principal) do
876
      reevaluate(repository, environment, request, run, principal)
877
    end
878
  end
879
880
  defp insert_run(repository, environment, request) do
881
    run = %Run{
882
      repository_id: repository.id,
883
      environment_id: environment.id,
884
      deployment_request_id: request.id,
885
      input_digest: request.input_digest,
886
      provider: environment.provider
887
    }
888
889
    Multi.new()
890
    |> Multi.insert(
891
      :run,
892
      Run.changeset(run, %{state: Policy.initial_state(environment)})
893
    )
894
    |> Multi.run(:event, fn repo, %{run: inserted} ->
895
      insert_event(repo, inserted, "run_created", Principal.system("control_plane"), %{},
896
        to: inserted.state
897
      )
898
    end)
899
    |> Repo.transaction()
900
    |> case do
901
      {:ok, %{run: run, event: event}} ->
902
        broadcast(run, event)
903
        {:ok, run}
904
905
      {:error, _step, reason, _changes} ->
906
        {:error, reason}
907
    end
908
  end
909
910
  # Concurrency is per environment and never crosses environments: superseding a
911
  # preview deployment must not supersede production, so candidates are selected
912
  # by environment id alone.
913
  defp apply_concurrency(
914
         _repository,
915
         _environment,
916
         %Protection{concurrency: "queue"},
917
         run,
918
         _principal
919
       ),
920
       do: {:ok, run}
921
922
  defp apply_concurrency(_repository, environment, %Protection{concurrency: mode}, run, principal) do
923
    others =
924
      Repo.all(
925
        from other in Run,
926
          where:
927
            other.environment_id == ^environment.id and other.id != ^run.id and
928
              other.state in ^Run.active_states(),
929
          order_by: [asc: other.inserted_at]
930
      )
931
932
    case mode do
933
      "reject" when others != [] ->
934
        transition(run, "failed", principal,
935
          reason: "environment_busy",
936
          event_type: "policy_denied"
937
        )
938
939
      "cancel" ->
940
        for other <- others, other.state != "deploying" do
941
          transition(other, "cancelled", principal, reason: "superseded_by_newer_request")
942
        end
943
944
        {:ok, run}
945
946
      "supersede" ->
947
        for other <- others, other.state != "deploying" do
948
          transition(other, "superseded", principal,
949
            reason: "superseded_by_newer_request",
950
            superseded_by_run_id: run.id
951
          )
952
        end
953
954
        {:ok, run}
955
956
      _queue_or_idle ->
957
        {:ok, run}
958
    end
959
  end
960
961
  defp insert_request(repository, environment, principal, principal_type, attrs) do
962
    input_digest =
963
      digest([repository.id, environment.id, attrs["commit_sha"], attrs["artifact_digest"]])
964
965
    request_digest =
966
      digest([input_digest, attrs["source_ref"], attrs["source_workflow"] || ""])
967
968
    request = %Request{
969
      repository_id: repository.id,
970
      environment_id: environment.id,
971
      principal_type: principal_type,
972
      requested_by_user_id: principal.user && principal.user.id,
973
      requested_by_grant_id: principal.grant && principal.grant.id,
974
      request_digest: request_digest,
975
      input_digest: input_digest,
976
      requested_at: DateTime.utc_now()
977
    }
978
979
    case Repo.insert(Request.changeset(request, attrs)) do
980
      {:ok, inserted} ->
981
        {:ok, inserted}
982
983
      {:error, changeset} ->
984
        replay(repository, environment, attrs, request_digest, changeset)
985
    end
986
  end
987
988
  # An idempotency key is a promise about bytes. Replaying it with the same
989
  # request digest returns the original record; replaying it with anything else
990
  # is a conflict, because the caller believes it deployed something it did not.
991
  defp replay(repository, environment, attrs, request_digest, changeset) do
992
    existing =
993
      Repo.one(
994
        from request in Request,
995
          where:
996
            request.repository_id == ^repository.id and
997
              request.environment_id == ^environment.id and
998
              request.idempotency_key == ^to_string(attrs["idempotency_key"])
999
      )
1000
1001
    cond do
1002
      is_nil(existing) -> {:error, changeset}
1003
      existing.request_digest == request_digest -> {:ok, existing}
1004
      true -> {:error, :idempotency_conflict}
1005
    end
1006
  end
1007
1008
  defp reevaluate_checking_runs(repository, %CheckResult{} = result, _principal) do
1009
    runs =
1010
      Repo.all(
1011
        from run in Run,
1012
          join: request in Request,
1013
          on: request.id == run.deployment_request_id,
1014
          where:
1015
            run.repository_id == ^repository.id and run.state in ~w(requested checking) and
1016
              request.commit_sha == ^result.commit_sha and
1017
              request.artifact_digest == ^result.artifact_digest,
1018
          preload: [:environment, :deployment_request]
1019
      )
1020
1021
    for run <- runs do
1022
      reevaluate(
1023
        repository,
1024
        run.environment,
1025
        run.deployment_request,
1026
        run,
1027
        Principal.system("control_plane")
1028
      )
1029
    end
1030
1031
    :ok
1032
  end
1033
1034
  # ---------------------------------------------------------------------------
1035
  # Events
1036
  # ---------------------------------------------------------------------------
1037
1038
  defp append_event(%Run{} = run, type, principal, detail) do
1039
    case insert_event(Repo, run, type, principal, detail, []) do
1040
      {:ok, event} ->
1041
        broadcast(run, event)
1042
        {:ok, event}
1043
1044
      {:error, reason} ->
1045
        {:error, reason}
1046
    end
1047
  end
1048
1049
  defp insert_event(repo, %Run{} = run, type, %Principal{} = principal, detail, states) do
1050
    sequence =
1051
      repo.one(
1052
        from event in Event,
1053
          where: event.deployment_run_id == ^run.id,
1054
          select: coalesce(max(event.sequence), 0)
1055
      ) + 1
1056
1057
    %Event{
1058
      repository_id: run.repository_id,
1059
      deployment_run_id: run.id,
1060
      actor_type: Principal.actor_type(principal),
1061
      actor_id: Principal.actor_id(principal)
1062
    }
1063
    |> Event.changeset(%{
1064
      "sequence" => sequence,
1065
      "type" => type,
1066
      "from_state" => Keyword.get(states, :from),
1067
      "to_state" => Keyword.get(states, :to),
1068
      "detail" => scrub(detail)
1069
    })
1070
    |> repo.insert()
1071
  end
1072
1073
  # Event detail reaches tenant reads and audit exports, so it is redacted and
1074
  # bounded here rather than at each caller.
1075
  defp scrub(detail) when is_map(detail) do
1076
    detail
1077
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
1078
    |> Map.new(fn {key, value} -> {to_string(key), scrub_value(value)} end)
1079
  end
1080
1081
  defp scrub_value(value) when is_binary(value),
1082
    do: value |> OpenAgents.LogSafety.redact() |> String.slice(0, 500)
1083
1084
  defp scrub_value(value) when is_list(value), do: Enum.map(value, &scrub_value/1)
1085
  defp scrub_value(value) when is_map(value), do: scrub(value)
1086
  defp scrub_value(value), do: value
1087
1088
  defp broadcast(%Run{} = run, %Event{} = event) do
1089
    Phoenix.PubSub.broadcast(
1090
      OpenAgents.PubSub,
1091
      run_topic(run),
1092
      {:deployment_event,
1093
       %{run_id: run.id, sequence: event.sequence, type: event.type, state: run.state}}
1094
    )
1095
  end
1096
1097
  # ---------------------------------------------------------------------------
1098
  # Recovery
1099
  # ---------------------------------------------------------------------------
1100
1101
  defp release_expired(now, principal) do
1102
    runs =
1103
      Repo.all(
1104
        from run in Run,
1105
          where:
1106
            run.state == "queued" and not is_nil(run.lease_expires_at) and
1107
              run.lease_expires_at < ^now
1108
      )
1109
1110
    for run <- runs do
1111
      Repo.update_all(
1112
        from(r in Run, where: r.id == ^run.id),
1113
        set: [lease_owner: nil, lease_expires_at: nil, updated_at: DateTime.utc_now()]
1114
      )
1115
1116
      append_event(run, "lease_released", principal, %{"reason" => "lease_expired"})
1117
    end
1118
1119
    length(runs)
1120
  end
1121
1122
  defp fail_uncertain(now, principal) do
1123
    runs =
1124
      Repo.all(
1125
        from run in Run,
1126
          where:
1127
            run.state == "deploying" and not is_nil(run.lease_expires_at) and
1128
              run.lease_expires_at < ^now
1129
      )
1130
1131
    for run <- runs do
1132
      transition(run, "failed", principal,
1133
        reason: "provider_result_uncertain",
1134
        event_type: "recovered",
1135
        detail: %{"recovery" => "lease_expired_while_deploying"}
1136
      )
1137
    end
1138
1139
    length(runs)
1140
  end
1141
1142
  # ---------------------------------------------------------------------------
1143
  # Helpers
1144
  # ---------------------------------------------------------------------------
1145
1146
  defp authorize_write(%Principal{kind: :user, user: %User{} = user}, %Repository{} = repository) do
1147
    if OpenAgents.Repositories.writable?(repository, user) do
1148
      :ok
1149
    else
1150
      {:error, {:forbidden, :not_writable}}
1151
    end
1152
  end
1153
1154
  defp authorize_write(%Principal{}, %Repository{}), do: {:error, {:forbidden, :not_writable}}
1155
1156
  defp fetch_environment_record(%Repository{} = repository, name) when is_binary(name) do
1157
    case Repo.one(
1158
           from environment in Environment,
1159
             where: environment.repository_id == ^repository.id and environment.name == ^name
1160
         ) do
1161
      %Environment{} = environment -> {:ok, environment}
1162
      nil -> {:error, :environment_not_found}
1163
    end
1164
  end
1165
1166
  defp fetch_environment_record(%Repository{}, _name), do: {:error, :environment_not_found}
1167
1168
  defp fetch_environment_by_id(%Repository{} = repository, id) do
1169
    case Repo.get(Environment, id) do
1170
      %Environment{} = environment ->
1171
        with :ok <- Authority.check_repository(repository, environment), do: {:ok, environment}
1172
1173
      nil ->
1174
        {:error, :environment_not_found}
1175
    end
1176
  end
1177
1178
  defp fetch_run_record(id) when is_binary(id) do
1179
    case Ecto.UUID.cast(id) do
1180
      {:ok, uuid} ->
1181
        case Repo.get(Run, uuid) do
1182
          %Run{} = run -> {:ok, run}
1183
          nil -> {:error, :run_not_found}
1184
        end
1185
1186
      :error ->
1187
        {:error, :run_not_found}
1188
    end
1189
  end
1190
1191
  defp fetch_run_record(_id), do: {:error, :run_not_found}
1192
1193
  defp fetch_request_record(id) do
1194
    case Repo.get(Request, id) do
1195
      %Request{} = request -> {:ok, request}
1196
      nil -> {:error, :request_not_found}
1197
    end
1198
  end
1199
1200
  defp check_results(%Repository{} = repository, commit_sha, artifact_digest) do
1201
    Repo.all(
1202
      from result in CheckResult,
1203
        where:
1204
          result.repository_id == ^repository.id and result.commit_sha == ^commit_sha and
1205
            result.artifact_digest == ^artifact_digest
1206
    )
1207
  end
1208
1209
  defp store_explanation(%Run{} = run, explanation) do
1210
    {_count, _runs} =
1211
      Repo.update_all(
1212
        from(r in Run, where: r.id == ^run.id),
1213
        set: [policy_explanation: explanation, updated_at: DateTime.utc_now()]
1214
      )
1215
1216
    %Run{run | policy_explanation: explanation}
1217
  end
1218
1219
  defp claim(run_id, worker, expires_at, now) do
1220
    Repo.update_all(
1221
      from(run in Run,
1222
        where: run.id == ^run_id and run.state == "queued",
1223
        where: is_nil(run.lease_expires_at) or run.lease_expires_at < ^now,
1224
        select: run
1225
      ),
1226
      set: [lease_owner: worker, lease_expires_at: expires_at, updated_at: now],
1227
      inc: [attempt_count: 1]
1228
    )
1229
  end
1230
1231
  defp approvable(%Run{state: state}) when state in ~w(requested checking waiting_for_approval),
1232
    do: :ok
1233
1234
  defp approvable(%Run{state: state}), do: {:error, {:illegal_transition, state, "queued"}}
1235
1236
  # An optimistic precondition: the caller states the state it believes the run
1237
  # holds, and a mismatch is refused rather than applied to a run that moved.
1238
  defp precondition(%Run{} = run, options) do
1239
    case Keyword.get(options, :if_state) do
1240
      nil -> :ok
1241
      state when state == run.state -> :ok
1242
      _mismatch -> {:error, :precondition_failed}
1243
    end
1244
  end
1245
1246
  defp cancel_requester(%Principal{user: %User{id: id}}), do: id
1247
  defp cancel_requester(%Principal{}), do: nil
1248
1249
  defp intent(attrs) do
1250
    %{
1251
      source_ref: attrs["source_ref"],
1252
      source_workflow: attrs["source_workflow"],
1253
      workflow_run_id: attrs["workflow_run_id"]
1254
    }
1255
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
1256
    |> Map.new()
1257
  end
1258
1259
  defp put_creator(changeset, %Principal{user: %User{id: id}}, %Environment{id: nil}),
1260
    do: Ecto.Changeset.put_change(changeset, :created_by_user_id, id)
1261
1262
  defp put_creator(changeset, _principal, _environment), do: changeset
1263
1264
  defp put_publisher(changeset, %Principal{kind: :workflow, grant: %WorkflowGrant{id: id}}),
1265
    do: Ecto.Changeset.put_change(changeset, :published_by_grant_id, id)
1266
1267
  defp put_publisher(changeset, %Principal{user: %User{id: id}}),
1268
    do: Ecto.Changeset.put_change(changeset, :published_by_user_id, id)
1269
1270
  defp put_publisher(changeset, %Principal{}), do: changeset
1271
1272
  defp verify_commit(%Repository{} = repository, commit_sha, commit_store)
1273
       when is_function(commit_store, 2) do
1274
    commit_store.(repository, to_string(commit_sha))
1275
  end
1276
1277
  # Configurable so a host without git storage — and a test that does not need
1278
  # one — can bind a different store, while the default reads real bytes.
1279
  defp default_commit_store do
1280
    Application.get_env(:openagents, :deployment_commit_store, &commit_exists/2)
1281
  end
1282
1283
  # The commit must exist in the repository's own storage. Without this, a
1284
  # request can name bytes the repository never received, and every downstream
1285
  # check is then bound to nothing.
1286
  defp commit_exists(%Repository{} = repository, commit_sha) do
1287
    path = OpenAgents.Forge.Repos.bare_path(repository.storage_key)
1288
1289
    case OpenAgents.Forge.Repos.git(path, ["cat-file", "-e", commit_sha <> "^{commit}"]) do
1290
      {_output, 0} -> :ok
1291
      _missing -> {:error, :unknown_commit}
1292
    end
1293
  end
1294
1295
  defp grant_environment_id(_repository, nil), do: {:ok, nil}
1296
1297
  defp grant_environment_id(repository, name) do
1298
    with {:ok, environment} <- fetch_environment_record(repository, name) do
1299
      {:ok, environment.id}
1300
    end
1301
  end
1302
1303
  defp cursor_run(_repository, nil), do: nil
1304
1305
  defp cursor_run(%Repository{} = repository, cursor) do
1306
    case fetch_run_record(cursor) do
1307
      {:ok, %Run{repository_id: repository_id} = run} when repository_id == repository.id -> run
1308
      _other -> nil
1309
    end
1310
  end
1311
1312
  # A grant's lifetime is clamped rather than trusted: a long-lived workflow
1313
  # credential is the thing this design is trying not to have.
1314
  defp grant_lifetime(seconds) when is_integer(seconds) and seconds > 0,
1315
    do: min(seconds, WorkflowGrant.maximum_lifetime_seconds())
1316
1317
  defp grant_lifetime(_invalid), do: 900
1318
1319
  defp limit(options) do
1320
    options
1321
    |> Keyword.get(:limit, @default_limit)
1322
    |> case do
1323
      value when is_integer(value) and value > 0 -> min(value, @maximum_limit)
1324
      _invalid -> @default_limit
1325
    end
1326
  end
1327
1328
  defp bounded_reason(reason) when is_atom(reason),
1329
    do: reason |> Atom.to_string() |> String.slice(0, 80)
1330
1331
  defp bounded_reason(reason) when is_binary(reason), do: String.slice(reason, 0, 80)
1332
  defp bounded_reason(_reason), do: "provider_failed"
1333
1334
  defp maybe_put(map, _key, nil), do: map
1335
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
1336
1337
  defp denil(:nil_value), do: nil
1338
  defp denil(value), do: value
1339
1340
  defp normalize(attrs) when is_map(attrs) do
1341
    Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
1342
  end
1343
1344
  defp digest(parts) when is_list(parts) do
1345
    parts
1346
    |> Enum.map_join("\n", &to_string/1)
1347
    |> digest()
1348
  end
1349
1350
  defp digest(value) when is_binary(value),
1351
    do: :crypto.hash(:sha256, value) |> Base.encode16(case: :lower)
1352
1353
  @doc "The largest page any listing returns."
1354
  @spec maximum_limit() :: pos_integer()
1355
  def maximum_limit, do: @maximum_limit
1356
1357
  @doc "The page size a listing uses when the caller does not choose one."
1358
  @spec default_limit() :: pos_integer()
1359
  def default_limit, do: @default_limit
1360
end
lib/openagents/deployments/approval.ex added +51

@@ -0,0 +1,51 @@

1
defmodule OpenAgents.Deployments.Approval do
2
  @moduledoc """
3
  One approver's decision on one run.
4
5
  The decision records the request digest it was made against, so an approval
6
  can never be carried onto different bytes: if the input changes, the run
7
  changes, and the old approval no longer applies to anything.
8
  """
9
10
  use Ecto.Schema
11
12
  import Ecto.Changeset
13
14
  @decisions ~w(approved rejected)
15
16
  @type t :: %__MODULE__{}
17
18
  @primary_key {:id, :binary_id, autogenerate: true}
19
  @foreign_key_type :binary_id
20
  @timestamps_opts [type: :utc_datetime_usec]
21
22
  schema "deployment_approvals" do
23
    field :decision, :string
24
    field :rule, :string
25
    field :request_digest, :string
26
    field :comment, :string
27
    field :decided_at, :utc_datetime_usec
28
29
    belongs_to :repository, OpenAgents.Repositories.Repository
30
    belongs_to :deployment_run, OpenAgents.Deployments.Run
31
    belongs_to :approver_user, OpenAgents.Accounts.User
32
33
    timestamps()
34
  end
35
36
  @doc false
37
  def changeset(approval, attrs) do
38
    approval
39
    |> cast(attrs, [:decision, :comment])
40
    |> validate_required([:decision, :rule, :request_digest, :decided_at])
41
    |> validate_inclusion(:decision, @decisions)
42
    |> validate_length(:comment, max: 500)
43
    |> unique_constraint(:approver_user_id,
44
      name: :deployment_approvals_deployment_run_id_approver_user_id_index
45
    )
46
  end
47
48
  @doc "The decisions an approver can record."
49
  @spec decisions() :: [String.t()]
50
  def decisions, do: @decisions
51
end
lib/openagents/deployments/authority.ex added +290

@@ -0,0 +1,290 @@

1
defmodule OpenAgents.Deployments.Authority do
2
  @moduledoc """
3
  Fail-closed authorization for every deployment operation.
4
5
  Authority is decided from the principal and the durable records it names,
6
  never from request parameters. Four rules hold throughout:
7
8
    * Repository boundary. A grant or membership for one repository authorizes
9
      nothing in another, so cross-repository reads, approvals, cancellations,
10
      and check publications are refused before any work happens.
11
    * Environment boundary. A grant issued for `preview` cannot address
12
      `production`, even inside its own repository.
13
    * A workflow grant cannot widen. Its repository, environment, source ref,
14
      source workflow, and workflow run are compared against the bound values.
15
    * Platform-operator authority is not tenant authority. An operator can
16
      recover a stuck control-plane run, but cannot request, approve, or publish
17
      checks on a tenant repository, and never receives tenant secrets.
18
19
  Sensitive transitions recheck authority rather than trusting the decision made
20
  when the request was created: membership is revoked and policy tightens
21
  between `requested` and `deploying`.
22
  """
23
24
  alias OpenAgents.Accounts
25
  alias OpenAgents.Accounts.User
26
  alias OpenAgents.Deployments.Environment
27
  alias OpenAgents.Deployments.Principal
28
  alias OpenAgents.Deployments.Protection
29
  alias OpenAgents.Deployments.Request
30
  alias OpenAgents.Deployments.Run
31
  alias OpenAgents.Deployments.WorkflowGrant
32
  alias OpenAgents.Repositories
33
  alias OpenAgents.Repositories.Repository
34
35
  @type reason ::
36
          :cross_repository
37
          | :cross_environment
38
          | :not_a_member
39
          | :not_writable
40
          | :not_an_approver
41
          | :self_approval
42
          | :grant_expired
43
          | :grant_scope
44
          | :source_ref_mismatch
45
          | :source_workflow_mismatch
46
          | :workflow_run_mismatch
47
          | :not_an_operator
48
          | :operator_is_not_tenant
49
  @type result :: :ok | {:error, {:forbidden, reason()}}
50
51
  @doc """
52
  Authorize reading a repository's deployment records.
53
54
  A public repository is readable by anyone who can read the repository, which
55
  is what visibility already means. Everything else requires membership, a grant
56
  bound to that repository, or platform-operator authority.
57
  """
58
  @spec authorize_read(Principal.t(), Repository.t()) :: result()
59
  def authorize_read(%Principal{kind: :system}, %Repository{}), do: :ok
60
61
  def authorize_read(%Principal{kind: :operator} = principal, %Repository{}),
62
    do: operator_check(principal)
63
64
  def authorize_read(%Principal{kind: :workflow} = principal, %Repository{} = repository),
65
    do: grant_repository_check(principal, repository)
66
67
  def authorize_read(%Principal{kind: :user, user: %User{} = user}, %Repository{} = repository) do
68
    if repository.visibility == "public" or Repositories.member?(repository, user) do
69
      :ok
70
    else
71
      forbidden(:not_a_member)
72
    end
73
  end
74
75
  @doc """
76
  Authorize creating a deployment request for one environment.
77
78
  A human needs write authority on the repository. A workflow needs a live grant
79
  bound to this repository and environment, carrying `deployments:request`, whose
80
  source ref, workflow, and workflow run match the intent it is recording. A
81
  platform operator is refused: fleet promotion is a separate operator API, and
82
  tenant deployment is not an operator power.
83
  """
84
  @spec authorize_request(Principal.t(), Repository.t(), Environment.t(), map()) :: result()
85
  def authorize_request(
86
        %Principal{kind: :user, user: %User{} = user},
87
        repository,
88
        _environment,
89
        _intent
90
      ) do
91
    if Repositories.writable?(repository, user), do: :ok, else: forbidden(:not_writable)
92
  end
93
94
  def authorize_request(%Principal{kind: :workflow} = principal, repository, environment, intent) do
95
    with :ok <- grant_repository_check(principal, repository),
96
         :ok <- grant_environment_check(principal, environment),
97
         :ok <- grant_live_check(principal),
98
         :ok <- grant_scope_check(principal, "deployments:request") do
99
      grant_intent_check(principal, intent)
100
    end
101
  end
102
103
  def authorize_request(%Principal{kind: :operator}, _repository, _environment, _intent),
104
    do: forbidden(:operator_is_not_tenant)
105
106
  def authorize_request(%Principal{kind: :system}, _repository, _environment, _intent),
107
    do: forbidden(:operator_is_not_tenant)
108
109
  @doc """
110
  Authorize publishing a check result from a trusted workflow identity.
111
112
  Only a workflow grant carrying `deployments:checks`, or a human with write
113
  authority, can publish. The bytes a check claims to have examined are the
114
  caller's to state, but the repository they belong to is not.
115
  """
116
  @spec authorize_publish_check(Principal.t(), Repository.t(), map()) :: result()
117
  def authorize_publish_check(%Principal{kind: :user, user: %User{} = user}, repository, _intent) do
118
    if Repositories.writable?(repository, user), do: :ok, else: forbidden(:not_writable)
119
  end
120
121
  def authorize_publish_check(%Principal{kind: :workflow} = principal, repository, intent) do
122
    with :ok <- grant_repository_check(principal, repository),
123
         :ok <- grant_live_check(principal),
124
         :ok <- grant_scope_check(principal, "deployments:checks") do
125
      grant_intent_check(principal, intent)
126
    end
127
  end
128
129
  def authorize_publish_check(%Principal{}, _repository, _intent),
130
    do: forbidden(:operator_is_not_tenant)
131
132
  @doc """
133
  Authorize an approval decision on one run.
134
135
  The approver must hold a repository role the environment admits, and must not
136
  be the principal that requested the deployment when the environment enforces
137
  separation of duties. Workflow grants cannot approve at all: a workflow that
138
  could approve its own deployment turns a two-party control into a one-party
139
  one.
140
  """
141
  @spec authorize_approval(Principal.t(), Repository.t(), Environment.t(), Request.t()) ::
142
          result()
143
  def authorize_approval(
144
        %Principal{kind: :user, user: %User{} = user},
145
        %Repository{} = repository,
146
        %Environment{} = environment,
147
        %Request{} = request
148
      ) do
149
    protection = environment.protection || %Protection{}
150
    role = Repositories.membership_role(repository, user)
151
152
    cond do
153
      is_nil(role) -> forbidden(:not_a_member)
154
      role not in protection.approver_roles -> forbidden(:not_an_approver)
155
      self_approval?(protection, request, user) -> forbidden(:self_approval)
156
      true -> :ok
157
    end
158
  end
159
160
  def authorize_approval(%Principal{}, _repository, _environment, _request),
161
    do: forbidden(:operator_is_not_tenant)
162
163
  @doc """
164
  Authorize cancelling a run.
165
166
  A human with write authority may cancel their repository's run. A workflow may
167
  cancel only a run its own grant created, so one workflow cannot cancel
168
  another's deployment. Cancellation is a request, not an immediate stop: the
169
  worker observes it and reaches a terminal state itself.
170
  """
171
  @spec authorize_cancel(Principal.t(), Repository.t(), Run.t(), Request.t()) :: result()
172
  def authorize_cancel(%Principal{kind: :user, user: %User{} = user}, repository, _run, _request) do
173
    if Repositories.writable?(repository, user), do: :ok, else: forbidden(:not_writable)
174
  end
175
176
  def authorize_cancel(
177
        %Principal{kind: :workflow} = principal,
178
        repository,
179
        _run,
180
        %Request{} = request
181
      ) do
182
    with :ok <- grant_repository_check(principal, repository),
183
         :ok <- grant_live_check(principal),
184
         :ok <- grant_scope_check(principal, "deployments:request") do
185
      if request.requested_by_grant_id == principal.grant.id do
186
        :ok
187
      else
188
        forbidden(:cross_repository)
189
      end
190
    end
191
  end
192
193
  def authorize_cancel(%Principal{kind: :operator} = principal, _repository, _run, _request),
194
    do: operator_check(principal)
195
196
  def authorize_cancel(%Principal{kind: :system}, _repository, _run, _request), do: :ok
197
198
  @doc """
199
  Authorize control-plane recovery of a stuck run.
200
201
  Recovery reconciles the control plane's own bookkeeping — expired leases,
202
  runs abandoned by a crashed worker — and never resolves a tenant secret or
203
  reports a provider outcome the provider did not report.
204
  """
205
  @spec authorize_recovery(Principal.t()) :: result()
206
  def authorize_recovery(%Principal{kind: :system}), do: :ok
207
  def authorize_recovery(%Principal{kind: :operator} = principal), do: operator_check(principal)
208
  def authorize_recovery(%Principal{}), do: forbidden(:not_an_operator)
209
210
  @doc "Whether this repository record belongs to this durable record's repository."
211
  @spec same_repository?(Repository.t(), map()) :: boolean()
212
  def same_repository?(%Repository{id: id}, %{repository_id: repository_id}),
213
    do: id == repository_id
214
215
  @doc """
216
  Confirm a durable record belongs to the repository the caller addressed.
217
218
  Every read path calls this after loading by id, so a valid id from another
219
  repository is a `cross_repository` denial rather than a successful read.
220
  """
221
  @spec check_repository(Repository.t(), map()) :: result()
222
  def check_repository(%Repository{} = repository, record) do
223
    if same_repository?(repository, record), do: :ok, else: forbidden(:cross_repository)
224
  end
225
226
  defp self_approval?(%Protection{separation_of_duties: false}, _request, _user), do: false
227
228
  defp self_approval?(%Protection{}, %Request{requested_by_user_id: nil}, _user), do: false
229
230
  defp self_approval?(%Protection{}, %Request{} = request, %User{id: user_id}),
231
    do: request.requested_by_user_id == user_id
232
233
  defp operator_check(%Principal{user: user}) do
234
    if Accounts.admin?(user), do: :ok, else: forbidden(:not_an_operator)
235
  end
236
237
  defp grant_repository_check(%Principal{grant: %WorkflowGrant{} = grant}, %Repository{} = repo) do
238
    if grant.repository_id == repo.id, do: :ok, else: forbidden(:cross_repository)
239
  end
240
241
  defp grant_repository_check(%Principal{}, %Repository{}), do: forbidden(:cross_repository)
242
243
  # A grant with no environment can address any environment in its repository;
244
  # a grant bound to one environment can address only that one.
245
  defp grant_environment_check(
246
         %Principal{grant: %WorkflowGrant{environment_id: nil}},
247
         %Environment{}
248
       ),
249
       do: :ok
250
251
  defp grant_environment_check(%Principal{grant: %WorkflowGrant{} = grant}, %Environment{} = env) do
252
    if grant.environment_id == env.id, do: :ok, else: forbidden(:cross_environment)
253
  end
254
255
  defp grant_live_check(%Principal{grant: %WorkflowGrant{} = grant}) do
256
    if WorkflowGrant.usable?(grant, DateTime.utc_now()), do: :ok, else: forbidden(:grant_expired)
257
  end
258
259
  defp grant_scope_check(%Principal{grant: %WorkflowGrant{} = grant}, scope) do
260
    if scope in grant.scopes, do: :ok, else: forbidden(:grant_scope)
261
  end
262
263
  # The intent a workflow records must match the grant it holds. Otherwise a
264
  # grant issued for a pull-request branch could deploy a tag, and a grant
265
  # issued for one workflow run could publish evidence for another.
266
  defp grant_intent_check(%Principal{grant: %WorkflowGrant{} = grant}, intent) do
267
    cond do
268
      mismatch?(intent, :source_ref, grant.source_ref) ->
269
        forbidden(:source_ref_mismatch)
270
271
      mismatch?(intent, :source_workflow, grant.source_workflow) ->
272
        forbidden(:source_workflow_mismatch)
273
274
      mismatch?(intent, :workflow_run_id, grant.workflow_run_id) ->
275
        forbidden(:workflow_run_mismatch)
276
277
      true ->
278
        :ok
279
    end
280
  end
281
282
  defp mismatch?(intent, key, bound) do
283
    case Map.get(intent, key) do
284
      nil -> false
285
      value -> value != bound
286
    end
287
  end
288
289
  defp forbidden(reason), do: {:error, {:forbidden, reason}}
290
end
lib/openagents/deployments/check_result.ex added +79

@@ -0,0 +1,79 @@

1
defmodule OpenAgents.Deployments.CheckResult do
2
  @moduledoc """
3
  A published check result bound to the exact bytes it examined.
4
5
  Identity is `{repository, name, commit, artifact digest}`. Publishing the same
6
  check name for a different commit or artifact writes a new row rather than
7
  relabelling old evidence, so a green result cannot be replayed onto bytes it
8
  never ran against.
9
  """
10
11
  use Ecto.Schema
12
13
  import Ecto.Changeset
14
15
  @statuses ~w(pending succeeded failed)
16
  @commit_pattern ~r/\A[0-9a-f]{40}\z/
17
  @artifact_pattern ~r/\A[a-z0-9]+:[0-9a-f]{32,89}\z/
18
19
  @type t :: %__MODULE__{}
20
21
  @primary_key {:id, :binary_id, autogenerate: true}
22
  @foreign_key_type :binary_id
23
  @timestamps_opts [type: :utc_datetime_usec]
24
25
  schema "deployment_check_results" do
26
    field :name, :string
27
    field :commit_sha, :string
28
    field :artifact_digest, :string
29
    field :status, :string
30
    field :evidence_url, :string
31
    field :evidence_digest, :string
32
    field :valid_until, :utc_datetime_usec
33
    field :published_by_grant_id, :binary_id
34
35
    belongs_to :repository, OpenAgents.Repositories.Repository
36
    belongs_to :published_by_user, OpenAgents.Accounts.User
37
38
    timestamps()
39
  end
40
41
  @doc false
42
  def changeset(check_result, attrs) do
43
    check_result
44
    |> cast(attrs, [
45
      :name,
46
      :commit_sha,
47
      :artifact_digest,
48
      :status,
49
      :evidence_url,
50
      :evidence_digest,
51
      :valid_until
52
    ])
53
    |> validate_required([:name, :commit_sha, :artifact_digest, :status])
54
    |> update_change(:commit_sha, &String.downcase/1)
55
    |> validate_length(:name, min: 1, max: 120)
56
    |> validate_format(:commit_sha, @commit_pattern)
57
    |> validate_format(:artifact_digest, @artifact_pattern)
58
    |> validate_inclusion(:status, @statuses)
59
    |> validate_length(:evidence_url, max: 500)
60
    |> validate_evidence_url()
61
    |> validate_format(:evidence_digest, ~r/\A[0-9a-f]{64}\z/)
62
    |> unique_constraint(:name, name: :deployment_check_results_identity_index)
63
  end
64
65
  @doc "The statuses a check result can hold."
66
  @spec statuses() :: [String.t()]
67
  def statuses, do: @statuses
68
69
  # Evidence is a link the tenant reads back, so only plain HTTPS is stored: a
70
  # credentialed or non-HTTP URL in a durable record is a leak, not a link.
71
  defp validate_evidence_url(changeset) do
72
    validate_change(changeset, :evidence_url, fn :evidence_url, url ->
73
      case URI.new(url) do
74
        {:ok, %URI{scheme: "https", host: host, userinfo: nil}} when is_binary(host) -> []
75
        _invalid -> [evidence_url: "must be an https URL without credentials"]
76
      end
77
    end)
78
  end
79
end
lib/openagents/deployments/environment.ex added +108

@@ -0,0 +1,108 @@

1
defmodule OpenAgents.Deployments.Environment do
2
  @moduledoc """
3
  A repository-scoped logical deployment target, such as `preview` or
4
  `production`.
5
6
  The environment owns the provider binding and the protection policy. It holds
7
  secret *references* only: the name of a secret the provider resolves at
8
  execution time. A durable record that carried the secret value would leak
9
  through every read path that returns the environment.
10
  """
11
12
  use Ecto.Schema
13
14
  import Ecto.Changeset
15
16
  alias OpenAgents.Deployments.Protection
17
18
  @kinds ~w(preview staging production)
19
  @name_pattern ~r/\A[a-z][a-z0-9-]{0,59}\z/
20
  @secret_reference_pattern ~r/\A[A-Z][A-Z0-9_]{0,79}\z/
21
22
  @type t :: %__MODULE__{}
23
24
  @primary_key {:id, :binary_id, autogenerate: true}
25
  @foreign_key_type :binary_id
26
  @timestamps_opts [type: :utc_datetime_usec]
27
28
  schema "deployment_environments" do
29
    field :name, :string
30
    field :kind, :string
31
    field :provider, :string
32
    field :provider_config, :map, default: %{}
33
    field :secret_references, {:array, :string}, default: []
34
    field :retention_days, :integer, default: 90
35
36
    embeds_one :protection, Protection, on_replace: :update
37
38
    belongs_to :repository, OpenAgents.Repositories.Repository
39
    belongs_to :created_by_user, OpenAgents.Accounts.User
40
41
    timestamps()
42
  end
43
44
  @doc false
45
  def changeset(environment, attrs) do
46
    environment
47
    |> cast(attrs, [
48
      :name,
49
      :kind,
50
      :provider,
51
      :provider_config,
52
      :secret_references,
53
      :retention_days
54
    ])
55
    |> cast_embed(:protection, required: true)
56
    |> validate_required([:name, :kind, :provider])
57
    |> validate_format(:name, @name_pattern)
58
    |> validate_inclusion(:kind, @kinds)
59
    |> validate_length(:provider, min: 1, max: 60)
60
    |> validate_number(:retention_days, greater_than: 0, less_than_or_equal_to: 3_650)
61
    |> validate_secret_references()
62
    |> validate_provider_config()
63
    |> unique_constraint(:name, name: :deployment_environments_repository_id_name_index)
64
    |> foreign_key_constraint(:repository_id)
65
  end
66
67
  @doc "The environment kinds a repository can define."
68
  @spec kinds() :: [String.t()]
69
  def kinds, do: @kinds
70
71
  defp validate_secret_references(changeset) do
72
    validate_change(changeset, :secret_references, fn :secret_references, references ->
73
      cond do
74
        length(references) > 20 ->
75
          [secret_references: "admits at most 20 references"]
76
77
        Enum.any?(references, &(not Regex.match?(@secret_reference_pattern, to_string(&1)))) ->
78
          [secret_references: "must name secrets in upper snake case"]
79
80
        true ->
81
          []
82
      end
83
    end)
84
  end
85
86
  # Provider configuration is tenant-authored, so it is bounded and shallow:
87
  # a nested document is where a secret value hides from review.
88
  defp validate_provider_config(changeset) do
89
    validate_change(changeset, :provider_config, fn :provider_config, config ->
90
      cond do
91
        not is_map(config) -> [provider_config: "must be a map"]
92
        map_size(config) > 20 -> [provider_config: "admits at most 20 keys"]
93
        not Enum.all?(config, &scalar_entry?/1) -> [provider_config: "must hold scalar values"]
94
        true -> []
95
      end
96
    end)
97
  end
98
99
  defp scalar_entry?({key, value}) when is_binary(key) do
100
    byte_size(key) <= 60 and scalar_value?(value)
101
  end
102
103
  defp scalar_entry?(_entry), do: false
104
105
  defp scalar_value?(value) when is_binary(value), do: byte_size(value) <= 500
106
  defp scalar_value?(value) when is_integer(value) or is_boolean(value), do: true
107
  defp scalar_value?(_value), do: false
108
end
lib/openagents/deployments/event.ex added +78

@@ -0,0 +1,78 @@

1
defmodule OpenAgents.Deployments.Event do
2
  @moduledoc """
3
  One append-only record of something that happened to a run.
4
5
  Events carry a per-run monotonic `sequence`, so a reader can poll or stream
6
  from a cursor and know it missed nothing. The table has no update timestamp
7
  because nothing rewrites a transition after it commits; a correction is a new
8
  event.
9
  """
10
11
  use Ecto.Schema
12
13
  import Ecto.Changeset
14
15
  alias OpenAgents.Deployments.Run
16
17
  @actor_types ~w(user workflow operator system provider)
18
  @maximum_detail_bytes 4_096
19
20
  @type t :: %__MODULE__{}
21
22
  @primary_key {:id, :binary_id, autogenerate: true}
23
  @foreign_key_type :binary_id
24
  @timestamps_opts [type: :utc_datetime_usec, updated_at: false]
25
26
  schema "deployment_events" do
27
    field :sequence, :integer
28
    field :type, :string
29
    field :from_state, :string
30
    field :to_state, :string
31
    field :detail, :map, default: %{}
32
    field :actor_type, :string
33
    field :actor_id, :string
34
    field :occurred_at, :utc_datetime_usec
35
36
    belongs_to :repository, OpenAgents.Repositories.Repository
37
    belongs_to :deployment_run, OpenAgents.Deployments.Run
38
39
    timestamps()
40
  end
41
42
  @doc false
43
  def changeset(event, attrs) do
44
    event
45
    |> cast(attrs, [:sequence, :type, :from_state, :to_state, :detail, :actor_type, :actor_id])
46
    |> validate_required([:sequence, :type, :detail, :actor_type])
47
    |> validate_number(:sequence, greater_than: 0)
48
    |> validate_length(:type, min: 1, max: 60)
49
    |> validate_inclusion(:actor_type, @actor_types)
50
    |> validate_length(:actor_id, max: 64)
51
    |> validate_state(:from_state)
52
    |> validate_state(:to_state)
53
    |> validate_detail()
54
    |> put_change(:occurred_at, DateTime.utc_now())
55
    |> unique_constraint(:sequence, name: :deployment_events_deployment_run_id_sequence_index)
56
  end
57
58
  @doc "The actor kinds an event can attribute a transition to."
59
  @spec actor_types() :: [String.t()]
60
  def actor_types, do: @actor_types
61
62
  defp validate_state(changeset, field),
63
    do: validate_inclusion(changeset, field, Run.states())
64
65
  # Event detail reaches tenant reads, webhooks, and audit exports, so it is
66
  # bounded here rather than trusted from the caller.
67
  defp validate_detail(changeset) do
68
    validate_change(changeset, :detail, fn :detail, detail ->
69
      encoded = Jason.encode_to_iodata!(detail)
70
71
      if IO.iodata_length(encoded) > @maximum_detail_bytes do
72
        [detail: "exceeds #{@maximum_detail_bytes} bytes"]
73
      else
74
        []
75
      end
76
    end)
77
  end
78
end
lib/openagents/deployments/execution.ex added +76

@@ -0,0 +1,76 @@

1
defmodule OpenAgents.Deployments.Execution do
2
  @moduledoc """
3
  The immutable, fully admitted work a provider is handed.
4
5
  An execution is built only after policy admitted the run, and it carries no
6
  caller authority: no token, no membership, no scope. A provider that is
7
  compromised can therefore misreport its own deployment, but cannot deploy to
8
  another environment or read another repository.
9
10
  Resolved secret values live here and only here, for the duration of one
11
  attempt. The struct redacts them from `inspect/1` so a crash report, a logger
12
  metadata dump, or an exception message cannot carry a tenant credential.
13
  """
14
15
  @derive {Inspect, except: [:secrets]}
16
17
  @enforce_keys [
18
    :run_id,
19
    :repository,
20
    :environment,
21
    :commit_sha,
22
    :artifact_digest,
23
    :input_digest,
24
    :attempt,
25
    :deadline
26
  ]
27
  defstruct [
28
    :run_id,
29
    :repository,
30
    :environment,
31
    :commit_sha,
32
    :artifact_digest,
33
    :input_digest,
34
    :attempt,
35
    :deadline,
36
    provider_config: %{},
37
    secrets: %{}
38
  ]
39
40
  @type t :: %__MODULE__{
41
          run_id: String.t(),
42
          repository: String.t(),
43
          environment: String.t(),
44
          commit_sha: String.t(),
45
          artifact_digest: String.t(),
46
          input_digest: String.t(),
47
          attempt: pos_integer(),
48
          deadline: DateTime.t(),
49
          provider_config: map(),
50
          secrets: %{optional(String.t()) => String.t()}
51
        }
52
53
  @doc "Whether the execution's deadline has passed at `now`."
54
  @spec expired?(t(), DateTime.t()) :: boolean()
55
  def expired?(%__MODULE__{} = execution, %DateTime{} = now),
56
    do: DateTime.compare(now, execution.deadline) != :lt
57
58
  defimpl Jason.Encoder do
59
    # Executions are never serialized with their secrets. Encoding exists for
60
    # bounded diagnostics, so it emits identities only.
61
    def encode(execution, opts) do
62
      Jason.Encode.map(
63
        %{
64
          "run_id" => execution.run_id,
65
          "repository" => execution.repository,
66
          "environment" => execution.environment,
67
          "commit_sha" => execution.commit_sha,
68
          "artifact_digest" => execution.artifact_digest,
69
          "input_digest" => execution.input_digest,
70
          "attempt" => execution.attempt
71
        },
72
        opts
73
      )
74
    end
75
  end
76
end
lib/openagents/deployments/lifecycle.ex added +47

@@ -0,0 +1,47 @@

1
defmodule OpenAgents.Deployments.Lifecycle do
2
  @moduledoc """
3
  The one place that says which deployment state may follow which.
4
5
  Every transition in the control plane is checked here before it is written, so
6
  a new caller, a retried worker, or a late provider callback cannot invent a
7
  path such as `waiting_for_approval -> succeeded`. Terminal states have no
8
  successors: a run that has finished stays finished, and a further attempt is a
9
  new run.
10
  """
11
12
  @transitions %{
13
    "requested" => ~w(checking waiting_for_approval queued failed cancelled superseded),
14
    "checking" => ~w(waiting_for_approval queued failed cancelled superseded),
15
    "waiting_for_approval" => ~w(queued failed cancelled superseded),
16
    "queued" => ~w(deploying failed cancelled superseded),
17
    "deploying" => ~w(succeeded failed cancelled),
18
    "succeeded" => [],
19
    "failed" => [],
20
    "cancelled" => [],
21
    "superseded" => []
22
  }
23
24
  @doc "The states that may legally follow `state`."
25
  @spec successors(String.t()) :: [String.t()]
26
  def successors(state) when is_binary(state), do: Map.get(@transitions, state, [])
27
28
  @doc "Whether `to` may legally follow `from`."
29
  @spec allowed?(String.t(), String.t()) :: boolean()
30
  def allowed?(from, to) when is_binary(from) and is_binary(to), do: to in successors(from)
31
32
  @doc """
33
  Check one transition, returning a typed error rather than a boolean.
34
35
  The error names both states so a rejected transition is legible in an event
36
  detail without the caller reconstructing it.
37
  """
38
  @spec check(String.t(), String.t()) ::
39
          :ok | {:error, {:illegal_transition, String.t(), String.t()}}
40
  def check(from, to) when is_binary(from) and is_binary(to) do
41
    if allowed?(from, to), do: :ok, else: {:error, {:illegal_transition, from, to}}
42
  end
43
44
  @doc "The full transition table, for documentation and proofs."
45
  @spec transitions() :: %{String.t() => [String.t()]}
46
  def transitions, do: @transitions
47
end
lib/openagents/deployments/policy.ex added +286

@@ -0,0 +1,286 @@

1
defmodule OpenAgents.Deployments.Policy do
2
  @moduledoc """
3
  Evaluates one environment's protection policy against one exact request.
4
5
  Evaluation is pure: it takes the environment, the request, the check results
6
  already published for those exact bytes, the approvals already recorded, and
7
  the current time, and returns the state the run may hold plus a durable
8
  explanation of every rule it considered.
9
10
  The explanation is the product, not a by-product. A blocked deployment that
11
  cannot say which rule blocked it forces an operator to guess, and a guess is
12
  usually "grant more authority". Explanations carry rule names, outcomes, and
13
  bounded details only — never a secret value, and never a provider credential.
14
  """
15
16
  alias OpenAgents.Deployments.Approval
17
  alias OpenAgents.Deployments.CheckResult
18
  alias OpenAgents.Deployments.Environment
19
  alias OpenAgents.Deployments.Protection
20
  alias OpenAgents.Deployments.Request
21
22
  @type outcome :: :satisfied | :pending | :blocked
23
  @type explanation :: [%{optional(String.t()) => term()}]
24
  @type decision ::
25
          {:admit, String.t(), explanation()}
26
          | {:deny, String.t(), explanation()}
27
28
  @doc """
29
  Evaluate the policy, returning the admitted state or a denial.
30
31
  `:admit` carries `"queued"` when nothing further is required, or
32
  `"waiting_for_approval"` when the policy needs decisions the run does not have
33
  yet. `:deny` carries a bounded reason such as `"frozen"` or `"checks_failed"`.
34
  """
35
  @spec evaluate(
36
          Environment.t(),
37
          Request.t(),
38
          [CheckResult.t()],
39
          [Approval.t()],
40
          DateTime.t()
41
        ) :: decision()
42
  def evaluate(
43
        %Environment{} = environment,
44
        %Request{} = request,
45
        check_results,
46
        approvals,
47
        %DateTime{} = now
48
      ) do
49
    protection = environment.protection || %Protection{}
50
51
    [
52
      source_rule(protection, request),
53
      freeze_rule(protection),
54
      window_rule(protection, now),
55
      artifact_age_rule(protection, request, now),
56
      checks_rule(protection, request, check_results, now),
57
      approvals_rule(protection, request, approvals)
58
    ]
59
    |> decide()
60
  end
61
62
  @doc """
63
  The state a run starts in, before policy has been evaluated for the first time.
64
65
  A run that has required checks starts in `checking`, so a reader can tell an
66
  unevaluated run from one that is genuinely waiting on nothing.
67
  """
68
  @spec initial_state(Environment.t()) :: String.t()
69
  def initial_state(%Environment{} = environment) do
70
    protection = environment.protection || %Protection{}
71
72
    if protection.required_checks == [], do: "requested", else: "checking"
73
  end
74
75
  defp decide(rules) do
76
    explanation = Enum.map(rules, fn {_outcome, entry} -> entry end)
77
78
    blocked = Enum.find(rules, fn {outcome, _entry} -> outcome == :blocked end)
79
    pending = Enum.find(rules, fn {outcome, _entry} -> outcome == :pending end)
80
81
    cond do
82
      blocked ->
83
        {_outcome, entry} = blocked
84
        {:deny, Map.fetch!(entry, "reason"), explanation}
85
86
      pending ->
87
        {_outcome, entry} = pending
88
        {:admit, pending_state(Map.fetch!(entry, "rule")), explanation}
89
90
      true ->
91
        {:admit, "queued", explanation}
92
    end
93
  end
94
95
  defp pending_state("required_approvals"), do: "waiting_for_approval"
96
  defp pending_state(_rule), do: "checking"
97
98
  defp source_rule(%Protection{} = protection, %Request{} = request) do
99
    cond do
100
      workflow_rejected?(protection, request) ->
101
        blocked("allowed_workflows", "source_workflow_not_allowed", %{
102
          "source_workflow" => request.source_workflow
103
        })
104
105
      ref_admitted?(protection, request.source_ref) ->
106
        satisfied("allowed_sources", %{"source_ref" => request.source_ref})
107
108
      true ->
109
        blocked("allowed_sources", "source_ref_not_allowed", %{"source_ref" => request.source_ref})
110
    end
111
  end
112
113
  defp workflow_rejected?(%Protection{allowed_workflows: []}, _request), do: false
114
115
  defp workflow_rejected?(%Protection{allowed_workflows: allowed}, %Request{} = request),
116
    do: request.source_workflow not in allowed
117
118
  defp ref_admitted?(%Protection{allowed_branches: [], allowed_tags: []}, _ref), do: true
119
120
  defp ref_admitted?(%Protection{} = protection, "refs/heads/" <> branch),
121
    do: pattern_match?(protection.allowed_branches, branch)
122
123
  defp ref_admitted?(%Protection{} = protection, "refs/tags/" <> tag),
124
    do: pattern_match?(protection.allowed_tags, tag)
125
126
  defp ref_admitted?(%Protection{}, _ref), do: false
127
128
  # A trailing `*` is the only wildcard: `release/*` admits a family of branches
129
  # without admitting `release-hotfix-escape`.
130
  defp pattern_match?(patterns, value) do
131
    Enum.any?(patterns, fn pattern ->
132
      case String.split(pattern, "*", parts: 2) do
133
        [^value] -> true
134
        [prefix, ""] -> String.starts_with?(value, prefix)
135
        _other -> false
136
      end
137
    end)
138
  end
139
140
  defp freeze_rule(%Protection{frozen: true} = protection) do
141
    blocked("freeze", "frozen", %{"freeze_reason" => protection.freeze_reason})
142
  end
143
144
  defp freeze_rule(%Protection{}), do: satisfied("freeze", %{})
145
146
  defp window_rule(%Protection{} = protection, now) do
147
    cond do
148
      Protection.unrestricted_window?(protection) ->
149
        satisfied("deployment_window", %{"window" => "unrestricted"})
150
151
      Protection.within_window?(protection, now) ->
152
        satisfied("deployment_window", %{"window" => "open"})
153
154
      true ->
155
        blocked("deployment_window", "outside_window", %{"window" => "closed"})
156
    end
157
  end
158
159
  defp artifact_age_rule(%Protection{maximum_artifact_age_seconds: nil}, _request, _now),
160
    do: satisfied("artifact_age", %{"limit" => "none"})
161
162
  defp artifact_age_rule(%Protection{} = protection, %Request{artifact_created_at: nil}, _now) do
163
    blocked("artifact_age", "artifact_age_unknown", %{
164
      "maximum_age_seconds" => protection.maximum_artifact_age_seconds
165
    })
166
  end
167
168
  defp artifact_age_rule(%Protection{} = protection, %Request{} = request, now) do
169
    age = DateTime.diff(now, request.artifact_created_at)
170
171
    if age <= protection.maximum_artifact_age_seconds do
172
      satisfied("artifact_age", %{"age_seconds" => age})
173
    else
174
      blocked("artifact_age", "artifact_too_old", %{"age_seconds" => age})
175
    end
176
  end
177
178
  defp checks_rule(%Protection{required_checks: []}, _request, _results, _now),
179
    do: satisfied("required_checks", %{"required" => []})
180
181
  defp checks_rule(%Protection{} = protection, %Request{} = request, check_results, now) do
182
    matching = Enum.filter(check_results, &matches_bytes?(&1, request))
183
    by_name = Map.new(matching, &{&1.name, &1})
184
185
    # A required check with no evidence at all is the common case, so each rule
186
    # is evaluated over `{name, result_or_nil}` rather than over results found.
187
    required = Enum.map(protection.required_checks, &{&1, by_name[&1]})
188
189
    failed = for {name, result} <- required, failed?(result), do: name
190
    expired = for {name, result} <- required, expired?(result, protection, now), do: name
191
    missing = for {name, result} <- required, awaiting?(result), do: name
192
193
    cond do
194
      failed != [] ->
195
        blocked("required_checks", "checks_failed", %{"failed" => Enum.sort(failed)})
196
197
      expired != [] ->
198
        blocked("required_checks", "checks_expired", %{"expired" => Enum.sort(expired)})
199
200
      missing != [] ->
201
        pending("required_checks", %{"awaiting" => Enum.sort(missing)})
202
203
      true ->
204
        satisfied("required_checks", %{"required" => Enum.sort(protection.required_checks)})
205
    end
206
  end
207
208
  # A check counts only for the exact commit and artifact it examined. This is
209
  # the whole defense against replaying a green result onto different bytes.
210
  defp matches_bytes?(%CheckResult{} = result, %Request{} = request) do
211
    result.commit_sha == request.commit_sha and result.artifact_digest == request.artifact_digest
212
  end
213
214
  defp failed?(%CheckResult{status: "failed"}), do: true
215
  defp failed?(_result), do: false
216
217
  defp awaiting?(%CheckResult{status: "pending"}), do: true
218
  defp awaiting?(%CheckResult{}), do: false
219
  defp awaiting?(nil), do: true
220
221
  defp expired?(%CheckResult{status: "succeeded"} = result, %Protection{} = protection, now) do
222
    hard_expiry =
223
      not is_nil(result.valid_until) and DateTime.compare(result.valid_until, now) != :gt
224
225
    policy_expiry =
226
      not is_nil(protection.check_validity_seconds) and
227
        DateTime.diff(now, result.updated_at) > protection.check_validity_seconds
228
229
    hard_expiry or policy_expiry
230
  end
231
232
  defp expired?(_result, _protection, _now), do: false
233
234
  defp approvals_rule(%Protection{required_approvals: required}, _request, _approvals)
235
       when required <= 0 do
236
    satisfied("required_approvals", %{"required" => 0})
237
  end
238
239
  defp approvals_rule(%Protection{} = protection, %Request{} = request, approvals) do
240
    for_bytes = Enum.filter(approvals, &(&1.request_digest == request.request_digest))
241
    rejected = Enum.filter(for_bytes, &(&1.decision == "rejected"))
242
243
    approved =
244
      for_bytes
245
      |> Enum.filter(&(&1.decision == "approved"))
246
      |> Enum.reject(&self_approval?(protection, request, &1))
247
      |> Enum.uniq_by(& &1.approver_user_id)
248
249
    cond do
250
      rejected != [] ->
251
        blocked("required_approvals", "rejected", %{"rejections" => length(rejected)})
252
253
      length(approved) >= protection.required_approvals ->
254
        satisfied("required_approvals", %{
255
          "required" => protection.required_approvals,
256
          "approved" => length(approved)
257
        })
258
259
      true ->
260
        pending("required_approvals", %{
261
          "required" => protection.required_approvals,
262
          "approved" => length(approved)
263
        })
264
    end
265
  end
266
267
  # Separation of duties is enforced twice: the approval endpoint refuses the
268
  # requester, and evaluation refuses to count such an approval even if one is
269
  # already on record from before the policy tightened.
270
  defp self_approval?(%Protection{separation_of_duties: false}, _request, _approval), do: false
271
272
  defp self_approval?(%Protection{}, %Request{requested_by_user_id: nil}, _approval), do: false
273
274
  defp self_approval?(%Protection{}, %Request{} = request, %Approval{} = approval),
275
    do: approval.approver_user_id == request.requested_by_user_id
276
277
  defp satisfied(rule, detail), do: {:satisfied, entry(rule, "satisfied", detail)}
278
  defp pending(rule, detail), do: {:pending, entry(rule, "pending", detail)}
279
280
  defp blocked(rule, reason, detail),
281
    do: {:blocked, Map.put(entry(rule, "blocked", detail), "reason", reason)}
282
283
  defp entry(rule, outcome, detail) do
284
    %{"rule" => rule, "outcome" => outcome, "detail" => detail}
285
  end
286
end
lib/openagents/deployments/principal.ex added +80

@@ -0,0 +1,80 @@

1
defmodule OpenAgents.Deployments.Principal do
2
  @moduledoc """
3
  The authority a caller holds when it reaches the deployment control plane.
4
5
  A principal is built once, at the edge, from a credential. It is not derived
6
  from anything in the request body, so a caller cannot claim a repository, an
7
  environment, a commit, or an operator role by asserting it. Four kinds exist:
8
9
    * `:user` — a human with a repository membership and the `deployments:write`
10
      token scope.
11
    * `:workflow` — a short-lived grant bound to one repository, one source ref,
12
      one workflow, and one workflow run.
13
    * `:operator` — a platform operator, which can recover stuck control-plane
14
      runs but holds no tenant deployment authority.
15
    * `:system` — the control plane acting on itself, for worker transitions.
16
  """
17
18
  alias OpenAgents.Accounts.User
19
  alias OpenAgents.Deployments.WorkflowGrant
20
21
  @kinds [:user, :workflow, :operator, :system]
22
23
  @type kind :: :user | :workflow | :operator | :system
24
  @type t :: %__MODULE__{
25
          kind: kind(),
26
          user: User.t() | nil,
27
          grant: WorkflowGrant.t() | nil,
28
          worker: String.t() | nil
29
        }
30
31
  @enforce_keys [:kind]
32
  defstruct [:kind, :user, :grant, :worker]
33
34
  @doc "A human principal acting under its own repository membership."
35
  @spec user(User.t()) :: t()
36
  def user(%User{} = user), do: %__MODULE__{kind: :user, user: user}
37
38
  @doc "A workflow principal acting under a bound, short-lived grant."
39
  @spec workflow(WorkflowGrant.t()) :: t()
40
  def workflow(%WorkflowGrant{} = grant), do: %__MODULE__{kind: :workflow, grant: grant}
41
42
  @doc "A platform operator principal, holding control-plane recovery authority only."
43
  @spec operator(User.t()) :: t()
44
  def operator(%User{} = user), do: %__MODULE__{kind: :operator, user: user}
45
46
  @doc "The control plane's own principal, used for worker-driven transitions."
47
  @spec system(String.t()) :: t()
48
  def system(worker) when is_binary(worker), do: %__MODULE__{kind: :system, worker: worker}
49
50
  @doc "The principal kinds the control plane recognizes."
51
  @spec kinds() :: [kind()]
52
  def kinds, do: @kinds
53
54
  @doc """
55
  The principal type recorded on a durable request.
56
57
  `:system` never holds an intent, so it has no request principal type.
58
  """
59
  @spec request_principal_type(t()) :: {:ok, String.t()} | {:error, :unsupported_principal}
60
  def request_principal_type(%__MODULE__{kind: :user}), do: {:ok, "user"}
61
  def request_principal_type(%__MODULE__{kind: :workflow}), do: {:ok, "workflow"}
62
  def request_principal_type(%__MODULE__{kind: :operator}), do: {:ok, "operator"}
63
  def request_principal_type(%__MODULE__{kind: :system}), do: {:error, :unsupported_principal}
64
65
  @doc "The actor kind recorded on an event."
66
  @spec actor_type(t()) :: String.t()
67
  def actor_type(%__MODULE__{kind: kind}), do: Atom.to_string(kind)
68
69
  @doc """
70
  The bounded actor identifier recorded on an event.
71
72
  A grant's identifier is the grant, never its token, and a user's identifier is
73
  the user id, never a login that can be renamed.
74
  """
75
  @spec actor_id(t()) :: String.t() | nil
76
  def actor_id(%__MODULE__{kind: :workflow, grant: %WorkflowGrant{id: id}}), do: id
77
  def actor_id(%__MODULE__{kind: :system, worker: worker}), do: worker
78
  def actor_id(%__MODULE__{user: %User{id: id}}), do: id
79
  def actor_id(%__MODULE__{}), do: nil
80
end
lib/openagents/deployments/protection.ex added +170

@@ -0,0 +1,170 @@

1
defmodule OpenAgents.Deployments.Protection do
2
  @moduledoc """
3
  One environment's protection policy, stored as a typed embedded document.
4
5
  The policy is data rather than code so an environment can tighten without a
6
  release, but it is a schema rather than a free map so an unknown key or an
7
  impossible window cannot reach evaluation. Every field answers one question
8
  the policy evaluator asks about an exact commit and artifact.
9
  """
10
11
  use Ecto.Schema
12
13
  import Ecto.Changeset
14
15
  @concurrency ~w(queue cancel reject supersede)
16
  @weekdays 1..7
17
18
  @type t :: %__MODULE__{}
19
20
  @primary_key false
21
  embedded_schema do
22
    field :required_checks, {:array, :string}, default: []
23
    field :required_approvals, :integer, default: 0
24
    field :separation_of_duties, :boolean, default: true
25
    field :approver_roles, {:array, :string}, default: ["owner", "maintainer"]
26
    field :allowed_branches, {:array, :string}, default: []
27
    field :allowed_tags, {:array, :string}, default: []
28
    field :allowed_workflows, {:array, :string}, default: []
29
    field :window_weekdays, {:array, :integer}, default: []
30
    field :window_start_minute, :integer
31
    field :window_end_minute, :integer
32
    field :frozen, :boolean, default: false
33
    field :freeze_reason, :string
34
    field :concurrency, :string, default: "queue"
35
    field :maximum_artifact_age_seconds, :integer
36
    field :check_validity_seconds, :integer
37
  end
38
39
  @doc false
40
  def changeset(protection, attrs) do
41
    protection
42
    |> cast(attrs, [
43
      :required_checks,
44
      :required_approvals,
45
      :separation_of_duties,
46
      :approver_roles,
47
      :allowed_branches,
48
      :allowed_tags,
49
      :allowed_workflows,
50
      :window_weekdays,
51
      :window_start_minute,
52
      :window_end_minute,
53
      :frozen,
54
      :freeze_reason,
55
      :concurrency,
56
      :maximum_artifact_age_seconds,
57
      :check_validity_seconds
58
    ])
59
    |> validate_number(:required_approvals,
60
      greater_than_or_equal_to: 0,
61
      less_than_or_equal_to: 10
62
    )
63
    |> validate_inclusion(:concurrency, @concurrency)
64
    |> validate_length(:freeze_reason, max: 200)
65
    |> validate_bounded_names(:required_checks)
66
    |> validate_subset(:approver_roles, ~w(owner maintainer contributor))
67
    |> validate_length(:approver_roles, min: 1)
68
    |> validate_bounded_names(:allowed_branches)
69
    |> validate_bounded_names(:allowed_tags)
70
    |> validate_bounded_names(:allowed_workflows)
71
    |> validate_weekdays()
72
    |> validate_minute(:window_start_minute)
73
    |> validate_minute(:window_end_minute)
74
    |> validate_window_pair()
75
    |> validate_number(:maximum_artifact_age_seconds, greater_than: 0)
76
    |> validate_number(:check_validity_seconds, greater_than: 0)
77
  end
78
79
  @doc "Whether the policy admits deployment at any hour of any day."
80
  @spec unrestricted_window?(t()) :: boolean()
81
  def unrestricted_window?(%__MODULE__{} = protection) do
82
    protection.window_weekdays == [] and is_nil(protection.window_start_minute) and
83
      is_nil(protection.window_end_minute)
84
  end
85
86
  @doc """
87
  Whether the policy admits deployment at `date_time`, in UTC.
88
89
  A window that wraps midnight is legal: `window_start_minute` above
90
  `window_end_minute` means the window runs from the start minute through the
91
  end minute on the following day.
92
  """
93
  @spec within_window?(t(), DateTime.t()) :: boolean()
94
  def within_window?(%__MODULE__{} = protection, %DateTime{} = date_time) do
95
    weekday_admitted?(protection, date_time) and minute_admitted?(protection, date_time)
96
  end
97
98
  defp weekday_admitted?(%__MODULE__{window_weekdays: []}, _date_time), do: true
99
100
  defp weekday_admitted?(%__MODULE__{window_weekdays: weekdays}, date_time),
101
    do: Date.day_of_week(DateTime.to_date(date_time)) in weekdays
102
103
  defp minute_admitted?(%__MODULE__{window_start_minute: nil, window_end_minute: nil}, _time),
104
    do: true
105
106
  defp minute_admitted?(%__MODULE__{} = protection, date_time) do
107
    minute = date_time.hour * 60 + date_time.minute
108
    start_minute = protection.window_start_minute || 0
109
    end_minute = protection.window_end_minute || 1_439
110
111
    if start_minute <= end_minute do
112
      minute >= start_minute and minute <= end_minute
113
    else
114
      minute >= start_minute or minute <= end_minute
115
    end
116
  end
117
118
  defp validate_bounded_names(changeset, field) do
119
    changeset
120
    |> validate_change(field, fn ^field, values ->
121
      cond do
122
        length(values) > 20 -> [{field, "admits at most 20 entries"}]
123
        Enum.any?(values, &(not valid_name?(&1))) -> [{field, "contains an invalid entry"}]
124
        true -> []
125
      end
126
    end)
127
  end
128
129
  defp valid_name?(value) when is_binary(value),
130
    do: byte_size(value) in 1..120 and String.printable?(value)
131
132
  defp valid_name?(_value), do: false
133
134
  defp validate_weekdays(changeset) do
135
    validate_change(changeset, :window_weekdays, fn :window_weekdays, weekdays ->
136
      if Enum.all?(weekdays, &(&1 in @weekdays)) and
137
           length(Enum.uniq(weekdays)) == length(weekdays) do
138
        []
139
      else
140
        [window_weekdays: "must be unique ISO weekday numbers"]
141
      end
142
    end)
143
  end
144
145
  defp validate_minute(changeset, field),
146
    do:
147
      validate_number(changeset, field, greater_than_or_equal_to: 0, less_than_or_equal_to: 1_439)
148
149
  defp validate_window_pair(changeset) do
150
    start_minute = get_field(changeset, :window_start_minute)
151
    end_minute = get_field(changeset, :window_end_minute)
152
153
    case {start_minute, end_minute} do
154
      {nil, nil} ->
155
        changeset
156
157
      {nil, _end} ->
158
        add_error(changeset, :window_start_minute, "is required with a window end")
159
160
      {_start, nil} ->
161
        add_error(changeset, :window_end_minute, "is required with a window start")
162
163
      {same, same} ->
164
        add_error(changeset, :window_end_minute, "must differ from the window start")
165
166
      {_start, _end} ->
167
        changeset
168
    end
169
  end
170
end
lib/openagents/deployments/provider.ex added +114

@@ -0,0 +1,114 @@

1
defmodule OpenAgents.Deployments.Provider do
2
  @moduledoc """
3
  The contract every deployment provider implements.
4
5
  A provider receives an `OpenAgents.Deployments.Execution` and nothing else. It
6
  never receives the caller's credential, the caller's membership, or the request
7
  parameters, because a provider is the least trusted part of this system: it
8
  talks to the outside world.
9
10
  Three results exist, and the third is the point:
11
12
    * `{:ok, receipt}` — the deployment is live, and the receipt is bounded,
13
      sanitized metadata the tenant may read.
14
    * `{:error, reason}` — the deployment definitively did not happen.
15
    * `{:uncertain, detail}` — the provider does not know. A timeout, a lost
16
      connection, or a partial rollout returns this. The control plane records an
17
      explicitly uncertain failure rather than guessing, because a success
18
      receipt for an unknown outcome is worse than no receipt.
19
20
  `cancel/1` is best-effort and bounded: it asks the provider to stop, and the
21
  run still reaches its terminal state through the normal lifecycle rather than
22
  by the canceller's assertion.
23
24
  Providers must be idempotent by `run_id`. The worker may call `deploy/1` again
25
  after a crash, and calling it twice for one run must not deploy twice.
26
  """
27
28
  alias OpenAgents.Deployments.Execution
29
30
  @type receipt :: %{optional(String.t()) => term()}
31
  @type result :: {:ok, receipt()} | {:error, atom()} | {:uncertain, receipt()}
32
33
  @doc "Deploy the admitted execution. Must be idempotent by `execution.run_id`."
34
  @callback deploy(Execution.t()) :: result()
35
36
  @doc "Ask the provider to stop an in-flight execution. Best-effort."
37
  @callback cancel(Execution.t()) :: :ok | {:error, atom()}
38
39
  @doc "The secret references this provider requires from the environment."
40
  @callback required_secret_references(map()) :: [String.t()]
41
42
  @maximum_receipt_keys 20
43
  @maximum_receipt_value_bytes 500
44
45
  @doc """
46
  Resolve a provider name to its module.
47
48
  Only configured providers resolve. A tenant naming an arbitrary module would
49
  otherwise choose which code the control plane runs.
50
  """
51
  @spec fetch(String.t()) :: {:ok, module()} | {:error, :unknown_provider}
52
  def fetch(name) when is_binary(name) do
53
    case Map.fetch(configured(), name) do
54
      {:ok, module} -> {:ok, module}
55
      :error -> {:error, :unknown_provider}
56
    end
57
  end
58
59
  @doc "The configured provider names."
60
  @spec names() :: [String.t()]
61
  def names, do: configured() |> Map.keys() |> Enum.sort()
62
63
  @doc """
64
  Sanitize a provider receipt before it is stored or read back.
65
66
  Providers are bounded here rather than trusted: a receipt is flattened to
67
  scalars, truncated, key-limited, and credential-redacted, so provider output
68
  cannot smuggle a secret into a durable record or an event stream.
69
  """
70
  @spec sanitize_receipt(term(), [String.t()]) :: receipt()
71
  def sanitize_receipt(receipt, secret_values \\ [])
72
73
  def sanitize_receipt(receipt, secret_values) when is_map(receipt) do
74
    receipt
75
    |> Enum.sort_by(fn {key, _value} -> to_string(key) end)
76
    |> Enum.take(@maximum_receipt_keys)
77
    |> Map.new(fn {key, value} -> {sanitize_key(key), sanitize_value(value, secret_values)} end)
78
  end
79
80
  def sanitize_receipt(_receipt, _secret_values), do: %{}
81
82
  defp configured do
83
    Application.get_env(:openagents, :deployment_providers, %{
84
      "fake" => OpenAgents.Deployments.Providers.Fake
85
    })
86
  end
87
88
  defp sanitize_key(key) do
89
    key
90
    |> to_string()
91
    |> String.slice(0, 60)
92
  end
93
94
  defp sanitize_value(value, secret_values) when is_binary(value) do
95
    value
96
    |> mask_secret_values(secret_values)
97
    |> OpenAgents.LogSafety.redact()
98
    |> String.slice(0, @maximum_receipt_value_bytes)
99
  end
100
101
  defp sanitize_value(value, _secrets) when is_integer(value) or is_boolean(value), do: value
102
  defp sanitize_value(%DateTime{} = value, _secrets), do: DateTime.to_iso8601(value)
103
  defp sanitize_value(value, _secrets) when is_atom(value), do: Atom.to_string(value)
104
  defp sanitize_value(_value, _secrets), do: nil
105
106
  # A provider that echoes a credential it was handed is a leak the control
107
  # plane can close, so resolved values are masked by exact match before the
108
  # generic credential patterns run.
109
  defp mask_secret_values(value, secret_values) do
110
    secret_values
111
    |> Enum.filter(&(is_binary(&1) and byte_size(&1) >= 4))
112
    |> Enum.reduce(value, &String.replace(&2, &1, "[REDACTED_SECRET]"))
113
  end
114
end
lib/openagents/deployments/providers/fake.ex added +160

@@ -0,0 +1,160 @@

1
defmodule OpenAgents.Deployments.Providers.Fake do
2
  @moduledoc """
3
  A provider that deploys nothing and records what it was asked to do.
4
5
  The fake provider is the first delivery phase's whole execution surface. It
6
  exists so the contract — admission, leases, idempotency by run id, uncertain
7
  results, cancellation, secret boundaries — can be proved before any real
8
  infrastructure is involved, and so those proofs keep running afterwards.
9
10
  It is idempotent by run id: a second `deploy/1` for a run it already deployed
11
  returns the original receipt instead of a second deployment. State lives in an
12
  ETS table owned by a supervised process, so a worker crash does not lose the
13
  record of what the provider already did.
14
15
  Behavior is chosen per run through `program/2`, which is how a test asks for a
16
  failure, a timeout, or an uncertain result without a real outage.
17
  """
18
19
  @behaviour OpenAgents.Deployments.Provider
20
21
  use GenServer
22
23
  alias OpenAgents.Deployments.Execution
24
25
  @table __MODULE__.Deployments
26
  @programs __MODULE__.Programs
27
28
  @doc "Start the fake provider's durable-enough bookkeeping."
29
  @spec start_link(keyword()) :: GenServer.on_start()
30
  def start_link(options), do: GenServer.start_link(__MODULE__, options, name: __MODULE__)
31
32
  @impl GenServer
33
  def init(_options) do
34
    :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
35
    :ets.new(@programs, [:named_table, :public, :set, read_concurrency: true])
36
    {:ok, %{}}
37
  end
38
39
  @impl OpenAgents.Deployments.Provider
40
  def deploy(%Execution{} = execution) do
41
    ensure_tables()
42
43
    case :ets.lookup(@table, execution.run_id) do
44
      [{_run_id, receipt}] ->
45
        {:ok, Map.put(receipt, "replayed", true)}
46
47
      [] ->
48
        execute(execution, program(execution.run_id))
49
    end
50
  end
51
52
  @impl OpenAgents.Deployments.Provider
53
  def cancel(%Execution{} = execution) do
54
    ensure_tables()
55
    :ets.insert(@programs, {execution.run_id, :cancelled})
56
    :ok
57
  end
58
59
  @impl OpenAgents.Deployments.Provider
60
  def required_secret_references(provider_config) when is_map(provider_config) do
61
    case Map.get(provider_config, "secret_reference") do
62
      reference when is_binary(reference) -> [reference]
63
      _absent -> []
64
    end
65
  end
66
67
  @doc """
68
  Program the outcome of one run's next deployment.
69
70
  `:succeed` is the default. `:fail` returns a definitive error, `:uncertain`
71
  returns an unknown outcome, and `:hang` returns an uncertain result after the
72
  execution deadline passes, which is how a timeout looks from here.
73
  """
74
  @spec program(String.t(), :succeed | :fail | :uncertain | :hang) :: :ok
75
  def program(run_id, outcome)
76
      when is_binary(run_id) and outcome in [:succeed, :fail, :uncertain, :hang] do
77
    ensure_tables()
78
    :ets.insert(@programs, {run_id, outcome})
79
    :ok
80
  end
81
82
  @doc "Whether this run was asked to stop."
83
  @spec cancelled?(String.t()) :: boolean()
84
  def cancelled?(run_id) when is_binary(run_id) do
85
    ensure_tables()
86
    :ets.lookup(@programs, run_id) == [{run_id, :cancelled}]
87
  end
88
89
  @doc "The receipt this provider already issued for a run, if any."
90
  @spec receipt(String.t()) :: {:ok, map()} | :error
91
  def receipt(run_id) when is_binary(run_id) do
92
    ensure_tables()
93
94
    case :ets.lookup(@table, run_id) do
95
      [{_run_id, receipt}] -> {:ok, receipt}
96
      [] -> :error
97
    end
98
  end
99
100
  @doc """
101
  Forget one run's recorded deployment and program.
102
103
  Bookkeeping is keyed by run id, which is unique, so nothing here clears every
104
  run: a concurrent test's programmed outcome must survive another test's setup.
105
  """
106
  @spec forget(String.t()) :: :ok
107
  def forget(run_id) when is_binary(run_id) do
108
    ensure_tables()
109
    :ets.delete(@table, run_id)
110
    :ets.delete(@programs, run_id)
111
    :ok
112
  end
113
114
  defp execute(%Execution{}, :fail), do: {:error, :provider_rejected}
115
116
  defp execute(%Execution{} = execution, :uncertain) do
117
    {:uncertain, %{"run_id" => execution.run_id, "detail" => "provider did not confirm"}}
118
  end
119
120
  defp execute(%Execution{} = execution, :hang) do
121
    {:uncertain, %{"run_id" => execution.run_id, "detail" => "provider timed out"}}
122
  end
123
124
  defp execute(%Execution{}, :cancelled), do: {:error, :cancelled}
125
126
  defp execute(%Execution{} = execution, :succeed) do
127
    receipt = %{
128
      "run_id" => execution.run_id,
129
      "commit_sha" => execution.commit_sha,
130
      "artifact_digest" => execution.artifact_digest,
131
      "environment" => execution.environment,
132
      "secret_references_resolved" =>
133
        execution.secrets |> Map.keys() |> Enum.sort() |> Enum.join(",")
134
    }
135
136
    :ets.insert(@table, {execution.run_id, receipt})
137
    {:ok, receipt}
138
  end
139
140
  defp program(run_id) do
141
    case :ets.lookup(@programs, run_id) do
142
      [{_run_id, outcome}] -> outcome
143
      [] -> :succeed
144
    end
145
  end
146
147
  # The provider is used from request-path tests and from workers, so the tables
148
  # are created on demand rather than requiring a supervised owner everywhere.
149
  defp ensure_tables do
150
    for table <- [@table, @programs] do
151
      if :ets.whereis(table) == :undefined do
152
        :ets.new(table, [:named_table, :public, :set, read_concurrency: true])
153
      end
154
    end
155
156
    :ok
157
  rescue
158
    ArgumentError -> :ok
159
  end
160
end
lib/openagents/deployments/request.ex added +86

@@ -0,0 +1,86 @@

1
defmodule OpenAgents.Deployments.Request do
2
  @moduledoc """
3
  One durable intent to deploy exact bytes to one environment.
4
5
  A request records what was asked for and who asked: the exact commit, the
6
  exact artifact digest, the source ref and workflow the intent came from, the
7
  principal kind, and the idempotency key the caller spent. It never records a
8
  decision — admission lives on the run — so a replayed key can be answered from
9
  the request alone.
10
  """
11
12
  use Ecto.Schema
13
14
  import Ecto.Changeset
15
16
  @principal_types ~w(user workflow operator)
17
  @commit_pattern ~r/\A[0-9a-f]{40}\z/
18
  @artifact_pattern ~r/\A[a-z0-9]+:[0-9a-f]{32,89}\z/
19
  @ref_pattern ~r/\A(?:refs\/(?:heads|tags)\/)[\x21-\x7e]{1,240}\z/
20
21
  @type t :: %__MODULE__{}
22
23
  @primary_key {:id, :binary_id, autogenerate: true}
24
  @foreign_key_type :binary_id
25
  @timestamps_opts [type: :utc_datetime_usec]
26
27
  schema "deployment_requests" do
28
    field :commit_sha, :string
29
    field :artifact_digest, :string
30
    field :artifact_created_at, :utc_datetime_usec
31
    field :source_ref, :string
32
    field :source_workflow, :string
33
    field :principal_type, :string
34
    field :requested_by_grant_id, :binary_id
35
    field :idempotency_key, :string
36
    field :request_digest, :string
37
    field :input_digest, :string
38
    field :requested_at, :utc_datetime_usec
39
40
    belongs_to :repository, OpenAgents.Repositories.Repository
41
    belongs_to :environment, OpenAgents.Deployments.Environment
42
    belongs_to :requested_by_user, OpenAgents.Accounts.User
43
    has_one :run, OpenAgents.Deployments.Run, foreign_key: :deployment_request_id
44
45
    timestamps()
46
  end
47
48
  @doc false
49
  def changeset(request, attrs) do
50
    request
51
    |> cast(attrs, [
52
      :commit_sha,
53
      :artifact_digest,
54
      :artifact_created_at,
55
      :source_ref,
56
      :source_workflow,
57
      :idempotency_key
58
    ])
59
    |> validate_required([
60
      :commit_sha,
61
      :artifact_digest,
62
      :source_ref,
63
      :idempotency_key,
64
      :principal_type,
65
      :request_digest,
66
      :input_digest,
67
      :requested_at
68
    ])
69
    |> update_change(:commit_sha, &String.downcase/1)
70
    |> validate_format(:commit_sha, @commit_pattern)
71
    |> validate_format(:artifact_digest, @artifact_pattern)
72
    |> validate_format(:source_ref, @ref_pattern)
73
    |> validate_length(:source_workflow, min: 1, max: 120)
74
    |> validate_length(:idempotency_key, min: 8, max: 255)
75
    |> validate_inclusion(:principal_type, @principal_types)
76
    |> unique_constraint(:idempotency_key,
77
      name: :deployment_requests_idempotency_index
78
    )
79
    |> foreign_key_constraint(:repository_id)
80
    |> foreign_key_constraint(:environment_id)
81
  end
82
83
  @doc "The principal kinds that can hold a deployment intent."
84
  @spec principal_types() :: [String.t()]
85
  def principal_types, do: @principal_types
86
end
lib/openagents/deployments/run.ex added +88

@@ -0,0 +1,88 @@

1
defmodule OpenAgents.Deployments.Run do
2
  @moduledoc """
3
  The admitted, immutable execution of one deployment request.
4
5
  The run carries the lifecycle state, the durable policy explanation, the lease
6
  a worker holds while it executes, and the provider receipt. It is the only
7
  record a provider ever sees, and it holds secret references rather than secret
8
  values so a provider crash, an event stream, or an audit export cannot spill a
9
  tenant credential.
10
  """
11
12
  use Ecto.Schema
13
14
  import Ecto.Changeset
15
16
  @states ~w(requested checking waiting_for_approval queued deploying succeeded failed cancelled superseded)
17
  @terminal_states ~w(succeeded failed cancelled superseded)
18
  @active_states ~w(requested checking waiting_for_approval queued deploying)
19
20
  @type t :: %__MODULE__{}
21
22
  @primary_key {:id, :binary_id, autogenerate: true}
23
  @foreign_key_type :binary_id
24
  @timestamps_opts [type: :utc_datetime_usec]
25
26
  schema "deployment_runs" do
27
    field :input_digest, :string
28
    field :state, :string, default: "requested"
29
    field :result_reason, :string
30
    field :provider, :string
31
    field :provider_receipt, :map, default: %{}
32
    field :policy_explanation, {:array, :map}, default: []
33
    field :attempt_count, :integer, default: 0
34
    field :lease_owner, :string
35
    field :lease_expires_at, :utc_datetime_usec
36
    field :cancel_requested_at, :utc_datetime_usec
37
    field :started_at, :utc_datetime_usec
38
    field :finished_at, :utc_datetime_usec
39
    field :superseded_by_run_id, :binary_id
40
41
    belongs_to :repository, OpenAgents.Repositories.Repository
42
    belongs_to :environment, OpenAgents.Deployments.Environment
43
    belongs_to :deployment_request, OpenAgents.Deployments.Request
44
    belongs_to :cancel_requested_by_user, OpenAgents.Accounts.User
45
46
    timestamps()
47
  end
48
49
  @doc false
50
  def changeset(run, attrs) do
51
    run
52
    |> cast(attrs, [
53
      :state,
54
      :result_reason,
55
      :provider_receipt,
56
      :policy_explanation,
57
      :attempt_count,
58
      :lease_owner,
59
      :lease_expires_at,
60
      :cancel_requested_at,
61
      :started_at,
62
      :finished_at,
63
      :superseded_by_run_id
64
    ])
65
    |> validate_required([:input_digest, :state, :provider])
66
    |> validate_inclusion(:state, @states)
67
    |> validate_length(:result_reason, max: 80)
68
    |> validate_length(:lease_owner, max: 120)
69
    |> validate_number(:attempt_count, greater_than_or_equal_to: 0)
70
    |> unique_constraint(:deployment_request_id)
71
  end
72
73
  @doc "Every legal lifecycle state."
74
  @spec states() :: [String.t()]
75
  def states, do: @states
76
77
  @doc "The states from which no further transition is legal."
78
  @spec terminal_states() :: [String.t()]
79
  def terminal_states, do: @terminal_states
80
81
  @doc "The states in which a run still holds or awaits work."
82
  @spec active_states() :: [String.t()]
83
  def active_states, do: @active_states
84
85
  @doc "Whether the run reached a terminal state."
86
  @spec terminal?(t()) :: boolean()
87
  def terminal?(%__MODULE__{state: state}), do: state in @terminal_states
88
end
lib/openagents/deployments/secret_resolver.ex added +48

@@ -0,0 +1,48 @@

1
defmodule OpenAgents.Deployments.SecretResolver do
2
  @moduledoc """
3
  Resolves an environment's declared secret references into values, at execution
4
  time only.
5
6
  Durable records hold references. Values exist for the duration of one attempt,
7
  inside one `OpenAgents.Deployments.Execution`, and are handed only to the
8
  provider bound to that environment. Nothing writes a value to a run, an event,
9
  a receipt, or a log.
10
11
  A resolver may only resolve references the environment declares. That bound is
12
  what stops a provider or a tenant configuration change from reading a
13
  credential belonging to another environment.
14
  """
15
16
  alias OpenAgents.Deployments.Environment
17
18
  @callback resolve(Environment.t(), [String.t()]) ::
19
              {:ok, %{optional(String.t()) => String.t()}}
20
              | {:error, {:missing_secret_reference, String.t()}}
21
22
  @doc """
23
  Resolve the references the environment declares, refusing anything else.
24
25
  An undeclared reference is a programming error in a provider, so it is refused
26
  rather than resolved.
27
  """
28
  @spec resolve(Environment.t(), [String.t()]) ::
29
          {:ok, %{optional(String.t()) => String.t()}}
30
          | {:error,
31
             {:missing_secret_reference, String.t()} | {:undeclared_secret_reference, String.t()}}
32
  def resolve(%Environment{} = environment, references) when is_list(references) do
33
    declared = MapSet.new(environment.secret_references)
34
35
    case Enum.find(references, &(not MapSet.member?(declared, &1))) do
36
      nil -> impl().resolve(environment, references)
37
      reference -> {:error, {:undeclared_secret_reference, reference}}
38
    end
39
  end
40
41
  defp impl do
42
    Application.get_env(
43
      :openagents,
44
      :deployment_secret_resolver,
45
      OpenAgents.Deployments.SecretResolver.Environment
46
    )
47
  end
48
end
lib/openagents/deployments/secret_resolver/environment.ex added +39

@@ -0,0 +1,39 @@

1
defmodule OpenAgents.Deployments.SecretResolver.Environment do
2
  @moduledoc """
3
  Resolves deployment secrets from the host process environment.
4
5
  The variable name is derived from the repository, the environment, and the
6
  reference, so one environment's binding cannot read another's value even when
7
  both declare the same reference name:
8
9
      OPENAGENTS_DEPLOY__<REPOSITORY>__<ENVIRONMENT>__<REFERENCE>
10
11
  This resolver exists because the first delivery phase runs against a fake
12
  provider, and a fake provider still has to prove the secret boundary. A hosted
13
  resolver replaces this module without changing any caller.
14
  """
15
16
  @behaviour OpenAgents.Deployments.SecretResolver
17
18
  alias OpenAgents.Deployments.Environment
19
20
  @impl true
21
  def resolve(%Environment{} = environment, references) do
22
    Enum.reduce_while(references, {:ok, %{}}, fn reference, {:ok, resolved} ->
23
      case System.get_env(variable_name(environment, reference)) do
24
        nil -> {:halt, {:error, {:missing_secret_reference, reference}}}
25
        value -> {:cont, {:ok, Map.put(resolved, reference, value)}}
26
      end
27
    end)
28
  end
29
30
  @doc "The host variable one environment's reference resolves from."
31
  @spec variable_name(Environment.t(), String.t()) :: String.t()
32
  def variable_name(%Environment{} = environment, reference) when is_binary(reference) do
33
    repository = environment.repository_id |> to_string() |> String.replace("-", "")
34
35
    "OPENAGENTS_DEPLOY__#{String.upcase(repository)}__#{upcase_segment(environment.name)}__#{reference}"
36
  end
37
38
  defp upcase_segment(value), do: value |> String.replace("-", "_") |> String.upcase()
39
end
lib/openagents/deployments/worker.ex added +169

@@ -0,0 +1,169 @@

1
defmodule OpenAgents.Deployments.Worker do
2
  @moduledoc """
3
  Drives queued deployment runs to a terminal state.
4
5
  The worker owns no state that matters. Everything it needs is in the database:
6
  the run, its lease, its attempt count, and its policy explanation. Restarting
7
  the worker, or losing the machine it runs on, therefore loses no deployment —
8
  `OpenAgents.Deployments.reconcile_leases/2` reclaims what the old worker held.
9
10
  One pass does, for one run:
11
12
    1. Claim a queued run by taking a lease. The claim is a conditional update,
13
      so two workers cannot claim the same run.
14
    2. Re-check the authority behind the request and re-evaluate policy. A run
15
      queued an hour ago is not admitted now if the environment froze or the
16
      requester lost membership.
17
    3. Resolve secrets and hand the provider an immutable execution.
18
    4. Record the provider's result, treating an uncertain result as an
19
      explicitly uncertain failure rather than a success.
20
21
  A cancellation requested while the provider is running is observed here, after
22
  the provider returns, so the run reaches a terminal state through the lifecycle
23
  rather than by the canceller's assertion.
24
  """
25
26
  use GenServer
27
28
  require Logger
29
30
  alias OpenAgents.Deployments
31
  alias OpenAgents.Deployments.Principal
32
  alias OpenAgents.Deployments.Provider
33
  alias OpenAgents.Deployments.Run
34
35
  @default_interval 1_000
36
37
  @doc "Start the worker loop."
38
  @spec start_link(keyword()) :: GenServer.on_start()
39
  def start_link(options) do
40
    {name, options} = Keyword.pop(options, :name, __MODULE__)
41
    GenServer.start_link(__MODULE__, options, name: name)
42
  end
43
44
  @doc """
45
  Run one pass synchronously, returning what it did.
46
47
  Tests and operators drive the loop through this call, so no test has to sleep
48
  and no operator has to guess whether a tick happened.
49
  """
50
  @spec tick(GenServer.server()) :: {:ok, Run.t()} | :empty | {:error, term()}
51
  def tick(server \\ __MODULE__), do: GenServer.call(server, :tick, 30_000)
52
53
  @impl GenServer
54
  def init(options) do
55
    state = %{
56
      identity: Keyword.get_lazy(options, :identity, &default_identity/0),
57
      interval: Keyword.get(options, :interval, @default_interval),
58
      lease_seconds: Keyword.get(options, :lease_seconds, 300),
59
      poll: Keyword.get(options, :poll, true)
60
    }
61
62
    if state.poll, do: schedule(state)
63
    {:ok, state}
64
  end
65
66
  @impl GenServer
67
  def handle_call(:tick, _from, state), do: {:reply, run_once(state), state}
68
69
  @impl GenServer
70
  def handle_info(:tick, state) do
71
    _result = run_once(state)
72
    schedule(state)
73
    {:noreply, state}
74
  end
75
76
  @doc """
77
  Execute one claimed run to a terminal state.
78
79
  Exposed so a recovery path or a test can drive one exact run without racing the
80
  claim query.
81
  """
82
  @spec execute(Run.t(), String.t(), keyword()) :: {:ok, Run.t()} | {:error, term()}
83
  def execute(%Run{} = run, identity, options \\ []) do
84
    principal = Principal.system(identity)
85
86
    with {:ok, {repository, environment, request}} <- Deployments.load_run_context(run),
87
         :ok <- Deployments.recheck_request_authority(repository, environment, request),
88
         {:ok, %Run{state: "queued"} = run} <-
89
           Deployments.reevaluate(repository, environment, request, run, principal),
90
         {:ok, run} <- Deployments.transition(run, "deploying", principal),
91
         {:ok, module} <- Provider.fetch(environment.provider),
92
         {:ok, execution} <-
93
           Deployments.build_execution(repository, environment, request, run, options) do
94
      result = deploy(module, execution)
95
      secret_values = Map.values(execution.secrets)
96
97
      finish(run, identity, result, secret_values)
98
    else
99
      {:ok, %Run{} = run} ->
100
        # Policy moved the run somewhere other than `queued`; that decision and
101
        # its explanation are already durable.
102
        {:ok, run}
103
104
      {:error, reason} ->
105
        halt(run, identity, reason)
106
    end
107
  end
108
109
  defp run_once(state) do
110
    case Deployments.claim_run(state.identity, lease_seconds: state.lease_seconds) do
111
      {:ok, run} -> execute(run, state.identity, timeout_seconds: state.lease_seconds)
112
      :empty -> :empty
113
    end
114
  end
115
116
  # A provider that raises or exits is uncertain, not failed: an exception after
117
  # the API call was made says nothing about whether the deployment happened.
118
  defp deploy(module, execution) do
119
    module.deploy(execution)
120
  rescue
121
    exception ->
122
      # Only the exception's type is logged. Its message can carry provider
123
      # response bodies, which can carry a resolved secret.
124
      Logger.error("deployment_provider_raised code=#{inspect(exception.__struct__)}")
125
      {:uncertain, %{"detail" => "provider raised"}}
126
  catch
127
    :exit, _reason -> {:uncertain, %{"detail" => "provider exited"}}
128
  end
129
130
  defp finish(%Run{} = run, identity, result, secret_values) do
131
    Deployments.finish_run(reloaded(run), identity, result, secret_values: secret_values)
132
  end
133
134
  # The run is reloaded before the terminal write so a cancellation requested
135
  # while the provider was running is visible to the transition.
136
  defp reloaded(%Run{} = run) do
137
    case OpenAgents.Repo.get(Run, run.id) do
138
      %Run{} = reloaded -> reloaded
139
      nil -> run
140
    end
141
  end
142
143
  # Reloaded because a halt can happen after the run already moved to
144
  # `deploying`, and the transition is conditional on the state it reads.
145
  defp halt(%Run{} = run, identity, reason) do
146
    principal = Principal.system(identity)
147
148
    case Deployments.transition(reloaded(run), "failed", principal,
149
           reason: failure_reason(reason),
150
           event_type: "worker_halted"
151
         ) do
152
      {:ok, halted} -> {:ok, halted}
153
      {:error, _transition_error} -> {:error, reason}
154
    end
155
  end
156
157
  defp failure_reason({:forbidden, reason}), do: "authority_revoked_" <> Atom.to_string(reason)
158
  defp failure_reason({:missing_secret_reference, _reference}), do: "secret_unavailable"
159
  defp failure_reason({:undeclared_secret_reference, _reference}), do: "secret_undeclared"
160
  defp failure_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
161
  defp failure_reason(_reason), do: "worker_error"
162
163
  defp schedule(state), do: Process.send_after(self(), :tick, state.interval)
164
165
  defp default_identity do
166
    node = node() |> Atom.to_string() |> String.slice(0, 80)
167
    node <> ":" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false)
168
  end
169
end
lib/openagents/deployments/workflow_grant.ex added +97

@@ -0,0 +1,97 @@

1
defmodule OpenAgents.Deployments.WorkflowGrant do
2
  @moduledoc """
3
  A short-lived credential a workflow run holds to act on one repository.
4
5
  The grant binds a repository, an optional environment, the source ref and
6
  workflow it was issued for, and the exact workflow run it belongs to. Only the
7
  token digest is stored. The grant cannot widen: authorization compares the
8
  request against these bound values rather than against anything the caller
9
  sends.
10
  """
11
12
  use Ecto.Schema
13
14
  import Ecto.Changeset
15
16
  @scopes ~w(deployments:request deployments:checks)
17
  @maximum_lifetime_seconds 3_600
18
19
  @type t :: %__MODULE__{}
20
21
  @primary_key {:id, :binary_id, autogenerate: true}
22
  @foreign_key_type :binary_id
23
  @timestamps_opts [type: :utc_datetime_usec]
24
25
  schema "deployment_workflow_grants" do
26
    field :token_digest, :string
27
    field :audience, :string
28
    field :source_ref, :string
29
    field :source_workflow, :string
30
    field :workflow_run_id, :string
31
    field :scopes, {:array, :string}, default: []
32
    field :expires_at, :utc_datetime_usec
33
    field :revoked_at, :utc_datetime_usec
34
35
    belongs_to :repository, OpenAgents.Repositories.Repository
36
    belongs_to :environment, OpenAgents.Deployments.Environment
37
    belongs_to :created_by_user, OpenAgents.Accounts.User
38
39
    timestamps()
40
  end
41
42
  @doc false
43
  def changeset(grant, attrs) do
44
    grant
45
    |> cast(attrs, [:audience, :source_ref, :source_workflow, :workflow_run_id, :scopes])
46
    |> validate_required([
47
      :token_digest,
48
      :audience,
49
      :source_ref,
50
      :source_workflow,
51
      :workflow_run_id,
52
      :expires_at
53
    ])
54
    |> validate_length(:audience, min: 1, max: 120)
55
    |> validate_length(:source_ref, min: 1, max: 255)
56
    |> validate_length(:source_workflow, min: 1, max: 120)
57
    |> validate_length(:workflow_run_id, min: 1, max: 64)
58
    |> validate_scopes()
59
    |> validate_lifetime()
60
    |> unique_constraint(:token_digest)
61
  end
62
63
  @doc "The scopes a workflow grant can carry."
64
  @spec scopes() :: [String.t()]
65
  def scopes, do: @scopes
66
67
  @doc "The longest lifetime a grant may be issued for."
68
  @spec maximum_lifetime_seconds() :: pos_integer()
69
  def maximum_lifetime_seconds, do: @maximum_lifetime_seconds
70
71
  @doc "Whether the grant is usable at `now`."
72
  @spec usable?(t(), DateTime.t()) :: boolean()
73
  def usable?(%__MODULE__{} = grant, %DateTime{} = now) do
74
    is_nil(grant.revoked_at) and DateTime.compare(grant.expires_at, now) == :gt
75
  end
76
77
  defp validate_scopes(changeset) do
78
    validate_change(changeset, :scopes, fn :scopes, scopes ->
79
      cond do
80
        scopes == [] -> [scopes: "must name at least one scope"]
81
        Enum.any?(scopes, &(&1 not in @scopes)) -> [scopes: "contains an unknown scope"]
82
        true -> []
83
      end
84
    end)
85
  end
86
87
  defp validate_lifetime(changeset) do
88
    expires_at = get_field(changeset, :expires_at)
89
90
    if is_nil(expires_at) or
91
         DateTime.diff(expires_at, DateTime.utc_now()) <= @maximum_lifetime_seconds do
92
      changeset
93
    else
94
      add_error(changeset, :expires_at, "exceeds the maximum grant lifetime")
95
    end
96
  end
97
end
lib/openagents/repositories/repository.ex modified +2

@@ -8,6 +8,8 @@ defmodule OpenAgents.Repositories.Repository do

8 8
  @foreign_key_type :binary_id
9 9
  @timestamps_opts [type: :utc_datetime_usec]
10 10
11
  @type t :: %__MODULE__{}
12
11 13
  schema "repositories" do
12 14
    field :owner, :string
13 15
    field :name, :string
lib/openagents/runtime_config.ex modified +2

@@ -353,6 +353,7 @@ defmodule OpenAgents.RuntimeConfig do

353 353
         {:ok, boot_convergence?} <- required_boolean(settings, :forge_boot_converge_enabled),
354 354
         {:ok, turn_recovery?} <- required_boolean(settings, :turn_recovery_enabled),
355 355
         {:ok, voice_retention?} <- required_boolean(settings, :voice_retention_enabled),
356
         {:ok, deployments?} <- required_boolean(settings, :deployment_control_plane_enabled),
356 357
         {:ok, voice_recovery?} <-
357 358
           required_boolean(settings, :voice_recovery_worker_enabled),
358 359
         {:ok, work_workers?} <- required_boolean(settings, :work_workers_enabled),

@@ -379,6 +380,7 @@ defmodule OpenAgents.RuntimeConfig do

379 380
        forge_deploy: forge_deploy?,
380 381
        boot_convergence: boot_convergence?,
381 382
        turn_recovery: turn_recovery?,
383
        deployment_control_plane: deployments?,
382 384
        voice_recovery: voice_recovery?,
383 385
        work_workers: work_workers?,
384 386
        conversation_reset: conversation_reset?,
lib/openagents/runtime_supervisor.ex modified +16 -1

@@ -38,7 +38,11 @@ defmodule OpenAgents.RuntimeSupervisor do

38 38
        OpenAgents.Leaderboard.Server,
39 39
        {Task.Supervisor, name: OpenAgents.ProviderTaskSupervisor},
40 40
        {Task.Supervisor, name: OpenAgents.ToolTaskSupervisor},
41
        {Task.Supervisor, name: OpenAgents.ShadowProgramTaskSupervisor}
41
        {Task.Supervisor, name: OpenAgents.ShadowProgramTaskSupervisor},
42
        # The fake provider's bookkeeping owns its tables from a supervised
43
        # process, so a worker crash cannot lose the record of what the
44
        # provider already did. It deploys nothing on its own.
45
        OpenAgents.Deployments.Providers.Fake
42 46
      ] ++
43 47
        maybe_scv_execution_reaper() ++
44 48
        maybe_forge() ++

@@ -47,6 +51,7 @@ defmodule OpenAgents.RuntimeSupervisor do

47 51
        maybe_work_recovery() ++
48 52
        maybe_voice_recovery() ++
49 53
        maybe_voice_retention() ++
54
        maybe_deployment_control_plane() ++
50 55
        maybe_ra_bootstrap()
51 56
52 57
    Supervisor.init(children, strategy: :one_for_one)

@@ -68,6 +73,16 @@ defmodule OpenAgents.RuntimeSupervisor do

68 73
    end
69 74
  end
70 75
76
  # The control plane's API is always available; its executing worker is gated,
77
  # because a host that should not run tenant deployments must not claim a run.
78
  defp maybe_deployment_control_plane do
79
    if OpenAgents.RuntimeConfig.feature_enabled?(:deployment_control_plane) do
80
      [OpenAgents.Deployments.Worker]
81
    else
82
      []
83
    end
84
  end
85
71 86
  defp maybe_semantic_worker do
72 87
    if OpenAgents.RuntimeConfig.feature_enabled?(:semantic_memory) do
73 88
      [OpenAgents.Memory.SemanticWorker]
lib/openagents_web/api_route_authority.ex modified +17

@@ -105,6 +105,23 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

105 105
      "post /api/v3/repos/:owner/:repo/pulls" => :required_bearer,
106 106
      "post /api/v3/repos/:owner/:repo/stacks" => :required_bearer,
107 107
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/append" => :required_bearer,
108
      # pipe_through :deployments_api — tenant deployment authority only. No
109
      # route here is anonymous, and none of them reaches the operator fleet
110
      # promotion surface.
111
      "get /api/v3/repos/:owner/:repo/deployment-environments" => :required_bearer,
112
      "put /api/v3/repos/:owner/:repo/deployment-environments/:name" => :required_bearer,
113
      "get /api/v3/repos/:owner/:repo/deployment-environments/:name/protection" =>
114
        :required_bearer,
115
      "post /api/v3/repos/:owner/:repo/deployments" => :required_bearer,
116
      "get /api/v3/repos/:owner/:repo/deployments" => :required_bearer,
117
      "get /api/v3/repos/:owner/:repo/deployments/:id" => :required_bearer,
118
      "post /api/v3/repos/:owner/:repo/deployments/:id/cancel" => :required_bearer,
119
      "post /api/v3/repos/:owner/:repo/deployments/:id/approvals" => :required_bearer,
120
      "get /api/v3/repos/:owner/:repo/deployments/:id/approvals" => :required_bearer,
121
      "get /api/v3/repos/:owner/:repo/deployments/:id/events" => :required_bearer,
122
      "post /api/v3/repos/:owner/:repo/deployment-checks" => :required_bearer,
123
      "post /api/v3/repos/:owner/:repo/deployment-workflow-grants" => :required_bearer,
124
      "delete /api/v3/repos/:owner/:repo/deployment-workflow-grants/:id" => :required_bearer,
108 125
      # pipe_through :forge_write_api — forum writes and identity claims.
109 126
      "post /api/v3/forum/topics" => :required_bearer,
110 127
      "post /api/v3/forum/topics/:topic_id/posts" => :required_bearer,
lib/openagents_web/controllers/deployment_controller.ex added +341

@@ -0,0 +1,341 @@

1
defmodule OpenAgentsWeb.DeploymentController do
2
  @moduledoc """
3
  The versioned deployment control-plane API for one repository.
4
5
  Every action derives its authority from `conn.assigns.principal`, which
6
  `OpenAgentsWeb.Plugs.DeploymentPrincipal` built from the credential alone. The
7
  body names bytes and intent; it never names who the caller is, which repository
8
  the credential belongs to, or which environment a workflow grant covers.
9
10
  Errors are typed and stable: one `error.code` per refusal reason, so a client
11
  can tell "you may not do this" from "this is not admitted yet" from "these
12
  bytes conflict with an earlier request".
13
  """
14
15
  use OpenAgentsWeb, :controller
16
17
  alias OpenAgents.Deployments
18
  alias OpenAgents.Deployments.Principal
19
  alias OpenAgents.Repositories
20
  alias OpenAgentsWeb.DeploymentJson
21
22
  def environments(conn, %{"owner" => owner, "repo" => repo}) do
23
    with {:ok, repository} <- repository(conn, owner, repo),
24
         {:ok, environments} <- Deployments.list_environments(repository, principal(conn)) do
25
      json(conn, %{"environments" => Enum.map(environments, &DeploymentJson.environment/1)})
26
    else
27
      {:error, reason} -> failure(conn, reason)
28
    end
29
  end
30
31
  def put_environment(conn, %{"owner" => owner, "repo" => repo, "name" => name} = params) do
32
    attrs = params |> environment_attrs() |> Map.put("name", name)
33
34
    with {:ok, repository} <- repository(conn, owner, repo),
35
         {:ok, environment} <- Deployments.put_environment(repository, principal(conn), attrs) do
36
      json(conn, DeploymentJson.environment(environment))
37
    else
38
      {:error, reason} -> failure(conn, reason)
39
    end
40
  end
41
42
  def protection(conn, %{"owner" => owner, "repo" => repo, "name" => name}) do
43
    with {:ok, repository} <- repository(conn, owner, repo),
44
         {:ok, environment} <- Deployments.fetch_environment(repository, principal(conn), name) do
45
      json(conn, DeploymentJson.protection(environment))
46
    else
47
      {:error, reason} -> failure(conn, reason)
48
    end
49
  end
50
51
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
52
    with {:ok, repository} <- repository(conn, owner, repo),
53
         {:ok, run} <-
54
           Deployments.request_deployment(repository, principal(conn), request_attrs(params)),
55
         {:ok, run} <- Deployments.fetch_run(repository, principal(conn), run.id) do
56
      conn |> put_status(:accepted) |> json(DeploymentJson.run(run))
57
    else
58
      {:error, reason} -> failure(conn, reason)
59
    end
60
  end
61
62
  def index(conn, %{"owner" => owner, "repo" => repo} = params) do
63
    with {:ok, repository} <- repository(conn, owner, repo),
64
         {:ok, runs} <- Deployments.list_runs(repository, principal(conn), list_options(params)) do
65
      json(conn, %{
66
        "deployments" => Enum.map(runs, &DeploymentJson.run/1),
67
        "cursor" => List.last(runs) && List.last(runs).id
68
      })
69
    else
70
      {:error, reason} -> failure(conn, reason)
71
    end
72
  end
73
74
  def show(conn, %{"owner" => owner, "repo" => repo, "id" => id}) do
75
    with {:ok, repository} <- repository(conn, owner, repo),
76
         {:ok, run} <- Deployments.fetch_run(repository, principal(conn), id) do
77
      json(conn, DeploymentJson.run(run))
78
    else
79
      {:error, reason} -> failure(conn, reason)
80
    end
81
  end
82
83
  def cancel(conn, %{"owner" => owner, "repo" => repo, "id" => id} = params) do
84
    options =
85
      case params["if_state"] do
86
        state when is_binary(state) -> [if_state: state]
87
        _absent -> []
88
      end
89
90
    with {:ok, repository} <- repository(conn, owner, repo),
91
         {:ok, run} <- Deployments.cancel_run(repository, principal(conn), id, options),
92
         {:ok, run} <- Deployments.fetch_run(repository, principal(conn), run.id) do
93
      json(conn, DeploymentJson.run(run))
94
    else
95
      {:error, reason} -> failure(conn, reason)
96
    end
97
  end
98
99
  def decide(conn, %{"owner" => owner, "repo" => repo, "id" => id} = params) do
100
    case params["decision"] do
101
      decision when decision in ~w(approved rejected) ->
102
        record_decision(conn, owner, repo, id, decision, params)
103
104
      _invalid ->
105
        failure(conn, :invalid_decision)
106
    end
107
  end
108
109
  def approvals(conn, %{"owner" => owner, "repo" => repo, "id" => id}) do
110
    with {:ok, repository} <- repository(conn, owner, repo),
111
         {:ok, run} <- Deployments.fetch_run(repository, principal(conn), id),
112
         {:ok, approvals} <- Deployments.list_approvals(repository, principal(conn), run) do
113
      json(conn, %{"approvals" => Enum.map(approvals, &DeploymentJson.approval/1)})
114
    else
115
      {:error, reason} -> failure(conn, reason)
116
    end
117
  end
118
119
  def events(conn, %{"owner" => owner, "repo" => repo, "id" => id} = params) do
120
    options = [
121
      after_sequence: integer(params["after_sequence"], 0),
122
      limit: integer(params["limit"], 25)
123
    ]
124
125
    with {:ok, repository} <- repository(conn, owner, repo),
126
         {:ok, run} <- Deployments.fetch_run(repository, principal(conn), id),
127
         {:ok, events} <- Deployments.list_events(repository, principal(conn), run, options) do
128
      json(conn, %{
129
        "events" => Enum.map(events, &DeploymentJson.event/1),
130
        "after_sequence" => (List.last(events) && List.last(events).sequence) || 0
131
      })
132
    else
133
      {:error, reason} -> failure(conn, reason)
134
    end
135
  end
136
137
  def publish_check(conn, %{"owner" => owner, "repo" => repo} = params) do
138
    with {:ok, repository} <- repository(conn, owner, repo),
139
         {:ok, result} <-
140
           Deployments.publish_check_result(repository, principal(conn), check_attrs(params)) do
141
      conn |> put_status(:created) |> json(DeploymentJson.check_result(result))
142
    else
143
      {:error, reason} -> failure(conn, reason)
144
    end
145
  end
146
147
  def issue_grant(conn, %{"owner" => owner, "repo" => repo} = params) do
148
    with {:ok, repository} <- repository(conn, owner, repo),
149
         {:ok, {grant, plaintext}} <-
150
           Deployments.issue_workflow_grant(repository, principal(conn), grant_attrs(params)) do
151
      conn
152
      |> put_status(:created)
153
      |> json(DeploymentJson.workflow_grant(grant, plaintext))
154
    else
155
      {:error, reason} -> failure(conn, reason)
156
    end
157
  end
158
159
  def revoke_grant(conn, %{"owner" => owner, "repo" => repo, "id" => id}) do
160
    with {:ok, repository} <- repository(conn, owner, repo),
161
         {:ok, grant} <- Deployments.revoke_workflow_grant(repository, principal(conn), id) do
162
      json(conn, %{"id" => grant.id, "revoked_at" => grant.revoked_at})
163
    else
164
      {:error, reason} -> failure(conn, reason)
165
    end
166
  end
167
168
  defp record_decision(conn, owner, repo, id, decision, params) do
169
    attrs = Map.take(params, ["comment"])
170
171
    with {:ok, repository} <- repository(conn, owner, repo),
172
         {:ok, run} <- Deployments.decide_run(repository, principal(conn), id, decision, attrs),
173
         {:ok, run} <- Deployments.fetch_run(repository, principal(conn), run.id) do
174
      json(conn, DeploymentJson.run(run))
175
    else
176
      {:error, reason} -> failure(conn, reason)
177
    end
178
  end
179
180
  defp principal(conn), do: conn.assigns.principal
181
182
  # A workflow grant is bound to a repository id, and `Authority` compares it, so
183
  # the lookup here only resolves the path. A human principal resolves it through
184
  # repository visibility, which is what stops a private repository from being
185
  # discovered by name.
186
  defp repository(conn, owner, repo) do
187
    case principal(conn) do
188
      %Principal{kind: :user, user: user} ->
189
        {:ok, Repositories.get_visible_by_path!(owner, repo, user)}
190
191
      %Principal{} ->
192
        {:ok, Repositories.get_by_path!(owner, repo)}
193
    end
194
  rescue
195
    Ecto.NoResultsError -> {:error, :repository_not_found}
196
  end
197
198
  defp environment_attrs(params) do
199
    Map.take(params, [
200
      "kind",
201
      "provider",
202
      "provider_config",
203
      "secret_references",
204
      "retention_days",
205
      "protection"
206
    ])
207
  end
208
209
  defp request_attrs(params) do
210
    Map.take(params, [
211
      "environment",
212
      "commit_sha",
213
      "artifact_digest",
214
      "artifact_created_at",
215
      "source_ref",
216
      "source_workflow",
217
      "workflow_run_id",
218
      "idempotency_key"
219
    ])
220
  end
221
222
  defp check_attrs(params) do
223
    Map.take(params, [
224
      "name",
225
      "commit_sha",
226
      "artifact_digest",
227
      "status",
228
      "evidence_url",
229
      "evidence_digest",
230
      "valid_until",
231
      "source_ref",
232
      "source_workflow",
233
      "workflow_run_id"
234
    ])
235
  end
236
237
  defp grant_attrs(params) do
238
    Map.take(params, [
239
      "environment",
240
      "audience",
241
      "scopes",
242
      "source_ref",
243
      "source_workflow",
244
      "workflow_run_id",
245
      "lifetime_seconds"
246
    ])
247
  end
248
249
  defp list_options(params) do
250
    [
251
      limit: integer(params["limit"], 25),
252
      cursor: params["cursor"],
253
      state: params["state"]
254
    ]
255
  end
256
257
  defp integer(value, default) when is_binary(value) do
258
    case Integer.parse(value) do
259
      {number, ""} -> number
260
      _malformed -> default
261
    end
262
  end
263
264
  defp integer(value, _default) when is_integer(value), do: value
265
  defp integer(_value, default), do: default
266
267
  defp failure(conn, {:forbidden, reason}),
268
    do: error(conn, :forbidden, "forbidden", Atom.to_string(reason))
269
270
  defp failure(conn, :repository_not_found),
271
    do: error(conn, :not_found, "not_found", "repository")
272
273
  defp failure(conn, :environment_not_found),
274
    do: error(conn, :not_found, "not_found", "environment")
275
276
  defp failure(conn, :run_not_found), do: error(conn, :not_found, "not_found", "deployment")
277
  defp failure(conn, :request_not_found), do: error(conn, :not_found, "not_found", "request")
278
279
  defp failure(conn, :idempotency_conflict),
280
    do: error(conn, :conflict, "idempotency_conflict", "same key, different bytes")
281
282
  defp failure(conn, :precondition_failed),
283
    do: error(conn, :conflict, "precondition_failed", "the run is no longer in that state")
284
285
  defp failure(conn, {:illegal_transition, from, to}),
286
    do: error(conn, :conflict, "illegal_transition", from <> " cannot become " <> to)
287
288
  defp failure(conn, {:policy_denied, reason}),
289
    do: error(conn, :unprocessable_entity, "policy_denied", reason)
290
291
  defp failure(conn, :unknown_provider),
292
    do: error(conn, :unprocessable_entity, "unknown_provider", "provider is not configured")
293
294
  defp failure(conn, :unknown_commit),
295
    do: error(conn, :unprocessable_entity, "unknown_commit", "the repository has no such commit")
296
297
  defp failure(conn, :invalid_decision),
298
    do: error(conn, :unprocessable_entity, "invalid_decision", "approved or rejected")
299
300
  defp failure(conn, :invalid_grant),
301
    do: error(conn, :unprocessable_entity, "invalid_grant", "the grant is expired or revoked")
302
303
  defp failure(conn, %Ecto.Changeset{} = changeset) do
304
    conn
305
    |> put_status(:unprocessable_entity)
306
    |> json(%{
307
      "error" => %{
308
        "code" => "invalid_request",
309
        "message" => "The request is not valid.",
310
        "detail" => changeset_errors(changeset)
311
      }
312
    })
313
  end
314
315
  defp failure(conn, reason) when is_atom(reason),
316
    do: error(conn, :unprocessable_entity, Atom.to_string(reason), nil)
317
318
  defp error(conn, status, code, detail) do
319
    body = %{"code" => code, "message" => message(code)}
320
321
    conn
322
    |> put_status(status)
323
    |> json(%{"error" => if(detail, do: Map.put(body, "detail", detail), else: body)})
324
  end
325
326
  defp message("forbidden"), do: "The credential does not carry that authority."
327
  defp message("not_found"), do: "Not Found"
328
  defp message("idempotency_conflict"), do: "That idempotency key already names different bytes."
329
  defp message("precondition_failed"), do: "The run changed state before this request."
330
  defp message("illegal_transition"), do: "That transition is not part of the lifecycle."
331
  defp message("policy_denied"), do: "Environment policy does not admit this deployment."
332
  defp message(code), do: "The request is not valid: " <> code
333
334
  defp changeset_errors(changeset) do
335
    Ecto.Changeset.traverse_errors(changeset, fn {message, options} ->
336
      Enum.reduce(options, message, fn {key, value}, accumulator ->
337
        String.replace(accumulator, "%{#{key}}", to_string(value))
338
      end)
339
    end)
340
  end
341
end
lib/openagents_web/controllers/deployment_json.ex added +162

@@ -0,0 +1,162 @@

1
defmodule OpenAgentsWeb.DeploymentJson do
2
  @moduledoc """
3
  Stable JSON for the deployment control plane.
4
5
  Two properties matter more than shape here. Nothing rendered carries a secret
6
  value: environments expose secret *references*, runs expose a sanitized
7
  provider receipt, and events expose bounded detail. And every state, reason,
8
  and policy explanation a client needs in order to know why a deployment is
9
  waiting is present, because a control plane that says only "failed" forces its
10
  users to guess.
11
  """
12
13
  alias OpenAgents.Deployments.Approval
14
  alias OpenAgents.Deployments.CheckResult
15
  alias OpenAgents.Deployments.Environment
16
  alias OpenAgents.Deployments.Event
17
  alias OpenAgents.Deployments.Request
18
  alias OpenAgents.Deployments.Run
19
  alias OpenAgents.Deployments.WorkflowGrant
20
21
  @doc "Render one environment, including its protection requirements."
22
  @spec environment(Environment.t()) :: map()
23
  def environment(%Environment{} = environment) do
24
    %{
25
      "name" => environment.name,
26
      "kind" => environment.kind,
27
      "provider" => environment.provider,
28
      "provider_config" => environment.provider_config,
29
      "secret_references" => environment.secret_references,
30
      "retention_days" => environment.retention_days,
31
      "protection" => protection(environment)
32
    }
33
  end
34
35
  @doc "Render an environment's protection requirements on their own."
36
  @spec protection(Environment.t()) :: map()
37
  def protection(%Environment{protection: nil}), do: %{}
38
39
  def protection(%Environment{protection: protection}) do
40
    %{
41
      "required_checks" => protection.required_checks,
42
      "required_approvals" => protection.required_approvals,
43
      "separation_of_duties" => protection.separation_of_duties,
44
      "approver_roles" => protection.approver_roles,
45
      "allowed_branches" => protection.allowed_branches,
46
      "allowed_tags" => protection.allowed_tags,
47
      "allowed_workflows" => protection.allowed_workflows,
48
      "window" => %{
49
        "weekdays" => protection.window_weekdays,
50
        "start_minute" => protection.window_start_minute,
51
        "end_minute" => protection.window_end_minute
52
      },
53
      "frozen" => protection.frozen,
54
      "freeze_reason" => protection.freeze_reason,
55
      "concurrency" => protection.concurrency,
56
      "maximum_artifact_age_seconds" => protection.maximum_artifact_age_seconds,
57
      "check_validity_seconds" => protection.check_validity_seconds
58
    }
59
  end
60
61
  @doc "Render one run, with the request it executes when that is loaded."
62
  @spec run(Run.t()) :: map()
63
  def run(%Run{} = run) do
64
    %{
65
      "id" => run.id,
66
      "state" => run.state,
67
      "result_reason" => run.result_reason,
68
      "provider" => run.provider,
69
      "provider_receipt" => run.provider_receipt,
70
      "policy_explanation" => run.policy_explanation,
71
      "attempt_count" => run.attempt_count,
72
      "input_digest" => run.input_digest,
73
      "cancel_requested" => not is_nil(run.cancel_requested_at),
74
      "superseded_by_run_id" => run.superseded_by_run_id,
75
      "started_at" => run.started_at,
76
      "finished_at" => run.finished_at,
77
      "created_at" => run.inserted_at,
78
      "environment" => environment_name(run),
79
      "request" => request(run)
80
    }
81
  end
82
83
  @doc "Render one recorded approval decision."
84
  @spec approval(Approval.t()) :: map()
85
  def approval(%Approval{} = approval) do
86
    %{
87
      "decision" => approval.decision,
88
      "rule" => approval.rule,
89
      "request_digest" => approval.request_digest,
90
      "comment" => approval.comment,
91
      "decided_at" => approval.decided_at
92
    }
93
  end
94
95
  @doc "Render one published check result."
96
  @spec check_result(CheckResult.t()) :: map()
97
  def check_result(%CheckResult{} = result) do
98
    %{
99
      "name" => result.name,
100
      "commit_sha" => result.commit_sha,
101
      "artifact_digest" => result.artifact_digest,
102
      "status" => result.status,
103
      "evidence_url" => result.evidence_url,
104
      "evidence_digest" => result.evidence_digest,
105
      "valid_until" => result.valid_until,
106
      "published_at" => result.updated_at
107
    }
108
  end
109
110
  @doc "Render one append-only run event."
111
  @spec event(Event.t()) :: map()
112
  def event(%Event{} = event) do
113
    %{
114
      "sequence" => event.sequence,
115
      "type" => event.type,
116
      "from_state" => event.from_state,
117
      "to_state" => event.to_state,
118
      "detail" => event.detail,
119
      "actor_type" => event.actor_type,
120
      "occurred_at" => event.occurred_at
121
    }
122
  end
123
124
  @doc """
125
  Render a workflow grant and its one-time token.
126
127
  The plaintext appears in this response and nowhere else, because only its
128
  digest is stored.
129
  """
130
  @spec workflow_grant(WorkflowGrant.t(), String.t()) :: map()
131
  def workflow_grant(%WorkflowGrant{} = grant, plaintext) do
132
    %{
133
      "id" => grant.id,
134
      "token" => plaintext,
135
      "audience" => grant.audience,
136
      "scopes" => grant.scopes,
137
      "source_ref" => grant.source_ref,
138
      "source_workflow" => grant.source_workflow,
139
      "workflow_run_id" => grant.workflow_run_id,
140
      "expires_at" => grant.expires_at
141
    }
142
  end
143
144
  defp request(%Run{deployment_request: %Request{} = request}) do
145
    %{
146
      "commit_sha" => request.commit_sha,
147
      "artifact_digest" => request.artifact_digest,
148
      "artifact_created_at" => request.artifact_created_at,
149
      "source_ref" => request.source_ref,
150
      "source_workflow" => request.source_workflow,
151
      "principal_type" => request.principal_type,
152
      "idempotency_key" => request.idempotency_key,
153
      "request_digest" => request.request_digest,
154
      "requested_at" => request.requested_at
155
    }
156
  end
157
158
  defp request(%Run{}), do: nil
159
160
  defp environment_name(%Run{environment: %Environment{name: name}}), do: name
161
  defp environment_name(%Run{}), do: nil
162
end
lib/openagents_web/plugs/deployment_principal.ex added +75

@@ -0,0 +1,75 @@

1
defmodule OpenAgentsWeb.Plugs.DeploymentPrincipal do
2
  @moduledoc """
3
  Builds the deployment principal for one request, from the credential alone.
4
5
  Two credentials reach the deployment API:
6
7
    * A first-party token carrying `deployments:write`, which authenticates a
8
      human. The token grants the *ability to speak to this API*; the repository
9
      and environment authority still comes from membership and policy.
10
    * A short-lived workflow grant (`oa_wfg_`), which authenticates a workflow
11
      run bound to one repository, ref, workflow, and run id.
12
13
  The principal is assigned once, here, and never derived from the request body,
14
  so a caller cannot claim a repository, an environment, or an operator role by
15
  sending one.
16
  """
17
18
  import Plug.Conn
19
20
  alias OpenAgents.ApiTokens
21
  alias OpenAgents.Deployments
22
  alias OpenAgents.Deployments.Principal
23
24
  @scope "deployments:write"
25
26
  def init(options), do: options
27
28
  def call(conn, _options) do
29
    case bearer(conn) do
30
      {:ok, "oa_wfg_" <> _rest = plaintext} -> workflow(conn, plaintext)
31
      {:ok, plaintext} -> human(conn, plaintext)
32
      {:error, _missing} -> refuse(conn)
33
    end
34
  end
35
36
  defp workflow(conn, plaintext) do
37
    case Deployments.authenticate_workflow_grant(plaintext) do
38
      {:ok, %Principal{} = principal} -> assign_principal(conn, principal, nil)
39
      {:error, _invalid} -> refuse(conn)
40
    end
41
  end
42
43
  defp human(conn, plaintext) do
44
    case ApiTokens.authenticate(plaintext, @scope) do
45
      {:ok, user, token} -> assign_principal(conn, Principal.user(user), {user, token})
46
      {:error, _denied} -> refuse(conn)
47
    end
48
  end
49
50
  defp assign_principal(conn, %Principal{} = principal, credential) do
51
    conn = conn |> put_resp_header("cache-control", "no-store") |> assign(:principal, principal)
52
53
    case credential do
54
      {user, token} -> conn |> assign(:current_user, user) |> assign(:api_token, token)
55
      nil -> conn
56
    end
57
  end
58
59
  defp bearer(conn) do
60
    case get_req_header(conn, "authorization") do
61
      ["Bearer " <> token] when token != "" -> {:ok, token}
62
      _missing_or_ambiguous -> {:error, :missing_credential}
63
    end
64
  end
65
66
  defp refuse(conn) do
67
    conn
68
    |> put_status(:unauthorized)
69
    |> put_resp_header("cache-control", "no-store")
70
    |> Phoenix.Controller.json(%{
71
      "error" => %{"code" => "invalid_credential", "message" => "Invalid deployment credential"}
72
    })
73
    |> halt()
74
  end
75
end
lib/openagents_web/route_authority.ex modified +23

@@ -261,6 +261,29 @@ defmodule OpenAgentsWeb.RouteAuthority do

261 261
        false
262 262
      )
263 263
264
  # The deployment control plane has no anonymous surface. Reading a deployment
265
  # discloses what a repository ships and when, so every route here authenticates
266
  # a tenant principal, and none of them carries the operator fleet authority
267
  # behind `/admin/forge`.
268
  defp policy(%{path: "/api/v3/repos/:owner/:repo/deployment" <> _path, verb: verb})
269
       when verb in [:get, :head],
270
       do:
271
         declaration(
272
           :authenticated_api,
273
           "first-party bearer token or workflow grant",
274
           "deployments:write",
275
           false
276
         )
277
278
  defp policy(%{path: "/api/v3/repos/:owner/:repo/deployment" <> _path}),
279
    do:
280
      declaration(
281
        :authenticated_api,
282
        "first-party bearer token or workflow grant",
283
        "deployments:write",
284
        true
285
      )
286
264 287
  defp policy(%{path: "/api/v3/chat/events", verb: verb}) when verb in [:get, :head],
265 288
    do: declaration(:authenticated_api, "first-party bearer token", "chat:account", false)
266 289
lib/openagents_web/router.ex modified +37

@@ -48,6 +48,15 @@ defmodule OpenAgentsWeb.Router do

48 48
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "chat:account"
49 49
  end
50 50
51
  # The deployment control plane authenticates two principals: a human holding
52
  # `deployments:write`, and a short-lived workflow grant. Neither carries the
53
  # operator-only fleet promotion authority.
54
  pipeline :deployments_api do
55
    plug :accepts, ["json"]
56
    plug OpenAgentsWeb.Plugs.RequestOrigin
57
    plug OpenAgentsWeb.Plugs.DeploymentPrincipal
58
  end
59
51 60
  pipeline :optional_forge_api do
52 61
    plug :accepts, ["json"]
53 62
    plug OpenAgentsWeb.Plugs.RequestOrigin

@@ -300,6 +309,34 @@ defmodule OpenAgentsWeb.Router do

300 309
    get "/repos/:owner/:repo/assignees/:assignee", AssigneeController, :show
301 310
  end
302 311
312
  scope "/api/v3", OpenAgentsWeb do
313
    pipe_through :deployments_api
314
315
    get "/repos/:owner/:repo/deployment-environments", DeploymentController, :environments
316
317
    put "/repos/:owner/:repo/deployment-environments/:name",
318
        DeploymentController,
319
        :put_environment
320
321
    get "/repos/:owner/:repo/deployment-environments/:name/protection",
322
        DeploymentController,
323
        :protection
324
325
    post "/repos/:owner/:repo/deployments", DeploymentController, :create
326
    get "/repos/:owner/:repo/deployments", DeploymentController, :index
327
    get "/repos/:owner/:repo/deployments/:id", DeploymentController, :show
328
    post "/repos/:owner/:repo/deployments/:id/cancel", DeploymentController, :cancel
329
    post "/repos/:owner/:repo/deployments/:id/approvals", DeploymentController, :decide
330
    get "/repos/:owner/:repo/deployments/:id/approvals", DeploymentController, :approvals
331
    get "/repos/:owner/:repo/deployments/:id/events", DeploymentController, :events
332
    post "/repos/:owner/:repo/deployment-checks", DeploymentController, :publish_check
333
    post "/repos/:owner/:repo/deployment-workflow-grants", DeploymentController, :issue_grant
334
335
    delete "/repos/:owner/:repo/deployment-workflow-grants/:id",
336
           DeploymentController,
337
           :revoke_grant
338
  end
339
303 340
  scope "/api/v3", OpenAgentsWeb do
304 341
    pipe_through :chat_account_api
305 342
priv/migration_lineages/prior-2026-08-19.json modified +3 -1

@@ -245,7 +245,9 @@

245 245
    20260823052000,
246 246
    20260823053000,
247 247
    20260823054500,
248
    20260823060000
248
    20260823060000,
249
    20260823070000,
250
    20260823071500
249 251
  ],
250 252
  "required_tables": [
251 253
    "users",
priv/repo/migrations/20260823070000_create_deployment_control_plane.exs added +249

@@ -0,0 +1,249 @@

1
defmodule OpenAgents.Repo.Migrations.CreateDeploymentControlPlane do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:deployment_environments, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
9
        null: false
10
11
      add :name, :string, null: false, size: 60
12
      add :kind, :string, null: false
13
      add :provider, :string, null: false
14
      add :provider_config, :map, null: false, default: %{}
15
      add :secret_references, {:array, :string}, null: false, default: []
16
      add :protection, :map, null: false, default: %{}
17
      add :retention_days, :integer, null: false, default: 90
18
      add :created_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
19
20
      timestamps(type: :utc_datetime_usec)
21
    end
22
23
    # An environment name is the identity a request addresses, so one repository
24
    # cannot hold two `production` environments with different protection.
25
    create unique_index(:deployment_environments, [:repository_id, :name])
26
27
    create constraint(:deployment_environments, :deployment_environments_kind_check,
28
             check: "kind in ('preview', 'staging', 'production')"
29
           )
30
31
    create constraint(:deployment_environments, :deployment_environments_retention_check,
32
             check: "retention_days > 0 and retention_days <= 3650"
33
           )
34
35
    create table(:deployment_requests, primary_key: false) do
36
      add :id, :binary_id, primary_key: true
37
38
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
39
        null: false
40
41
      add :environment_id,
42
          references(:deployment_environments, type: :binary_id, on_delete: :delete_all),
43
          null: false
44
45
      add :commit_sha, :string, null: false, size: 40
46
      add :artifact_digest, :string, null: false, size: 100
47
      add :artifact_created_at, :utc_datetime_usec
48
      add :source_ref, :string, null: false, size: 255
49
      add :source_workflow, :string, size: 120
50
      add :principal_type, :string, null: false
51
      add :requested_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
52
      add :requested_by_grant_id, :binary_id
53
      add :idempotency_key, :string, null: false, size: 255
54
      add :request_digest, :string, null: false, size: 64
55
      add :input_digest, :string, null: false, size: 64
56
      add :requested_at, :utc_datetime_usec, null: false
57
58
      timestamps(type: :utc_datetime_usec)
59
    end
60
61
    # Idempotency is scoped to the environment the key was spent on: replaying a
62
    # key with the same payload returns the original request, and replaying it
63
    # with different bytes is a conflict rather than a second deployment.
64
    create unique_index(
65
             :deployment_requests,
66
             [:repository_id, :environment_id, :idempotency_key],
67
             name: :deployment_requests_idempotency_index
68
           )
69
70
    create index(:deployment_requests, [:environment_id, :inserted_at])
71
72
    create constraint(:deployment_requests, :deployment_requests_principal_check,
73
             check: "principal_type in ('user', 'workflow', 'operator')"
74
           )
75
76
    create constraint(:deployment_requests, :deployment_requests_commit_check,
77
             check: "commit_sha ~ '^[0-9a-f]{40}$'"
78
           )
79
80
    create constraint(:deployment_requests, :deployment_requests_digest_check,
81
             check: "request_digest ~ '^[0-9a-f]{64}$' and input_digest ~ '^[0-9a-f]{64}$'"
82
           )
83
84
    create table(:deployment_runs, primary_key: false) do
85
      add :id, :binary_id, primary_key: true
86
87
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
88
        null: false
89
90
      add :environment_id,
91
          references(:deployment_environments, type: :binary_id, on_delete: :delete_all),
92
          null: false
93
94
      add :deployment_request_id,
95
          references(:deployment_requests, type: :binary_id, on_delete: :delete_all),
96
          null: false
97
98
      add :input_digest, :string, null: false, size: 64
99
      add :state, :string, null: false
100
      add :result_reason, :string, size: 80
101
      add :provider, :string, null: false
102
      add :provider_receipt, :map, null: false, default: %{}
103
      add :policy_explanation, {:array, :map}, null: false, default: []
104
      add :attempt_count, :integer, null: false, default: 0
105
      add :lease_owner, :string, size: 120
106
      add :lease_expires_at, :utc_datetime_usec
107
      add :cancel_requested_at, :utc_datetime_usec
108
109
      add :cancel_requested_by_user_id,
110
          references(:users, type: :binary_id, on_delete: :nilify_all)
111
112
      add :started_at, :utc_datetime_usec
113
      add :finished_at, :utc_datetime_usec
114
      add :superseded_by_run_id, :binary_id
115
116
      timestamps(type: :utc_datetime_usec)
117
    end
118
119
    # One admitted run per request. A retried worker resumes the existing run
120
    # rather than starting a second execution of the same immutable input.
121
    create unique_index(:deployment_runs, [:deployment_request_id])
122
    create index(:deployment_runs, [:environment_id, :state])
123
    create index(:deployment_runs, [:state, :lease_expires_at])
124
    create index(:deployment_runs, [:repository_id, :inserted_at])
125
126
    create constraint(:deployment_runs, :deployment_runs_state_check,
127
             check:
128
               "state in ('requested', 'checking', 'waiting_for_approval', 'queued', 'deploying', 'succeeded', 'failed', 'cancelled', 'superseded')"
129
           )
130
131
    create table(:deployment_approvals, primary_key: false) do
132
      add :id, :binary_id, primary_key: true
133
134
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
135
        null: false
136
137
      add :deployment_run_id,
138
          references(:deployment_runs, type: :binary_id, on_delete: :delete_all),
139
          null: false
140
141
      add :approver_user_id, references(:users, type: :binary_id, on_delete: :restrict),
142
        null: false
143
144
      add :decision, :string, null: false
145
      add :rule, :string, null: false, size: 80
146
      add :request_digest, :string, null: false, size: 64
147
      add :comment, :string, size: 500
148
      add :decided_at, :utc_datetime_usec, null: false
149
150
      timestamps(type: :utc_datetime_usec)
151
    end
152
153
    # One decision per approver per run, so a single approver cannot satisfy a
154
    # two-approval policy by voting twice.
155
    create unique_index(:deployment_approvals, [:deployment_run_id, :approver_user_id])
156
157
    create constraint(:deployment_approvals, :deployment_approvals_decision_check,
158
             check: "decision in ('approved', 'rejected')"
159
           )
160
161
    create table(:deployment_check_results, primary_key: false) do
162
      add :id, :binary_id, primary_key: true
163
164
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
165
        null: false
166
167
      add :name, :string, null: false, size: 120
168
      add :commit_sha, :string, null: false, size: 40
169
      add :artifact_digest, :string, null: false, size: 100
170
      add :status, :string, null: false
171
      add :evidence_url, :string, size: 500
172
      add :evidence_digest, :string, size: 64
173
      add :valid_until, :utc_datetime_usec
174
      add :published_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
175
      add :published_by_grant_id, :binary_id
176
177
      timestamps(type: :utc_datetime_usec)
178
    end
179
180
    # A check result is identified by the exact bytes it examined. Publishing
181
    # the same check name for a different artifact adds a row instead of
182
    # relabelling the old evidence.
183
    create unique_index(
184
             :deployment_check_results,
185
             [:repository_id, :name, :commit_sha, :artifact_digest],
186
             name: :deployment_check_results_identity_index
187
           )
188
189
    create constraint(:deployment_check_results, :deployment_check_results_status_check,
190
             check: "status in ('pending', 'succeeded', 'failed')"
191
           )
192
193
    create constraint(:deployment_check_results, :deployment_check_results_commit_check,
194
             check: "commit_sha ~ '^[0-9a-f]{40}$'"
195
           )
196
197
    create table(:deployment_events, primary_key: false) do
198
      add :id, :binary_id, primary_key: true
199
200
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
201
        null: false
202
203
      add :deployment_run_id,
204
          references(:deployment_runs, type: :binary_id, on_delete: :delete_all),
205
          null: false
206
207
      add :sequence, :integer, null: false
208
      add :type, :string, null: false, size: 60
209
      add :from_state, :string, size: 30
210
      add :to_state, :string, size: 30
211
      add :detail, :map, null: false, default: %{}
212
      add :actor_type, :string, null: false, size: 20
213
      add :actor_id, :string, size: 64
214
      add :occurred_at, :utc_datetime_usec, null: false
215
216
      # Append-only: an event has an insertion time and no update time, because
217
      # nothing may rewrite a transition after the fact.
218
      timestamps(type: :utc_datetime_usec, updated_at: false)
219
    end
220
221
    create unique_index(:deployment_events, [:deployment_run_id, :sequence])
222
    create index(:deployment_events, [:repository_id, :inserted_at])
223
224
    create table(:deployment_workflow_grants, primary_key: false) do
225
      add :id, :binary_id, primary_key: true
226
227
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :delete_all),
228
        null: false
229
230
      add :environment_id,
231
          references(:deployment_environments, type: :binary_id, on_delete: :delete_all)
232
233
      add :token_digest, :string, null: false, size: 64
234
      add :audience, :string, null: false, size: 120
235
      add :source_ref, :string, null: false, size: 255
236
      add :source_workflow, :string, null: false, size: 120
237
      add :workflow_run_id, :string, null: false, size: 64
238
      add :scopes, {:array, :string}, null: false, default: []
239
      add :expires_at, :utc_datetime_usec, null: false
240
      add :revoked_at, :utc_datetime_usec
241
      add :created_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
242
243
      timestamps(type: :utc_datetime_usec)
244
    end
245
246
    create unique_index(:deployment_workflow_grants, [:token_digest])
247
    create index(:deployment_workflow_grants, [:repository_id, :expires_at])
248
  end
249
end
priv/repo/migrations/20260823071500_allow_deployments_write_api_token_scope.exs added +20

@@ -0,0 +1,20 @@

1
defmodule OpenAgents.Repo.Migrations.AllowDeploymentsWriteApiTokenScope do
2
  use Ecto.Migration
3
4
  def up do
5
    drop constraint(:api_tokens, :api_tokens_scopes_allowed)
6
7
    create constraint(:api_tokens, :api_tokens_scopes_allowed,
8
             check:
9
               "scopes <@ ARRAY['chat:account', 'forge:write', 'deployments:write']::varchar[]"
10
           )
11
  end
12
13
  def down do
14
    drop constraint(:api_tokens, :api_tokens_scopes_allowed)
15
16
    create constraint(:api_tokens, :api_tokens_scopes_allowed,
17
             check: "scopes <@ ARRAY['chat:account', 'forge:write']::varchar[]"
18
           )
19
  end
20
end
test/openagents/deployments/lifecycle_test.exs added +41

@@ -0,0 +1,41 @@

1
defmodule OpenAgents.Deployments.LifecycleTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Deployments.Lifecycle
5
6
  test "every terminal state is final" do
7
    for state <- ~w(succeeded failed cancelled superseded) do
8
      assert Lifecycle.successors(state) == []
9
    end
10
  end
11
12
  test "only a deploying run can succeed" do
13
    for {state, successors} <- Lifecycle.transitions(), state != "deploying" do
14
      refute "succeeded" in successors
15
    end
16
17
    assert Lifecycle.allowed?("deploying", "succeeded")
18
  end
19
20
  test "a run cannot skip the provider" do
21
    refute Lifecycle.allowed?("queued", "succeeded")
22
    refute Lifecycle.allowed?("requested", "deploying")
23
  end
24
25
  test "a deploying run cannot be superseded out from under the provider" do
26
    refute Lifecycle.allowed?("deploying", "superseded")
27
    assert Lifecycle.allowed?("deploying", "cancelled")
28
  end
29
30
  test "an illegal transition is reported as itself" do
31
    assert Lifecycle.check("succeeded", "deploying") ==
32
             {:error, {:illegal_transition, "succeeded", "deploying"}}
33
34
    assert Lifecycle.check("queued", "deploying") == :ok
35
  end
36
37
  test "an unknown state has no successors" do
38
    assert Lifecycle.successors("nonsense") == []
39
    refute Lifecycle.allowed?("nonsense", "queued")
40
  end
41
end
test/openagents/deployments/policy_test.exs added +234

@@ -0,0 +1,234 @@

1
defmodule OpenAgents.Deployments.PolicyTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Deployments.Approval
5
  alias OpenAgents.Deployments.CheckResult
6
  alias OpenAgents.Deployments.Environment
7
  alias OpenAgents.Deployments.Policy
8
  alias OpenAgents.Deployments.Protection
9
  alias OpenAgents.Deployments.Request
10
11
  @commit String.duplicate("ab", 20)
12
  @artifact "sha256:" <> String.duplicate("c", 64)
13
  # A Wednesday, 12:00 UTC.
14
  @now ~U[2026-08-19 12:00:00Z]
15
16
  describe "source rules" do
17
    test "an unrestricted policy admits any ref" do
18
      assert {:admit, "queued", _explanation} = evaluate(%{}, %{})
19
    end
20
21
    test "a branch outside the allowed list is blocked" do
22
      protection = %{allowed_branches: ["main", "release/*"]}
23
24
      assert {:admit, "queued", _} = evaluate(protection, %{source_ref: "refs/heads/main"})
25
      assert {:admit, "queued", _} = evaluate(protection, %{source_ref: "refs/heads/release/1.2"})
26
27
      assert {:deny, "source_ref_not_allowed", _} =
28
               evaluate(protection, %{source_ref: "refs/heads/release-escape"})
29
30
      assert {:deny, "source_ref_not_allowed", _} =
31
               evaluate(protection, %{source_ref: "refs/tags/v1.0.0"})
32
    end
33
34
    test "a tag policy admits tags and refuses branches" do
35
      protection = %{allowed_tags: ["v*"]}
36
37
      assert {:admit, "queued", _} = evaluate(protection, %{source_ref: "refs/tags/v1.0.0"})
38
39
      assert {:deny, "source_ref_not_allowed", _} =
40
               evaluate(protection, %{source_ref: "refs/heads/main"})
41
    end
42
43
    test "a source workflow outside the allowed list is blocked" do
44
      protection = %{allowed_workflows: ["deploy.yml"]}
45
46
      assert {:admit, "queued", _} = evaluate(protection, %{source_workflow: "deploy.yml"})
47
48
      assert {:deny, "source_workflow_not_allowed", _} =
49
               evaluate(protection, %{source_workflow: "pull-request.yml"})
50
    end
51
  end
52
53
  describe "freeze and windows" do
54
    test "a freeze blocks with its reason preserved" do
55
      assert {:deny, "frozen", explanation} =
56
               evaluate(%{frozen: true, freeze_reason: "incident 42"}, %{})
57
58
      entry = Enum.find(explanation, &(&1["rule"] == "freeze"))
59
      assert entry["detail"]["freeze_reason"] == "incident 42"
60
    end
61
62
    test "a window admits inside its hours and blocks outside them" do
63
      protection = %{window_weekdays: [3], window_start_minute: 600, window_end_minute: 1_020}
64
65
      assert {:admit, "queued", _} = evaluate(protection, %{}, @now)
66
      assert {:deny, "outside_window", _} = evaluate(protection, %{}, ~U[2026-08-19 18:00:00Z])
67
      assert {:deny, "outside_window", _} = evaluate(protection, %{}, ~U[2026-08-20 12:00:00Z])
68
    end
69
  end
70
71
  describe "artifact age" do
72
    test "an artifact older than the limit is blocked" do
73
      protection = %{maximum_artifact_age_seconds: 3_600}
74
75
      assert {:admit, "queued", _} =
76
               evaluate(
77
                 protection,
78
                 %{artifact_created_at: DateTime.add(@now, -60, :second)},
79
                 @now
80
               )
81
82
      assert {:deny, "artifact_too_old", _} =
83
               evaluate(
84
                 protection,
85
                 %{artifact_created_at: DateTime.add(@now, -7_200, :second)},
86
                 @now
87
               )
88
    end
89
90
    test "an unknown artifact age is blocked rather than assumed fresh" do
91
      assert {:deny, "artifact_age_unknown", _} =
92
               evaluate(%{maximum_artifact_age_seconds: 3_600}, %{artifact_created_at: nil}, @now)
93
    end
94
  end
95
96
  describe "required checks" do
97
    test "a missing check keeps the run checking" do
98
      assert {:admit, "checking", _} = evaluate(%{required_checks: ["build"]}, %{})
99
    end
100
101
    test "a check for other bytes does not count" do
102
      other = check(%{commit_sha: String.duplicate("ef", 20)})
103
104
      assert {:admit, "checking", _} = evaluate(%{required_checks: ["build"]}, %{}, @now, [other])
105
    end
106
107
    test "a green check for the exact bytes admits the run" do
108
      assert {:admit, "queued", _} =
109
               evaluate(%{required_checks: ["build"]}, %{}, @now, [check(%{})])
110
    end
111
112
    test "a pending check keeps the run checking" do
113
      assert {:admit, "checking", _} =
114
               evaluate(%{required_checks: ["build"]}, %{}, @now, [check(%{status: "pending"})])
115
    end
116
117
    test "a failed check denies the run" do
118
      assert {:deny, "checks_failed", _} =
119
               evaluate(%{required_checks: ["build"]}, %{}, @now, [check(%{status: "failed"})])
120
    end
121
122
    test "a check past its own validity is expired, not green" do
123
      expired = check(%{valid_until: DateTime.add(@now, -1, :second)})
124
125
      assert {:deny, "checks_expired", _} =
126
               evaluate(%{required_checks: ["build"]}, %{}, @now, [expired])
127
    end
128
129
    test "a check older than the policy validity window is expired" do
130
      stale = check(%{updated_at: DateTime.add(@now, -7_200, :second)})
131
      protection = %{required_checks: ["build"], check_validity_seconds: 3_600}
132
133
      assert {:deny, "checks_expired", _} = evaluate(protection, %{}, @now, [stale])
134
    end
135
  end
136
137
  describe "required approvals" do
138
    test "a run without its approvals waits" do
139
      assert {:admit, "waiting_for_approval", _} = evaluate(%{required_approvals: 1}, %{})
140
    end
141
142
    test "approvals for other bytes do not count" do
143
      approvals = [approval(%{request_digest: "other-digest"})]
144
145
      assert {:admit, "waiting_for_approval", _} =
146
               evaluate(%{required_approvals: 1}, %{}, @now, [], approvals)
147
    end
148
149
    test "one approval for the exact request admits the run" do
150
      assert {:admit, "queued", _} =
151
               evaluate(%{required_approvals: 1}, %{}, @now, [], [approval(%{})])
152
    end
153
154
    test "one rejection denies the run even alongside approvals" do
155
      approvals = [approval(%{}), approval(%{decision: "rejected"})]
156
157
      assert {:deny, "rejected", _} = evaluate(%{required_approvals: 1}, %{}, @now, [], approvals)
158
    end
159
160
    test "a denial explains every rule it considered" do
161
      assert {:deny, _reason, explanation} = evaluate(%{frozen: true}, %{})
162
163
      assert Enum.map(explanation, & &1["rule"]) == [
164
               "allowed_sources",
165
               "freeze",
166
               "deployment_window",
167
               "artifact_age",
168
               "required_checks",
169
               "required_approvals"
170
             ]
171
    end
172
  end
173
174
  describe "initial state" do
175
    test "an environment with required checks starts a run in checking" do
176
      assert Policy.initial_state(environment(%{required_checks: ["build"]})) == "checking"
177
      assert Policy.initial_state(environment(%{})) == "requested"
178
    end
179
  end
180
181
  defp evaluate(protection, request, now \\ @now, checks \\ [], approvals \\ []) do
182
    Policy.evaluate(environment(protection), request(request), checks, approvals, now)
183
  end
184
185
  defp environment(protection) do
186
    %Environment{
187
      name: "production",
188
      kind: "production",
189
      provider: "fake",
190
      protection: struct!(%Protection{}, protection)
191
    }
192
  end
193
194
  defp request(attrs) do
195
    struct!(
196
      %Request{
197
        commit_sha: @commit,
198
        artifact_digest: @artifact,
199
        source_ref: "refs/heads/main",
200
        source_workflow: "deploy.yml",
201
        principal_type: "user",
202
        request_digest: "request-digest",
203
        requested_by_user_id: Ecto.UUID.generate()
204
      },
205
      attrs
206
    )
207
  end
208
209
  defp check(attrs) do
210
    struct!(
211
      %CheckResult{
212
        name: "build",
213
        commit_sha: @commit,
214
        artifact_digest: @artifact,
215
        status: "succeeded",
216
        updated_at: @now
217
      },
218
      attrs
219
    )
220
  end
221
222
  defp approval(attrs) do
223
    struct!(
224
      %Approval{
225
        decision: "approved",
226
        rule: "required_approvals",
227
        request_digest: "request-digest",
228
        approver_user_id: Ecto.UUID.generate(),
229
        decided_at: @now
230
      },
231
      attrs
232
    )
233
  end
234
end
test/openagents/deployments_test.exs added +822

@@ -0,0 +1,822 @@

1
defmodule OpenAgents.DeploymentsTest do
2
  use OpenAgents.DataCase, async: true
3
4
  import OpenAgents.AccountsFixtures
5
  import OpenAgents.DeploymentsFixtures
6
7
  alias OpenAgents.Deployments
8
  alias OpenAgents.Deployments.Principal
9
  alias OpenAgents.Deployments.Providers.Fake
10
  alias OpenAgents.Deployments.Run
11
  alias OpenAgents.Deployments.Worker
12
13
  setup do
14
    owner = repository_user_fixture("deploy-owner")
15
    repository = repository_with_member_fixture(owner, %{visibility: "private"}, "owner")
16
17
    %{owner: owner, repository: repository, principal: Principal.user(owner)}
18
  end
19
20
  describe "environments" do
21
    test "a writable member defines an environment and reads its protection", context do
22
      environment =
23
        environment_fixture(context.repository, context.owner, %{
24
          "protection" => %{"required_checks" => ["build"], "required_approvals" => 1}
25
        })
26
27
      assert environment.provider == "fake"
28
      assert environment.protection.required_checks == ["build"]
29
30
      assert {:ok, [^environment]} =
31
               Deployments.list_environments(context.repository, context.principal)
32
    end
33
34
    test "an unknown provider is refused before anything is written", context do
35
      assert {:error, :unknown_provider} =
36
               Deployments.put_environment(context.repository, context.principal, %{
37
                 "name" => "production",
38
                 "kind" => "production",
39
                 "provider" => "Elixir.System",
40
                 "protection" => %{}
41
               })
42
    end
43
44
    test "a non-member cannot define an environment", context do
45
      stranger = Principal.user(repository_user_fixture("deploy-stranger"))
46
47
      assert {:error, {:forbidden, :not_writable}} =
48
               Deployments.put_environment(context.repository, stranger, %{
49
                 "name" => "production",
50
                 "kind" => "production",
51
                 "provider" => "fake",
52
                 "protection" => %{}
53
               })
54
    end
55
56
    test "a private repository's deployments are unreadable by a non-member", context do
57
      environment_fixture(context.repository, context.owner)
58
      stranger = Principal.user(repository_user_fixture("deploy-outsider"))
59
60
      assert {:error, {:forbidden, :not_a_member}} =
61
               Deployments.list_environments(context.repository, stranger)
62
    end
63
  end
64
65
  describe "requesting a deployment" do
66
    setup context do
67
      %{environment: environment_fixture(context.repository, context.owner)}
68
    end
69
70
    test "an unprotected environment queues immediately", context do
71
      run = run_fixture(context.repository, context.owner)
72
73
      assert run.state == "queued"
74
      assert run.provider == "fake"
75
      assert Enum.any?(run.policy_explanation, &(&1["rule"] == "required_checks"))
76
    end
77
78
    test "a commit the repository never received is refused", context do
79
      assert {:error, :unknown_commit} =
80
               Deployments.request_deployment(
81
                 context.repository,
82
                 context.principal,
83
                 %{
84
                   "environment" => "production",
85
                   "commit_sha" => commit_sha(),
86
                   "artifact_digest" => artifact_digest(),
87
                   "source_ref" => "refs/heads/main",
88
                   "idempotency_key" => "unknown-commit"
89
                 },
90
                 commit_store: fn _repository, _sha -> {:error, :unknown_commit} end
91
               )
92
    end
93
94
    test "replaying an idempotency key with the same bytes returns the same run", context do
95
      attrs = %{"idempotency_key" => "same-key-1234"}
96
97
      first = run_fixture(context.repository, context.owner, attrs)
98
      second = run_fixture(context.repository, context.owner, attrs)
99
100
      assert first.id == second.id
101
    end
102
103
    test "replaying an idempotency key with different bytes is a conflict", context do
104
      run_fixture(context.repository, context.owner, %{"idempotency_key" => "conflict-1234"})
105
106
      assert {:error, :idempotency_conflict} =
107
               Deployments.request_deployment(
108
                 context.repository,
109
                 context.principal,
110
                 %{
111
                   "environment" => "production",
112
                   "commit_sha" => String.duplicate("ef", 20),
113
                   "artifact_digest" => artifact_digest(),
114
                   "source_ref" => "refs/heads/main",
115
                   "idempotency_key" => "conflict-1234"
116
                 },
117
                 commit_store: any_commit()
118
               )
119
    end
120
121
    test "an invalid commit sha never reaches the database", context do
122
      assert {:error, changeset} =
123
               Deployments.request_deployment(
124
                 context.repository,
125
                 context.principal,
126
                 %{
127
                   "environment" => "production",
128
                   "commit_sha" => "abc",
129
                   "artifact_digest" => artifact_digest(),
130
                   "source_ref" => "refs/heads/main",
131
                   "idempotency_key" => "bad-commit-1234"
132
                 },
133
                 commit_store: any_commit()
134
               )
135
136
      assert %{commit_sha: ["has invalid format"]} = errors_on(changeset)
137
    end
138
139
    test "a platform operator holds no tenant deployment authority", context do
140
      operator = Principal.operator(context.owner)
141
142
      assert {:error, {:forbidden, :operator_is_not_tenant}} =
143
               Deployments.request_deployment(
144
                 context.repository,
145
                 operator,
146
                 %{
147
                   "environment" => "production",
148
                   "commit_sha" => commit_sha(),
149
                   "artifact_digest" => artifact_digest(),
150
                   "source_ref" => "refs/heads/main",
151
                   "idempotency_key" => "operator-1234"
152
                 },
153
                 commit_store: any_commit()
154
               )
155
    end
156
  end
157
158
  describe "repository boundary" do
159
    setup context do
160
      environment_fixture(context.repository, context.owner)
161
162
      other_owner = repository_user_fixture("deploy-other-owner")
163
      other_repository = repository_with_member_fixture(other_owner, %{}, "owner")
164
      environment_fixture(other_repository, other_owner)
165
166
      %{
167
        run: run_fixture(context.repository, context.owner),
168
        other_repository: other_repository,
169
        other_principal: Principal.user(other_owner)
170
      }
171
    end
172
173
    test "a run cannot be read through another repository", context do
174
      assert {:error, {:forbidden, :cross_repository}} =
175
               Deployments.fetch_run(
176
                 context.other_repository,
177
                 context.other_principal,
178
                 context.run.id
179
               )
180
    end
181
182
    test "a run cannot be cancelled through another repository", context do
183
      assert {:error, {:forbidden, :cross_repository}} =
184
               Deployments.cancel_run(
185
                 context.other_repository,
186
                 context.other_principal,
187
                 context.run.id
188
               )
189
    end
190
191
    test "a run cannot be approved through another repository", context do
192
      assert {:error, {:forbidden, :cross_repository}} =
193
               Deployments.decide_run(
194
                 context.other_repository,
195
                 context.other_principal,
196
                 context.run.id,
197
                 "approved"
198
               )
199
    end
200
201
    test "an unknown run id is not found rather than forbidden", context do
202
      assert {:error, :run_not_found} =
203
               Deployments.fetch_run(context.repository, context.principal, Ecto.UUID.generate())
204
205
      assert {:error, :run_not_found} =
206
               Deployments.fetch_run(context.repository, context.principal, "not-a-uuid")
207
    end
208
  end
209
210
  describe "required checks" do
211
    setup context do
212
      environment =
213
        environment_fixture(context.repository, context.owner, %{
214
          "protection" => %{"required_checks" => ["build"]}
215
        })
216
217
      %{environment: environment, run: run_fixture(context.repository, context.owner)}
218
    end
219
220
    test "a run waits in checking until evidence for its exact bytes arrives", context do
221
      assert context.run.state == "checking"
222
223
      {:ok, _result} =
224
        Deployments.publish_check_result(context.repository, context.principal, %{
225
          "name" => "build",
226
          "commit_sha" => commit_sha(),
227
          "artifact_digest" => artifact_digest(),
228
          "status" => "succeeded"
229
        })
230
231
      assert {:ok, %Run{state: "queued"}} =
232
               Deployments.fetch_run(context.repository, context.principal, context.run.id)
233
    end
234
235
    test "a green check for different bytes does not admit the run", context do
236
      {:ok, _result} =
237
        Deployments.publish_check_result(context.repository, context.principal, %{
238
          "name" => "build",
239
          "commit_sha" => String.duplicate("ef", 20),
240
          "artifact_digest" => artifact_digest(),
241
          "status" => "succeeded"
242
        })
243
244
      assert {:ok, %Run{state: "checking"}} =
245
               Deployments.fetch_run(context.repository, context.principal, context.run.id)
246
    end
247
248
    test "a different artifact under the same check name is a separate result", context do
249
      for artifact <- [artifact_digest(), "sha256:" <> String.duplicate("d", 64)] do
250
        {:ok, _result} =
251
          Deployments.publish_check_result(context.repository, context.principal, %{
252
            "name" => "build",
253
            "commit_sha" => commit_sha(),
254
            "artifact_digest" => artifact,
255
            "status" => "succeeded"
256
          })
257
      end
258
259
      {:ok, results} =
260
        Deployments.list_check_results(
261
          context.repository,
262
          context.principal,
263
          commit_sha(),
264
          artifact_digest()
265
        )
266
267
      assert length(results) == 1
268
    end
269
270
    test "a failed check denies the run durably", context do
271
      {:ok, _result} =
272
        Deployments.publish_check_result(context.repository, context.principal, %{
273
          "name" => "build",
274
          "commit_sha" => commit_sha(),
275
          "artifact_digest" => artifact_digest(),
276
          "status" => "failed"
277
        })
278
279
      assert {:ok, %Run{state: "failed", result_reason: "checks_failed"}} =
280
               Deployments.fetch_run(context.repository, context.principal, context.run.id)
281
    end
282
  end
283
284
  describe "approvals" do
285
    setup context do
286
      approver = repository_user_fixture("deploy-approver")
287
288
      {:ok, _membership} =
289
        OpenAgents.Repositories.add_member(context.repository, approver, "maintainer")
290
291
      environment_fixture(context.repository, context.owner, %{
292
        "protection" => %{"required_approvals" => 1}
293
      })
294
295
      %{approver: approver, run: run_fixture(context.repository, context.owner)}
296
    end
297
298
    test "a run waits for approval and queues once another member approves", context do
299
      assert context.run.state == "waiting_for_approval"
300
301
      assert {:ok, %Run{state: "queued"}} =
302
               Deployments.decide_run(
303
                 context.repository,
304
                 Principal.user(context.approver),
305
                 context.run.id,
306
                 "approved"
307
               )
308
    end
309
310
    test "the requester cannot approve its own deployment", context do
311
      assert {:error, {:forbidden, :self_approval}} =
312
               Deployments.decide_run(
313
                 context.repository,
314
                 context.principal,
315
                 context.run.id,
316
                 "approved"
317
               )
318
    end
319
320
    test "a contributor is not an approver under the default policy", context do
321
      contributor = repository_user_fixture("deploy-contributor")
322
323
      {:ok, _membership} =
324
        OpenAgents.Repositories.add_member(context.repository, contributor, "contributor")
325
326
      assert {:error, {:forbidden, :not_an_approver}} =
327
               Deployments.decide_run(
328
                 context.repository,
329
                 Principal.user(contributor),
330
                 context.run.id,
331
                 "approved"
332
               )
333
    end
334
335
    test "a rejection denies the run", context do
336
      assert {:ok, %Run{state: "failed", result_reason: "rejected"}} =
337
               Deployments.decide_run(
338
                 context.repository,
339
                 Principal.user(context.approver),
340
                 context.run.id,
341
                 "rejected"
342
               )
343
    end
344
345
    test "a workflow grant cannot approve", context do
346
      {grant, _plaintext} = workflow_grant_fixture(context.repository, context.owner)
347
348
      assert {:error, {:forbidden, :operator_is_not_tenant}} =
349
               Deployments.decide_run(
350
                 context.repository,
351
                 Principal.workflow(grant),
352
                 context.run.id,
353
                 "approved"
354
               )
355
    end
356
  end
357
358
  describe "workflow grants" do
359
    setup context do
360
      environment_fixture(context.repository, context.owner, %{
361
        "name" => "preview",
362
        "kind" => "preview"
363
      })
364
365
      environment_fixture(context.repository, context.owner)
366
367
      :ok
368
    end
369
370
    test "a grant deploys only its own repository, environment, and source context", context do
371
      {grant, plaintext} =
372
        workflow_grant_fixture(context.repository, context.owner, %{"environment" => "preview"})
373
374
      assert {:ok, %Principal{kind: :workflow}} =
375
               Deployments.authenticate_workflow_grant(plaintext)
376
377
      attrs = %{
378
        "environment" => "preview",
379
        "commit_sha" => commit_sha(),
380
        "artifact_digest" => artifact_digest(),
381
        "source_ref" => "refs/heads/main",
382
        "source_workflow" => "deploy.yml",
383
        "workflow_run_id" => grant.workflow_run_id,
384
        "idempotency_key" => "workflow-1234"
385
      }
386
387
      assert {:ok, %Run{state: "queued"}} =
388
               Deployments.request_deployment(
389
                 context.repository,
390
                 Principal.workflow(grant),
391
                 attrs,
392
                 commit_store: any_commit()
393
               )
394
395
      assert {:error, {:forbidden, :cross_environment}} =
396
               Deployments.request_deployment(
397
                 context.repository,
398
                 Principal.workflow(grant),
399
                 %{attrs | "environment" => "production", "idempotency_key" => "widen-env-1234"},
400
                 commit_store: any_commit()
401
               )
402
403
      assert {:error, {:forbidden, :source_ref_mismatch}} =
404
               Deployments.request_deployment(
405
                 context.repository,
406
                 Principal.workflow(grant),
407
                 %{attrs | "source_ref" => "refs/tags/v1", "idempotency_key" => "widen-ref-1234"},
408
                 commit_store: any_commit()
409
               )
410
411
      assert {:error, {:forbidden, :source_workflow_mismatch}} =
412
               Deployments.request_deployment(
413
                 context.repository,
414
                 Principal.workflow(grant),
415
                 %{
416
                   attrs
417
                   | "source_workflow" => "other.yml",
418
                     "idempotency_key" => "widen-wf-1234"
419
                 },
420
                 commit_store: any_commit()
421
               )
422
423
      assert {:error, {:forbidden, :workflow_run_mismatch}} =
424
               Deployments.request_deployment(
425
                 context.repository,
426
                 Principal.workflow(grant),
427
                 %{
428
                   attrs
429
                   | "workflow_run_id" => "other-run",
430
                     "idempotency_key" => "widen-run-1234"
431
                 },
432
                 commit_store: any_commit()
433
               )
434
    end
435
436
    test "a grant cannot address another repository", context do
437
      {grant, _plaintext} = workflow_grant_fixture(context.repository, context.owner)
438
439
      other_owner = repository_user_fixture("deploy-grant-other")
440
      other_repository = repository_with_member_fixture(other_owner, %{}, "owner")
441
      environment_fixture(other_repository, other_owner)
442
443
      assert {:error, {:forbidden, :cross_repository}} =
444
               Deployments.request_deployment(
445
                 other_repository,
446
                 Principal.workflow(grant),
447
                 %{
448
                   "environment" => "production",
449
                   "commit_sha" => commit_sha(),
450
                   "artifact_digest" => artifact_digest(),
451
                   "source_ref" => "refs/heads/main",
452
                   "source_workflow" => "deploy.yml",
453
                   "workflow_run_id" => grant.workflow_run_id,
454
                   "idempotency_key" => "cross-repo-1234"
455
                 },
456
                 commit_store: any_commit()
457
               )
458
    end
459
460
    test "a revoked grant authenticates nothing", context do
461
      {grant, plaintext} = workflow_grant_fixture(context.repository, context.owner)
462
463
      {:ok, _revoked} =
464
        Deployments.revoke_workflow_grant(context.repository, context.principal, grant.id)
465
466
      assert {:error, :invalid_grant} = Deployments.authenticate_workflow_grant(plaintext)
467
    end
468
469
    test "a grant lifetime is clamped to the maximum", context do
470
      {grant, _plaintext} =
471
        workflow_grant_fixture(context.repository, context.owner, %{
472
          "lifetime_seconds" => 100_000
473
        })
474
475
      assert DateTime.diff(grant.expires_at, DateTime.utc_now()) <= 3_600
476
    end
477
478
    test "a workflow cannot cancel another workflow's run", context do
479
      {first, _plaintext} = workflow_grant_fixture(context.repository, context.owner)
480
      {second, _plaintext} = workflow_grant_fixture(context.repository, context.owner)
481
482
      {:ok, run} =
483
        Deployments.request_deployment(
484
          context.repository,
485
          Principal.workflow(first),
486
          %{
487
            "environment" => "production",
488
            "commit_sha" => commit_sha(),
489
            "artifact_digest" => artifact_digest(),
490
            "source_ref" => "refs/heads/main",
491
            "source_workflow" => "deploy.yml",
492
            "workflow_run_id" => first.workflow_run_id,
493
            "idempotency_key" => "grant-cancel-1234"
494
          },
495
          commit_store: any_commit()
496
        )
497
498
      assert {:error, {:forbidden, :cross_repository}} =
499
               Deployments.cancel_run(context.repository, Principal.workflow(second), run.id)
500
501
      assert {:ok, %Run{state: "cancelled"}} =
502
               Deployments.cancel_run(context.repository, Principal.workflow(first), run.id)
503
    end
504
  end
505
506
  describe "cancellation and supersession" do
507
    test "cancelling a queued run reaches a terminal state at once", context do
508
      environment_fixture(context.repository, context.owner)
509
      run = run_fixture(context.repository, context.owner)
510
511
      assert {:ok, %Run{state: "cancelled"}} =
512
               Deployments.cancel_run(context.repository, context.principal, run.id)
513
    end
514
515
    test "an optimistic precondition refuses a run that moved", context do
516
      environment_fixture(context.repository, context.owner)
517
      run = run_fixture(context.repository, context.owner)
518
519
      assert {:error, :precondition_failed} =
520
               Deployments.cancel_run(context.repository, context.principal, run.id,
521
                 if_state: "deploying"
522
               )
523
    end
524
525
    test "a terminal run cannot be cancelled again", context do
526
      environment_fixture(context.repository, context.owner)
527
      run = run_fixture(context.repository, context.owner)
528
529
      {:ok, cancelled} = Deployments.cancel_run(context.repository, context.principal, run.id)
530
531
      assert {:error, {:illegal_transition, "cancelled", "cancelled"}} =
532
               Deployments.cancel_run(context.repository, context.principal, cancelled.id)
533
    end
534
535
    test "a preview environment supersedes its older waiting runs", context do
536
      environment_fixture(context.repository, context.owner, %{
537
        "name" => "preview",
538
        "kind" => "preview",
539
        "protection" => %{"concurrency" => "supersede", "required_approvals" => 1}
540
      })
541
542
      older = run_fixture(context.repository, context.owner, %{"environment" => "preview"})
543
      newer = run_fixture(context.repository, context.owner, %{"environment" => "preview"})
544
545
      assert {:ok, %Run{state: "superseded", superseded_by_run_id: superseded_by}} =
546
               Deployments.fetch_run(context.repository, context.principal, older.id)
547
548
      assert superseded_by == newer.id
549
    end
550
551
    test "production does not supersede implicitly", context do
552
      environment_fixture(context.repository, context.owner, %{
553
        "protection" => %{"required_approvals" => 1}
554
      })
555
556
      older = run_fixture(context.repository, context.owner)
557
      _newer = run_fixture(context.repository, context.owner)
558
559
      assert {:ok, %Run{state: "waiting_for_approval"}} =
560
               Deployments.fetch_run(context.repository, context.principal, older.id)
561
    end
562
  end
563
564
  describe "provider execution" do
565
    setup context do
566
      environment_fixture(context.repository, context.owner)
567
      %{run: run_fixture(context.repository, context.owner)}
568
    end
569
570
    test "one queued run is claimed by exactly one worker", context do
571
      assert {:ok, claimed} = Deployments.claim_run("worker-a")
572
      assert claimed.id == context.run.id
573
      assert claimed.lease_owner == "worker-a"
574
      assert :empty = Deployments.claim_run("worker-b")
575
    end
576
577
    test "a successful run records a sanitized receipt and no secret values" do
578
      {:ok, claimed} = Deployments.claim_run("worker-a")
579
580
      assert {:ok, %Run{state: "succeeded"} = finished} =
581
               Worker.execute(claimed, "worker-a")
582
583
      assert finished.provider_receipt["commit_sha"] == commit_sha()
584
      refute Map.has_key?(finished.provider_receipt, "secrets")
585
      assert is_nil(finished.lease_owner)
586
    end
587
588
    test "a duplicate execution never deploys twice", context do
589
      {:ok, claimed} = Deployments.claim_run("worker-a")
590
      {:ok, _finished} = Worker.execute(claimed, "worker-a")
591
592
      {:ok, receipt} = Fake.receipt(context.run.id)
593
      assert receipt["run_id"] == context.run.id
594
595
      assert {:error, {:illegal_transition, "succeeded", _to}} =
596
               Worker.execute(%Run{claimed | state: "succeeded"}, "worker-a")
597
    end
598
599
    test "a provider failure never produces a success receipt", context do
600
      :ok = Fake.program(context.run.id, :fail)
601
      {:ok, claimed} = Deployments.claim_run("worker-a")
602
603
      assert {:ok, %Run{state: "failed", result_reason: "provider_rejected"}} =
604
               Worker.execute(claimed, "worker-a")
605
    end
606
607
    test "an uncertain provider result is an explicitly uncertain failure", context do
608
      :ok = Fake.program(context.run.id, :uncertain)
609
      {:ok, claimed} = Deployments.claim_run("worker-a")
610
611
      assert {:ok, %Run{state: "failed", result_reason: "provider_result_uncertain"}} =
612
               Worker.execute(claimed, "worker-a")
613
    end
614
615
    test "a provider timeout is uncertain, not successful", context do
616
      :ok = Fake.program(context.run.id, :hang)
617
      {:ok, claimed} = Deployments.claim_run("worker-a")
618
619
      assert {:ok, %Run{state: "failed", result_reason: "provider_result_uncertain"}} =
620
               Worker.execute(claimed, "worker-a")
621
    end
622
623
    test "authority revoked between queueing and deploying halts the run", context do
624
      deployer = repository_user_fixture("deploy-losing-access")
625
626
      {:ok, _membership} =
627
        OpenAgents.Repositories.add_member(context.repository, deployer, "contributor")
628
629
      _run =
630
        run_fixture(context.repository, deployer, %{"idempotency_key" => "revoked-access-1234"})
631
632
      :ok = OpenAgents.Repositories.remove_member(context.repository, context.owner, deployer.id)
633
634
      {:ok, _cancelled} =
635
        Deployments.cancel_run(context.repository, context.principal, context.run.id)
636
637
      {:ok, claimed} = Deployments.claim_run("worker-a")
638
639
      assert {:ok, %Run{state: "failed", result_reason: "authority_revoked_not_writable"}} =
640
               Worker.execute(claimed, "worker-a")
641
    end
642
643
    test "a freeze applied after queueing denies the run before the provider runs", context do
644
      environment_fixture(context.repository, context.owner, %{
645
        "protection" => %{"frozen" => true, "freeze_reason" => "incident"}
646
      })
647
648
      {:ok, claimed} = Deployments.claim_run("worker-a")
649
650
      assert {:ok, %Run{state: "failed", result_reason: "frozen"}} =
651
               Worker.execute(claimed, "worker-a")
652
653
      assert Fake.receipt(context.run.id) == :error
654
    end
655
656
    test "cancellation requested while deploying is honored after the provider reports",
657
         context do
658
      {:ok, claimed} = Deployments.claim_run("worker-a")
659
660
      {:ok, deploying} =
661
        Deployments.transition(claimed, "deploying", Principal.system("worker-a"))
662
663
      {:ok, _cancelling} =
664
        Deployments.cancel_run(context.repository, context.principal, deploying.id)
665
666
      assert {:ok, %Run{state: "cancelled"}} =
667
               Deployments.finish_run(
668
                 OpenAgents.Repo.get!(Run, deploying.id),
669
                 "worker-a",
670
                 {:ok, %{"detail" => "deployed"}}
671
               )
672
    end
673
  end
674
675
  describe "recovery" do
676
    setup context do
677
      environment_fixture(context.repository, context.owner)
678
      %{run: run_fixture(context.repository, context.owner)}
679
    end
680
681
    test "a lease abandoned before the provider ran is requeued", context do
682
      {:ok, claimed} = Deployments.claim_run("worker-a", lease_seconds: 1)
683
      later = DateTime.add(DateTime.utc_now(), 60, :second)
684
685
      assert {:ok, %{requeued: 1, uncertain: 0}} =
686
               Deployments.reconcile_leases(Principal.system("reconciler"), now: later)
687
688
      assert {:ok, %Run{state: "queued", lease_owner: nil}} =
689
               Deployments.fetch_run(context.repository, context.principal, claimed.id)
690
691
      assert {:ok, _reclaimed} = Deployments.claim_run("worker-b")
692
    end
693
694
    test "a lease abandoned while deploying is uncertain, never succeeded", context do
695
      {:ok, claimed} = Deployments.claim_run("worker-a", lease_seconds: 1)
696
697
      {:ok, _deploying} =
698
        Deployments.transition(claimed, "deploying", Principal.system("worker-a"))
699
700
      later = DateTime.add(DateTime.utc_now(), 60, :second)
701
702
      assert {:ok, %{requeued: 0, uncertain: 1}} =
703
               Deployments.reconcile_leases(Principal.system("reconciler"), now: later)
704
705
      assert {:ok, %Run{state: "failed", result_reason: "provider_result_uncertain"}} =
706
               Deployments.fetch_run(context.repository, context.principal, claimed.id)
707
    end
708
709
    test "a worker that lost its lease cannot renew it" do
710
      {:ok, claimed} = Deployments.claim_run("worker-a")
711
712
      assert {:ok, _renewed} = Deployments.renew_lease(claimed, "worker-a")
713
      assert :error = Deployments.renew_lease(claimed, "worker-b")
714
    end
715
716
    test "only operator or system authority reconciles", context do
717
      assert {:error, {:forbidden, :not_an_operator}} =
718
               Deployments.reconcile_leases(context.principal)
719
    end
720
  end
721
722
  describe "events" do
723
    setup context do
724
      environment_fixture(context.repository, context.owner)
725
      %{run: run_fixture(context.repository, context.owner)}
726
    end
727
728
    test "a run's history is append-only and monotonic", context do
729
      {:ok, events} = Deployments.list_events(context.repository, context.principal, context.run)
730
731
      assert Enum.map(events, & &1.sequence) == Enum.to_list(1..length(events))
732
      assert hd(events).type == "run_created"
733
    end
734
735
    test "a subscriber sees committed transitions only", context do
736
      :ok = Deployments.subscribe(context.run)
737
738
      {:ok, _cancelled} =
739
        Deployments.cancel_run(context.repository, context.principal, context.run.id)
740
741
      assert_receive {:deployment_event, %{run_id: run_id, state: "cancelled"}}
742
      assert run_id == context.run.id
743
    end
744
745
    test "event detail redacts credentials", context do
746
      {:ok, claimed} = Deployments.claim_run("worker-a")
747
748
      {:ok, _failed} =
749
        Deployments.finish_run(claimed, "worker-a", {:error, "Bearer sk-secret-token-value"})
750
751
      {:ok, events} = Deployments.list_events(context.repository, context.principal, claimed)
752
      details = Enum.map(events, & &1.detail)
753
754
      refute Enum.any?(details, fn detail ->
755
               detail |> inspect() |> String.contains?("sk-secret-token-value")
756
             end)
757
    end
758
759
    test "a provider receipt cannot echo a resolved secret value", _context do
760
      receipt =
761
        OpenAgents.Deployments.Provider.sanitize_receipt(
762
          %{"echo" => "value=super-secret-value"},
763
          ["super-secret-value"]
764
        )
765
766
      assert receipt["echo"] == "value=[REDACTED_SECRET]"
767
    end
768
  end
769
770
  describe "secret boundary" do
771
    test "an execution resolves declared references only, and never persists them", context do
772
      environment =
773
        environment_fixture(context.repository, context.owner, %{
774
          "provider_config" => %{"secret_reference" => "DEPLOY_TOKEN"},
775
          "secret_references" => ["DEPLOY_TOKEN"]
776
        })
777
778
      variable =
779
        OpenAgents.Deployments.SecretResolver.Environment.variable_name(
780
          environment,
781
          "DEPLOY_TOKEN"
782
        )
783
784
      System.put_env(variable, "resolved-secret-value")
785
      on_exit(fn -> System.delete_env(variable) end)
786
787
      run = run_fixture(context.repository, context.owner)
788
      {:ok, claimed} = Deployments.claim_run("worker-a")
789
790
      assert {:ok, %Run{state: "succeeded"} = finished} = Worker.execute(claimed, "worker-a")
791
      assert finished.provider_receipt["secret_references_resolved"] == "DEPLOY_TOKEN"
792
793
      refute finished |> inspect(limit: :infinity) |> String.contains?("resolved-secret-value")
794
      assert run.id == finished.id
795
    end
796
797
    test "a missing secret fails the run rather than deploying without it", context do
798
      environment_fixture(context.repository, context.owner, %{
799
        "provider_config" => %{"secret_reference" => "DEPLOY_TOKEN"},
800
        "secret_references" => ["DEPLOY_TOKEN"]
801
      })
802
803
      run = run_fixture(context.repository, context.owner)
804
      {:ok, claimed} = Deployments.claim_run("worker-a")
805
806
      assert {:ok, %Run{state: "failed", result_reason: "secret_unavailable"}} =
807
               Worker.execute(claimed, "worker-a")
808
809
      assert Fake.receipt(run.id) == :error
810
    end
811
812
    test "a provider cannot resolve a reference the environment did not declare", context do
813
      environment =
814
        environment_fixture(context.repository, context.owner, %{
815
          "secret_references" => ["DECLARED_TOKEN"]
816
        })
817
818
      assert {:error, {:undeclared_secret_reference, "OTHER_TOKEN"}} =
819
               OpenAgents.Deployments.SecretResolver.resolve(environment, ["OTHER_TOKEN"])
820
    end
821
  end
822
end
test/openagents/runtime_config_test.exs modified +20

@@ -320,6 +320,25 @@ defmodule OpenAgents.RuntimeConfigTest do

320 320
    assert RuntimeConfig.feature_enabled?(config, :scv_deploy)
321 321
  end
322 322
323
  test "the deployment control plane worker is off unless configuration enables it" do
324
    # DEPLOYPLANE-005: the API records and evaluates runs on any host, but only
325
    # a host whose configuration admits the feature may claim and execute one.
326
    assert {:ok, disabled} = RuntimeConfig.validate(staging_settings())
327
    refute RuntimeConfig.feature_enabled?(disabled, :deployment_control_plane)
328
329
    assert {:ok, enabled} =
330
             staging_settings()
331
             |> Map.put(:deployment_control_plane_enabled, true)
332
             |> RuntimeConfig.validate()
333
334
    assert RuntimeConfig.feature_enabled?(enabled, :deployment_control_plane)
335
336
    assert {:error, %{setting: :deployment_control_plane_enabled}} =
337
             staging_settings()
338
             |> Map.put(:deployment_control_plane_enabled, "yes")
339
             |> RuntimeConfig.validate()
340
  end
341
323 342
  defp staging_settings do
324 343
    current = Map.new(Application.get_all_env(:openagents))
325 344

@@ -338,6 +357,7 @@ defmodule OpenAgents.RuntimeConfigTest do

338 357
      forge_boot_converge_enabled: false,
339 358
      turn_recovery_enabled: false,
340 359
      voice_retention_enabled: false,
360
      deployment_control_plane_enabled: false,
341 361
      computer_controller_enabled: false,
342 362
      ra_enabled: false,
343 363
      forge_repos: ["openagents.com"],
test/openagents_web/controllers/deployment_controller_test.exs added +387

@@ -0,0 +1,387 @@

1
defmodule OpenAgentsWeb.DeploymentControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  import OpenAgents.AccountsFixtures
5
  import OpenAgents.DeploymentsFixtures
6
7
  setup %{conn: conn} do
8
    # The API does not take a commit store argument, so the store is bound here.
9
    # Commit existence itself is proved in the context test.
10
    previous = Application.get_env(:openagents, :deployment_commit_store)
11
    Application.put_env(:openagents, :deployment_commit_store, any_commit())
12
13
    on_exit(fn ->
14
      case previous do
15
        nil -> Application.delete_env(:openagents, :deployment_commit_store)
16
        store -> Application.put_env(:openagents, :deployment_commit_store, store)
17
      end
18
    end)
19
20
    owner = repository_user_fixture("api-deploy-owner")
21
    repository = repository_with_member_fixture(owner, %{visibility: "private"}, "owner")
22
    environment_fixture(repository, owner)
23
24
    %{
25
      conn: put_deployments_api_token(conn, owner),
26
      owner: owner,
27
      repository: repository,
28
      path: "/api/v3/repos/#{repository.owner}/#{repository.name}"
29
    }
30
  end
31
32
  describe "authentication" do
33
    test "a request without a credential is refused", context do
34
      response =
35
        build_conn()
36
        |> get("#{context.path}/deployment-environments")
37
        |> json_response(401)
38
39
      assert response == %{
40
               "error" => %{
41
                 "code" => "invalid_credential",
42
                 "message" => "Invalid deployment credential"
43
               }
44
             }
45
    end
46
47
    test "a forge credential does not carry deployment authority", context do
48
      assert build_conn()
49
             |> put_forge_api_token("deploy-scope-boundary", context.repository)
50
             |> get("#{context.path}/deployment-environments")
51
             |> json_response(401)
52
    end
53
54
    test "a workflow grant authenticates and is bound to its own repository", context do
55
      {grant, plaintext} = workflow_grant_fixture(context.repository, context.owner)
56
57
      other_owner = repository_user_fixture("api-deploy-other")
58
      other = repository_with_member_fixture(other_owner, %{}, "owner")
59
      environment_fixture(other, other_owner)
60
61
      response =
62
        build_conn()
63
        |> put_req_header("authorization", "Bearer " <> plaintext)
64
        |> post("/api/v3/repos/#{other.owner}/#{other.name}/deployments", %{
65
          "environment" => "production",
66
          "commit_sha" => commit_sha(),
67
          "artifact_digest" => artifact_digest(),
68
          "source_ref" => "refs/heads/main",
69
          "source_workflow" => "deploy.yml",
70
          "workflow_run_id" => grant.workflow_run_id,
71
          "idempotency_key" => "api-cross-repo-1234"
72
        })
73
        |> json_response(403)
74
75
      assert response["error"]["code"] == "forbidden"
76
      assert response["error"]["detail"] == "cross_repository"
77
    end
78
  end
79
80
  describe "environments" do
81
    test "an owner reads environments and protection", context do
82
      assert %{"environments" => [environment]} =
83
               context.conn
84
               |> get("#{context.path}/deployment-environments")
85
               |> json_response(200)
86
87
      assert environment["name"] == "production"
88
      assert environment["provider"] == "fake"
89
90
      assert %{"required_checks" => [], "required_approvals" => 0} =
91
               context.conn
92
               |> get("#{context.path}/deployment-environments/production/protection")
93
               |> json_response(200)
94
    end
95
96
    test "an owner updates protection", context do
97
      response =
98
        context.conn
99
        |> put("#{context.path}/deployment-environments/staging", %{
100
          "kind" => "staging",
101
          "provider" => "fake",
102
          "protection" => %{"required_checks" => ["build"], "required_approvals" => 1}
103
        })
104
        |> json_response(200)
105
106
      assert response["protection"]["required_checks"] == ["build"]
107
    end
108
109
    test "a secret value is never echoed back", context do
110
      response =
111
        context.conn
112
        |> put("#{context.path}/deployment-environments/production", %{
113
          "kind" => "production",
114
          "provider" => "fake",
115
          "secret_references" => ["DEPLOY_TOKEN"],
116
          "protection" => %{}
117
        })
118
        |> json_response(200)
119
120
      assert response["secret_references"] == ["DEPLOY_TOKEN"]
121
      refute Map.has_key?(response, "secrets")
122
    end
123
124
    test "an unreadable repository is not found rather than forbidden", context do
125
      stranger = repository_user_fixture("api-deploy-stranger")
126
127
      assert build_conn()
128
             |> put_deployments_api_token(stranger)
129
             |> get("#{context.path}/deployment-environments")
130
             |> json_response(404)
131
    end
132
  end
133
134
  describe "deployments" do
135
    test "creating, reading, listing, and cancelling one deployment", context do
136
      created =
137
        context.conn
138
        |> post("#{context.path}/deployments", deployment_body("api-create-1234"))
139
        |> json_response(202)
140
141
      assert created["state"] == "queued"
142
      assert created["request"]["commit_sha"] == commit_sha()
143
      assert created["environment"] == "production"
144
145
      assert %{"deployments" => [listed], "cursor" => cursor} =
146
               context.conn |> get("#{context.path}/deployments") |> json_response(200)
147
148
      assert listed["id"] == created["id"]
149
      assert cursor == created["id"]
150
151
      assert context.conn
152
             |> get("#{context.path}/deployments/#{created["id"]}")
153
             |> json_response(200)
154
             |> Map.fetch!("id") == created["id"]
155
156
      assert %{"state" => "cancelled"} =
157
               context.conn
158
               |> post("#{context.path}/deployments/#{created["id"]}/cancel", %{})
159
               |> json_response(200)
160
    end
161
162
    test "a replayed idempotency key with different bytes conflicts", context do
163
      body = deployment_body("api-conflict-1234")
164
165
      assert context.conn |> post("#{context.path}/deployments", body) |> json_response(202)
166
167
      response =
168
        context.conn
169
        |> post("#{context.path}/deployments", %{
170
          body
171
          | "commit_sha" => String.duplicate("ef", 20)
172
        })
173
        |> json_response(409)
174
175
      assert response["error"]["code"] == "idempotency_conflict"
176
    end
177
178
    test "a stale precondition refuses the cancellation", context do
179
      created =
180
        context.conn
181
        |> post("#{context.path}/deployments", deployment_body("api-precondition-1234"))
182
        |> json_response(202)
183
184
      response =
185
        context.conn
186
        |> post("#{context.path}/deployments/#{created["id"]}/cancel", %{
187
          "if_state" => "deploying"
188
        })
189
        |> json_response(409)
190
191
      assert response["error"]["code"] == "precondition_failed"
192
    end
193
194
    test "an invalid commit sha is a typed validation error", context do
195
      response =
196
        context.conn
197
        |> post("#{context.path}/deployments", %{
198
          deployment_body("api-invalid-1234")
199
          | "commit_sha" => "abc"
200
        })
201
        |> json_response(422)
202
203
      assert response["error"]["code"] == "invalid_request"
204
      assert response["error"]["detail"]["commit_sha"] == ["has invalid format"]
205
    end
206
207
    test "an unknown deployment id is not found", context do
208
      assert context.conn
209
             |> get("#{context.path}/deployments/#{Ecto.UUID.generate()}")
210
             |> json_response(404)
211
    end
212
213
    test "another repository's deployment is a cross-repository refusal", context do
214
      created =
215
        context.conn
216
        |> post("#{context.path}/deployments", deployment_body("api-boundary-1234"))
217
        |> json_response(202)
218
219
      other_owner = repository_user_fixture("api-deploy-boundary")
220
      other = repository_with_member_fixture(other_owner, %{}, "owner")
221
222
      response =
223
        build_conn()
224
        |> put_deployments_api_token(other_owner)
225
        |> get("/api/v3/repos/#{other.owner}/#{other.name}/deployments/#{created["id"]}")
226
        |> json_response(403)
227
228
      assert response["error"]["detail"] == "cross_repository"
229
    end
230
  end
231
232
  describe "approvals and checks" do
233
    setup context do
234
      approver = repository_user_fixture("api-deploy-approver")
235
236
      {:ok, _membership} =
237
        OpenAgents.Repositories.add_member(context.repository, approver, "maintainer")
238
239
      environment_fixture(context.repository, context.owner, %{
240
        "protection" => %{"required_checks" => ["build"], "required_approvals" => 1}
241
      })
242
243
      created =
244
        context.conn
245
        |> post("#{context.path}/deployments", deployment_body("api-approval-1234"))
246
        |> json_response(202)
247
248
      %{approver: approver, run_id: created["id"], created: created}
249
    end
250
251
    test "a run waits for checks, then approval, then queues", context do
252
      assert context.created["state"] == "checking"
253
254
      assert context.conn
255
             |> post("#{context.path}/deployment-checks", %{
256
               "name" => "build",
257
               "commit_sha" => commit_sha(),
258
               "artifact_digest" => artifact_digest(),
259
               "status" => "succeeded"
260
             })
261
             |> json_response(201)
262
             |> Map.fetch!("status") == "succeeded"
263
264
      assert %{"state" => "waiting_for_approval"} =
265
               context.conn
266
               |> get("#{context.path}/deployments/#{context.run_id}")
267
               |> json_response(200)
268
269
      assert %{"state" => "queued"} =
270
               build_conn()
271
               |> put_deployments_api_token(context.approver)
272
               |> post("#{context.path}/deployments/#{context.run_id}/approvals", %{
273
                 "decision" => "approved"
274
               })
275
               |> json_response(200)
276
277
      assert %{"approvals" => [approval]} =
278
               context.conn
279
               |> get("#{context.path}/deployments/#{context.run_id}/approvals")
280
               |> json_response(200)
281
282
      assert approval["decision"] == "approved"
283
    end
284
285
    test "the requester cannot approve its own run", context do
286
      response =
287
        context.conn
288
        |> post("#{context.path}/deployments/#{context.run_id}/approvals", %{
289
          "decision" => "approved"
290
        })
291
        |> json_response(403)
292
293
      assert response["error"]["detail"] == "self_approval"
294
    end
295
296
    test "a decision the lifecycle does not define is refused", context do
297
      response =
298
        context.conn
299
        |> post("#{context.path}/deployments/#{context.run_id}/approvals", %{
300
          "decision" => "maybe"
301
        })
302
        |> json_response(422)
303
304
      assert response["error"]["code"] == "invalid_decision"
305
    end
306
  end
307
308
  describe "events" do
309
    test "history is readable, ordered, and paginated by sequence", context do
310
      created =
311
        context.conn
312
        |> post("#{context.path}/deployments", deployment_body("api-events-1234"))
313
        |> json_response(202)
314
315
      assert %{"events" => events, "after_sequence" => after_sequence} =
316
               context.conn
317
               |> get("#{context.path}/deployments/#{created["id"]}/events")
318
               |> json_response(200)
319
320
      assert Enum.map(events, & &1["sequence"]) == Enum.to_list(1..length(events))
321
      assert after_sequence == length(events)
322
323
      assert %{"events" => []} =
324
               context.conn
325
               |> get("#{context.path}/deployments/#{created["id"]}/events",
326
                 after_sequence: to_string(after_sequence)
327
               )
328
               |> json_response(200)
329
    end
330
  end
331
332
  describe "workflow grants" do
333
    test "issuing returns the token once and revoking ends it", context do
334
      issued =
335
        context.conn
336
        |> post("#{context.path}/deployment-workflow-grants", %{
337
          "audience" => "openagents-deployments",
338
          "scopes" => ["deployments:request"],
339
          "source_ref" => "refs/heads/main",
340
          "source_workflow" => "deploy.yml",
341
          "workflow_run_id" => "wfr-api-1"
342
        })
343
        |> json_response(201)
344
345
      assert is_binary(issued["token"])
346
347
      assert context.conn
348
             |> get("#{context.path}/deployments")
349
             |> json_response(200)
350
351
      assert context.conn
352
             |> delete("#{context.path}/deployment-workflow-grants/#{issued["id"]}")
353
             |> json_response(200)
354
             |> Map.fetch!("revoked_at")
355
356
      assert build_conn()
357
             |> put_req_header("authorization", "Bearer " <> issued["token"])
358
             |> get("#{context.path}/deployments")
359
             |> json_response(401)
360
    end
361
362
    test "a grant is never issued to a non-member", context do
363
      stranger = repository_user_fixture("api-grant-stranger")
364
365
      assert build_conn()
366
             |> put_deployments_api_token(stranger)
367
             |> post("#{context.path}/deployment-workflow-grants", %{
368
               "audience" => "openagents-deployments",
369
               "scopes" => ["deployments:request"],
370
               "source_ref" => "refs/heads/main",
371
               "source_workflow" => "deploy.yml",
372
               "workflow_run_id" => "wfr-api-2"
373
             })
374
             |> json_response(404)
375
    end
376
  end
377
378
  defp deployment_body(idempotency_key) do
379
    %{
380
      "environment" => "production",
381
      "commit_sha" => commit_sha(),
382
      "artifact_digest" => artifact_digest(),
383
      "source_ref" => "refs/heads/main",
384
      "idempotency_key" => idempotency_key
385
    }
386
  end
387
end
test/support/conn_case.ex modified +10

@@ -115,6 +115,16 @@ defmodule OpenAgentsWeb.ConnCase do

115 115
    put_api_token_for_user(conn, user, ["forge:write"])
116 116
  end
117 117
118
  @doc """
119
  Authenticate an existing account for the deployment control plane.
120
121
  Deployment authority is its own scope, so a test cannot borrow `forge:write`
122
  to reach a deployment route.
123
  """
124
  def put_deployments_api_token(conn, %OpenAgents.Accounts.User{} = user) do
125
    put_api_token_for_user(conn, user, ["deployments:write"])
126
  end
127
118 128
  def put_chat_api_token(conn, key) when is_binary(key) do
119 129
    user = github_user("api-token-" <> key)
120 130
    put_api_token_for_user(conn, user, ["chat:account"])
test/support/fixtures/deployments_fixtures.ex added +89

@@ -0,0 +1,89 @@

1
defmodule OpenAgents.DeploymentsFixtures do
2
  @moduledoc """
3
  Test helpers for the deployment control plane.
4
5
  Requests are created with an explicit commit store, so a test proves policy and
6
  authority without needing real git storage. The commit check itself is proved
7
  separately.
8
  """
9
10
  alias OpenAgents.Deployments
11
  alias OpenAgents.Deployments.Principal
12
13
  @commit String.duplicate("ab", 20)
14
  @artifact "sha256:" <> String.duplicate("c", 64)
15
16
  @doc "A full commit sha every fixture agrees on."
17
  def commit_sha, do: @commit
18
19
  @doc "An artifact digest every fixture agrees on."
20
  def artifact_digest, do: @artifact
21
22
  @doc "A commit store that admits every commit, for tests about policy."
23
  def any_commit, do: fn _repository, _commit_sha -> :ok end
24
25
  @doc "Define one environment with the given protection policy."
26
  def environment_fixture(repository, user, attrs \\ %{}) do
27
    attrs =
28
      Map.merge(
29
        %{
30
          "name" => "production",
31
          "kind" => "production",
32
          "provider" => "fake",
33
          "protection" => %{}
34
        },
35
        stringify(attrs)
36
      )
37
38
    {:ok, environment} = Deployments.put_environment(repository, Principal.user(user), attrs)
39
    environment
40
  end
41
42
  @doc "Record one deployment request and return its run."
43
  def run_fixture(repository, user, attrs \\ %{}) do
44
    attrs =
45
      Map.merge(
46
        %{
47
          "environment" => "production",
48
          "commit_sha" => @commit,
49
          "artifact_digest" => @artifact,
50
          "source_ref" => "refs/heads/main",
51
          "idempotency_key" => "idempotency-" <> Integer.to_string(unique())
52
        },
53
        stringify(attrs)
54
      )
55
56
    {:ok, run} =
57
      Deployments.request_deployment(repository, Principal.user(user), attrs,
58
        commit_store: any_commit()
59
      )
60
61
    run
62
  end
63
64
  @doc "Issue a workflow grant and return the grant with its plaintext token."
65
  def workflow_grant_fixture(repository, user, attrs \\ %{}) do
66
    attrs =
67
      Map.merge(
68
        %{
69
          "audience" => "openagents-deployments",
70
          "scopes" => ["deployments:request", "deployments:checks"],
71
          "source_ref" => "refs/heads/main",
72
          "source_workflow" => "deploy.yml",
73
          "workflow_run_id" => "wfr-" <> Integer.to_string(unique())
74
        },
75
        stringify(attrs)
76
      )
77
78
    {:ok, {grant, plaintext}} =
79
      Deployments.issue_workflow_grant(repository, Principal.user(user), attrs)
80
81
    {grant, plaintext}
82
  end
83
84
  defp stringify(attrs) do
85
    Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
86
  end
87
88
  defp unique, do: System.unique_integer([:positive, :monotonic])
89
end

This page updates live while a promote is in flight · changelog