Add relup deployment settlement

9555dad8f442 · AtlantisPleb · · parent cf23f84bb9ba

Add relup deployment settlement

Deploy story

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

built
3 modules in 14.5 s
deployed
live · 3 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 3 modules on 0 nodes · push→live —

Changed files

  • modified docs/operations/forge-hot-loop.md
  • modified docs/operations/release-deployment-fallbacks.md
  • modified lib/openagents/forge/relup_deployment.ex
  • modified lib/openagents/forge/relup_package.ex
  • modified lib/openagents/forge/targets.ex
  • modified test/openagents/forge/relup_deployment_test.exs
  • modified test/openagents/forge/relup_package_test.exs
  • modified test/openagents/forge/target_lifecycle_test.exs

Diff

8 files changed, +338 -14

docs/operations/forge-hot-loop.md modified +14 -6

@@ -147,7 +147,10 @@ this fallback order:

147 147
   dependencies, ERTS, native code, migrations, assets, or another structural
148 148
   change that cannot use a relup.
149 149
3. Settle the original target with the fallback result so its verified build
150
   becomes the baseline for later direct-load classification.
150
   becomes the baseline for later direct-load classification. Call
151
   `OpenAgents.Forge.Targets.finish_relup_deployment/2` for a relup or
152
   `OpenAgents.Forge.Targets.finish_rolling_replacement/2` for a rolling
153
   replacement.
151 154
152 155
## Failure modes
153 156

@@ -274,6 +277,11 @@ The 2026-08-22 activation established this baseline:

274 277
- Forge and GitHub exposed `b3ae6c6` as `main`, and the public status endpoint
275 278
  reported the mirror `current`. Every node also reported the same hot
276 279
  artifact digest and target SHA.
280
- The documentation and CLI parity release upgraded the three-node fleet from
281
  `0.2.0@bd6c808` to `0.2.1@cf23f84` through a packaged relup in 53.876
282
  seconds. Every node returned `permanent`, the exact-SHA gate passed all 13
283
  stages and 2,044 tests, and the retained Forge target was settled with the
284
  relup package and manifest digests.
277 285
- Forge classified the activation change as `needs_rolling_replace`, and the
278 286
  operator settled it as `live` only after every node reported revision
279 287
  `3479f12`, complete cluster membership, and local health. Later compatible

@@ -288,11 +296,11 @@ The 2026-08-22 activation established this baseline:

288 296
  repository-owned fleet template now remove those retired containers and
289 297
  replace the disposable builder sidecar before pulling a new builder image.
290 298
291
The activation did not change the application version from `0.2.0`. Keep that
292
version for direct loads. For full compatible packages, increment only the
293
patch component. Change the minor component only for a planned compatibility
294
boundary, and never increment a release version merely to record a source
295
commit.
299
The initial activation did not change the application version from `0.2.0`.
300
Keep a release version unchanged for direct loads. For full compatible
301
packages, increment only the patch component. Change the minor component only
302
for a planned compatibility boundary, and never increment a release version
303
merely to record a source commit.
296 304
297 305
After a rollout or storage repair, force convergence before validating the
298 306
mirror:
docs/operations/release-deployment-fallbacks.md modified +17 -2

@@ -148,6 +148,21 @@ the prior permanent release, and aborts before touching another node. If the

148 148
reverse path fails, keep the node out of readiness and move to operator-directed
149 149
rolling recovery.
150 150
151
After the coordinator returns, settle its bounded result against the retained
152
Forge target:
153
154
```elixir
155
OpenAgents.Forge.Targets.finish_relup_deployment(target_id, relup_result)
156
```
157
158
Settlement accepts only the newest target in `needs_rolling_replace`, requires
159
the target's complete verified build receipt, and verifies the target SHA,
160
source SHA, package manifest digest, target artifact digest, release versions,
161
duration, and per-node permanence. It changes the target to `live` or `failed`
162
and inserts a second immutable deployment receipt in one transaction. A
163
successful settlement makes that build the baseline for later direct-load
164
classification.
165
151 166
`OpenAgents.Forge.RelupNode` retains the immutable tar in
152 167
`releases/.openagents-relup-cache/<sha256>.tar.gz`. It copies those exact bytes
153 168
back to the filename consumed by `unpack_release/1` before every attempt. This

@@ -207,8 +222,8 @@ last-known-good SHA and digest, waits for that node's full health, records the

207 222
recovery result, and aborts. It never replaces a second node while the first is
208 223
missing or unhealthy.
209 224
210
After the coordinator returns, settle its bounded result against the Forge
211
target:
225
After the rolling coordinator returns, settle its bounded result against the
226
Forge target:
212 227
213 228
```elixir
214 229
OpenAgents.Forge.Targets.finish_rolling_replacement(target_id, rolling_result)
lib/openagents/forge/relup_deployment.ex modified +8

@@ -182,9 +182,15 @@ defmodule OpenAgents.Forge.RelupDeployment do

182 182
      not Regex.match?(@sha_pattern, Map.get(request, :sha, "")) ->
183 183
        {:error, :invalid_git_sha}
184 184
185
      not Regex.match?(@sha_pattern, Map.get(request, :from_revision, "")) ->
186
        {:error, :invalid_from_git_sha}
187
185 188
      not Regex.match?(@digest_pattern, Map.get(request, :artifact_digest, "")) ->
186 189
        {:error, :invalid_artifact_digest}
187 190
191
      not Regex.match?(@digest_pattern, Map.get(request, :package_manifest_digest, "")) ->
192
        {:error, :invalid_package_manifest_digest}
193
188 194
      not is_binary(Map.get(request, :artifact_bytes)) ->
189 195
        {:error, :invalid_artifact}
190 196

@@ -231,7 +237,9 @@ defmodule OpenAgents.Forge.RelupDeployment do

231 237
    %{
232 238
      schema: "openagents.relup-deployment.v1",
233 239
      sha: request.sha,
240
      from_revision: request.from_revision,
234 241
      artifact_digest: request.artifact_digest,
242
      package_manifest_digest: request.package_manifest_digest,
235 243
      from_version: request.from_version,
236 244
      to_version: request.to_version,
237 245
      status: status,
lib/openagents/forge/relup_package.ex modified +18 -3

@@ -20,7 +20,7 @@ defmodule OpenAgents.Forge.RelupPackage do

20 20
  def load(directory, opts \\ [])
21 21
22 22
  def load(directory, opts) when is_binary(directory) do
23
    with {:ok, manifest} <- read_manifest(directory),
23
    with {:ok, manifest, package_manifest_digest} <- read_manifest(directory),
24 24
         :ok <- validate_manifest(manifest, opts),
25 25
         {:ok, artifact_bytes} <- read_artifact(directory, manifest),
26 26
         :ok <- verify_artifact(artifact_bytes, manifest["to_artifact_digest"]) do

@@ -30,6 +30,7 @@ defmodule OpenAgents.Forge.RelupPackage do

30 30
      {:ok,
31 31
       %{
32 32
         sha: manifest["to_revision"],
33
         from_revision: manifest["from_revision"],
33 34
         release_name: manifest["release_name"],
34 35
         from_version: manifest["from_version"],
35 36
         to_version: manifest["to_version"],

@@ -37,6 +38,7 @@ defmodule OpenAgents.Forge.RelupPackage do

37 38
         to_state_version: manifest["to_state_version"],
38 39
         artifact_bytes: artifact_bytes,
39 40
         artifact_digest: manifest["to_artifact_digest"],
41
         package_manifest_digest: package_manifest_digest,
40 42
         expected_nodes: nodes,
41 43
         expected_fleet_size: length(nodes)
42 44
       }}

@@ -47,11 +49,24 @@ defmodule OpenAgents.Forge.RelupPackage do

47 49
48 50
  @doc "Validate a relup package and deploy it one node at a time."
49 51
  def deploy(directory, opts \\ []) do
52
    started_at = System.monotonic_time(:millisecond)
53
50 54
    with {:ok, request} <- load(directory, opts) do
51 55
      deployment_opts =
52 56
        Keyword.drop(opts, [:current_revision, :expected_nodes, :system_architecture])
53 57
54
      RelupDeployment.run(request, deployment_opts)
58
      case RelupDeployment.run(request, deployment_opts) do
59
        {status, result} when status in [:ok, :error] and is_map(result) ->
60
          {status,
61
           Map.put(
62
             result,
63
             :duration_ms,
64
             System.monotonic_time(:millisecond) - started_at
65
           )}
66
67
        other ->
68
          other
69
      end
55 70
    end
56 71
  end
57 72

@@ -62,7 +77,7 @@ defmodule OpenAgents.Forge.RelupPackage do

62 77
         true <- stat.type == :regular and stat.size <= @maximum_manifest_bytes,
63 78
         {:ok, bytes} <- File.read(path),
64 79
         {:ok, manifest} <- Jason.decode(bytes) do
65
      {:ok, manifest}
80
      {:ok, manifest, digest(bytes)}
66 81
    else
67 82
      {:error, :enoent} -> {:error, :missing_package_manifest}
68 83
      {:error, %Jason.DecodeError{}} -> {:error, :invalid_package_manifest_json}
lib/openagents/forge/targets.ex modified +192 -3

@@ -8,9 +8,9 @@ defmodule OpenAgents.Forge.Targets do

8 8
  another promotion, receipted like any other. Status advances through the
9 9
  deploy lane (`promoted → building → built → deploying →
10 10
  live | failed | reverted | needs_rolling_replace`) with bounded details
11
  at every step. An operator-approved rolling replacement can settle a
12
  `needs_rolling_replace` target as `live` or `failed` with a second immutable
13
  receipt. The `forge:target` broadcast is what wakes the builder.
11
  at every step. An operator-approved relup or rolling replacement can settle
12
  a `needs_rolling_replace` target as `live` or `failed` with a second
13
  immutable receipt. The `forge:target` broadcast is what wakes the builder.
14 14
  """
15 15
16 16
  import Ecto.Query

@@ -254,6 +254,119 @@ defmodule OpenAgents.Forge.Targets do

254 254
    end
255 255
  end
256 256
257
  @doc """
258
  Settle an operator-approved relup against its verified Forge build.
259
260
  The relup coordinator returns the bounded result passed to this function.
261
  Settlement succeeds only for the newest target, only after Forge classified
262
  it as `needs_rolling_replace`, and only when a complete build receipt exists.
263
  The target update and the second immutable deployment receipt commit in one
264
  transaction. The original classification receipt remains intact.
265
  """
266
  def finish_relup_deployment(target_id, relup_result) when is_map(relup_result) do
267
    result =
268
      :global.trans({{:forge_target_deploy, target_id}, self()}, fn ->
269
        Repo.transaction(fn ->
270
          target = Repo.get(Target, target_id, lock: "FOR UPDATE") || Repo.rollback(:not_found)
271
272
          current_id =
273
            Target
274
            |> where([t], t.repo == ^target.repo)
275
            |> order_by([t], desc: t.inserted_at)
276
            |> limit(1)
277
            |> select([t], t.id)
278
            |> Repo.one()
279
280
          if current_id != target.id, do: Repo.rollback(:superseded_target)
281
282
          unless target.status == "needs_rolling_replace" do
283
            requested_status = result_value(relup_result, :status) || "invalid"
284
            Repo.rollback({:invalid_transition, target.status, requested_status})
285
          end
286
287
          relup =
288
            case validate_relup_result(relup_result, target.sha) do
289
              {:ok, relup} -> relup
290
              {:error, reason} -> Repo.rollback(reason)
291
            end
292
293
          build =
294
            BuildReceipt
295
            |> where([b], b.target_id == ^target.id and b.status == "complete")
296
            |> order_by([b], desc: b.inserted_at)
297
            |> limit(1)
298
            |> Repo.one()
299
300
          if is_nil(build), do: Repo.rollback(:complete_build_receipt_not_found)
301
302
          deployment_id = Ecto.UUID.generate()
303
          now = DateTime.utc_now()
304
305
          details = %{
306
            "artifact_digest" => relup.artifact_digest,
307
            "build_artifact_digest" => build.artifact_digest,
308
            "deployment_id" => deployment_id,
309
            "deployment_lane" => "relup",
310
            "from_revision" => relup.from_revision,
311
            "from_version" => relup.from_version,
312
            "package_manifest_digest" => relup.package_manifest_digest,
313
            "relup_duration_ms" => relup.duration_ms,
314
            "relup_error_code" => relup.error_code,
315
            "relup_node_results" => relup.node_results,
316
            "to_version" => relup.to_version
317
          }
318
319
          target =
320
            target
321
            |> Target.status_changeset(relup.status, details)
322
            |> Repo.update!()
323
324
          receipt_attrs = %{
325
            artifact_digest: relup.artifact_digest,
326
            completed_at: now,
327
            deployment_id: deployment_id,
328
            error_code: relup.error_code,
329
            expected_nodes: relup.expected_nodes,
330
            manifest_digest: relup.package_manifest_digest,
331
            modules: build.modules,
332
            node_results: relup.node_results,
333
            nodes:
334
              Enum.map(relup.expected_nodes, fn node ->
335
                "#{node}=#{relup.node_results[node]}"
336
              end),
337
            push_to_live_ms: relup.duration_ms,
338
            repo: target.repo,
339
            result: relup.status,
340
            rollback_verified:
341
              relup.status == "failed" and
342
                Enum.all?(relup.node_results, fn {_node, status} -> status == "reversed" end),
343
            sha: target.sha,
344
            started_at: DateTime.add(now, -relup.duration_ms, :millisecond),
345
            target_id: target.id
346
          }
347
348
          receipt =
349
            %DeployReceipt{}
350
            |> DeployReceipt.changeset(receipt_attrs)
351
            |> Repo.insert()
352
            |> case do
353
              {:ok, receipt} -> receipt
354
              {:error, changeset} -> Repo.rollback({:invalid_receipt, changeset})
355
            end
356
357
          %{target: target, receipt: receipt}
358
        end)
359
      end)
360
361
    with {:ok, %{target: target} = committed} <- result do
362
      broadcast_status(target)
363
      {:ok, committed}
364
    end
365
  end
366
367
  def finish_relup_deployment(_target_id, _relup_result),
368
    do: {:error, :invalid_relup_result}
369
257 370
  @doc """
258 371
  Settle an operator-approved rolling replacement against its verified build.
259 372

@@ -361,6 +474,70 @@ defmodule OpenAgents.Forge.Targets do

361 474
  def finish_rolling_replacement(_target_id, _rolling_result),
362 475
    do: {:error, :invalid_rolling_result}
363 476
477
  defp validate_relup_result(result, target_sha) do
478
    schema = result_value(result, :schema)
479
    sha = result_value(result, :sha)
480
    from_revision = result_value(result, :from_revision)
481
    artifact_digest = result_value(result, :artifact_digest)
482
    package_manifest_digest = result_value(result, :package_manifest_digest)
483
    from_version = result_value(result, :from_version)
484
    to_version = result_value(result, :to_version)
485
    status = result_value(result, :status)
486
    node_results = result_value(result, :node_results)
487
    error_code = result_value(result, :error_code)
488
    duration_ms = result_value(result, :duration_ms)
489
490
    cond do
491
      schema != "openagents.relup-deployment.v1" ->
492
        {:error, :invalid_relup_result}
493
494
      sha != target_sha ->
495
        {:error, :relup_sha_mismatch}
496
497
      not valid_sha?(from_revision) ->
498
        {:error, :invalid_relup_result}
499
500
      not valid_digest?(artifact_digest) or not valid_digest?(package_manifest_digest) ->
501
        {:error, :invalid_relup_result}
502
503
      not valid_version?(from_version) or not valid_version?(to_version) or
504
          from_version == to_version ->
505
        {:error, :invalid_relup_result}
506
507
      status not in ~w(live failed) ->
508
        {:error, :invalid_relup_result}
509
510
      not valid_node_results?(node_results) ->
511
        {:error, :invalid_relup_result}
512
513
      not valid_duration?(duration_ms) ->
514
        {:error, :invalid_relup_result}
515
516
      status == "live" and
517
          (Enum.any?(node_results, fn {_node, node_status} -> node_status != "permanent" end) or
518
             not is_nil(error_code)) ->
519
        {:error, :invalid_relup_result}
520
521
      status == "failed" and not bounded_error?(error_code) ->
522
        {:error, :invalid_relup_result}
523
524
      true ->
525
        {:ok,
526
         %{
527
           artifact_digest: artifact_digest,
528
           duration_ms: duration_ms,
529
           error_code: error_code,
530
           expected_nodes: node_results |> Map.keys() |> Enum.sort(),
531
           from_revision: from_revision,
532
           from_version: from_version,
533
           node_results: node_results,
534
           package_manifest_digest: package_manifest_digest,
535
           status: status,
536
           to_version: to_version
537
         }}
538
    end
539
  end
540
364 541
  defp validate_rolling_result(result, target_sha) do
365 542
    schema = result_value(result, :schema)
366 543
    sha = result_value(result, :sha)

@@ -422,6 +599,18 @@ defmodule OpenAgents.Forge.Targets do

422 599
  defp valid_sha?(value) when is_binary(value), do: Regex.match?(~r/\A[0-9a-f]{40}\z/, value)
423 600
  defp valid_sha?(_value), do: false
424 601
602
  defp valid_digest?(value) when is_binary(value),
603
    do: Regex.match?(~r/\A[0-9a-f]{64}\z/, value)
604
605
  defp valid_digest?(_value), do: false
606
607
  defp valid_version?(value) when is_binary(value),
608
    do: Regex.match?(~r/\A[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?\z/, value)
609
610
  defp valid_version?(_value), do: false
611
612
  defp valid_duration?(value), do: is_integer(value) and value in 0..86_400_000
613
425 614
  defp valid_image_digest?(value) when is_binary(value),
426 615
    do: Regex.match?(~r/\Asha256:[0-9a-f]{64}\z/, value)
427 616
test/openagents/forge/relup_deployment_test.exs modified +12

@@ -9,7 +9,9 @@ defmodule OpenAgents.Forge.RelupDeploymentTest do

9 9
  alias OpenAgents.Test.ReleaseHandler
10 10
11 11
  @sha String.duplicate("a", 40)
12
  @from_sha String.duplicate("c", 40)
12 13
  @digest String.duplicate("b", 64)
14
  @manifest_digest String.duplicate("d", 64)
13 15
  @nodes [:first@local, :second@local, :third@local]
14 16
15 17
  test "upgrades and makes each node permanent before touching the next" do

@@ -101,6 +103,7 @@ defmodule OpenAgents.Forge.RelupDeploymentTest do

101 103
  defp request do
102 104
    %{
103 105
      sha: @sha,
106
      from_revision: @from_sha,
104 107
      release_name: "openagents",
105 108
      from_version: "0.1.0",
106 109
      to_version: "0.2.0",

@@ -108,6 +111,7 @@ defmodule OpenAgents.Forge.RelupDeploymentTest do

108 111
      to_state_version: 2,
109 112
      artifact_bytes: "artifact",
110 113
      artifact_digest: @digest,
114
      package_manifest_digest: @manifest_digest,
111 115
      expected_nodes: @nodes,
112 116
      expected_fleet_size: 3
113 117
    }

@@ -163,6 +167,14 @@ defmodule OpenAgents.Forge.RelupDeploymentTest do

163 167
               )
164 168
    end
165 169
170
    test "refuses a malformed source revision or package manifest digest" do
171
      assert {:error, :invalid_from_git_sha} =
172
               RelupDeployment.run(request() |> Map.put(:from_revision, "not-a-sha"))
173
174
      assert {:error, :invalid_package_manifest_digest} =
175
               RelupDeployment.run(request() |> Map.put(:package_manifest_digest, "short"))
176
    end
177
166 178
    test "refuses state version regression and unsupported state versions" do
167 179
      regression =
168 180
        request()
test/openagents/forge/relup_package_test.exs modified +2

@@ -19,10 +19,12 @@ defmodule OpenAgents.Forge.RelupPackageTest do

19 19
             )
20 20
21 21
    assert request.sha == @to_sha
22
    assert request.from_revision == @from_sha
22 23
    assert request.from_version == "0.2.0"
23 24
    assert request.to_version == "0.3.0"
24 25
    assert request.expected_nodes == [:first@local, :second@local]
25 26
    assert request.artifact_bytes == "target release"
27
    assert request.package_manifest_digest =~ ~r/\A[0-9a-f]{64}\z/
26 28
  end
27 29
28 30
  test "refuses a package for another revision, platform, or artifact" do
test/openagents/forge/target_lifecycle_test.exs modified +75

@@ -227,6 +227,62 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

227 227
             Targets.finish_rolling_replacement(target.id, rolling_result(sha, "live"))
228 228
  end
229 229
230
  test "relup settlement makes the verified package the live baseline", %{sha: sha} do
231
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
232
    {:ok, _building} = Targets.advance(target.id, "building")
233
    {:ok, _built} = Targets.advance(target.id, "built")
234
    {:ok, _relup} = Targets.advance(target.id, "needs_rolling_replace")
235
236
    build_digest = String.duplicate("a", 64)
237
    package_digest = String.duplicate("c", 64)
238
    package_manifest_digest = String.duplicate("d", 64)
239
240
    insert_build_receipt!(
241
      target,
242
      %{"classification" => "needs_rolling_replace", "source_sha" => sha},
243
      build_digest
244
    )
245
246
    result =
247
      relup_result(sha, "live")
248
      |> Map.put(:artifact_digest, package_digest)
249
      |> Map.put(:package_manifest_digest, package_manifest_digest)
250
251
    assert {:ok, %{target: live, receipt: receipt}} =
252
             Targets.finish_relup_deployment(target.id, result)
253
254
    assert live.status == "live"
255
    assert live.details["deployment_lane"] == "relup"
256
    assert live.details["artifact_digest"] == package_digest
257
    assert live.details["build_artifact_digest"] == build_digest
258
    assert receipt.result == "live"
259
    assert receipt.artifact_digest == package_digest
260
    assert receipt.manifest_digest == package_manifest_digest
261
    assert receipt.push_to_live_ms == 53_876
262
263
    assert receipt.node_results == %{
264
             "openagents@10.42.0.11" => "permanent",
265
             "openagents@10.42.0.12" => "permanent"
266
           }
267
  end
268
269
  test "relup settlement refuses a nonpermanent live result", %{sha: sha} do
270
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
271
    {:ok, _building} = Targets.advance(target.id, "building")
272
    {:ok, _built} = Targets.advance(target.id, "built")
273
    {:ok, _relup} = Targets.advance(target.id, "needs_rolling_replace")
274
    insert_build_receipt!(target, %{"source_sha" => sha}, String.duplicate("a", 64))
275
276
    result =
277
      relup_result(sha, "live")
278
      |> put_in([:node_results, "openagents@10.42.0.12"], "current")
279
280
    assert {:error, :invalid_relup_result} =
281
             Targets.finish_relup_deployment(target.id, result)
282
283
    assert Repo.get!(OpenAgents.Forge.Target, target.id).status == "needs_rolling_replace"
284
  end
285
230 286
  test "rolling replacement records a complete large module inventory", %{sha: sha} do
231 287
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
232 288
    {:ok, _building} = Targets.advance(target.id, "building")

@@ -385,4 +441,23 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

385 441
      recovery: nil
386 442
    }
387 443
  end
444
445
  defp relup_result(sha, status) do
446
    %{
447
      schema: "openagents.relup-deployment.v1",
448
      sha: sha,
449
      from_revision: String.duplicate("b", 40),
450
      artifact_digest: String.duplicate("c", 64),
451
      package_manifest_digest: String.duplicate("d", 64),
452
      from_version: "0.2.0",
453
      to_version: "0.2.1",
454
      status: status,
455
      node_results: %{
456
        "openagents@10.42.0.11" => "permanent",
457
        "openagents@10.42.0.12" => "permanent"
458
      },
459
      error_code: nil,
460
      duration_ms: 53_876
461
    }
462
  end
388 463
end

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