Complete release deployment fallbacks

e0970e772fc8 · Christopher David · · parent 71a92784cfb2

Complete release deployment fallbacks

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

  • added .githooks/pre-push
  • modified INVARIANTS.md
  • modified README.md
  • modified docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md
  • added docs/operations/release-deployment-fallbacks.md
  • added lib/mix/tasks/openagents.relup.ex
  • modified lib/openagents/application.ex
  • modified lib/openagents/changelog/backfill.ex
  • modified lib/openagents/cluster.ex
  • added lib/openagents/forge/gate_receipt.ex
  • added lib/openagents/forge/relup_deployment.ex
  • added lib/openagents/forge/relup_node.ex
  • added lib/openagents/forge/rolling_provider.ex
  • added lib/openagents/forge/rolling_replacement.ex
  • added lib/openagents/release_assembler.ex
  • added lib/openagents/release_state.ex
  • added lib/openagents/release_state/state.ex
  • modified mix.exs
  • added ops/ci/contracts.sh
  • added ops/ci/gate.sh
  • added ops/deploy/build-image.sh
  • added ops/relup-proof/common.sh
  • modified ops/relup-proof/kill-during-install.sh
  • modified ops/relup-proof/run.sh
  • modified ops/relup-proof/version-chain.sh
  • modified rel/openagents.appup.exs
  • modified test/openagents/cluster/code_change_test.exs
  • added test/openagents/forge/gate_receipt_test.exs
  • added test/openagents/forge/relup_deployment_test.exs
  • added test/openagents/forge/relup_node_test.exs
  • added test/openagents/forge/rolling_replacement_test.exs
  • deleted test/openagents/test/relup_genserver_test.exs
  • added test/support/openagents/test/release_handler.ex
  • deleted test/support/openagents/test/relup_genserver.ex
  • added test/support/openagents/test/rolling_provider.ex
  • deleted test/support/upgradable_counter.ex

Diff

36 files changed, +2687 -170

.githooks/pre-push added +5

@@ -0,0 +1,5 @@

1
#!/bin/sh
2
set -eu
3
4
repo_root=$(git rev-parse --show-toplevel)
5
exec "$repo_root/ops/ci/gate.sh"
INVARIANTS.md modified +40 -14

@@ -1631,25 +1631,50 @@ production WebSocket read-back.

1631 1631
1632 1632
### RELEASE-004 — CI runs on owned infrastructure only, and gates every release
1633 1633
1634
Status: Proposed
1634
Status: Current
1635 1635
1636 1636
The target release gate permits no hosted CI: no GitHub Actions workflows, no
1637 1637
GitHub-hosted or third-party runners, and no repository automation, secrets, or
1638 1638
scheduling handed to external CI compute. All checks run on owned machines.
1639
The full matrix must bind unit, browser, distributed cluster, coverage,
1640
release, relup, version-chain, failure, and staging evidence to the exact
1641
candidate SHA, and every deploy command must refuse a stale or absent receipt.
1642
1643
This complete deploy refusal is proposed, not implemented. The repository
1644
currently has an exact-SHA baseline receipt, merged coverage, release smoke,
1645
and relup proof primitives, but Gate 12 must compose them into the final owned
1646
release gate and bind every deployment entry point to its receipt.
1647
1648
Current progress evidence: `ops/ci/baseline.sh`, `ops/ci/coverage.sh`,
1649
`ops/ci/release-smoke.sh`, `ops/relup-proof/run.sh`,
1650
`ops/relup-proof/version-chain.sh`, and
1639
The full local matrix binds unit, browser, distributed cluster, direct
1640
transaction, release, relup, version-chain, interrupted-install, rolling
1641
replacement, and repository-contract evidence to the exact candidate SHA.
1642
Relup and rolling coordinators refuse a stale or absent receipt before they
1643
change a node. `.githooks/pre-push` invokes the same gate. A bounded, logged
1644
emergency override exists only for operator-directed recovery.
1645
1646
Staging evidence remains a separate later gate. A local receipt does not claim
1647
that the candidate passed staging or authorize a production release.
1648
1649
Evidence: `ops/ci/gate.sh`, `.githooks/pre-push`,
1650
`OpenAgents.Forge.GateReceipt`, `OpenAgents.Forge.GateReceiptTest`,
1651
`ops/relup-proof/run.sh`, `ops/relup-proof/version-chain.sh`, and
1651 1652
`ops/relup-proof/kill-during-install.sh`.
1652 1653
1654
### RELEASE-005 — Every code change has a fail-closed deployment class
1655
1656
Status: Current
1657
1658
Direct BEAM candidates use an exact-fleet prepare, canary, apply, verify,
1659
commit, and rollback transaction. The supported `0.1.0` to `0.2.0` application
1660
transition uses a two-way relup, versioned process state, node-by-node health
1661
checks, and reverse installation. Every structural or unclassified candidate
1662
uses digest-addressed rolling replacement with readiness drain, remaining-
1663
capacity and quorum checks, exact rejoin verification, and last-known-good
1664
image recovery. A failed relup or replacement aborts before another node
1665
changes.
1666
1667
All deployment workers remain disabled until isolated staging proves their
1668
complete provider and topology. Current means that the local mechanism and its
1669
refusal and recovery paths exist; it does not authorize staging or production.
1670
1671
Evidence: `OpenAgents.Forge.Deployment`, `OpenAgents.Forge.RelupDeployment`,
1672
`OpenAgents.Forge.RelupNode`, `OpenAgents.ReleaseState`,
1673
`OpenAgents.Forge.RollingReplacement`,
1674
`test/openagents/forge/relup_deployment_test.exs`,
1675
`test/openagents/forge/rolling_replacement_test.exs`, and
1676
`docs/operations/release-deployment-fallbacks.md`.
1677
1653 1678
### STATUS-001 — The status page publishes one bounded, content-free projection
1654 1679
1655 1680
Status: Current

@@ -1811,6 +1836,7 @@ contract; the invariant prose above defines the assertion, not the filename.

1811 1836
| RELEASE-001 | `ops/ci/release-smoke.sh`, `test/openagents_web/controllers/health_controller_test.exs` |
1812 1837
| RELEASE-002 | `test/openagents/github_oauth/runtime_config_test.exs`, `ops/ci/reference-check.sh` |
1813 1838
| RELEASE-003 | `test/openagents_web/allowed_origins_test.exs`, `ops/ci/release-smoke.sh` |
1814
| RELEASE-004 | Proposed; current primitives are listed in its entry above. |
1839
| RELEASE-004 | `ops/ci/gate.sh`, `test/openagents/forge/gate_receipt_test.exs` |
1840
| RELEASE-005 | `test/openagents/forge/relup_deployment_test.exs`, `test/openagents/forge/rolling_replacement_test.exs` |
1815 1841
| STATUS-001 | `test/openagents/network_status_test.exs`, `test/openagents_web/live/network_status_live_test.exs` |
1816 1842
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs` |
README.md modified +11 -10

@@ -26,10 +26,12 @@ No part of this repository is approved for production deployment yet.

26 26
- Issues, comments, labels, milestones, projects, public status, changelog, and
27 27
  bounded source-browsing surfaces.
28 28
- Git HTTP, push receipts, promotion targets, build receipts, and local BEAM
29
  deployment primitives.
30
- An owned local test gate covering browser JavaScript, the Phoenix application,
31
  distributed cluster cases, merged coverage, and a disposable production
32
  release smoke test.
29
  deployment primitives, including transactional direct loading, two-way
30
  relups, and provider-neutral rolling replacement.
31
- An owned exact-SHA release gate covering browser JavaScript, the Phoenix
32
  application, distributed cluster cases, direct transactions, relup and
33
  interruption recovery, rolling replacement, repository contracts, and a
34
  disposable packaged-release smoke test.
33 35
34 36
"Implemented" means the code and local tests exist. It does not mean the
35 37
feature has passed staging, security review, failure injection, or a soak.

@@ -47,8 +49,6 @@ feature has passed staging, security review, failure injection, or a soak.

47 49
### Planned or blocked on hardening
48 50
49 51
- Repository-backed tenant isolation for every issue and project record.
50
- Complete route-authority, token-lifecycle, recovery, build-isolation, and
51
  transactional fleet-deployment gates.
52 52
- Separate web and distributed staging lanes, a full regression matrix,
53 53
  failure-injection drills, and a 48-hour soak.
54 54
- Any production rollout. Production remains explicitly out of scope until the

@@ -89,15 +89,16 @@ mix setup

89 89
mix phx.server
90 90
```
91 91
92
Before committing, run the repository-owned gate:
92
Before committing, run the fast repository checks:
93 93
94 94
```sh
95 95
mix precommit
96 96
```
97 97
98
Distributed, merged-coverage, relup, and release-smoke checks live under
99
`ops/` and are composed by the exact-SHA baseline gate while hardening is in
100
progress. This repository deliberately has no hosted CI configuration.
98
Before pushing a release candidate, provision a disposable database and run
99
`ops/ci/gate.sh` as documented in
100
[the release deployment fallback runbook](docs/operations/release-deployment-fallbacks.md).
101
This repository deliberately has no hosted CI configuration.
101 102
102 103
## Contributing and source control
103 104
docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md modified +39

@@ -1096,6 +1096,45 @@ should run:

1096 1096
**Exit criteria:** Every supported change has a safe deployment class, a tested
1097 1097
rollback or recovery path, and an exact-SHA gate receipt.
1098 1098
1099
### Gate 11 implementation status
1100
1101
Implemented locally on 2026-08-20:
1102
1103
- Replaced the relup placeholders with explicit `0.1.0` and `0.2.0` release
1104
  builds, a generated forward and reverse `relup`, and a packaged candidate
1105
  that carries the generated release instructions.
1106
- Added `OpenAgents.ReleaseState` and its versioned state struct. Focused and
1107
  packaged-node proofs preserve the process PID and observations through
1108
  upgrade, downgrade, and re-upgrade.
1109
- Added a deterministic install barrier and a live recovery proof that kills
1110
  the emulator after the point of no return, verifies that the prior permanent
1111
  release boots, re-stages the consumed immutable tar, and completes the retry.
1112
- Added `OpenAgents.Forge.RelupNode` and
1113
  `OpenAgents.Forge.RelupDeployment` for digest verification, immutable cache
1114
  retention, stage restoration, preflight, one-node-at-a-time installation,
1115
  health and state checks, permanence, reverse relup, and fleet-wide reversal
1116
  of already-upgraded nodes after a later failure.
1117
- Added `OpenAgents.Forge.RollingReplacement` and the staging-provider
1118
  behavior. The coordinator removes one node from readiness, waits for drain,
1119
  checks remaining capacity and quorum, replaces by image digest, and requires
1120
  membership, boot, database, SHA, digest, and readiness convergence before it
1121
  continues. Unsafe pre-replacement capacity restores readiness; failed rejoin
1122
  restores the last-known-good image and stops the rollout.
1123
- Added an exact-SHA receipt verifier, the owned `ops/ci/gate.sh`, a pre-push
1124
  hook, and a receipt-gated immutable local image builder. The release gate
1125
  composes warning-free compilation, precommit, distributed, browser, direct,
1126
  relup, interruption, rolling, contract, and packaged startup checks.
1127
- Documented operation and additive migration constraints in the
1128
  [release deployment fallback runbook](operations/release-deployment-fallbacks.md).
1129
- Retained the direct lane's existing end-to-end push, build, canary, fleet,
1130
  and live proof with the exact `OpenAgents.BuildInfo` allowlist entry. The
1131
  broader allowlist remains unchanged and disabled outside tests.
1132
1133
The implementation is ready for an exact-commit local gate. Keep every deploy
1134
lane disabled until Gate 12 provides an isolated distributed staging fleet and
1135
an infrastructure-specific rolling provider. No staging or production
1136
environment changed during this gate.
1137
1099 1138
## Gate 12: Build an isolated staging environment
1100 1139
1101 1140
The existing Cloud Run staging service can validate the web application, OAuth,
docs/operations/release-deployment-fallbacks.md added +185

@@ -0,0 +1,185 @@

1
# Release deployment fallbacks
2
3
Date: 2026-08-20
4
5
Status: Implemented and locally proven; keep both lanes disabled until the Gate 12 isolated staging fleet exists
6
7
## Purpose
8
9
Use this runbook when a candidate cannot use the direct BEAM transaction. The
10
classifier must choose one strategy for the complete candidate:
11
12
- Use a relup only for the supported `0.1.0` to `0.2.0` application transition
13
  and its tested reverse transition.
14
- Use rolling replacement for ERTS, OTP, dependency, native-code, asset,
15
  configuration, migration, module-deletion, or otherwise unclassified
16
  changes.
17
18
Neither lane is production-approved. Gate 12 must connect the provider-neutral
19
coordinators to an isolated three-node staging fleet and retain staging-only
20
receipts before anyone enables a deployment worker.
21
22
## Local release gate
23
24
Install the repository hook once on each owned development or release machine:
25
26
```sh
27
git config core.hooksPath .githooks
28
```
29
30
Provision a disposable PostgreSQL database with pgvector already installed,
31
then run the complete exact-SHA gate:
32
33
```sh
34
OPENAGENTS_RELEASE_SMOKE_DISPOSABLE=1 \
35
OPENAGENTS_RELEASE_SMOKE_DATABASE_URL='ecto://USER:PASSWORD@HOST/DATABASE' \
36
ops/ci/gate.sh
37
```
38
39
The gate requires a clean worktree. It runs warning-free compilation,
40
`mix precommit`, distributed tests, browser tests, the direct transaction,
41
forward and reverse relup proofs, interrupted-install recovery, rolling
42
replacement tests, repository contracts, and packaged release startup. It
43
writes a content-free receipt to
44
`.git/openagents/release-gate-receipts/<full-sha>.json` only after every stage
45
passes without an automatic retry.
46
47
Both deployment coordinators call `OpenAgents.Forge.GateReceipt.verify/2`
48
before changing a node. A new commit has a new SHA and therefore invalidates
49
the prior receipt. The emergency override is an explicit function option with
50
a bounded reason; it emits a warning and exists only for operator-directed
51
recovery. Do not use it for an ordinary release.
52
53
## Relup lane
54
55
### Supported transition
56
57
The repository owns one explicit two-way transition:
58
59
| Direction | Application version | State schema |
60
| --- | --- | --- |
61
| Upgrade | `0.1.0` to `0.2.0` | 1 to 2 |
62
| Downgrade | `0.2.0` to `0.1.0` | 2 to 1 |
63
64
`OpenAgents.ReleaseState` is a supervised, long-lived process with a versioned
65
`OpenAgents.ReleaseState.State` struct. `code_change/3` preserves the PID and
66
bounded observations in both directions. `rel/openagents.appup.exs` names the
67
advanced update explicitly. Do not add a version transition until its forward,
68
reverse, and re-upgrade state paths have focused tests and a real packaged-node
69
proof.
70
71
Build the release pair and relup:
72
73
```sh
74
ops/relup-proof/run.sh
75
```
76
77
The script builds explicit release versions, generates both directions with
78
`mix openagents.relup`, embeds the generated `relup` in the candidate tar, and
79
caches checksummed proof artifacts under `.git/openagents/relup-proof/<sha>/`.
80
It never deletes a shared release directory.
81
82
Run the live state and interruption drills against a disposable database:
83
84
```sh
85
OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 \
86
OPENAGENTS_RELUP_PROOF_DATABASE_URL='ecto://USER:PASSWORD@HOST/DATABASE' \
87
ops/relup-proof/version-chain.sh
88
89
OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 \
90
OPENAGENTS_RELUP_PROOF_DATABASE_URL='ecto://USER:PASSWORD@HOST/DATABASE' \
91
ops/relup-proof/kill-during-install.sh
92
```
93
94
### Fleet sequence
95
96
`OpenAgents.Forge.RelupDeployment` performs this sequence:
97
98
1. Verify the complete local gate receipt for the candidate SHA.
99
2. Snapshot the exact sorted member set and configured fleet size.
100
3. Stage one node's release tar in a digest-addressed cache.
101
4. Verify the cached and consumable tar digests.
102
5. Restore the consumable tar from cache, then unpack it.
103
6. Generate version-specific runtime configuration and run
104
   `release_handler.check_install_release/1`.
105
7. Install the candidate without changing the permanent release.
106
8. Verify current release status, application readiness, and the expected
107
   migrated state schema.
108
9. Make the release permanent, verify permanence, and recheck exact fleet
109
   membership.
110
10. Start the next node only after the previous node passes every check.
111
112
If post-install health or permanence verification fails, the coordinator
113
installs the reverse relup, verifies the prior release and state schema, restores
114
the prior permanent release, and aborts before touching another node. If the
115
reverse path fails, keep the node out of readiness and move to operator-directed
116
rolling recovery.
117
118
`OpenAgents.Forge.RelupNode` retains the immutable tar in
119
`releases/.openagents-relup-cache/<sha256>.tar.gz`. It copies those exact bytes
120
back to the filename consumed by `unpack_release/1` before every attempt. This
121
restaging step is mandatory after an interrupted install.
122
123
## Rolling replacement lane
124
125
Build a local immutable image only after the exact release gate passes:
126
127
```sh
128
ops/deploy/build-image.sh openagents:<full-sha>
129
```
130
131
The script records the content-addressed `sha256:` image ID under
132
`.git/openagents/images/<full-sha>.json`. A mutable tag is a convenience label,
133
not deployment identity. Pass only the digest to the replacement provider.
134
135
`OpenAgents.Forge.RollingReplacement` owns the provider-neutral rollout. Gate
136
12 must implement `OpenAgents.Forge.RollingProvider` for the isolated staging
137
infrastructure. Keep machine inventory, credentials, addresses, and provider
138
resource names outside the repository.
139
140
For each node, the coordinator performs this sequence:
141
142
1. Verify the exact-SHA release receipt and exact initial member set.
143
2. Remove the node from external readiness.
144
3. Drain until no local work singleton remains.
145
4. Verify that the remaining nodes meet the configured ready-capacity floor
146
   and retain quorum.
147
5. Replace the node with the target image digest.
148
6. Wait for exact BEAM membership, readiness, boot convergence, database
149
   access, source SHA, and image digest.
150
7. Recheck exact fleet membership before selecting another node.
151
152
If a node does not rejoin, the coordinator asks the provider to restore the
153
last-known-good SHA and digest, waits for that node's full health, records the
154
recovery result, and aborts. It never replaces a second node while the first is
155
missing or unhealthy.
156
157
## Database compatibility
158
159
Keep schema changes additive while old and new releases overlap. During a
160
relup or rolling rollout, both versions must be able to read and write the same
161
schema. Do not combine a reversible application transition with a destructive
162
column removal, type contraction, trigger removal, or irreversible data
163
rewrite. Ship contract migrations only in a later release after rollback to
164
the old application version is no longer supported and staging has proven the
165
new rollback boundary.
166
167
## Staging admission
168
169
Do not run either fleet lane against the current web-only staging service. Gate
170
12 must first provide stable node identities, private distribution, a separate
171
staging database instance, durable release artifacts, node-local caches,
172
readiness removal, drain support, and a provider implementation. Then retain
173
evidence for:
174
175
- one complete allowlisted push-to-build-to-canary-to-fleet-to-live direct
176
  transaction;
177
- one relup upgrade, downgrade, re-upgrade, health-triggered reverse, and
178
  interrupted-install retry;
179
- one no-op digest-addressed rolling rollout;
180
- one failed rejoin that restores the prior digest and stops before the next
181
  node;
182
- additive migration compatibility while both application versions run.
183
184
Production remains out of scope until the later staging, rollback, and soak
185
gates pass and receive separate approval.
lib/mix/tasks/openagents.relup.ex added +81

@@ -0,0 +1,81 @@

1
defmodule Mix.Tasks.Openagents.Relup do
2
  @moduledoc """
3
  Generates a two-way `relup` in an explicit output directory.
4
5
      mix openagents.relup --target /path/to/0.2.0/openagents \
6
        --from /path/to/0.1.0/openagents --outdir /tmp/proof
7
  """
8
9
  use Mix.Task
10
11
  @shortdoc "Generates an explicit two-way OpenAgents relup"
12
  @switches [target: :string, from: :string, outdir: :string]
13
14
  @impl true
15
  def run(arguments) do
16
    {options, remaining, invalid} = OptionParser.parse(arguments, strict: @switches)
17
18
    if remaining != [] or invalid != [] do
19
      Mix.raise("openagents.relup received unsupported arguments")
20
    end
21
22
    target = required_path!(options, :target)
23
    from = required_path!(options, :from)
24
    outdir = required_directory!(options, :outdir)
25
26
    paths =
27
      [target, from]
28
      |> Enum.flat_map(fn release ->
29
        release
30
        |> Path.join("../../../lib/*/ebin")
31
        |> Path.expand()
32
        |> Path.wildcard()
33
      end)
34
      |> Enum.uniq()
35
      |> Enum.map(&to_charlist/1)
36
37
    case :systools.make_relup(
38
           to_charlist(target),
39
           [to_charlist(from)],
40
           [to_charlist(from)],
41
           path: paths,
42
           outdir: to_charlist(outdir),
43
           silent: true,
44
           warnings_as_errors: true
45
         ) do
46
      {:ok, _relup, _module, []} ->
47
        Mix.shell().info("Generated #{Path.join(outdir, "relup")}")
48
49
      {:ok, _relup, module, warnings} ->
50
        Mix.raise(
51
          "relup generation produced warnings: #{format(module, :format_warning, warnings)}"
52
        )
53
54
      {:error, module, reason} ->
55
        Mix.raise("relup generation failed: #{format(module, :format_error, reason)}")
56
57
      other ->
58
        Mix.raise("relup generation returned #{inspect(other)}")
59
    end
60
  end
61
62
  defp required_path!(options, key) do
63
    path = options |> Keyword.fetch!(key) |> Path.expand()
64
65
    if File.regular?(path <> ".rel"),
66
      do: path,
67
      else: Mix.raise("--#{key} must name a release resource without the .rel suffix")
68
  end
69
70
  defp required_directory!(options, key) do
71
    path = options |> Keyword.fetch!(key) |> Path.expand()
72
    File.mkdir_p!(path)
73
    path
74
  end
75
76
  defp format(module, function, value) do
77
    module
78
    |> apply(function, [value])
79
    |> IO.iodata_to_binary()
80
  end
81
end
lib/openagents/application.ex modified +1

@@ -37,6 +37,7 @@ defmodule OpenAgents.Application do

37 37
    children = [
38 38
      OpenAgentsWeb.Telemetry,
39 39
      OpenAgents.Repo,
40
      OpenAgents.ReleaseState,
40 41
      # Deployment identity and boot convergence must settle before cluster
41 42
      # discovery or the endpoint can make this node externally reachable.
42 43
      OpenAgents.Forge.DeploymentNode,
lib/openagents/changelog/backfill.ex modified +7 -7

@@ -100,7 +100,7 @@ defmodule OpenAgents.Changelog.Backfill do

100 100
        ~U[2026-08-19 05:38:00Z],
101 101
        "d82d174",
102 102
        "feature",
103
        "openagents.com/status became the public network page: fleet, quorum, and the live rollout pipeline with push→live timing."
103
        "The status surface gained bounded fleet, quorum, rollout, and push-to-live projections for staging validation."
104 104
      ),
105 105
      e(
106 106
        ~U[2026-08-19 05:13:00Z],

@@ -112,25 +112,25 @@ defmodule OpenAgents.Changelog.Backfill do

112 112
        ~U[2026-08-19 04:01:00Z],
113 113
        "70c34ba",
114 114
        "forge",
115
        "The hot-load deploy lane shipped: push → promote → incremental build → canary → fleet-wide live, receipted; first loop 13.2 s push→live."
115
        "The local hot-load proof covers push → promote → incremental build → canary → fleet commit with immutable receipts."
116 116
      ),
117 117
      e(
118 118
        ~U[2026-08-19 03:44:00Z],
119 119
        "677c9ba",
120 120
        "forge",
121
        "Sarah serves her own git: smart-HTTP forge at openagents.com/git, pushes acked only after WAL persist to object storage."
121
        "The forge implements smart HTTP and acknowledges a push only after durable WAL persistence."
122 122
      ),
123 123
      e(
124 124
        ~U[2026-08-19 03:41:00Z],
125 125
        "eb93e53",
126 126
        "infra",
127
        "Releases are gated by owned chaos drills — node kill, Raft partition, SIGKILL-mid-upgrade — on our own machines, no hosted CI."
127
        "The owned local gate includes node, Raft, and interrupted-upgrade drills without hosted CI."
128 128
      ),
129 129
      e(
130 130
        ~U[2026-08-19 02:47:00Z],
131 131
        "c0a96f1",
132 132
        "infra",
133
        "Live in-place hot code upgrade (relup) proven on the fleet: 0.1.0 → 0.2.0 with state migrated and no restart."
133
        "The relup foundation can generate a 0.1.0 to 0.2.0 state migration for later isolated-fleet proof."
134 134
      ),
135 135
136 136
      # ── 2026-08-18 — the Immortal Sarah build day

@@ -138,7 +138,7 @@ defmodule OpenAgents.Changelog.Backfill do

138 138
        ~U[2026-08-19 00:43:00Z],
139 139
        "2071eaf",
140 140
        "infra",
141
        "Zero-downtime rolling node replacement: drain, replace, rejoin — the cluster never loses quorum during a deploy."
141
        "The rolling-replacement contract defines drain, replace, rejoin, and quorum checks for later isolated-fleet proof."
142 142
      ),
143 143
      e(
144 144
        ~U[2026-08-18 23:21:00Z],

@@ -150,7 +150,7 @@ defmodule OpenAgents.Changelog.Backfill do

150 150
        ~U[2026-08-18 21:12:00Z],
151 151
        "f6afd61",
152 152
        "infra",
153
        "Sarah became a 3-node clustered BEAM fleet on GCE — the single-instance Cloud Run era ended."
153
        "A local three-node BEAM proof established membership, quorum, and state-handoff contracts for isolated staging."
154 154
      ),
155 155
      e(
156 156
        ~U[2026-08-18 23:13:00Z],
lib/openagents/cluster.ex modified +1 -1

@@ -13,7 +13,7 @@ defmodule OpenAgents.Cluster do

13 13
  @erpc_timeout_ms 5_000
14 14
15 15
  @doc "The currently expected relup marker for live nodes."
16
  def relup_marker, do: "v2-relup-live"
16
  def relup_marker, do: "v2-relup-capable"
17 17
18 18
  @doc "All cluster members, including this node."
19 19
  @spec members() :: [node()]
lib/openagents/forge/gate_receipt.ex added +99

@@ -0,0 +1,99 @@

1
defmodule OpenAgents.Forge.GateReceipt do
2
  @moduledoc """
3
  Verifies the content-free, exact-SHA receipt required by release deployments.
4
5
  Receipts live under `.git`, not in the source tree. Changing the checked-out
6
  commit therefore invalidates the receipt without creating a commit that can
7
  attest to itself.
8
  """
9
10
  require Logger
11
12
  @schema "openagents.release-gate.v1"
13
  @sha_pattern ~r/\A[0-9a-f]{40}\z/
14
  @maximum_bytes 65_536
15
  @required_stages ~w(
16
    compile
17
    precommit
18
    cluster
19
    javascript
20
    direct_transaction
21
    relup
22
    version_chain
23
    interrupted_install
24
    rolling_replacement
25
    contracts
26
    release_smoke
27
  )
28
29
  @doc "Verify the local release-gate receipt for `sha`."
30
  def verify(sha, opts \\ [])
31
32
  def verify(sha, opts) when is_binary(sha) do
33
    if Regex.match?(@sha_pattern, sha) do
34
      case Keyword.get(opts, :emergency_override) do
35
        reason when is_binary(reason) and byte_size(reason) in 8..512 ->
36
          Logger.warning("release_gate_emergency_override sha=#{sha} reason=#{reason}")
37
          {:ok, %{schema: "openagents.release-gate.emergency.v1", git_sha: sha}}
38
39
        _no_override ->
40
          verify_receipt(sha, opts)
41
      end
42
    else
43
      {:error, :invalid_git_sha}
44
    end
45
  end
46
47
  def verify(_sha, _opts), do: {:error, :invalid_git_sha}
48
49
  @doc "Return the default receipt path for an exact Git SHA."
50
  def path(sha, opts \\ []) do
51
    root = Keyword.get_lazy(opts, :repo_root, &repo_root!/0)
52
    Path.join([root, ".git", "openagents", "release-gate-receipts", "#{sha}.json"])
53
  end
54
55
  defp verify_receipt(sha, opts) do
56
    with true <- Regex.match?(@sha_pattern, sha) or {:error, :invalid_git_sha},
57
         {:ok, stat} <- File.stat(path(sha, opts)),
58
         true <-
59
           (stat.type == :regular and stat.size <= @maximum_bytes) or
60
             {:error, :invalid_receipt_file},
61
         {:ok, bytes} <- File.read(path(sha, opts)),
62
         {:ok, receipt} <- Jason.decode(bytes),
63
         :ok <- validate(receipt, sha) do
64
      {:ok, receipt}
65
    else
66
      {:error, :enoent} -> {:error, :missing_gate_receipt}
67
      {:error, %Jason.DecodeError{}} -> {:error, :invalid_gate_receipt_json}
68
      {:error, reason} -> {:error, reason}
69
      false -> {:error, :invalid_gate_receipt}
70
    end
71
  end
72
73
  defp validate(receipt, sha) when is_map(receipt) do
74
    stages = Map.get(receipt, "stages", %{})
75
76
    cond do
77
      receipt["schema"] != @schema -> {:error, :wrong_gate_receipt_schema}
78
      receipt["git_sha"] != sha -> {:error, :stale_gate_receipt}
79
      receipt["status"] != "passed" -> {:error, :gate_not_passed}
80
      not complete_stages?(stages) -> {:error, :incomplete_gate_receipt}
81
      true -> :ok
82
    end
83
  end
84
85
  defp validate(_receipt, _sha), do: {:error, :invalid_gate_receipt}
86
87
  defp complete_stages?(stages) when is_map(stages) do
88
    Enum.all?(@required_stages, fn stage -> get_in(stages, [stage, "status"]) == "passed" end)
89
  end
90
91
  defp complete_stages?(_stages), do: false
92
93
  defp repo_root! do
94
    case System.cmd("git", ["rev-parse", "--show-toplevel"], stderr_to_stdout: true) do
95
      {root, 0} -> String.trim(root)
96
      {_output, _status} -> raise "release gate verification requires a Git worktree"
97
    end
98
  end
99
end
lib/openagents/forge/relup_deployment.ex added +228

@@ -0,0 +1,228 @@

1
defmodule OpenAgents.Forge.RelupDeployment do
2
  @moduledoc """
3
  Upgrades an exact fleet one node at a time through a two-way OTP relup.
4
5
  The coordinator rechecks membership between nodes, makes a candidate
6
  permanent only after health and state verification, and installs the reverse
7
  relup when post-install health fails.
8
  """
9
10
  alias OpenAgents.Forge.GateReceipt
11
  alias OpenAgents.Forge.RelupNode
12
13
  @sha_pattern ~r/\A[0-9a-f]{40}\z/
14
  @digest_pattern ~r/\A[0-9a-f]{64}\z/
15
  @default_timeout_ms 120_000
16
17
  @doc "Deploy one two-way relup across the exact expected fleet."
18
  def run(request, opts \\ []) do
19
    with :ok <- validate_request(request),
20
         {:ok, _receipt} <- gate_verify(request.sha, opts),
21
         {:ok, nodes} <- snapshot_members(request, opts) do
22
      deploy_nodes(nodes, request, opts, %{}, [])
23
    end
24
  end
25
26
  defp deploy_nodes([], request, _opts, results, _completed) do
27
    {:ok, public_result(request, "live", results, nil)}
28
  end
29
30
  defp deploy_nodes([node | remaining], request, opts, results, completed) do
31
    with :ok <- stable_membership(request, opts) do
32
      case deploy_node(node, request, opts) do
33
        {:ok, result} ->
34
          case stable_membership(request, opts) do
35
            :ok ->
36
              deploy_nodes(
37
                remaining,
38
                request,
39
                opts,
40
                Map.put(results, to_string(node), result),
41
                [node | completed]
42
              )
43
44
            {:error, reason} ->
45
              fail_deployment(node, reason, [node | completed], request, opts, results)
46
          end
47
48
        {:error, reason} ->
49
          fail_deployment(node, reason, completed, request, opts, results)
50
      end
51
    else
52
      {:error, reason} ->
53
        fail_deployment(node, reason, completed, request, opts, results)
54
    end
55
  end
56
57
  defp fail_deployment(node, reason, rollback_nodes, request, opts, results) do
58
    {rollback_results, rollback_status} = rollback_completed(rollback_nodes, request, opts)
59
60
    failure =
61
      results
62
      |> Map.merge(rollback_results)
63
      |> Map.put(to_string(node), safe_code(reason))
64
65
    error_code =
66
      if rollback_status == :ok,
67
        do: safe_code(reason),
68
        else: "fleet_reverse_failed:" <> safe_code(reason)
69
70
    {:error, public_result(request, "failed", failure, error_code)}
71
  end
72
73
  defp rollback_completed(nodes, request, opts) do
74
    Enum.reduce(nodes, {%{}, :ok}, fn node, {results, status} ->
75
      case call(node, :reverse, [request], opts) do
76
        {:ok, _result} ->
77
          {Map.put(results, to_string(node), "reversed"), status}
78
79
        {:error, reason} ->
80
          {Map.put(results, to_string(node), "reverse_failed:" <> safe_code(reason)), :error}
81
      end
82
    end)
83
  end
84
85
  defp deploy_node(node, request, opts) do
86
    preinstall_steps = [:stage, :verify_stage, :unpack, :check_install]
87
88
    with :ok <- run_steps(node, preinstall_steps, request, opts),
89
         {:ok, _result} <- call(node, :install, [request], opts) do
90
      finish_installed_node(node, request, opts)
91
    end
92
  end
93
94
  defp finish_installed_node(node, request, opts) do
95
    with {:ok, _result} <- call(node, :verify, [request, :current], opts),
96
         {:ok, _result} <- call(node, :make_permanent, [request], opts),
97
         {:ok, _result} <- call(node, :verify, [request, :permanent], opts) do
98
      {:ok, "permanent"}
99
    else
100
      {:error, reason} -> reverse_after_failure(node, request, reason, opts)
101
    end
102
  end
103
104
  defp reverse_after_failure(node, request, reason, opts) do
105
    case call(node, :reverse, [request], opts) do
106
      {:ok, _result} ->
107
        {:error, {:node_reversed, safe_code(reason)}}
108
109
      {:error, reverse_reason} ->
110
        {:error, {:reverse_failed, safe_code(reason), safe_code(reverse_reason)}}
111
    end
112
  end
113
114
  defp run_steps(node, steps, request, opts) do
115
    Enum.reduce_while(steps, :ok, fn step, :ok ->
116
      case call(node, step, [request], opts) do
117
        {:ok, _result} -> {:cont, :ok}
118
        {:error, reason} -> {:halt, {:error, {step, reason}}}
119
      end
120
    end)
121
  end
122
123
  defp snapshot_members(request, opts) do
124
    current = members(opts)
125
126
    cond do
127
      current != request.expected_nodes -> {:error, :fleet_membership_mismatch}
128
      length(current) != request.expected_fleet_size -> {:error, :fleet_size_mismatch}
129
      true -> {:ok, current}
130
    end
131
  end
132
133
  defp stable_membership(request, opts) do
134
    if members(opts) == request.expected_nodes, do: :ok, else: {:error, :membership_changed}
135
  end
136
137
  defp members(opts) do
138
    Keyword.get(opts, :members, &OpenAgents.Cluster.members/0).()
139
    |> Enum.sort()
140
  end
141
142
  defp call(node, function, arguments, opts) do
143
    rpc = Keyword.get(opts, :rpc, &default_rpc/5)
144
145
    case rpc.(node, RelupNode, function, arguments, timeout(opts)) do
146
      {:ok, _result} = success -> success
147
      {:error, _reason} = error -> error
148
      other -> {:error, {:unexpected_rpc_result, safe_code(other)}}
149
    end
150
  rescue
151
    error -> {:error, {:rpc_exception, safe_code(error)}}
152
  catch
153
    kind, reason -> {:error, {:rpc_exit, safe_code({kind, reason})}}
154
  end
155
156
  defp default_rpc(node, module, function, arguments, timeout) do
157
    if node == Node.self() do
158
      apply(module, function, arguments)
159
    else
160
      :erpc.call(node, module, function, arguments, timeout)
161
    end
162
  end
163
164
  defp gate_verify(sha, opts) do
165
    case Keyword.fetch(opts, :gate_verifier) do
166
      {:ok, verifier} -> verifier.(sha)
167
      :error -> GateReceipt.verify(sha, Keyword.get(opts, :gate_receipt_options, []))
168
    end
169
  end
170
171
  defp validate_request(request) when is_map(request) do
172
    cond do
173
      not Regex.match?(@sha_pattern, Map.get(request, :sha, "")) ->
174
        {:error, :invalid_git_sha}
175
176
      not Regex.match?(@digest_pattern, Map.get(request, :artifact_digest, "")) ->
177
        {:error, :invalid_artifact_digest}
178
179
      not is_binary(Map.get(request, :artifact_bytes)) ->
180
        {:error, :invalid_artifact}
181
182
      Map.get(request, :release_name) != "openagents" ->
183
        {:error, :invalid_release_name}
184
185
      Map.get(request, :from_version) != "0.1.0" ->
186
        {:error, :unsupported_from_version}
187
188
      Map.get(request, :to_version) != "0.2.0" ->
189
        {:error, :unsupported_to_version}
190
191
      Map.get(request, :from_state_version) != 1 ->
192
        {:error, :unsupported_from_state_version}
193
194
      Map.get(request, :to_state_version) != 2 ->
195
        {:error, :unsupported_to_state_version}
196
197
      not is_list(Map.get(request, :expected_nodes)) ->
198
        {:error, :invalid_expected_nodes}
199
200
      Map.get(request, :expected_nodes) != Enum.sort(Map.get(request, :expected_nodes)) ->
201
        {:error, :unsorted_expected_nodes}
202
203
      Map.get(request, :expected_fleet_size) != length(Map.get(request, :expected_nodes)) ->
204
        {:error, :invalid_expected_fleet_size}
205
206
      true ->
207
        :ok
208
    end
209
  end
210
211
  defp validate_request(_request), do: {:error, :invalid_request}
212
213
  defp public_result(request, status, node_results, error_code) do
214
    %{
215
      schema: "openagents.relup-deployment.v1",
216
      sha: request.sha,
217
      artifact_digest: request.artifact_digest,
218
      from_version: request.from_version,
219
      to_version: request.to_version,
220
      status: status,
221
      node_results: node_results,
222
      error_code: error_code
223
    }
224
  end
225
226
  defp timeout(opts), do: Keyword.get(opts, :timeout_ms, @default_timeout_ms)
227
  defp safe_code(reason), do: OpenAgents.OperationalLog.code(reason)
228
end
lib/openagents/forge/relup_node.ex added +307

@@ -0,0 +1,307 @@

1
defmodule OpenAgents.Forge.RelupNode do
2
  @moduledoc """
3
  Performs one node's verified OTP release-handler transaction.
4
5
  The immutable artifact remains in a digest-addressed cache. Before every
6
  unpack attempt, this module restores the release tar to the path consumed by
7
  `release_handler`, so an interrupted install can retry from the same bytes.
8
  """
9
10
  alias OpenAgents.ReleaseState
11
12
  @digest_pattern ~r/\A[0-9a-f]{64}\z/
13
  @version_pattern ~r/\A[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?\z/
14
  @maximum_artifact_bytes 536_870_912
15
16
  @doc "Cache and stage an immutable release artifact."
17
  def stage(request, opts \\ []) do
18
    with :ok <- validate_request(request),
19
         true <-
20
           byte_size(request.artifact_bytes) <= @maximum_artifact_bytes or
21
             {:error, :artifact_too_large},
22
         true <-
23
           digest(request.artifact_bytes) == request.artifact_digest or
24
             {:error, :artifact_digest_mismatch},
25
         :ok <- ensure_release_directories(opts),
26
         :ok <- persist_cache(request, opts),
27
         :ok <- restage(request, opts) do
28
      {:ok, %{"phase" => "staged", "artifact_digest" => request.artifact_digest}}
29
    end
30
  end
31
32
  @doc "Verify that both cached and staged artifacts match the request."
33
  def verify_stage(request, opts \\ []) do
34
    with :ok <- verify_file(cache_path(request, opts), request.artifact_digest),
35
         :ok <- verify_file(stage_path(request, opts), request.artifact_digest) do
36
      {:ok, %{"phase" => "stage_verified"}}
37
    end
38
  end
39
40
  @doc "Restore the consumable tar and unpack it when needed."
41
  def unpack(request, opts \\ []) do
42
    with :ok <- restage(request, opts) do
43
      if release_known?(request.to_version, opts) do
44
        {:ok, %{"phase" => "unpacked", "restaged" => true}}
45
      else
46
        expected_version = to_charlist(request.to_version)
47
48
        case handler_call(opts, :unpack_release, [to_charlist(release_basename(request))]) do
49
          {:ok, ^expected_version} ->
50
            {:ok, %{"phase" => "unpacked", "restaged" => true}}
51
52
          {:error, reason} ->
53
            {:error, {:unpack_failed, safe_code(reason)}}
54
55
          _other ->
56
            {:error, :unexpected_unpack_result}
57
        end
58
      end
59
    end
60
  end
61
62
  @doc "Generate runtime configuration and preflight the relup."
63
  def check_install(request, opts \\ []) do
64
    with :ok <- generate_config(request.to_version, opts) do
65
      expected_from = to_charlist(request.from_version)
66
67
      case handler_call(opts, :check_install_release, [to_charlist(request.to_version)]) do
68
        {:ok, ^expected_from, _description} ->
69
          {:ok, %{"phase" => "checked"}}
70
71
        {:error, reason} ->
72
          {:error, {:check_failed, safe_code(reason)}}
73
74
        _other ->
75
          {:error, :unexpected_check_result}
76
      end
77
    end
78
  end
79
80
  @doc "Install the unpacked candidate without making it permanent."
81
  def install(request, opts \\ []) do
82
    expected_from = to_charlist(request.from_version)
83
84
    case handler_call(opts, :install_release, [to_charlist(request.to_version)]) do
85
      {:ok, ^expected_from, _description} ->
86
        {:ok, %{"phase" => "installed"}}
87
88
      {:continue_after_restart, ^expected_from, _description} ->
89
        {:ok, %{"phase" => "restart_required"}}
90
91
      {:error, reason} ->
92
        {:error, {:install_failed, safe_code(reason)}}
93
94
      _other ->
95
        {:error, :unexpected_install_result}
96
    end
97
  end
98
99
  @doc "Verify the running release, application health, and migrated state."
100
  def verify(request, permanence \\ :current, opts \\ []) do
101
    expected_status = if permanence == :permanent, do: :permanent, else: :current
102
103
    with true <-
104
           release_status(request.to_version, opts) == expected_status or
105
             {:error, :release_status_mismatch},
106
         %{"ready" => true} <- health_report(opts),
107
         %{schema_version: schema} <- state_snapshot(opts),
108
         true <- schema == request.to_state_version or {:error, :state_version_mismatch} do
109
      {:ok, %{"phase" => "verified", "permanence" => to_string(permanence)}}
110
    else
111
      {:error, reason} -> {:error, reason}
112
      _unhealthy -> {:error, :health_check_failed}
113
    end
114
  end
115
116
  @doc "Make the candidate release permanent."
117
  def make_permanent(request, opts \\ []) do
118
    case handler_call(opts, :make_permanent, [to_charlist(request.to_version)]) do
119
      :ok -> {:ok, %{"phase" => "permanent"}}
120
      {:error, reason} -> {:error, {:make_permanent_failed, safe_code(reason)}}
121
      _other -> {:error, :unexpected_make_permanent_result}
122
    end
123
  end
124
125
  @doc "Install and verify the reverse relup, then restore permanence."
126
  def reverse(request, opts \\ []) do
127
    with {:ok, _result} <- reverse_install(request, opts),
128
         :ok <- verify_reverse_health(request, opts),
129
         :ok <- reverse_permanent(request, opts),
130
         true <-
131
           release_status(request.from_version, opts) == :permanent or
132
             {:error, :reverse_not_permanent} do
133
      {:ok, %{"phase" => "reversed", "restored" => true}}
134
    end
135
  end
136
137
  defp reverse_install(request, opts) do
138
    from_version = to_charlist(request.from_version)
139
    to_version = to_charlist(request.to_version)
140
141
    case handler_call(opts, :install_release, [to_charlist(request.from_version)]) do
142
      {:ok, ^from_version, _description} ->
143
        {:ok, :installed}
144
145
      {:ok, ^to_version, _description} ->
146
        {:ok, :installed}
147
148
      {:error, reason} ->
149
        {:error, {:reverse_install_failed, safe_code(reason)}}
150
151
      _other ->
152
        {:error, :unexpected_reverse_install_result}
153
    end
154
  end
155
156
  defp verify_reverse_health(request, opts) do
157
    with true <-
158
           release_status(request.from_version, opts) in [:current, :permanent] or
159
             {:error, :reverse_status_mismatch},
160
         %{"ready" => true} <- health_report(opts),
161
         %{schema_version: schema} <- state_snapshot(opts),
162
         true <- schema == request.from_state_version or {:error, :reverse_state_version_mismatch} do
163
      :ok
164
    else
165
      {:error, reason} -> {:error, reason}
166
      _unhealthy -> {:error, :reverse_health_check_failed}
167
    end
168
  end
169
170
  defp reverse_permanent(request, opts) do
171
    case handler_call(opts, :make_permanent, [to_charlist(request.from_version)]) do
172
      :ok -> :ok
173
      {:error, reason} -> {:error, {:reverse_permanent_failed, safe_code(reason)}}
174
      _other -> {:error, :unexpected_reverse_permanent_result}
175
    end
176
  end
177
178
  defp validate_request(request) when is_map(request) do
179
    cond do
180
      Map.get(request, :release_name) != "openagents" -> {:error, :invalid_release_name}
181
      not version?(Map.get(request, :from_version)) -> {:error, :invalid_from_version}
182
      not version?(Map.get(request, :to_version)) -> {:error, :invalid_to_version}
183
      not is_binary(Map.get(request, :artifact_bytes)) -> {:error, :invalid_artifact}
184
      not digest?(Map.get(request, :artifact_digest)) -> {:error, :invalid_artifact_digest}
185
      Map.get(request, :from_state_version) not in [1, 2] -> {:error, :invalid_from_state_version}
186
      Map.get(request, :to_state_version) not in [1, 2] -> {:error, :invalid_to_state_version}
187
      true -> :ok
188
    end
189
  end
190
191
  defp validate_request(_request), do: {:error, :invalid_request}
192
193
  defp version?(version), do: is_binary(version) and Regex.match?(@version_pattern, version)
194
  defp digest?(value), do: is_binary(value) and Regex.match?(@digest_pattern, value)
195
  defp digest(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)
196
197
  defp ensure_release_directories(opts) do
198
    File.mkdir_p!(cache_dir(opts))
199
    File.mkdir_p!(releases_dir(opts))
200
    :ok
201
  rescue
202
    _error -> {:error, :release_directory_unavailable}
203
  end
204
205
  defp persist_cache(request, opts) do
206
    path = cache_path(request, opts)
207
208
    case verify_file(path, request.artifact_digest) do
209
      :ok ->
210
        :ok
211
212
      {:error, :missing_artifact} ->
213
        atomic_write(path, request.artifact_bytes)
214
215
      {:error, _reason} ->
216
        {:error, :cached_artifact_conflict}
217
    end
218
  end
219
220
  defp restage(request, opts) do
221
    with :ok <- verify_file(cache_path(request, opts), request.artifact_digest),
222
         {:ok, bytes} <- File.read(cache_path(request, opts)),
223
         :ok <- atomic_write(stage_path(request, opts), bytes),
224
         :ok <- verify_file(stage_path(request, opts), request.artifact_digest) do
225
      :ok
226
    end
227
  end
228
229
  defp atomic_write(path, bytes) do
230
    temporary = path <> ".tmp.#{System.unique_integer([:positive])}"
231
232
    with :ok <- File.write(temporary, bytes, [:binary, :exclusive]),
233
         :ok <- File.chmod(temporary, 0o600),
234
         :ok <- File.rename(temporary, path) do
235
      :ok
236
    else
237
      {:error, reason} ->
238
        File.rm(temporary)
239
        {:error, {:artifact_write_failed, safe_code(reason)}}
240
    end
241
  end
242
243
  defp verify_file(path, expected_digest) do
244
    case File.read(path) do
245
      {:ok, bytes} ->
246
        if digest(bytes) == expected_digest, do: :ok, else: {:error, :artifact_digest_mismatch}
247
248
      {:error, :enoent} ->
249
        {:error, :missing_artifact}
250
251
      {:error, _reason} ->
252
        {:error, :artifact_unreadable}
253
    end
254
  end
255
256
  defp release_known?(version, opts), do: release_status(version, opts) != nil
257
258
  defp release_status(version, opts) do
259
    opts
260
    |> handler_call(:which_releases, [])
261
    |> Enum.find_value(fn
262
      {_name, found_version, _applications, status}
263
      when status in [:unpacked, :current, :permanent, :old] ->
264
        if to_string(found_version) == version, do: status
265
266
      _other ->
267
        nil
268
    end)
269
  rescue
270
    _error -> nil
271
  catch
272
    _kind, _reason -> nil
273
  end
274
275
  defp handler_call(opts, function, arguments) do
276
    apply(Keyword.get(opts, :release_handler, :release_handler), function, arguments)
277
  end
278
279
  defp generate_config(version, opts) do
280
    case Keyword.get(opts, :generate_config, &Castle.generate/1).(version) do
281
      :ok -> :ok
282
      {:error, reason} -> {:error, {:config_generation_failed, safe_code(reason)}}
283
      _other -> :ok
284
    end
285
  rescue
286
    _error -> {:error, :config_generation_failed}
287
  end
288
289
  defp health_report(opts), do: Keyword.get(opts, :health, &OpenAgents.Cluster.local_report/0).()
290
  defp state_snapshot(opts), do: Keyword.get(opts, :state, &ReleaseState.snapshot/0).()
291
292
  defp release_basename(request), do: "#{request.release_name}-#{request.to_version}"
293
294
  defp root(opts),
295
    do: Keyword.get_lazy(opts, :release_root, fn -> to_string(:code.root_dir()) end)
296
297
  defp releases_dir(opts), do: Path.join(root(opts), "releases")
298
  defp cache_dir(opts), do: Path.join(releases_dir(opts), ".openagents-relup-cache")
299
300
  defp cache_path(request, opts),
301
    do: Path.join(cache_dir(opts), "#{request.artifact_digest}.tar.gz")
302
303
  defp stage_path(request, opts),
304
    do: Path.join(releases_dir(opts), "#{release_basename(request)}.tar.gz")
305
306
  defp safe_code(reason), do: OpenAgents.OperationalLog.code(reason)
307
end
lib/openagents/forge/rolling_provider.ex added +35

@@ -0,0 +1,35 @@

1
defmodule OpenAgents.Forge.RollingProvider do
2
  @moduledoc """
3
  Infrastructure boundary for one-node-at-a-time replacement.
4
5
  Gate 12 must supply an isolated staging implementation. Provider callbacks
6
  receive only bounded deployment context; credentials and private host data
7
  remain in operator-owned configuration.
8
  """
9
10
  @type context :: %{
11
          required(:sha) => binary(),
12
          required(:image_digest) => binary(),
13
          required(:expected_nodes) => [node()]
14
        }
15
16
  @callback remove_readiness(node(), context()) :: :ok | {:error, term()}
17
  @callback restore_readiness(node(), context()) :: :ok | {:error, term()}
18
  @callback drain(node(), context()) :: {:ok, non_neg_integer()} | {:error, term()}
19
  @callback capacity([node()], context()) ::
20
              {:ok, %{required(:ready) => non_neg_integer(), required(:quorum) => boolean()}}
21
              | {:error, term()}
22
  @callback replace(node(), binary(), context()) :: :ok | {:error, term()}
23
  @callback status(node(), context()) ::
24
              {:ok,
25
               %{
26
                 required(:member) => boolean(),
27
                 required(:ready) => boolean(),
28
                 required(:boot_converged) => boolean(),
29
                 required(:database_ready) => boolean(),
30
                 required(:sha) => binary(),
31
                 required(:image_digest) => binary()
32
               }}
33
              | {:error, term()}
34
  @callback rollback(node(), binary(), context()) :: :ok | {:error, term()}
35
end
lib/openagents/forge/rolling_replacement.ex added +325

@@ -0,0 +1,325 @@

1
defmodule OpenAgents.Forge.RollingReplacement do
2
  @moduledoc """
3
  Coordinates a readiness-gated, one-node-at-a-time immutable image rollout.
4
5
  The coordinator requires an exact-SHA local release-gate receipt, verifies
6
  remaining capacity and quorum after each drain, and never starts another
7
  replacement until the prior node has rejoined and passed all health checks.
8
  A failed replacement triggers a last-known-good image rollback and aborts the
9
  fleet sequence.
10
  """
11
12
  alias OpenAgents.Forge.GateReceipt
13
14
  @sha_pattern ~r/\A[0-9a-f]{40}\z/
15
  @digest_pattern ~r/\Asha256:[0-9a-f]{64}\z/
16
  @default_wait_attempts 120
17
  @default_wait_interval_ms 1_000
18
19
  @doc "Roll an immutable image across the exact expected node set."
20
  def run(request, opts \\ []) do
21
    with :ok <- validate_request(request),
22
         {:ok, _receipt} <- gate_verify(request.sha, opts),
23
         {:ok, provider} <- provider(opts),
24
         :ok <- initial_membership(request, opts) do
25
      replace_nodes(request.expected_nodes, request, provider, opts, %{})
26
    end
27
  end
28
29
  defp replace_nodes([], request, _provider, _opts, results) do
30
    {:ok, public_result(request, "live", results, nil, nil)}
31
  end
32
33
  defp replace_nodes([node | remaining], request, provider, opts, results) do
34
    case replace_node(node, request, provider, opts) do
35
      {:ok, "ready"} ->
36
        replace_nodes(
37
          remaining,
38
          request,
39
          provider,
40
          opts,
41
          Map.put(results, to_string(node), "ready")
42
        )
43
44
      {:error, reason, recovery} ->
45
        failed_results = Map.put(results, to_string(node), safe_code(reason))
46
47
        {:error,
48
         public_result(
49
           request,
50
           "failed",
51
           failed_results,
52
           safe_code(reason),
53
           recovery
54
         )}
55
    end
56
  end
57
58
  defp replace_node(node, request, provider, opts) do
59
    remaining = Enum.reject(request.expected_nodes, &(&1 == node))
60
    context = context(request)
61
62
    case provider.remove_readiness(node, context) do
63
      :ok ->
64
        with :ok <- wait_for_drain(provider, node, context, opts),
65
             :ok <- verify_capacity(provider, remaining, request, context) do
66
          replace_drained_node(node, request, provider, context, opts)
67
        else
68
          {:error, reason} ->
69
            restore_admission(provider, node, context, reason)
70
71
          other ->
72
            restore_admission(provider, node, context, {:unexpected_provider_result, other})
73
        end
74
75
      {:error, reason} ->
76
        {:error, {:readiness_removal_failed, reason}, "readiness_unchanged"}
77
78
      other ->
79
        {:error, {:invalid_readiness_result, other}, "readiness_unchanged"}
80
    end
81
  end
82
83
  defp replace_drained_node(node, request, provider, context, opts) do
84
    with :ok <- provider.replace(node, request.image_digest, context),
85
         :ok <- wait_for_target(provider, node, request, context, opts),
86
         :ok <- exact_membership(request, opts) do
87
      {:ok, "ready"}
88
    else
89
      {:error, reason} ->
90
        recover_node(provider, node, request, context, reason, opts)
91
92
      other ->
93
        recover_node(provider, node, request, context, {:unexpected_provider_result, other}, opts)
94
    end
95
  end
96
97
  defp restore_admission(provider, node, context, reason) do
98
    recovery =
99
      case provider.restore_readiness(node, context) do
100
        :ok -> "readiness_restored"
101
        {:error, restore_reason} -> "readiness_restore_failed:" <> safe_code(restore_reason)
102
        other -> "readiness_restore_failed:" <> safe_code(other)
103
      end
104
105
    {:error, reason, recovery}
106
  end
107
108
  defp recover_node(provider, node, request, context, reason, opts) do
109
    recovery =
110
      with :ok <- provider.rollback(node, request.previous_image_digest, context),
111
           :ok <- wait_for_previous(provider, node, request, context, opts) do
112
        "last_known_good_restored"
113
      else
114
        {:error, recovery_reason} -> "rollback_failed:" <> safe_code(recovery_reason)
115
        other -> "rollback_failed:" <> safe_code(other)
116
      end
117
118
    {:error, reason, recovery}
119
  end
120
121
  defp wait_for_drain(provider, node, context, opts) do
122
    poll(opts, fn ->
123
      case provider.drain(node, context) do
124
        {:ok, 0} -> :ok
125
        {:ok, count} when is_integer(count) and count > 0 -> :retry
126
        {:error, reason} -> {:error, {:drain_failed, reason}}
127
        other -> {:error, {:invalid_drain_result, other}}
128
      end
129
    end)
130
  end
131
132
  defp verify_capacity(provider, remaining, request, context) do
133
    case provider.capacity(remaining, context) do
134
      {:ok, %{ready: ready, quorum: true}}
135
      when is_integer(ready) and ready >= request.minimum_ready ->
136
        :ok
137
138
      {:ok, %{quorum: false}} ->
139
        {:error, :quorum_lost_after_drain}
140
141
      {:ok, %{ready: ready}} when is_integer(ready) ->
142
        {:error, {:insufficient_capacity, ready, request.minimum_ready}}
143
144
      {:error, reason} ->
145
        {:error, {:capacity_check_failed, reason}}
146
147
      other ->
148
        {:error, {:invalid_capacity_result, other}}
149
    end
150
  end
151
152
  defp wait_for_target(provider, node, request, context, opts) do
153
    poll(opts, fn ->
154
      case provider.status(node, context) do
155
        {:ok,
156
         %{
157
           member: true,
158
           ready: true,
159
           boot_converged: true,
160
           database_ready: true,
161
           sha: sha,
162
           image_digest: digest
163
         }}
164
        when sha == request.sha and digest == request.image_digest ->
165
          :ok
166
167
        {:ok, _not_ready} ->
168
          :retry
169
170
        {:error, reason} ->
171
          {:error, {:rejoin_check_failed, reason}}
172
173
        other ->
174
          {:error, {:invalid_rejoin_result, other}}
175
      end
176
    end)
177
  end
178
179
  defp wait_for_previous(provider, node, request, context, opts) do
180
    poll(opts, fn ->
181
      case provider.status(node, context) do
182
        {:ok,
183
         %{
184
           member: true,
185
           ready: true,
186
           boot_converged: true,
187
           database_ready: true,
188
           sha: sha,
189
           image_digest: digest
190
         }}
191
        when sha == request.previous_sha and digest == request.previous_image_digest ->
192
          :ok
193
194
        {:ok, _not_ready} ->
195
          :retry
196
197
        {:error, reason} ->
198
          {:error, {:rollback_rejoin_check_failed, reason}}
199
200
        other ->
201
          {:error, {:invalid_rollback_rejoin_result, other}}
202
      end
203
    end)
204
  end
205
206
  defp poll(opts, function) do
207
    attempts = Keyword.get(opts, :wait_attempts, @default_wait_attempts)
208
    interval = Keyword.get(opts, :wait_interval_ms, @default_wait_interval_ms)
209
    do_poll(function, attempts, interval)
210
  end
211
212
  defp do_poll(_function, 0, _interval), do: {:error, :wait_timeout}
213
214
  defp do_poll(function, attempts, interval) do
215
    case function.() do
216
      :ok -> :ok
217
      :retry -> wait(interval) && do_poll(function, attempts - 1, interval)
218
      {:error, _reason} = error -> error
219
    end
220
  end
221
222
  defp wait(0), do: true
223
224
  defp wait(milliseconds) do
225
    receive do
226
    after
227
      milliseconds -> true
228
    end
229
  end
230
231
  defp initial_membership(request, opts), do: exact_membership(request, opts)
232
233
  defp exact_membership(request, opts) do
234
    if members(opts) == request.expected_nodes,
235
      do: :ok,
236
      else: {:error, :fleet_membership_mismatch}
237
  end
238
239
  defp members(opts) do
240
    Keyword.get(opts, :members, &OpenAgents.Cluster.members/0).()
241
    |> Enum.sort()
242
  end
243
244
  defp provider(opts) do
245
    case Keyword.fetch(opts, :provider) do
246
      {:ok, provider} when is_atom(provider) -> {:ok, provider}
247
      _missing -> {:error, :rolling_provider_not_configured}
248
    end
249
  end
250
251
  defp gate_verify(sha, opts) do
252
    case Keyword.fetch(opts, :gate_verifier) do
253
      {:ok, verifier} -> verifier.(sha)
254
      :error -> GateReceipt.verify(sha, Keyword.get(opts, :gate_receipt_options, []))
255
    end
256
  end
257
258
  defp validate_request(request) when is_map(request) do
259
    nodes = Map.get(request, :expected_nodes)
260
261
    cond do
262
      not Regex.match?(@sha_pattern, Map.get(request, :sha, "")) ->
263
        {:error, :invalid_git_sha}
264
265
      not Regex.match?(@sha_pattern, Map.get(request, :previous_sha, "")) ->
266
        {:error, :invalid_previous_git_sha}
267
268
      not Regex.match?(@digest_pattern, Map.get(request, :image_digest, "")) ->
269
        {:error, :invalid_image_digest}
270
271
      not Regex.match?(@digest_pattern, Map.get(request, :previous_image_digest, "")) ->
272
        {:error, :invalid_previous_image_digest}
273
274
      request.image_digest == request.previous_image_digest ->
275
        {:error, :image_digest_unchanged}
276
277
      not is_list(nodes) or nodes == [] ->
278
        {:error, :invalid_expected_nodes}
279
280
      nodes != Enum.sort(nodes) or length(nodes) != length(Enum.uniq(nodes)) ->
281
        {:error, :invalid_expected_nodes}
282
283
      Map.get(request, :expected_fleet_size) != length(nodes) ->
284
        {:error, :invalid_expected_fleet_size}
285
286
      not is_integer(Map.get(request, :minimum_ready)) ->
287
        {:error, :invalid_minimum_ready}
288
289
      request.minimum_ready < div(request.expected_fleet_size, 2) + 1 ->
290
        {:error, :minimum_ready_below_quorum}
291
292
      request.minimum_ready > request.expected_fleet_size - 1 ->
293
        {:error, :minimum_ready_exceeds_remaining_fleet}
294
295
      true ->
296
        :ok
297
    end
298
  end
299
300
  defp validate_request(_request), do: {:error, :invalid_request}
301
302
  defp context(request) do
303
    %{
304
      sha: request.sha,
305
      image_digest: request.image_digest,
306
      expected_nodes: request.expected_nodes
307
    }
308
  end
309
310
  defp public_result(request, status, node_results, error_code, recovery) do
311
    %{
312
      schema: "openagents.rolling-replacement.v1",
313
      sha: request.sha,
314
      previous_sha: request.previous_sha,
315
      image_digest: request.image_digest,
316
      previous_image_digest: request.previous_image_digest,
317
      status: status,
318
      node_results: node_results,
319
      error_code: error_code,
320
      recovery: recovery
321
    }
322
  end
323
324
  defp safe_code(reason), do: OpenAgents.OperationalLog.code(reason)
325
end
lib/openagents/release_assembler.ex added +26

@@ -0,0 +1,26 @@

1
defmodule OpenAgents.ReleaseAssembler do
2
  @moduledoc false
3
4
  @doc false
5
  def pre_assemble(release), do: Forecastle.pre_assemble(release)
6
7
  @doc false
8
  def post_assemble(release) do
9
    release = Forecastle.post_assemble(release)
10
11
    case System.get_env("OPENAGENTS_RELUP_PATH") do
12
      nil ->
13
        release
14
15
      path ->
16
        source = Path.expand(path)
17
18
        if File.regular?(source) do
19
          File.cp!(source, Path.join(release.version_path, "relup"))
20
          release
21
        else
22
          raise "OPENAGENTS_RELUP_PATH must name a regular relup file"
23
        end
24
    end
25
  end
26
end
lib/openagents/release_state.ex added +92

@@ -0,0 +1,92 @@

1
defmodule OpenAgents.ReleaseState do
2
  @moduledoc """
3
  Holds bounded runtime observations across supported release upgrades.
4
5
  The process uses an explicitly versioned state struct. The `0.1.0` release
6
  uses schema 1 and the `0.2.0` release uses schema 2. A two-way relup calls
7
  `code_change/3`, which preserves the PID and observations while adding or
8
  removing schema 2's integrity field.
9
  """
10
11
  use GenServer
12
13
  alias OpenAgents.ReleaseState.State
14
15
  @current_schema System.get_env("OPENAGENTS_RELUP_STATE_VERSION", "2")
16
                  |> String.to_integer()
17
  @maximum_observations 100
18
19
  if @current_schema not in [1, 2] do
20
    raise "OPENAGENTS_RELUP_STATE_VERSION must be 1 or 2"
21
  end
22
23
  def start_link(opts \\ []) do
24
    case Keyword.get(opts, :name, __MODULE__) do
25
      nil -> GenServer.start_link(__MODULE__, opts)
26
      name -> GenServer.start_link(__MODULE__, opts, name: name)
27
    end
28
  end
29
30
  @doc "Return the process's current state."
31
  def snapshot(server \\ __MODULE__), do: GenServer.call(server, :snapshot)
32
33
  @doc "Store one bounded observation for relup continuity checks."
34
  def observe(value, server \\ __MODULE__), do: GenServer.call(server, {:observe, value})
35
36
  @doc "Return the schema version compiled into this release."
37
  def current_schema, do: @current_schema
38
39
  @doc false
40
  def install_barrier do
41
    with path when is_binary(path) <- System.get_env("OPENAGENTS_RELUP_INSTALL_BARRIER_PATH"),
42
         delay when is_binary(delay) <- System.get_env("OPENAGENTS_RELUP_INSTALL_BARRIER_MS"),
43
         {milliseconds, ""} <- Integer.parse(delay),
44
         true <- milliseconds > 0,
45
         false <- File.exists?(path) do
46
      File.write!(path, "entered\n", [:exclusive])
47
48
      receive do
49
      after
50
        milliseconds -> :ok
51
      end
52
    else
53
      _not_enabled -> :ok
54
    end
55
  end
56
57
  @impl true
58
  def init(_opts), do: {:ok, state_for(@current_schema, [])}
59
60
  @impl true
61
  def handle_call(:snapshot, _from, state), do: {:reply, state, state}
62
63
  def handle_call({:observe, value}, _from, %State{} = state) do
64
    observations = Enum.take([value | state.observations], @maximum_observations)
65
    next = state_for(state.schema_version, observations)
66
    {:reply, :ok, next}
67
  end
68
69
  @impl true
70
  def code_change({:down, _from_version}, %State{} = state, _extra) do
71
    {:ok, state_for(1, state.observations)}
72
  end
73
74
  def code_change(_from_version, %State{schema_version: 1} = state, _extra) do
75
    {:ok, state_for(2, state.observations)}
76
  end
77
78
  def code_change(_from_version, %State{} = state, _extra) do
79
    {:ok, state_for(@current_schema, state.observations)}
80
  end
81
82
  defp state_for(1, observations) do
83
    %State{schema_version: 1, observations: observations, integrity: nil}
84
  end
85
86
  defp state_for(2, observations) do
87
    integrity =
88
      :crypto.hash(:sha256, :erlang.term_to_binary(observations)) |> Base.encode16(case: :lower)
89
90
    %State{schema_version: 2, observations: observations, integrity: integrity}
91
  end
92
end
lib/openagents/release_state/state.ex added +12

@@ -0,0 +1,12 @@

1
defmodule OpenAgents.ReleaseState.State do
2
  @moduledoc "Versioned state retained by `OpenAgents.ReleaseState` across relups."
3
4
  @enforce_keys [:schema_version, :observations]
5
  defstruct [:schema_version, :observations, :integrity]
6
7
  @type t :: %__MODULE__{
8
          schema_version: 1 | 2,
9
          observations: [term()],
10
          integrity: binary() | nil
11
        }
12
end
mix.exs modified +20 -4

@@ -4,7 +4,7 @@ defmodule OpenAgents.MixProject do

4 4
  def project do
5 5
    [
6 6
      app: :openagents,
7
      version: "0.1.0",
7
      version: release_version(),
8 8
      elixir: "~> 1.17",
9 9
      elixirc_paths: elixirc_paths(Mix.env()),
10 10
      start_permanent: Mix.env() == :prod,

@@ -34,15 +34,31 @@ defmodule OpenAgents.MixProject do

34 34
    ]
35 35
  end
36 36
37
  defp release_version do
38
    System.get_env("OPENAGENTS_RELEASE_VSN", "0.2.0")
39
  end
40
37 41
  # Hot-upgrade-capable release: castle/forecastle add appup + relup generation
38 42
  # and release_handler runtime support on top of `mix release`.
39 43
  defp releases do
40
    [
41
      openagents: [
44
    release_path = System.get_env("OPENAGENTS_RELEASE_PATH")
45
46
    options =
47
      [
42 48
        include_erts: true,
43 49
        include_src: false,
44
        steps: [&Forecastle.pre_assemble/1, :assemble, &Forecastle.post_assemble/1, :tar]
50
        steps: [
51
          &OpenAgents.ReleaseAssembler.pre_assemble/1,
52
          :assemble,
53
          &OpenAgents.ReleaseAssembler.post_assemble/1,
54
          :tar
55
        ]
45 56
      ]
57
58
    options = if release_path, do: Keyword.put(options, :path, release_path), else: options
59
60
    [
61
      openagents: options
46 62
    ]
47 63
  end
48 64
ops/ci/contracts.sh added +16

@@ -0,0 +1,16 @@

1
#!/bin/sh
2
set -eu
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6
7
cd "$repo_root"
8
9
ops/ci/reference-check.sh
10
elixir ops/ci/docs-check.exs
11
MIX_ENV=test mix test --warnings-as-errors \
12
  test/openagents/log_safety_test.exs \
13
  test/openagents/runtime_config_test.exs \
14
  test/openagents_web/icon_affordances_test.exs \
15
  test/openagents_web/icons_test.exs \
16
  test/openagents_web/ui_test.exs
ops/ci/gate.sh added +182

@@ -0,0 +1,182 @@

1
#!/bin/sh
2
set -eu
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6
receipt_root="$repo_root/.git/openagents/release-gate-receipts"
7
required_stages='compile precommit cluster javascript direct_transaction relup version_chain interrupted_install rolling_replacement contracts release_smoke'
8
mode=${1:-run}
9
10
if [ ! -d "$repo_root/.git" ]; then
11
  echo "release gate must run from a Git worktree" >&2
12
  exit 1
13
fi
14
15
git_sha=$(git -C "$repo_root" rev-parse --verify HEAD)
16
receipt_path="$receipt_root/$git_sha.json"
17
18
verify_receipt() {
19
  [ -f "$receipt_path" ] || return 1
20
21
  jq -e \
22
    --arg sha "$git_sha" \
23
    --arg stages "$required_stages" '
24
      .schema == "openagents.release-gate.v1" and
25
      .git_sha == $sha and
26
      .status == "passed" and
27
      ([($stages | split(" "))[] as $stage | .stages[$stage].status == "passed"] | all)
28
    ' "$receipt_path" >/dev/null
29
}
30
31
case "$mode" in
32
  --verify)
33
    if verify_receipt; then
34
      echo "Release gate receipt is valid for $git_sha"
35
      exit 0
36
    fi
37
38
    echo "no complete release gate receipt exists for $git_sha" >&2
39
    exit 1
40
    ;;
41
42
  --force | run) ;;
43
44
  *)
45
    echo "usage: ops/ci/gate.sh [--force|--verify]" >&2
46
    exit 64
47
    ;;
48
esac
49
50
if [ "$mode" != "--force" ] && verify_receipt; then
51
  echo "Release gate already passed for $git_sha"
52
  exit 0
53
fi
54
55
if [ -n "$(git -C "$repo_root" status --porcelain --untracked-files=all)" ]; then
56
  echo "release gate requires a clean worktree" >&2
57
  exit 1
58
fi
59
60
if [ "${OPENAGENTS_RELEASE_SMOKE_DISPOSABLE:-}" != "1" ]; then
61
  echo "set OPENAGENTS_RELEASE_SMOKE_DISPOSABLE=1 for a disposable database" >&2
62
  exit 1
63
fi
64
65
if [ -z "${OPENAGENTS_RELEASE_SMOKE_DATABASE_URL:-}" ]; then
66
  echo "OPENAGENTS_RELEASE_SMOKE_DATABASE_URL is required" >&2
67
  exit 1
68
fi
69
70
for command_name in jq mix npm; do
71
  if ! command -v "$command_name" >/dev/null 2>&1; then
72
    echo "$command_name is required for the release gate" >&2
73
    exit 1
74
  fi
75
done
76
77
run_root=$(mktemp -d /tmp/openagents-release-gate.XXXXXX)
78
started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
79
started_epoch=$(date +%s)
80
81
cleanup() {
82
  find "$run_root" -depth -delete
83
}
84
85
trap cleanup EXIT INT TERM
86
87
run_stage() {
88
  stage_name=$1
89
  shift
90
  stage_log="$run_root/$stage_name.log"
91
  stage_started=$(date +%s)
92
93
  echo "Running $stage_name"
94
95
  set +e
96
  "$@" >"$stage_log" 2>&1
97
  stage_status=$?
98
  set -e
99
100
  cat "$stage_log"
101
102
  if [ "$stage_status" -ne 0 ]; then
103
    echo "$stage_name failed" >&2
104
    exit "$stage_status"
105
  fi
106
107
  stage_finished=$(date +%s)
108
  eval "${stage_name}_duration_seconds=$((stage_finished - stage_started))"
109
}
110
111
cd "$repo_root"
112
113
run_stage compile env MIX_ENV=test mix compile --warnings-as-errors
114
run_stage precommit env MIX_ENV=test mix precommit
115
run_stage cluster env MIX_ENV=test mix test --warnings-as-errors --only cluster
116
run_stage javascript npm --prefix assets test
117
run_stage direct_transaction env MIX_ENV=test mix test --warnings-as-errors \
118
  test/openagents/forge/deployment_node_test.exs \
119
  test/openagents/forge/deployment_cluster_test.exs \
120
  test/openagents/forge/hot_loader_test.exs \
121
  test/openagents/forge/boot_converge_test.exs
122
run_stage relup ops/relup-proof/run.sh
123
run_stage version_chain env \
124
  OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 \
125
  OPENAGENTS_RELUP_PROOF_DATABASE_URL="$OPENAGENTS_RELEASE_SMOKE_DATABASE_URL" \
126
  ops/relup-proof/version-chain.sh
127
run_stage interrupted_install env \
128
  OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 \
129
  OPENAGENTS_RELUP_PROOF_DATABASE_URL="$OPENAGENTS_RELEASE_SMOKE_DATABASE_URL" \
130
  ops/relup-proof/kill-during-install.sh
131
run_stage rolling_replacement env MIX_ENV=test mix test --warnings-as-errors \
132
  test/openagents/forge/rolling_replacement_test.exs
133
run_stage contracts ops/ci/contracts.sh
134
run_stage release_smoke ops/ci/release-smoke.sh
135
136
if [ "$(git rev-parse --verify HEAD)" != "$git_sha" ]; then
137
  echo "Git HEAD changed while the release gate was running" >&2
138
  exit 1
139
fi
140
141
if [ -n "$(git status --porcelain --untracked-files=all)" ]; then
142
  echo "release gate left the worktree dirty" >&2
143
  exit 1
144
fi
145
146
completed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
147
completed_epoch=$(date +%s)
148
total_duration_seconds=$((completed_epoch - started_epoch))
149
receipt_temp="$receipt_path.tmp.$$"
150
151
mkdir -p "$receipt_root"
152
umask 077
153
154
cat >"$receipt_temp" <<EOF
155
{
156
  "schema": "openagents.release-gate.v1",
157
  "git_sha": "$git_sha",
158
  "status": "passed",
159
  "started_at": "$started_at",
160
  "completed_at": "$completed_at",
161
  "total_duration_seconds": $total_duration_seconds,
162
  "automatic_retries": 0,
163
  "stages": {
164
    "compile": {"status": "passed", "duration_seconds": $compile_duration_seconds},
165
    "precommit": {"status": "passed", "duration_seconds": $precommit_duration_seconds},
166
    "cluster": {"status": "passed", "duration_seconds": $cluster_duration_seconds},
167
    "javascript": {"status": "passed", "duration_seconds": $javascript_duration_seconds},
168
    "direct_transaction": {"status": "passed", "duration_seconds": $direct_transaction_duration_seconds},
169
    "relup": {"status": "passed", "duration_seconds": $relup_duration_seconds},
170
    "version_chain": {"status": "passed", "duration_seconds": $version_chain_duration_seconds},
171
    "interrupted_install": {"status": "passed", "duration_seconds": $interrupted_install_duration_seconds},
172
    "rolling_replacement": {"status": "passed", "duration_seconds": $rolling_replacement_duration_seconds},
173
    "contracts": {"status": "passed", "duration_seconds": $contracts_duration_seconds},
174
    "release_smoke": {"status": "passed", "duration_seconds": $release_smoke_duration_seconds}
175
  }
176
}
177
EOF
178
179
mv "$receipt_temp" "$receipt_path"
180
181
echo "Release gate passed for $git_sha"
182
echo "Receipt: .git/openagents/release-gate-receipts/$git_sha.json"
ops/deploy/build-image.sh added +56

@@ -0,0 +1,56 @@

1
#!/bin/sh
2
set -eu
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6
7
if [ -n "$(git -C "$repo_root" status --porcelain --untracked-files=all)" ]; then
8
  echo "image build requires a clean worktree" >&2
9
  exit 1
10
fi
11
12
git_sha=$(git -C "$repo_root" rev-parse --verify HEAD)
13
tag=${1:-"openagents:$git_sha"}
14
image_root="$repo_root/.git/openagents/images"
15
result_path="$image_root/$git_sha.json"
16
iid_file=$(mktemp /tmp/openagents-image-iid.XXXXXX)
17
18
cleanup() {
19
  unlink "$iid_file" 2>/dev/null || true
20
}
21
22
trap cleanup EXIT INT TERM
23
24
"$repo_root/ops/ci/gate.sh" --verify
25
26
docker build \
27
  --iidfile "$iid_file" \
28
  --label "org.opencontainers.image.revision=$git_sha" \
29
  --tag "$tag" \
30
  --target final \
31
  "$repo_root"
32
33
image_digest=$(tr -d '\n' <"$iid_file")
34
35
case "$image_digest" in
36
  sha256:????????????????????????????????????????????????????????????????) ;;
37
  *)
38
    echo "container builder did not return an immutable SHA-256 image ID" >&2
39
    exit 1
40
    ;;
41
esac
42
43
mkdir -p "$image_root"
44
umask 077
45
46
cat >"$result_path" <<EOF
47
{
48
  "schema": "openagents.local-image.v1",
49
  "git_sha": "$git_sha",
50
  "image_digest": "$image_digest",
51
  "tag": "$tag"
52
}
53
EOF
54
55
echo "Built $tag as $image_digest"
56
echo "Receipt: .git/openagents/images/$git_sha.json"
ops/relup-proof/common.sh added +152

@@ -0,0 +1,152 @@

1
#!/bin/sh
2
set -eu
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6
7
proof_key() {
8
  git_sha=$(git -C "$repo_root" rev-parse --verify HEAD)
9
10
  if [ -n "$(git -C "$repo_root" status --porcelain --untracked-files=all)" ]; then
11
    if [ "${OPENAGENTS_RELUP_PROOF_ALLOW_DIRTY:-}" != "1" ]; then
12
      echo "relup proof requires a clean worktree" >&2
13
      exit 1
14
    fi
15
16
    dirty_digest=$(
17
      {
18
        git -C "$repo_root" diff --binary HEAD
19
        git -C "$repo_root" ls-files --others --exclude-standard | sort | while IFS= read -r file; do
20
          printf '%s\n' "$file"
21
          sha256sum "$repo_root/$file"
22
        done
23
      } | sha256sum | cut -d ' ' -f 1
24
    )
25
    printf '%s\n' "worktree-$git_sha-$dirty_digest"
26
  else
27
    printf '%s\n' "$git_sha"
28
  fi
29
}
30
31
proof_root() {
32
  printf '%s\n' "$repo_root/.git/openagents/relup-proof/$(proof_key)"
33
}
34
35
require_proof_artifacts() {
36
  artifact_root=$(proof_root)
37
38
  for artifact in openagents-0.1.0.tar.gz openagents-0.2.0.tar.gz relup proof.json; do
39
    if [ ! -f "$artifact_root/$artifact" ]; then
40
      echo "missing relup proof artifact: $artifact" >&2
41
      echo "run ops/relup-proof/run.sh first" >&2
42
      exit 1
43
    fi
44
  done
45
}
46
47
require_disposable_database() {
48
  if [ "${OPENAGENTS_RELUP_PROOF_DISPOSABLE:-}" != "1" ]; then
49
    echo "set OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 for a disposable database" >&2
50
    exit 1
51
  fi
52
53
  proof_database_url=${OPENAGENTS_RELUP_PROOF_DATABASE_URL:-${OPENAGENTS_RELEASE_SMOKE_DATABASE_URL:-}}
54
55
  if [ -z "$proof_database_url" ]; then
56
    echo "OPENAGENTS_RELUP_PROOF_DATABASE_URL is required" >&2
57
    exit 1
58
  fi
59
}
60
61
prepare_runtime() {
62
  require_proof_artifacts
63
  require_disposable_database
64
65
  runtime_root=$(mktemp -d /tmp/openagents-relup-runtime.XXXXXX)
66
  tar -xzf "$artifact_root/openagents-0.1.0.tar.gz" -C "$runtime_root"
67
  cp "$artifact_root/openagents-0.2.0.tar.gz" "$runtime_root/releases/openagents-0.2.0.tar.gz"
68
69
  release_bin="$runtime_root/bin/openagents"
70
  release_log="$runtime_root/release.log"
71
  release_pid=
72
  proof_port=$((42000 + ($$ % 10000)))
73
  proof_secret=$(openssl rand -base64 64 | tr -d '\n')
74
  proof_token_key=$(openssl rand -base64 32 | tr -d '\n')
75
}
76
77
profile() {
78
  env \
79
    DATABASE_URL="$proof_database_url" \
80
    GITHUB_CLIENT_ID="relup-proof-client" \
81
    GITHUB_CLIENT_SECRET="relup-proof-secret" \
82
    GITHUB_TOKEN_ENCRYPTION_KEY="$proof_token_key" \
83
    GITHUB_TOKEN_ENCRYPTION_KEY_ID="staging-relup-proof-2026-08" \
84
    OPENAI_API_KEY="relup-proof-openai-key" \
85
    OPENAGENTS_RELUP_INSTALL_BARRIER_MS="${OPENAGENTS_RELUP_INSTALL_BARRIER_MS:-0}" \
86
    OPENAGENTS_RELUP_INSTALL_BARRIER_PATH="${OPENAGENTS_RELUP_INSTALL_BARRIER_PATH:-}" \
87
    PHX_SERVER="true" \
88
    POOL_SIZE="2" \
89
    PORT="$proof_port" \
90
    RELEASE_DISTRIBUTION="name" \
91
    RELEASE_NODE="openagents@127.0.0.1" \
92
    SECRET_KEY_BASE="$proof_secret" \
93
    "$repo_root/ops/staging/gate-5-profile.sh" "$@"
94
}
95
96
start_release() {
97
  profile "$release_bin" start >"$release_log" 2>&1 &
98
  release_pid=$!
99
100
  attempt=0
101
  until profile "$release_bin" rpc 'if Process.whereis(OpenAgents.ReleaseState), do: IO.puts("relup-proof-ready")' 2>/dev/null | grep -q 'relup-proof-ready'; do
102
    attempt=$((attempt + 1))
103
104
    if ! kill -0 "$release_pid" 2>/dev/null; then
105
      echo "relup proof release exited during startup" >&2
106
      tail -80 "$release_log" >&2
107
      exit 1
108
    fi
109
110
    if [ "$attempt" -ge 120 ]; then
111
      echo "relup proof release did not become reachable" >&2
112
      tail -80 "$release_log" >&2
113
      exit 1
114
    fi
115
116
    sleep 0.5
117
  done
118
}
119
120
stop_release() {
121
  if [ -n "${release_pid:-}" ] && kill -0 "$release_pid" 2>/dev/null; then
122
    profile "$release_bin" stop >/dev/null 2>&1 || kill -TERM "$release_pid" 2>/dev/null || true
123
    wait "$release_pid" 2>/dev/null || true
124
  fi
125
126
  release_pid=
127
}
128
129
cleanup_runtime() {
130
  stop_release
131
132
  if [ -n "${runtime_root:-}" ] && [ -d "$runtime_root" ]; then
133
    find "$runtime_root" -depth -delete
134
  fi
135
}
136
137
rpc_assert() {
138
  expression=$1
139
  expected=$2
140
  output=$(profile "$release_bin" rpc "$expression")
141
142
  if ! printf '%s\n' "$output" | grep -Fq "$expected"; then
143
    echo "relup RPC assertion failed: expected $expected" >&2
144
    printf '%s\n' "$output" >&2
145
    exit 1
146
  fi
147
}
148
149
unpack_and_check_candidate() {
150
  rpc_assert ':release_handler.unpack_release(~c"openagents-0.2.0") |> IO.inspect(label: "unpack")' 'unpack: {:ok, ~c"0.2.0"}'
151
  rpc_assert 'Castle.generate("0.2.0"); :release_handler.check_install_release(~c"0.2.0") |> IO.inspect(label: "check")' 'check: {:ok, ~c"0.1.0"'
152
}
ops/relup-proof/kill-during-install.sh modified +65 -5

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

1
#!/bin/bash
2
# Kill the emulator during install_release, verify prior release boots, restage
3
# consumed artifact, and complete the retry.
4
# This script is a placeholder for the kill-during-install drill.
1
#!/bin/sh
5 2
set -eu
6
echo "kill-during-install drill: to be wired after the relup deploy lane is implemented"
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
. "$script_dir/common.sh"
6
7
prepare_runtime
8
barrier_path="$runtime_root/install-barrier-entered"
9
export OPENAGENTS_RELUP_INSTALL_BARRIER_MS=30000
10
export OPENAGENTS_RELUP_INSTALL_BARRIER_PATH="$barrier_path"
11
install_client_pid=
12
13
cleanup() {
14
  if [ -n "${install_client_pid:-}" ]; then
15
    kill -TERM "$install_client_pid" 2>/dev/null || true
16
    wait "$install_client_pid" 2>/dev/null || true
17
  fi
18
19
  cleanup_runtime
20
}
21
22
trap cleanup EXIT INT TERM
23
start_release
24
unpack_and_check_candidate
25
26
profile "$release_bin" rpc ':release_handler.install_release(~c"0.2.0") |> IO.inspect(label: "interrupted_install")' >/dev/null 2>&1 &
27
install_client_pid=$!
28
29
attempt=0
30
until [ -f "$barrier_path" ]; do
31
  attempt=$((attempt + 1))
32
33
  if [ "$attempt" -ge 120 ]; then
34
    echo "install barrier was not reached" >&2
35
    tail -80 "$release_log" >&2
36
    exit 1
37
  fi
38
39
  sleep 0.25
40
done
41
42
runtime_pid=$(profile "$release_bin" pid | tail -1)
43
kill -KILL "$runtime_pid"
44
wait "$release_pid" 2>/dev/null || true
45
release_pid=
46
wait "$install_client_pid" 2>/dev/null || true
47
install_client_pid=
48
49
rm -f "$runtime_root/releases/openagents-0.2.0.tar.gz"
50
cp "$artifact_root/openagents-0.2.0.tar.gz" "$runtime_root/releases/openagents-0.2.0.tar.gz"
51
52
start_release
53
rpc_assert ':release_handler.which_releases(:permanent) |> IO.inspect(label: "permanent_after_kill")' '~c"0.1.0"'
54
55
rpc_assert '
56
  known = Enum.any?(:release_handler.which_releases(), fn {_, version, _, _} -> version == ~c"0.2.0" end)
57
  result = if known, do: {:ok, ~c"0.2.0"}, else: :release_handler.unpack_release(~c"openagents-0.2.0")
58
  IO.inspect(result, label: "restaged")
59
' 'restaged: {:ok, ~c"0.2.0"}'
60
61
rpc_assert 'Castle.generate("0.2.0"); :release_handler.check_install_release(~c"0.2.0") |> IO.inspect(label: "retry_check")' 'retry_check: {:ok, ~c"0.1.0"'
62
rpc_assert ':release_handler.install_release(~c"0.2.0") |> IO.inspect(label: "retry_install")' 'retry_install: {:ok, ~c"0.1.0"'
63
rpc_assert ':release_handler.make_permanent(~c"0.2.0") |> IO.inspect(label: "retry_commit")' 'retry_commit: :ok'
64
rpc_assert ':release_handler.which_releases(:permanent) |> IO.inspect(label: "permanent_after_retry")' '~c"0.2.0"'
65
66
echo "Interrupted relup install recovery proof passed"
ops/relup-proof/run.sh modified +104 -12

@@ -1,18 +1,110 @@

1
#!/bin/bash
1
#!/bin/sh
2 2
set -eu
3 3
4
# Foundation relup proof: build the current release and show that the release
5
# artifacts are produced. Full live code_change/3 proofs are left to the
6
# version-chain and kill-during-install drills once multiple release versions
7
# are built.
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
. "$script_dir/common.sh"
8 6
9
cd "$(dirname "$0")/../.."
7
cd "$repo_root"
10 8
11
echo "=== Building OpenAgents release ==="
12
rm -rf _build/prod/rel/openagents
13
MIX_ENV=prod mix do deps.get, compile, assets.deploy, release
9
artifact_root=$(proof_root)
10
build_root=$(mktemp -d /tmp/openagents-relup-build.XXXXXX)
11
publish_root="$artifact_root.tmp.$$"
12
assets_digested=0
14 13
15
echo "=== Verifying release tar ==="
16
ls -l _build/prod/rel/openagents/releases/*/openagents.tar.gz
14
cleanup() {
15
  if [ "$assets_digested" = "1" ]; then
16
    (cd "$repo_root" && MIX_ENV=prod mix phx.digest.clean --all >/dev/null 2>&1) || true
17
  fi
17 18
18
echo "=== Relup foundation is in place ==="
19
  if [ -d "$build_root" ]; then
20
    find "$build_root" -depth -delete
21
  fi
22
23
  if [ -d "$publish_root" ]; then
24
    find "$publish_root" -depth -delete
25
  fi
26
}
27
28
trap cleanup EXIT INT TERM
29
30
mkdir -p "$publish_root"
31
32
echo "Building production assets"
33
assets_digested=1
34
MIX_ENV=prod mix assets.deploy
35
36
echo "Building explicit 0.1.0 release"
37
env -u RELUP_FROM -u RELUP_TO -u OPENAGENTS_RELUP_PATH \
38
  MIX_ENV=prod \
39
  OPENAGENTS_RELEASE_PATH="$build_root/release-0.1.0" \
40
  OPENAGENTS_RELEASE_VSN="0.1.0" \
41
  OPENAGENTS_RELUP_STATE_VERSION="1" \
42
  mix do compile --force --warnings-as-errors + release --overwrite
43
44
echo "Building explicit 0.2.0 release resource"
45
env -u OPENAGENTS_RELUP_PATH \
46
  MIX_ENV=prod \
47
  OPENAGENTS_RELEASE_PATH="$build_root/release-0.2.0" \
48
  OPENAGENTS_RELEASE_VSN="0.2.0" \
49
  OPENAGENTS_RELUP_STATE_VERSION="2" \
50
  RELUP_FROM="0.1.0" \
51
  RELUP_TO="0.2.0" \
52
  mix do compile --force --warnings-as-errors + release --overwrite
53
54
echo "Generating forward and reverse relup"
55
MIX_ENV=prod mix openagents.relup \
56
  --target "$build_root/release-0.2.0/releases/0.2.0/openagents" \
57
  --from "$build_root/release-0.1.0/releases/0.1.0/openagents" \
58
  --outdir "$build_root"
59
60
echo "Reassembling 0.2.0 with the generated relup"
61
env \
62
  MIX_ENV=prod \
63
  OPENAGENTS_RELEASE_PATH="$build_root/release-0.2.0" \
64
  OPENAGENTS_RELEASE_VSN="0.2.0" \
65
  OPENAGENTS_RELUP_PATH="$build_root/relup" \
66
  OPENAGENTS_RELUP_STATE_VERSION="2" \
67
  RELUP_FROM="0.1.0" \
68
  RELUP_TO="0.2.0" \
69
  mix release --overwrite
70
71
cp "$build_root/release-0.1.0/openagents-0.1.0.tar.gz" "$publish_root/openagents-0.1.0.tar.gz"
72
cp "$build_root/release-0.2.0/openagents-0.2.0.tar.gz" "$publish_root/openagents-0.2.0.tar.gz"
73
cp "$build_root/relup" "$publish_root/relup"
74
75
tar -tzf "$publish_root/openagents-0.2.0.tar.gz" | grep -Fxq 'releases/0.2.0/relup'
76
77
RELUP_FILE="$publish_root/relup" elixir -e '
78
  {:ok, [relup]} = :file.consult(String.to_charlist(System.fetch_env!("RELUP_FILE")))
79
  {~c"0.2.0", [{~c"0.1.0", _, _}], [{~c"0.1.0", _, _}]} = relup
80
'
81
82
v1_digest=$(sha256sum "$publish_root/openagents-0.1.0.tar.gz" | cut -d ' ' -f 1)
83
v2_digest=$(sha256sum "$publish_root/openagents-0.2.0.tar.gz" | cut -d ' ' -f 1)
84
relup_digest=$(sha256sum "$publish_root/relup" | cut -d ' ' -f 1)
85
git_sha=$(git rev-parse --verify HEAD)
86
87
cat >"$publish_root/proof.json" <<EOF
88
{
89
  "schema": "openagents.relup-build-proof.v1",
90
  "git_sha": "$git_sha",
91
  "from_version": "0.1.0",
92
  "to_version": "0.2.0",
93
  "forward": true,
94
  "reverse": true,
95
  "release_0_1_0_digest": "$v1_digest",
96
  "release_0_2_0_digest": "$v2_digest",
97
  "relup_digest": "$relup_digest"
98
}
99
EOF
100
101
mkdir -p "$(dirname -- "$artifact_root")"
102
103
if [ -d "$artifact_root" ]; then
104
  find "$artifact_root" -depth -delete
105
fi
106
107
mv "$publish_root" "$artifact_root"
108
109
echo "Relup build proof passed"
110
echo "Artifacts: $artifact_root"
ops/relup-proof/version-chain.sh modified +33 -4

@@ -1,5 +1,34 @@

1
#!/bin/bash
2
# Upgrade one live process through every supported release version.
3
# This script is a placeholder for the version-chain drill.
1
#!/bin/sh
4 2
set -eu
5
echo "version-chain drill: to be wired after multiple release versions are available"
3
4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
. "$script_dir/common.sh"
6
7
prepare_runtime
8
trap cleanup_runtime EXIT INT TERM
9
start_release
10
11
rpc_assert ':release_handler.which_releases(:permanent) |> IO.inspect(label: "permanent")' '~c"0.1.0"'
12
rpc_assert 'OpenAgents.ReleaseState.observe("retained-through-relup"); IO.puts("observed")' 'observed'
13
state_pid=$(profile "$release_bin" rpc 'OpenAgents.ReleaseState |> Process.whereis() |> :erlang.pid_to_list() |> IO.puts()' | tail -1)
14
15
unpack_and_check_candidate
16
rpc_assert ':release_handler.install_release(~c"0.2.0") |> IO.inspect(label: "upgrade")' 'upgrade: {:ok, ~c"0.1.0"'
17
rpc_assert 'OpenAgents.ReleaseState.snapshot() |> IO.inspect(label: "state")' 'schema_version: 2'
18
rpc_assert 'OpenAgents.ReleaseState.snapshot() |> IO.inspect(label: "state")' 'retained-through-relup'
19
rpc_assert 'OpenAgents.ReleaseState |> Process.whereis() |> :erlang.pid_to_list() |> IO.puts()' "$state_pid"
20
rpc_assert ':release_handler.make_permanent(~c"0.2.0") |> IO.inspect(label: "commit")' 'commit: :ok'
21
22
rpc_assert ':release_handler.install_release(~c"0.1.0") |> IO.inspect(label: "downgrade")' 'downgrade: {:ok'
23
rpc_assert 'OpenAgents.ReleaseState.snapshot() |> IO.inspect(label: "state")' 'schema_version: 1'
24
rpc_assert 'OpenAgents.ReleaseState.snapshot() |> IO.inspect(label: "state")' 'retained-through-relup'
25
rpc_assert 'OpenAgents.ReleaseState |> Process.whereis() |> :erlang.pid_to_list() |> IO.puts()' "$state_pid"
26
rpc_assert ':release_handler.make_permanent(~c"0.1.0") |> IO.inspect(label: "commit")' 'commit: :ok'
27
28
rpc_assert ':release_handler.install_release(~c"0.2.0") |> IO.inspect(label: "reupgrade")' 'reupgrade: {:ok'
29
rpc_assert 'OpenAgents.ReleaseState.snapshot() |> IO.inspect(label: "state")' 'schema_version: 2'
30
rpc_assert 'OpenAgents.ReleaseState.snapshot() |> IO.inspect(label: "state")' 'retained-through-relup'
31
rpc_assert 'OpenAgents.ReleaseState |> Process.whereis() |> :erlang.pid_to_list() |> IO.puts()' "$state_pid"
32
rpc_assert ':release_handler.make_permanent(~c"0.2.0") |> IO.inspect(label: "commit")' 'commit: :ok'
33
34
echo "Relup version-chain proof passed"
rel/openagents.appup.exs modified +19 -10

@@ -1,16 +1,25 @@

1
# Hot-upgrade instructions for the :openagents app. A pure code swap of one
2
# module uses {:load_module, Mod} (no state migration — the safest relup); a
3
# stateful server change would use {:update, Mod, {:advanced, []}} to run
4
# code_change/3.
5
# RELUP_TO/RELUP_FROM drive a two-build relup; unset => a plain build with no steps.
1
# Supported hot-upgrade instructions for the :openagents app. Release proofs
2
# build 0.1.0 and 0.2.0 explicitly. The advanced update runs code_change/3 in
3
# both directions, and the optional barrier makes interruption recovery
4
# deterministic without affecting normal installs.
6 5
(fn ->
7 6
   to = System.get_env("RELUP_TO")
8 7
   from = System.get_env("RELUP_FROM")
9 8
10
   if is_binary(to) and is_binary(from) do
11
     step = [{:load_module, OpenAgents.BuildInfo}]
12
     {String.to_charlist(to), [{String.to_charlist(from), step}], [{String.to_charlist(from), step}]}
13
   else
14
     {String.to_charlist(System.get_env("OPENAGENTS_RELEASE_VSN", "0.1.0")), [], []}
9
   case {to, from} do
10
     {"0.2.0", "0.1.0"} ->
11
       steps = [
12
         {:update, OpenAgents.ReleaseState, {:advanced, []}},
13
         {:apply, {OpenAgents.ReleaseState, :install_barrier, []}},
14
         {:load_module, OpenAgents.BuildInfo}
15
       ]
16
17
       {~c"0.2.0", [{~c"0.1.0", steps}], [{~c"0.1.0", steps}]}
18
19
     {nil, nil} ->
20
       {String.to_charlist(System.get_env("OPENAGENTS_RELEASE_VSN", "0.2.0")), [], []}
21
22
     _unsupported ->
23
       raise "RELUP_FROM and RELUP_TO must select the supported 0.1.0 to 0.2.0 transition"
15 24
   end
16 25
 end).()
test/openagents/cluster/code_change_test.exs modified +25 -21

@@ -7,34 +7,38 @@ defmodule OpenAgents.Cluster.CodeChangeTest do

7 7
  """
8 8
  use ExUnit.Case, async: true
9 9
10
  alias OpenAgents.Test.UpgradableCounter, as: Counter
10
  alias OpenAgents.ReleaseState
11
  alias OpenAgents.ReleaseState.State
11 12
12
  test "code_change/3 migrates old (v1) state to the new struct without restarting the process" do
13
    {:ok, pid} = Counter.start_link()
13
  test "upgrade, downgrade, and re-upgrade preserve PID and observations" do
14
    pid = start_supervised!({ReleaseState, name: nil})
15
    :ok = ReleaseState.observe("retained", pid)
14 16
15
    # Simulate a process that was started under the OLD release: force its state
16
    # to the v1 shape (a bare map with :count, no :label / :version).
17
    :sys.replace_state(pid, fn _new -> %{count: 7} end)
18
    assert :sys.get_state(pid) == %{count: 7}
17
    :sys.replace_state(pid, fn %State{} = state ->
18
      %State{state | schema_version: 1, integrity: nil}
19
    end)
19 20
20
    # Suspend → change_code (runs code_change("1", state, [])) → resume, exactly
21
    # as a relup's {update, Mod, {advanced, _}} instruction does.
22 21
    :ok = :sys.suspend(pid)
23
    :ok = :sys.change_code(pid, Counter, "1", [])
22
    :ok = :sys.change_code(pid, ReleaseState, ~c"0.1.0", [])
24 23
    :ok = :sys.resume(pid)
25 24
26
    # Same process, migrated state: the v1 count survived and the v2 fields were
27
    # filled in — no drop, no restart.
28
    assert Process.alive?(pid)
29
    migrated = :sys.get_state(pid)
30
    assert migrated == %Counter{version: 2, count: 7, label: "default"}
31
    assert GenServer.call(pid, :get) == migrated
25
    assert %State{schema_version: 2, observations: ["retained"], integrity: integrity} =
26
             ReleaseState.snapshot(pid)
32 27
33
    GenServer.stop(pid)
34
  end
28
    assert is_binary(integrity)
29
30
    :ok = :sys.suspend(pid)
31
    :ok = :sys.change_code(pid, ReleaseState, {:down, ~c"0.2.0"}, [])
32
    :ok = :sys.resume(pid)
33
34
    assert %State{schema_version: 1, observations: ["retained"], integrity: nil} =
35
             ReleaseState.snapshot(pid)
36
37
    :ok = :sys.suspend(pid)
38
    :ok = :sys.change_code(pid, ReleaseState, ~c"0.1.0", [])
39
    :ok = :sys.resume(pid)
35 40
36
  test "code_change/3 is idempotent for a state already at the current version" do
37
    state = %Counter{version: 2, count: 3, label: "x"}
38
    assert Counter.code_change("2", state, []) == {:ok, state}
41
    assert %State{schema_version: 2, observations: ["retained"]} = ReleaseState.snapshot(pid)
42
    assert %State{} = :sys.get_state(pid)
39 43
  end
40 44
end
test/openagents/forge/gate_receipt_test.exs added +74

@@ -0,0 +1,74 @@

1
defmodule OpenAgents.Forge.GateReceiptTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Forge.GateReceipt
5
6
  @sha String.duplicate("a", 40)
7
  @stages ~w(
8
    compile
9
    precommit
10
    cluster
11
    javascript
12
    direct_transaction
13
    relup
14
    version_chain
15
    interrupted_install
16
    rolling_replacement
17
    contracts
18
    release_smoke
19
  )
20
21
  test "accepts only a complete receipt bound to the requested SHA" do
22
    root = temporary_root()
23
    path = GateReceipt.path(@sha, repo_root: root)
24
    File.mkdir_p!(Path.dirname(path))
25
26
    receipt = %{
27
      "schema" => "openagents.release-gate.v1",
28
      "git_sha" => @sha,
29
      "status" => "passed",
30
      "stages" => Map.new(@stages, &{&1, %{"status" => "passed"}})
31
    }
32
33
    File.write!(path, Jason.encode!(receipt))
34
    assert {:ok, ^receipt} = GateReceipt.verify(@sha, repo_root: root)
35
36
    assert {:error, :missing_gate_receipt} =
37
             GateReceipt.verify(String.duplicate("b", 40), repo_root: root)
38
  end
39
40
  test "rejects an incomplete receipt" do
41
    root = temporary_root()
42
    path = GateReceipt.path(@sha, repo_root: root)
43
    File.mkdir_p!(Path.dirname(path))
44
45
    File.write!(
46
      path,
47
      Jason.encode!(%{
48
        "schema" => "openagents.release-gate.v1",
49
        "git_sha" => @sha,
50
        "status" => "passed",
51
        "stages" => %{}
52
      })
53
    )
54
55
    assert {:error, :incomplete_gate_receipt} = GateReceipt.verify(@sha, repo_root: root)
56
  end
57
58
  test "never lets an emergency reason bypass Git SHA validation" do
59
    assert {:error, :invalid_git_sha} =
60
             GateReceipt.verify("not-a-sha", emergency_override: "operator recovery")
61
  end
62
63
  defp temporary_root do
64
    path =
65
      Path.join(
66
        System.tmp_dir!(),
67
        "openagents-gate-receipt-#{System.unique_integer([:positive])}"
68
      )
69
70
    File.mkdir_p!(path)
71
    on_exit(fn -> File.rm_rf!(path) end)
72
    path
73
  end
74
end
test/openagents/forge/relup_deployment_test.exs added +118

@@ -0,0 +1,118 @@

1
defmodule OpenAgents.Forge.RelupDeploymentTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Forge.RelupDeployment
5
6
  @sha String.duplicate("a", 40)
7
  @digest String.duplicate("b", 64)
8
  @nodes [:first@local, :second@local, :third@local]
9
10
  test "upgrades and makes each node permanent before touching the next" do
11
    parent = self()
12
13
    rpc = fn node, _module, function, _arguments, _timeout ->
14
      send(parent, {:phase, node, function})
15
      {:ok, %{"phase" => to_string(function)}}
16
    end
17
18
    assert {:ok, %{status: "live"}} =
19
             RelupDeployment.run(request(),
20
               members: fn -> @nodes end,
21
               gate_verifier: fn @sha -> {:ok, %{}} end,
22
               rpc: rpc
23
             )
24
25
    expected =
26
      for node <- @nodes,
27
          phase <- [
28
            :stage,
29
            :verify_stage,
30
            :unpack,
31
            :check_install,
32
            :install,
33
            :verify,
34
            :make_permanent,
35
            :verify
36
          ],
37
          do: {:phase, node, phase}
38
39
    assert drain_messages([]) == expected
40
  end
41
42
  test "reverses an unhealthy node and aborts before the next node" do
43
    parent = self()
44
45
    rpc = fn node, _module, function, arguments, _timeout ->
46
      send(parent, {:phase, node, function})
47
48
      case {node, function, arguments} do
49
        {:second@local, :verify, [_request, :current]} -> {:error, :unhealthy}
50
        {:second@local, :reverse, _arguments} -> {:ok, %{"restored" => true}}
51
        _other -> {:ok, %{}}
52
      end
53
    end
54
55
    assert {:error, %{status: "failed", error_code: error_code}} =
56
             RelupDeployment.run(request(),
57
               members: fn -> @nodes end,
58
               gate_verifier: fn @sha -> {:ok, %{}} end,
59
               rpc: rpc
60
             )
61
62
    assert is_binary(error_code)
63
    events = drain_messages([])
64
    assert {:phase, :second@local, :reverse} in events
65
    assert {:phase, :first@local, :reverse} in events
66
    refute Enum.any?(events, fn {_, node, _phase} -> node == :third@local end)
67
  end
68
69
  test "reverses the newly permanent node when membership changes before continuation" do
70
    parent = self()
71
    calls = start_supervised!({Agent, fn -> 0 end})
72
73
    members = fn ->
74
      Agent.get_and_update(calls, fn count ->
75
        next = count + 1
76
        members = if next >= 3, do: Enum.drop(@nodes, -1), else: @nodes
77
        {members, next}
78
      end)
79
    end
80
81
    rpc = fn node, _module, function, _arguments, _timeout ->
82
      send(parent, {:phase, node, function})
83
      {:ok, %{}}
84
    end
85
86
    assert {:error, %{status: "failed"}} =
87
             RelupDeployment.run(request(),
88
               members: members,
89
               gate_verifier: fn @sha -> {:ok, %{}} end,
90
               rpc: rpc
91
             )
92
93
    assert {:phase, :first@local, :reverse} in drain_messages([])
94
  end
95
96
  defp request do
97
    %{
98
      sha: @sha,
99
      release_name: "openagents",
100
      from_version: "0.1.0",
101
      to_version: "0.2.0",
102
      from_state_version: 1,
103
      to_state_version: 2,
104
      artifact_bytes: "artifact",
105
      artifact_digest: @digest,
106
      expected_nodes: @nodes,
107
      expected_fleet_size: 3
108
    }
109
  end
110
111
  defp drain_messages(acc) do
112
    receive do
113
      event -> drain_messages([event | acc])
114
    after
115
      0 -> Enum.reverse(acc)
116
    end
117
  end
118
end
test/openagents/forge/relup_node_test.exs added +71

@@ -0,0 +1,71 @@

1
defmodule OpenAgents.Forge.RelupNodeTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.Forge.RelupNode
5
  alias OpenAgents.Test.ReleaseHandler
6
7
  setup do
8
    start_supervised!(
9
      {ReleaseHandler, %{releases: [{~c"openagents", ~c"0.1.0", [], :permanent}]}}
10
    )
11
12
    root =
13
      Path.join(System.tmp_dir!(), "openagents-relup-node-#{System.unique_integer([:positive])}")
14
15
    File.mkdir_p!(Path.join(root, "releases"))
16
    on_exit(fn -> File.rm_rf!(root) end)
17
18
    artifact = "immutable release artifact"
19
20
    request = %{
21
      release_name: "openagents",
22
      from_version: "0.1.0",
23
      to_version: "0.2.0",
24
      from_state_version: 1,
25
      to_state_version: 2,
26
      artifact_bytes: artifact,
27
      artifact_digest: sha256(artifact)
28
    }
29
30
    opts = [
31
      release_root: root,
32
      release_handler: ReleaseHandler,
33
      generate_config: fn _version -> :ok end,
34
      health: fn -> %{"ready" => true} end,
35
      state: fn -> %{schema_version: 2} end
36
    ]
37
38
    %{root: root, request: request, opts: opts}
39
  end
40
41
  test "restages the consumed artifact from immutable cache", %{
42
    root: root,
43
    request: request,
44
    opts: opts
45
  } do
46
    assert {:ok, %{"phase" => "staged"}} = RelupNode.stage(request, opts)
47
    assert {:ok, %{"phase" => "stage_verified"}} = RelupNode.verify_stage(request, opts)
48
    assert {:ok, %{"phase" => "unpacked"}} = RelupNode.unpack(request, opts)
49
50
    staged = Path.join([root, "releases", "openagents-0.2.0.tar.gz"])
51
    File.rm!(staged)
52
53
    assert {:ok, %{"phase" => "unpacked", "restaged" => true}} = RelupNode.unpack(request, opts)
54
    assert File.read!(staged) == request.artifact_bytes
55
  end
56
57
  test "checks, installs, verifies, and makes the release permanent", %{
58
    request: request,
59
    opts: opts
60
  } do
61
    assert {:ok, _result} = RelupNode.stage(request, opts)
62
    assert {:ok, _result} = RelupNode.unpack(request, opts)
63
    assert {:ok, %{"phase" => "checked"}} = RelupNode.check_install(request, opts)
64
    assert {:ok, %{"phase" => "installed"}} = RelupNode.install(request, opts)
65
    assert {:ok, %{"phase" => "verified"}} = RelupNode.verify(request, :current, opts)
66
    assert {:ok, %{"phase" => "permanent"}} = RelupNode.make_permanent(request, opts)
67
    assert {:ok, %{"phase" => "verified"}} = RelupNode.verify(request, :permanent, opts)
68
  end
69
70
  defp sha256(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)
71
end
test/openagents/forge/rolling_replacement_test.exs added +97

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

1
defmodule OpenAgents.Forge.RollingReplacementTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.Forge.RollingReplacement
5
  alias OpenAgents.Test.RollingProvider
6
7
  @sha String.duplicate("a", 40)
8
  @previous_sha String.duplicate("d", 40)
9
  @target "sha256:" <> String.duplicate("b", 64)
10
  @previous "sha256:" <> String.duplicate("c", 64)
11
  @nodes [:first@local, :second@local, :third@local]
12
13
  test "replaces one node at a time only after readiness returns" do
14
    start_provider(nil)
15
16
    assert {:ok, %{status: "live"}} = run()
17
18
    replace_events =
19
      RollingProvider.events()
20
      |> Enum.filter(&match?({:replace, _node, _digest}, &1))
21
22
    assert replace_events == [
23
             {:replace, :first@local, @target},
24
             {:replace, :second@local, @target},
25
             {:replace, :third@local, @target}
26
           ]
27
  end
28
29
  test "restores the prior image and aborts before another replacement when rejoin fails" do
30
    start_provider(:second@local)
31
32
    assert {:error, %{status: "failed", recovery: "last_known_good_restored"}} = run()
33
34
    events = RollingProvider.events()
35
    assert {:rollback, :second@local, @previous} in events
36
    refute {:replace, :third@local, @target} in events
37
  end
38
39
  test "refuses deployment without an exact gate receipt" do
40
    start_provider(nil)
41
42
    assert {:error, :missing_gate_receipt} =
43
             RollingReplacement.run(request(),
44
               provider: RollingProvider,
45
               members: fn -> @nodes end,
46
               gate_verifier: fn @sha -> {:error, :missing_gate_receipt} end
47
             )
48
49
    assert RollingProvider.events() == []
50
  end
51
52
  test "restores readiness without replacing an image when drained capacity is unsafe" do
53
    start_provider(nil, {:ok, %{ready: 1, quorum: true}})
54
55
    assert {:error, %{status: "failed", recovery: "readiness_restored"}} = run()
56
57
    events = RollingProvider.events()
58
    assert {:restore_readiness, :first@local} in events
59
    refute Enum.any?(events, &match?({:replace, _node, _digest}, &1))
60
  end
61
62
  defp run do
63
    RollingReplacement.run(request(),
64
      provider: RollingProvider,
65
      members: fn -> @nodes end,
66
      gate_verifier: fn @sha -> {:ok, %{}} end,
67
      wait_attempts: 1,
68
      wait_interval_ms: 0
69
    )
70
  end
71
72
  defp request do
73
    %{
74
      sha: @sha,
75
      previous_sha: @previous_sha,
76
      image_digest: @target,
77
      previous_image_digest: @previous,
78
      expected_nodes: @nodes,
79
      expected_fleet_size: 3,
80
      minimum_ready: 2
81
    }
82
  end
83
84
  defp start_provider(fail_node, capacity \\ nil) do
85
    start_supervised!(
86
      {RollingProvider,
87
       %{
88
         capacity: capacity,
89
         events: [],
90
         fail_node: fail_node,
91
         previous_sha: @previous_sha,
92
         rolled_back: MapSet.new(),
93
         digests: Map.new(@nodes, &{&1, @previous})
94
       }}
95
    )
96
  end
97
end
test/openagents/test/relup_genserver_test.exs deleted -15

@@ -1,15 +0,0 @@

1
defmodule OpenAgents.Test.RelupGenServerTest do
2
  use ExUnit.Case
3
4
  alias OpenAgents.Test.RelupGenServer
5
6
  test "migrates state through code_change/3" do
7
    {:ok, pid} = RelupGenServer.start_link(counter: 10)
8
9
    :ok = :sys.suspend(pid)
10
    :ok = :sys.change_code(pid, RelupGenServer, 1, [])
11
    :ok = :sys.resume(pid)
12
13
    assert %RelupGenServer{counter: 11, version: 2} = :sys.get_state(pid)
14
  end
15
end
test/support/openagents/test/release_handler.ex added +70

@@ -0,0 +1,70 @@

1
defmodule OpenAgents.Test.ReleaseHandler do
2
  @moduledoc false
3
4
  use Agent
5
6
  def start_link(initial) do
7
    Agent.start_link(fn -> initial end, name: __MODULE__)
8
  end
9
10
  def which_releases do
11
    Agent.get(__MODULE__, & &1.releases)
12
  end
13
14
  def unpack_release(name) do
15
    version = name |> to_string() |> String.replace_prefix("openagents-", "")
16
17
    Agent.update(__MODULE__, fn state ->
18
      release = {~c"openagents", to_charlist(version), [], :unpacked}
19
      %{state | releases: [release | state.releases]}
20
    end)
21
22
    {:ok, to_charlist(version)}
23
  end
24
25
  def check_install_release(version) do
26
    {:ok, other_version(version), ~c"test relup"}
27
  end
28
29
  def install_release(version) do
30
    version = to_string(version)
31
32
    Agent.update(__MODULE__, fn state ->
33
      releases =
34
        Enum.map(state.releases, fn {name, found, applications, status} ->
35
          cond do
36
            to_string(found) == version -> {name, found, applications, :current}
37
            status == :current -> {name, found, applications, :old}
38
            true -> {name, found, applications, status}
39
          end
40
        end)
41
42
      %{state | releases: releases}
43
    end)
44
45
    {:ok, other_version(version), ~c"test relup"}
46
  end
47
48
  def make_permanent(version) do
49
    version = to_string(version)
50
51
    Agent.update(__MODULE__, fn state ->
52
      releases =
53
        Enum.map(state.releases, fn {name, found, applications, status} ->
54
          cond do
55
            to_string(found) == version -> {name, found, applications, :permanent}
56
            status == :permanent -> {name, found, applications, :old}
57
            true -> {name, found, applications, status}
58
          end
59
        end)
60
61
      %{state | releases: releases}
62
    end)
63
64
    :ok
65
  end
66
67
  defp other_version(version) do
68
    if to_string(version) == "0.2.0", do: ~c"0.1.0", else: ~c"0.2.0"
69
  end
70
end
test/support/openagents/test/relup_genserver.ex deleted -33

@@ -1,33 +0,0 @@

1
defmodule OpenAgents.Test.RelupGenServer do
2
  @moduledoc """
3
  Versioned GenServer used to prove `code_change/3` state migrations.
4
  """
5
6
  use GenServer
7
8
  defstruct [:counter, :version]
9
10
  def start_link(opts \\ []) do
11
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
12
  end
13
14
  @impl true
15
  def init(opts) do
16
    counter = Keyword.get(opts, :counter, 0)
17
    {:ok, %__MODULE__{counter: counter, version: 1}}
18
  end
19
20
  @impl true
21
  def handle_call(:state, _from, state) do
22
    {:reply, state, state}
23
  end
24
25
  @impl true
26
  def code_change(1, %__MODULE__{counter: counter}, _extra) do
27
    {:ok, %__MODULE__{counter: counter + 1, version: 2}}
28
  end
29
30
  def code_change(_old_vsn, state, _extra) do
31
    {:ok, state}
32
  end
33
end
test/support/openagents/test/rolling_provider.ex added +91

@@ -0,0 +1,91 @@

1
defmodule OpenAgents.Test.RollingProvider do
2
  @moduledoc false
3
4
  @behaviour OpenAgents.Forge.RollingProvider
5
6
  use Agent
7
8
  def start_link(initial) do
9
    Agent.start_link(fn -> initial end, name: __MODULE__)
10
  end
11
12
  def events, do: Agent.get(__MODULE__, &Enum.reverse(&1.events))
13
14
  @impl true
15
  def remove_readiness(node, _context) do
16
    event({:remove_readiness, node})
17
    :ok
18
  end
19
20
  @impl true
21
  def restore_readiness(node, _context) do
22
    event({:restore_readiness, node})
23
    :ok
24
  end
25
26
  @impl true
27
  def drain(node, _context) do
28
    event({:drain, node})
29
    {:ok, 0}
30
  end
31
32
  @impl true
33
  def capacity(nodes, _context) do
34
    event({:capacity, nodes})
35
36
    Agent.get(__MODULE__, fn state ->
37
      Map.get(state, :capacity) || {:ok, %{ready: length(nodes), quorum: true}}
38
    end)
39
  end
40
41
  @impl true
42
  def replace(node, digest, _context) do
43
    event({:replace, node, digest})
44
45
    Agent.update(__MODULE__, fn state ->
46
      %{state | digests: Map.put(state.digests, node, digest)}
47
    end)
48
49
    :ok
50
  end
51
52
  @impl true
53
  def status(node, context) do
54
    event({:status, node})
55
56
    Agent.get(__MODULE__, fn state ->
57
      rolled_back? = MapSet.member?(state.rolled_back, node)
58
      failed? = state.fail_node == node and not rolled_back?
59
      digest = Map.fetch!(state.digests, node)
60
61
      {:ok,
62
       %{
63
         member: not failed?,
64
         ready: not failed?,
65
         boot_converged: not failed?,
66
         database_ready: not failed?,
67
         sha: if(rolled_back?, do: state.previous_sha, else: context.sha),
68
         image_digest: digest
69
       }}
70
    end)
71
  end
72
73
  @impl true
74
  def rollback(node, digest, _context) do
75
    event({:rollback, node, digest})
76
77
    Agent.update(__MODULE__, fn state ->
78
      %{
79
        state
80
        | digests: Map.put(state.digests, node, digest),
81
          rolled_back: MapSet.put(state.rolled_back, node)
82
      }
83
    end)
84
85
    :ok
86
  end
87
88
  defp event(value) do
89
    Agent.update(__MODULE__, fn state -> %{state | events: [value | state.events]} end)
90
  end
91
end
test/support/upgradable_counter.ex deleted -34

@@ -1,34 +0,0 @@

1
defmodule OpenAgents.Test.UpgradableCounter do
2
  @moduledoc """
3
  A minimal GenServer that carries a **versioned struct state** and a real
4
  `code_change/3`, to exercise the M4 hot-upgrade mechanism (OTP
5
  `:sys.change_code`) in a test: an in-place code swap must migrate the live
6
  process's state from the old shape to the new one without restarting it.
7
8
  This is the discipline every stateful server in the system follows so a relup
9
  can `{update, Mod, {advanced, Extra}}` it (suspend → load → code_change →
10
  resume) instead of dropping the process on deploy.
11
  """
12
  use GenServer
13
14
  # v2 state: v1 was %{count: n} with no label; v2 adds :label and a version tag.
15
  defstruct version: 2, count: 0, label: "default"
16
17
  def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, opts)
18
19
  @impl true
20
  def init(_opts), do: {:ok, %__MODULE__{}}
21
22
  @impl true
23
  def handle_call(:get, _from, state), do: {:reply, state, state}
24
25
  # Migrate an old (v1) state to the current (v2) struct. Idempotent for a state
26
  # already at v2. This is the whole point of the discipline: the exact function
27
  # a relup's advanced update runs against each live process.
28
  @impl true
29
  def code_change("1", %{count: count}, _extra) do
30
    {:ok, %__MODULE__{version: 2, count: count, label: "default"}}
31
  end
32
33
  def code_change(_old_vsn, %__MODULE__{} = state, _extra), do: {:ok, state}
34
end

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