Settle rolling Forge deployments

b35c87844e4f · AtlantisPleb · · parent 62b056c51c5b

Settle rolling Forge deployments

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 docs/operations/release-deployment-fallbacks.md
  • modified lib/openagents/forge/targets.ex
  • modified test/openagents/forge/target_lifecycle_test.exs

Diff

3 files changed, +349 -3

docs/operations/release-deployment-fallbacks.md modified +14

@@ -154,6 +154,20 @@ last-known-good SHA and digest, waits for that node's full health, records the

154 154
recovery result, and aborts. It never replaces a second node while the first is
155 155
missing or unhealthy.
156 156
157
After the coordinator returns, settle its bounded result against the Forge
158
target:
159
160
```elixir
161
OpenAgents.Forge.Targets.finish_rolling_replacement(target_id, rolling_result)
162
```
163
164
Settlement accepts only the newest target in `needs_rolling_replace`, requires
165
the target's complete verified build receipt, and verifies that the result SHA
166
matches the target. It changes the target to `live` or `failed` and inserts a
167
second immutable deployment receipt in one transaction. Forge preserves the
168
earlier `needs_rolling_replace` classification receipt. A successful settlement
169
makes that build manifest the baseline for later direct-load classification.
170
157 171
## Database compatibility
158 172
159 173
Keep schema changes additive while old and new releases overlap. During a
lib/openagents/forge/targets.ex modified +202 -2

@@ -8,13 +8,16 @@ 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; the `forge:target` broadcast is what wakes the builder.
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.
12 14
  """
13 15
14 16
  import Ecto.Query
15 17
16
  alias OpenAgents.Forge.Target
18
  alias OpenAgents.Forge.BuildReceipt
17 19
  alias OpenAgents.Forge.DeployReceipt
20
  alias OpenAgents.Forge.Target
18 21
  alias OpenAgents.Repo
19 22
20 23
  @statuses ~w(promoted building built deploying live failed reverted needs_rolling_replace)

@@ -246,6 +249,203 @@ defmodule OpenAgents.Forge.Targets do

246 249
    end
247 250
  end
248 251
252
  @doc """
253
  Settle an operator-approved rolling replacement against its verified build.
254
255
  The rolling coordinator returns the bounded result passed to this function.
256
  Settlement succeeds only for the newest target, only after Forge classified
257
  it as `needs_rolling_replace`, and only when a complete build receipt exists.
258
  The target update and the second immutable deployment receipt commit in one
259
  database transaction. The original classification receipt remains intact.
260
  """
261
  def finish_rolling_replacement(target_id, rolling_result) when is_map(rolling_result) do
262
    result =
263
      :global.trans({{:forge_target_deploy, target_id}, self()}, fn ->
264
        Repo.transaction(fn ->
265
          target = Repo.get(Target, target_id, lock: "FOR UPDATE") || Repo.rollback(:not_found)
266
267
          current_id =
268
            Target
269
            |> where([t], t.repo == ^target.repo)
270
            |> order_by([t], desc: t.inserted_at)
271
            |> limit(1)
272
            |> select([t], t.id)
273
            |> Repo.one()
274
275
          if current_id != target.id, do: Repo.rollback(:superseded_target)
276
277
          unless target.status == "needs_rolling_replace" do
278
            requested_status = result_value(rolling_result, :status) || "invalid"
279
            Repo.rollback({:invalid_transition, target.status, requested_status})
280
          end
281
282
          rolling =
283
            case validate_rolling_result(rolling_result, target.sha) do
284
              {:ok, rolling} -> rolling
285
              {:error, reason} -> Repo.rollback(reason)
286
            end
287
288
          build =
289
            BuildReceipt
290
            |> where([b], b.target_id == ^target.id and b.status == "complete")
291
            |> order_by([b], desc: b.inserted_at)
292
            |> limit(1)
293
            |> Repo.one()
294
295
          if is_nil(build), do: Repo.rollback(:complete_build_receipt_not_found)
296
297
          deployment_id = Ecto.UUID.generate()
298
          manifest_digest = manifest_digest(build.manifest)
299
          now = DateTime.utc_now()
300
301
          details = %{
302
            "artifact_digest" => build.artifact_digest,
303
            "deployment_id" => deployment_id,
304
            "image_digest" => rolling.image_digest,
305
            "manifest_digest" => manifest_digest,
306
            "previous_image_digest" => rolling.previous_image_digest,
307
            "previous_sha" => rolling.previous_sha,
308
            "rolling_error_code" => rolling.error_code,
309
            "rolling_node_results" => rolling.node_results,
310
            "rolling_recovery" => rolling.recovery
311
          }
312
313
          target =
314
            target
315
            |> Target.status_changeset(rolling.status, details)
316
            |> Repo.update!()
317
318
          receipt_attrs = %{
319
            artifact_digest: build.artifact_digest,
320
            completed_at: now,
321
            deployment_id: deployment_id,
322
            error_code: rolling.error_code,
323
            expected_nodes: rolling.expected_nodes,
324
            manifest_digest: manifest_digest,
325
            modules: build.modules,
326
            node_results: rolling.node_results,
327
            nodes: Enum.map(rolling.expected_nodes, &"#{&1}=#{rolling.node_results[&1]}"),
328
            repo: target.repo,
329
            result: rolling.status,
330
            rollback_verified:
331
              rolling.status == "failed" and rolling.recovery == "last_known_good_restored",
332
            sha: target.sha,
333
            started_at: now,
334
            target_id: target.id
335
          }
336
337
          receipt =
338
            %DeployReceipt{}
339
            |> DeployReceipt.changeset(receipt_attrs)
340
            |> Repo.insert()
341
            |> case do
342
              {:ok, receipt} -> receipt
343
              {:error, changeset} -> Repo.rollback({:invalid_receipt, changeset})
344
            end
345
346
          %{target: target, receipt: receipt}
347
        end)
348
      end)
349
350
    with {:ok, %{target: target} = committed} <- result do
351
      broadcast_status(target)
352
      {:ok, committed}
353
    end
354
  end
355
356
  def finish_rolling_replacement(_target_id, _rolling_result),
357
    do: {:error, :invalid_rolling_result}
358
359
  defp validate_rolling_result(result, target_sha) do
360
    schema = result_value(result, :schema)
361
    sha = result_value(result, :sha)
362
    previous_sha = result_value(result, :previous_sha)
363
    image_digest = result_value(result, :image_digest)
364
    previous_image_digest = result_value(result, :previous_image_digest)
365
    status = result_value(result, :status)
366
    node_results = result_value(result, :node_results)
367
    error_code = result_value(result, :error_code)
368
    recovery = result_value(result, :recovery)
369
370
    cond do
371
      schema != "openagents.rolling-replacement.v1" ->
372
        {:error, :invalid_rolling_result}
373
374
      sha != target_sha ->
375
        {:error, :rolling_sha_mismatch}
376
377
      not valid_sha?(previous_sha) ->
378
        {:error, :invalid_rolling_result}
379
380
      not valid_image_digest?(image_digest) or not valid_image_digest?(previous_image_digest) ->
381
        {:error, :invalid_rolling_result}
382
383
      status not in ~w(live failed) ->
384
        {:error, :invalid_rolling_result}
385
386
      not valid_node_results?(node_results) ->
387
        {:error, :invalid_rolling_result}
388
389
      status == "live" and
390
          (Enum.any?(node_results, fn {_node, node_status} -> node_status != "ready" end) or
391
             not is_nil(error_code) or not is_nil(recovery)) ->
392
        {:error, :invalid_rolling_result}
393
394
      status == "failed" and not bounded_error?(error_code) ->
395
        {:error, :invalid_rolling_result}
396
397
      status == "failed" and not bounded_recovery?(recovery) ->
398
        {:error, :invalid_rolling_result}
399
400
      true ->
401
        {:ok,
402
         %{
403
           error_code: error_code,
404
           expected_nodes: node_results |> Map.keys() |> Enum.sort(),
405
           image_digest: image_digest,
406
           node_results: node_results,
407
           previous_image_digest: previous_image_digest,
408
           previous_sha: previous_sha,
409
           recovery: recovery,
410
           status: status
411
         }}
412
    end
413
  end
414
415
  defp result_value(result, key), do: Map.get(result, key, Map.get(result, to_string(key)))
416
417
  defp valid_sha?(value) when is_binary(value), do: Regex.match?(~r/\A[0-9a-f]{40}\z/, value)
418
  defp valid_sha?(_value), do: false
419
420
  defp valid_image_digest?(value) when is_binary(value),
421
    do: Regex.match?(~r/\Asha256:[0-9a-f]{64}\z/, value)
422
423
  defp valid_image_digest?(_value), do: false
424
425
  defp valid_node_results?(results) when is_map(results) and map_size(results) in 1..100 do
426
    Enum.all?(results, fn {node, status} ->
427
      is_binary(node) and byte_size(node) in 1..255 and is_binary(status) and
428
        byte_size(status) in 1..255
429
    end)
430
  end
431
432
  defp valid_node_results?(_results), do: false
433
434
  defp bounded_error?(value) when is_binary(value),
435
    do: byte_size(value) in 1..128 and Regex.match?(~r/\A[a-z0-9_]+\z/, value)
436
437
  defp bounded_error?(_value), do: false
438
439
  defp bounded_recovery?(value) when is_binary(value), do: byte_size(value) in 1..255
440
  defp bounded_recovery?(_value), do: false
441
442
  defp manifest_digest(manifest) do
443
    manifest
444
    |> Jason.encode!()
445
    |> then(&:crypto.hash(:sha256, &1))
446
    |> Base.encode16(case: :lower)
447
  end
448
249 449
  defp bounded_details(details) do
250 450
    details
251 451
    |> Enum.map(fn {key, value} -> {to_string(key), bound_value(value)} end)
test/openagents/forge/target_lifecycle_test.exs modified +133 -1

@@ -1,6 +1,6 @@

1 1
defmodule OpenAgents.Forge.TargetLifecycleTest do
2 2
  use OpenAgents.DataCase, async: false
3
  alias OpenAgents.Forge.{DeployReceipt, Repos, Targets}
3
  alias OpenAgents.Forge.{BuildReceipt, DeployReceipt, Repos, Targets}
4 4
5 5
  setup do
6 6
    base = Path.join(System.tmp_dir!(), "forge-targets-#{System.unique_integer([:positive])}")

@@ -168,6 +168,104 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

168 168
    assert receipt.manifest_digest == manifest_digest
169 169
  end
170 170
171
  test "rolling replacement settlement makes the verified build the live baseline", %{sha: sha} do
172
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
173
    {:ok, _building} = Targets.advance(target.id, "building")
174
    {:ok, _built} = Targets.advance(target.id, "built")
175
    {:ok, _rolling} = Targets.advance(target.id, "needs_rolling_replace")
176
177
    artifact_digest = String.duplicate("a", 64)
178
    manifest = %{"classification" => "needs_rolling_replace", "source_sha" => sha}
179
180
    insert_build_receipt!(target, manifest, artifact_digest)
181
182
    assert {:ok, %{target: live, receipt: receipt}} =
183
             Targets.finish_rolling_replacement(target.id, rolling_result(sha, "live"))
184
185
    assert live.status == "live"
186
    assert live.details["image_digest"] == "sha256:" <> String.duplicate("c", 64)
187
    assert receipt.result == "live"
188
    assert receipt.artifact_digest == artifact_digest
189
    assert receipt.modules == ["Elixir.OpenAgents.BuildInfo"]
190
    assert receipt.expected_nodes == ["openagents@10.42.0.11", "openagents@10.42.0.12"]
191
192
    assert receipt.node_results == %{
193
             "openagents@10.42.0.11" => "ready",
194
             "openagents@10.42.0.12" => "ready"
195
           }
196
197
    assert Targets.live("demo").id == target.id
198
199
    assert {:error, {:invalid_transition, "live", "live"}} =
200
             Targets.finish_rolling_replacement(target.id, rolling_result(sha, "live"))
201
  end
202
203
  test "rolling replacement settlement refuses a superseded target", %{sha: sha} do
204
    {:ok, first} = Targets.promote("demo", sha, "operator:first")
205
    {:ok, _building} = Targets.advance(first.id, "building")
206
    {:ok, _built} = Targets.advance(first.id, "built")
207
    {:ok, _rolling} = Targets.advance(first.id, "needs_rolling_replace")
208
    insert_build_receipt!(first, %{"source_sha" => sha}, String.duplicate("a", 64))
209
210
    {:ok, second} = Targets.promote("demo", sha, "operator:second")
211
212
    assert {:error, :superseded_target} =
213
             Targets.finish_rolling_replacement(first.id, rolling_result(sha, "live"))
214
215
    assert Targets.current("demo").id == second.id
216
  end
217
218
  test "rolling replacement settlement records a verified rollback failure", %{sha: sha} do
219
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
220
    {:ok, _building} = Targets.advance(target.id, "building")
221
    {:ok, _built} = Targets.advance(target.id, "built")
222
    {:ok, _rolling} = Targets.advance(target.id, "needs_rolling_replace")
223
    insert_build_receipt!(target, %{"source_sha" => sha}, String.duplicate("a", 64))
224
225
    result =
226
      rolling_result(sha, "failed")
227
      |> Map.put(:node_results, %{
228
        "openagents@10.42.0.11" => "ready",
229
        "openagents@10.42.0.12" => "rejoin_check_failed"
230
      })
231
      |> Map.put(:error_code, "rejoin_check_failed")
232
      |> Map.put(:recovery, "last_known_good_restored")
233
234
    assert {:ok, %{target: failed, receipt: receipt}} =
235
             Targets.finish_rolling_replacement(target.id, result)
236
237
    assert failed.status == "failed"
238
    assert receipt.result == "failed"
239
    assert receipt.rollback_verified
240
    assert receipt.error_code == "rejoin_check_failed"
241
  end
242
243
  test "rolling replacement settlement requires a complete build receipt", %{sha: sha} do
244
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
245
    {:ok, _building} = Targets.advance(target.id, "building")
246
    {:ok, _built} = Targets.advance(target.id, "built")
247
    {:ok, _rolling} = Targets.advance(target.id, "needs_rolling_replace")
248
249
    assert {:error, :complete_build_receipt_not_found} =
250
             Targets.finish_rolling_replacement(target.id, rolling_result(sha, "live"))
251
  end
252
253
  test "rolling replacement settlement refuses a result for another SHA", %{sha: sha} do
254
    {:ok, target} = Targets.promote("demo", sha, "operator:test")
255
    {:ok, _building} = Targets.advance(target.id, "building")
256
    {:ok, _built} = Targets.advance(target.id, "built")
257
    {:ok, _rolling} = Targets.advance(target.id, "needs_rolling_replace")
258
    insert_build_receipt!(target, %{"source_sha" => sha}, String.duplicate("a", 64))
259
260
    assert {:error, :rolling_sha_mismatch} =
261
             Targets.finish_rolling_replacement(
262
               target.id,
263
               rolling_result(String.duplicate("e", 40), "live")
264
             )
265
266
    assert Repo.get!(OpenAgents.Forge.Target, target.id).status == "needs_rolling_replace"
267
  end
268
171 269
  test "PostgreSQL rejects deployment receipt mutation", %{sha: sha} do
172 270
    target =
173 271
      %OpenAgents.Forge.Target{}

@@ -200,4 +298,38 @@ defmodule OpenAgents.Forge.TargetLifecycleTest do

200 298
      )
201 299
    end
202 300
  end
301
302
  defp insert_build_receipt!(target, manifest, artifact_digest) do
303
    %BuildReceipt{}
304
    |> BuildReceipt.changeset(%{
305
      repo: target.repo,
306
      sha: target.sha,
307
      target_id: target.id,
308
      status: "complete",
309
      manifest: manifest,
310
      modules: ["Elixir.OpenAgents.BuildInfo"],
311
      artifact: "#{artifact_digest}.tar.gz",
312
      artifact_digest: artifact_digest,
313
      duration_ms: 1,
314
      completed_at: DateTime.utc_now()
315
    })
316
    |> Repo.insert!()
317
  end
318
319
  defp rolling_result(sha, status) do
320
    %{
321
      schema: "openagents.rolling-replacement.v1",
322
      sha: sha,
323
      previous_sha: String.duplicate("b", 40),
324
      image_digest: "sha256:" <> String.duplicate("c", 64),
325
      previous_image_digest: "sha256:" <> String.duplicate("d", 64),
326
      status: status,
327
      node_results: %{
328
        "openagents@10.42.0.11" => "ready",
329
        "openagents@10.42.0.12" => "ready"
330
      },
331
      error_code: nil,
332
      recovery: nil
333
    }
334
  end
203 335
end

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