Make a general relup describe the two revisions it upgrades between

19326be8e35a · AtlantisPleb · · parent 1bacb84d05ba

Make a general relup describe the two revisions it upgrades between

`:systools.make_relup/4` copies an appup into the relup verbatim; it never
compares module contents. While the admitted transition was pinned to
0.1.0-to-0.2.0 that was safe, because those two proof releases differed only in
ReleaseState and BuildInfo -- exactly the two modules the appup named. Once the
pin came off and package-relup.sh started packaging arbitrary revision pairs,
the constant instruction list became a half-upgrade: a relup between two real
revisions loaded those two modules, verify passed, BuildInfo.revision/0
reported the new SHA, and the node ran an interleaved mixture of two revisions
while reporting itself converged.

rel/openagents.appup.exs now asks OpenAgents.Release.Appup to diff the two
builds' compiled modules and emit one instruction per module whose code MD5
differs: load_module, the supervisor update for a supervisor, add_module and
delete_module for either side's exclusives, and always the explicit advanced
ReleaseState update plus the install barrier. `mix openagents.relup` then
checks the generated relup against that same diff, so a stale appup or missing
build environment fails packaging rather than shipping a relup that installs
part of a revision. The build fails closed when RELUP_FROM and RELUP_TO are set
without the from-build's ebin directory. Both sides must be `_build` ebin
directories, because protocol consolidation happens later in `mix release` and
comparing a release ebin against a `_build` ebin would report modules as added
or deleted that neither revision touched. `:appup` moved to the end of the
compiler list so the appup can call a module the project compiled.

ReleaseState.code_change/3 forced schema 1 on every downgrade, a hardcode from
the same pinned proof. The new admission allows from_state == to_state == 2,
which is the modal case and what package-relup.sh defaults to. In OTP a
dn-mode advanced update runs code_change in the NEW module before the old code
loads, so that clause is what executes: a 0.2.0-to-0.3.0 reverse corrupted the
process to schema 1, verify_reverse_health/2 then refused with
reverse_state_version_mismatch, and reverse_permanent never ran -- leaving the
node on downgraded code as `current` with the new version still permanent. On a
fleet, rollback_completed/3 did that to every already-completed node. The
target schema now travels in the appup's Extra term, generated per direction,
and a downgrade given no schema refuses instead of guessing.

RelupNode.unpack/2 short-circuited on any version string release_handler had
seen, so re-cutting a version from a different SHA installed the stale bytes.
It now records which artifact each unpacked version came from and refuses with
unpacked_version_conflict when they disagree, while still letting an
interrupted install retry from the same bytes.

install-proof.sh armed its cleanup trap before the disposable-database guard,
so a refused run still ran dropdb; it dropped a variable it never created; and
it forced -h localhost while the app connected to the URL's host. It now
refuses before the trap, unless the host is loopback and the database name
looks disposable. Every release_handler call ended in `|| true` and
make_permanent was never checked in either direction while the script printed
that it had succeeded. Each call now records its result to a file on the node
and the shell asserts on that, which survives the rpc channel dropping IO
mid-call. The reverse leg asserts exactly what verify_reverse_health/2 asserts,
using the versions and state schemas from package.json rather than a hardcoded
pair.

INVARIANTS RELEASE-005 described the pinned transition and is rewritten for the
general lane, including what the reverse path guarantees. The two hot-deploy
documents claimed the gaps were closed in code and that only operator work
remained; RelupDeployment.run/2 has no caller outside tests, neither packaging
script is a gate stage, and nothing binds a package to the revision its gate
receipt covers. They now say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o8HwTaqLKEWCHTjsjFtrB
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified INVARIANTS.md
  • modified docs/2026-08-21-hot-deploy-gap-audit.md
  • modified docs/operations/forge-hot-loop.md
  • modified docs/operations/release-deployment-fallbacks.md
  • modified lib/mix/tasks/openagents.relup.ex
  • modified lib/openagents/forge/relup_node.ex
  • added lib/openagents/release/appup.ex
  • modified lib/openagents/release_state.ex
  • modified mix.exs
  • modified ops/forge/package-relup.sh
  • modified ops/relup-proof/install-proof.sh
  • modified ops/relup-proof/run.sh
  • modified rel/openagents.appup.exs
  • modified test/openagents/cluster/code_change_test.exs
  • modified test/openagents/forge/relup_deployment_test.exs
  • modified test/openagents/forge/relup_node_test.exs
  • added test/openagents/release/appup_test.exs
  • modified test/support/openagents/test/release_handler.ex

Diff

18 files changed, +1209 -200

INVARIANTS.md modified +30 -9

@@ -1731,22 +1731,43 @@ Evidence: `ops/ci/gate.sh`, `.githooks/pre-push`,

1731 1731
Status: Current
1732 1732
1733 1733
Direct BEAM candidates use an exact-fleet prepare, canary, apply, verify,
1734
commit, and rollback transaction. The supported `0.1.0` to `0.2.0` application
1735
transition uses a two-way relup, versioned process state, node-by-node health
1736
checks, and reverse installation. Every structural or unclassified candidate
1737
uses digest-addressed rolling replacement with readiness drain, remaining-
1738
capacity and quorum checks, exact rejoin verification, and last-known-good
1739
image recovery. A failed relup or replacement aborts before another node
1740
changes.
1734
commit, and rollback transaction. An application transition between any two
1735
concrete `X.Y.Z` versions uses a two-way relup, versioned process state,
1736
node-by-node health checks, and reverse installation. Every structural or
1737
unclassified candidate uses digest-addressed rolling replacement with readiness
1738
drain, remaining-capacity and quorum checks, exact rejoin verification, and
1739
last-known-good image recovery. A failed relup or replacement aborts before
1740
another node changes.
1741
1742
A packaged relup describes the two revisions it was built from. The appup is
1743
derived from both builds' compiled modules, so the relup carries an instruction
1744
for every module whose code differs, and packaging refuses when the generated
1745
relup omits one. A node therefore cannot install part of a revision while
1746
reporting itself converged. A version that a node already unpacked from
1747
different artifact bytes is refused rather than reused.
1748
1749
The reverse path restores the `from` release rather than a fixed one. Each
1750
direction's target state schema travels in the appup, so a downgrade migrates
1751
process state to the schema the `from` release compiled — including a pair
1752
whose schemas match, which keeps its schema instead of being forced back to
1753
schema 1. Reverse verification checks release status, node readiness, and that
1754
exact schema before it restores permanence, and a downgrade that is given no
1755
target schema refuses instead of guessing.
1741 1756
1742 1757
All deployment workers remain disabled until isolated staging proves their
1743 1758
complete provider and topology. Current means that the local mechanism and its
1744 1759
refusal and recovery paths exist; it does not authorize staging or production.
1760
`OpenAgents.Forge.RelupDeployment.run/2` has no production caller, and the
1761
release gate runs neither `ops/forge/package-relup.sh` nor
1762
`ops/relup-proof/install-proof.sh`.
1745 1763
1746 1764
Evidence: `OpenAgents.Forge.Deployment`, `OpenAgents.Forge.RelupDeployment`,
1747 1765
`OpenAgents.Forge.RelupNode`, `OpenAgents.ReleaseState`,
1748
`OpenAgents.Forge.RollingReplacement`,
1766
`OpenAgents.Release.Appup`, `OpenAgents.Forge.RollingReplacement`,
1749 1767
`test/openagents/forge/relup_deployment_test.exs`,
1768
`test/openagents/forge/relup_node_test.exs`,
1769
`test/openagents/release/appup_test.exs`,
1770
`test/openagents/cluster/code_change_test.exs`,
1750 1771
`test/openagents/forge/rolling_replacement_test.exs`, and
1751 1772
`docs/operations/release-deployment-fallbacks.md`.
1752 1773

@@ -1981,7 +2002,7 @@ contract; the invariant prose above defines the assertion, not the filename.

1981 2002
| RELEASE-002 | `test/openagents/github_oauth/runtime_config_test.exs`, `ops/ci/reference-check.sh` |
1982 2003
| RELEASE-003 | `test/openagents_web/allowed_origins_test.exs`, `ops/ci/release-smoke.sh` |
1983 2004
| RELEASE-004 | `ops/ci/gate.sh`, `test/openagents/forge/gate_receipt_test.exs` |
1984
| RELEASE-005 | `test/openagents/forge/relup_deployment_test.exs`, `test/openagents/forge/rolling_replacement_test.exs` |
2005
| RELEASE-005 | `test/openagents/forge/relup_deployment_test.exs`, `test/openagents/forge/relup_node_test.exs`, `test/openagents/release/appup_test.exs`, `test/openagents/cluster/code_change_test.exs`, `test/openagents/forge/rolling_replacement_test.exs` |
1985 2006
| STATUS-001 | `test/openagents/network_status_test.exs`, `test/openagents_web/live/network_status_live_test.exs` |
1986 2007
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs` |
1987 2008
| REPOSITORY-001 | `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/forge/git_http_test.exs` |
docs/2026-08-21-hot-deploy-gap-audit.md modified +11 -5

@@ -20,7 +20,7 @@ What is missing is **enablement**, and it is fenced deliberately:

20 20
21 21
Meanwhile every production deploy rides the structural class: one immutable image built locally under amd64 emulation (~9 minutes for commit `eda094c`), pushed to Artifact Registry, qualified on staging, then rolled one node at a time (~7 minutes). Push-to-live measured about 23 minutes on 2026-08-21 for a release whose changes were mostly hot-loadable LiveView and template work — the exact class Sarah moved in seconds.
22 22
23
Closing the gap is operator work, not engineering work: apply the staging Terraform, run Gates 12–15 against the real builder image, pin the builder digest into fleet metadata, flip the feature flags, then extend the runbooks with the consolidated decision table and drill lessons listed in section 5.
23
Closing the gap for the direct-load lane is operator work: apply the staging Terraform, run Gates 12–15 against the real builder image, pin the builder digest into fleet metadata, flip the feature flags, then extend the runbooks with the consolidated decision table and drill lessons listed in section 5. The general relup lane is the exception — it still needs engineering work, described in the addendum below.
24 24
25 25
### Addendum: post-measurement production events (2026-08-21, later)
26 26

@@ -35,10 +35,16 @@ After this audit was measured, two claims moved:

35 35
   general relup path was, at measurement time, unconnected: `BuildArtifact`
36 36
   classifies only `direct_candidate` and `needs_rolling_replace`, and
37 37
   `RelupDeployment` accepted only the fixed `0.1.0 → 0.2.0` proof transition.
38
   Later the same day the relup lane was connected for general version pairs —
39
   coordinator admission, appup generation, packaging, and install proofs; see
40
   [`docs/operations/forge-hot-loop.md`](operations/forge-hot-loop.md).
41
   Production approval for the lane remains an open operator decision.
38
   Later the same day the relup **mechanism** was generalized — coordinator
39
   admission, appup generation from the two builds' compiled modules,
40
   packaging, and install proofs; see
41
   [`docs/operations/forge-hot-loop.md`](operations/forge-hot-loop.md). The
42
   **lane** is still not connected: no classifier emits a relup class,
43
   `RelupDeployment.run/2` has no caller outside tests, the release gate runs
44
   neither `ops/forge/package-relup.sh` nor
45
   `ops/relup-proof/install-proof.sh`, and no receipt binds a package to the
46
   revision it was built from. Those are engineering gaps, not operator ones.
47
   Production approval for the lane also remains an open operator decision.
42 48
43 49
---
44 50
docs/operations/forge-hot-loop.md modified +34 -16

@@ -16,7 +16,7 @@ flips it on.

16 16
| --- | --- | --- |
17 17
| Direct BEAM transaction on production | Works | `fa4b792` loaded across three nodes via the transaction protocol; `live` target and deployment receipt recorded; uptimes unbroken |
18 18
| Automated push → promote → build → hot-load loop | Not yet operating | No receipted automated deploy exists, so `/api/status` reports `loop.last_ms: null` and `push_to_live_ms: null` |
19
| General relup lane | Proof harness only | The classifier emits only `direct_candidate` or `needs_rolling_replace`; `RelupDeployment` admits only the fixed `0.1.0 → 0.2.0` proof transition while production already runs `0.2.0`. Relups are not production-approved (`release-deployment-fallbacks.md`) |
19
| General relup lane | Manual tools only | `RelupDeployment` admits any distinct `X.Y.Z` pair, but the classifier emits only `direct_candidate` or `needs_rolling_replace`, `RelupDeployment.run/2` has no caller outside tests, and the release gate runs neither packaging script. Relups are not production-approved (`release-deployment-fallbacks.md`) |
20 20
| Rolling image replacement | Works, default for structural changes | Current release tooling path |
21 21
22 22
Two consequences worth stating plainly:

@@ -126,38 +126,56 @@ human reaction time.

126 126
A `reverted` outcome still warrants checking fleet convergence even though
127 127
this design captures each node's prior object code for exact rollback.
128 128
129
## The relup lane is now connected
129
## The relup lane generalized, but nothing drives it
130 130
131
As of 2026-08-21 (later), the three gaps recorded below are closed in code:
131
As of 2026-08-21 (later), the mechanism handles arbitrary version pairs:
132 132
133 133
1. **Coordinator admission is general.** `RelupDeployment` admits any distinct
134 134
   `X.Y.Z` pair whose state versions stay within `[1, 2]` and never regress —
135 135
   matching what `RelupNode` already enforced per node. The packaged appup on
136 136
   the target nodes remains the real gate: `check_install_release` refuses
137 137
   honestly when no relup can be produced between two versions.
138
2. **Appup generation works for any admitted pair.** `rel/openagents.appup.exs`
139
   generates forward and reverse instructions from `RELUP_FROM`/`RELUP_TO`
140
   for arbitrary distinct versions instead of raising outside the proof
141
   transition.
138
2. **Appup generation describes the pair it was built from.**
139
   `rel/openagents.appup.exs` asks `OpenAgents.Release.Appup` to diff the two
140
   builds' compiled modules and emits one instruction per module that differs,
141
   plus the advanced `ReleaseState` update carrying each direction's target
142
   state schema. `mix openagents.relup` then checks the generated relup against
143
   the same diff, so packaging fails rather than shipping a relup that would
144
   install part of a revision.
142 145
3. **Packaging and install proofs exist as tools.**
143 146
   `ops/forge/package-relup.sh --from-version A --to-version B [--from-rev]
144 147
   [--to-rev]` builds both releases in isolated worktrees, generates the
145 148
   two-way relup, embeds it, and emits digest-addressed tarballs plus a
146 149
   `package.json` ready for deployment requests.
147
   `ops/relup-proof/install-proof.sh` then proves the pair against a live
150
   `ops/relup-proof/install-proof.sh` then proves that package against a live
148 151
   single-node release: forward install, permanent commit, reverse rollback,
149
   and re-upgrade, asserting `ReleaseState` observations survive every
150
   transition. Both were executed successfully for `0.2.0 → 0.3.0`.
151
152
Still required before the lane carries production traffic — operator work,
153
not engineering work:
152
   and re-upgrade, asserting each `release_handler` result, both state
153
   schemas, node readiness, and `ReleaseState` retention. It reads the versions
154
   and schemas from `package.json`, and it creates and drops a database, so it
155
   refuses to run unless `OPENAGENTS_RELUP_PROOF_DISPOSABLE=1`, the URL host is
156
   loopback, and the database name contains `proof`, `smoke`, or `test`.
157
158
What is not true yet, and is engineering work rather than operator work:
159
160
- **No code calls the lane.** `RelupDeployment.run/2` has no caller outside
161
  tests. Nothing classifies a candidate as a relup, builds the request, or
162
  triggers a fleet relup; an operator drives the two scripts by hand.
163
- **The release gate does not run either script.** `ops/ci/gate.sh` runs
164
  `ops/relup-proof/run.sh`, `version-chain.sh`, and `kill-during-install.sh`,
165
  which exercise the pinned `0.1.0 → 0.2.0` pair only. Neither
166
  `package-relup.sh` nor `install-proof.sh` is a gate stage, so no receipt
167
  binds a general pair to a candidate SHA.
168
- **Nothing binds a package to its gate receipt.** `package.json` records the
169
  revisions it was built from, but `RelupDeployment` verifies the receipt for
170
  `request.sha` without checking that the artifact came from that revision.
171
172
Operator work still required before the lane carries production traffic:
154 173
155 174
- Production approval recorded against
156 175
  [`docs/operations/release-deployment-fallbacks.md`](release-deployment-fallbacks.md),
157 176
  which remains the authority that relups are not production-approved.
158
- Wiring `package-relup.sh` into automation so a promoted SHA packages and
159
  deploys without manual steps. Until then the lane is driven by the same
160
  commands above, which are safe to rehearse on staging.
177
- A staging rehearsal of `package-relup.sh` followed by `install-proof.sh`
178
  against a disposable database, before any fleet use.
161 179
162 180
Note on scope: hot-load diffs and relups remain different artifact classes.
163 181
A BEAM-diff artifact cannot drive `release_handler`; only a full release
docs/operations/release-deployment-fallbacks.md modified +7

@@ -124,6 +124,13 @@ rolling recovery.

124 124
back to the filename consumed by `unpack_release/1` before every attempt. This
125 125
restaging step is mandatory after an interrupted install.
126 126
127
The node also records which artifact each unpacked version came from, in
128
`releases/.openagents-relup-cache/unpacked-<version>`. Reusing an already
129
unpacked version requires the recorded digest to match the request, so
130
re-cutting a version number from a different revision fails with
131
`unpacked_version_conflict` instead of installing the bytes the node unpacked
132
the first time. Recover by cutting a new version number for the new revision.
133
127 134
## Rolling replacement lane
128 135
129 136
Build a local immutable image only after the exact release gate passes:
lib/mix/tasks/openagents.relup.ex modified +42 -6

@@ -3,13 +3,31 @@ defmodule Mix.Tasks.Openagents.Relup do

3 3
  Generates a two-way `relup` in an explicit output directory.
4 4
5 5
      mix openagents.relup --target /path/to/0.2.0/openagents \
6
        --from /path/to/0.1.0/openagents --outdir /tmp/proof
6
        --from /path/to/0.1.0/openagents --outdir /tmp/proof \
7
        --from-ebin /path/to/0.1.0/_build/prod/lib/openagents/ebin \
8
        --to-ebin /path/to/0.2.0/_build/prod/lib/openagents/ebin \
9
        --from-state 1 --to-state 2
10
11
  The `ebin` and state arguments are required: after generating the relup the
12
  task checks it against `OpenAgents.Release.Appup`, so a relup that covers
13
  fewer modules than the two builds differ in fails here rather than
14
  half-upgrading a node.
7 15
  """
8 16
9 17
  use Mix.Task
10 18
11
  @shortdoc "Generates an explicit two-way OpenAgents relup"
12
  @switches [target: :string, from: :string, outdir: :string]
19
  alias OpenAgents.Release.Appup
20
21
  @shortdoc "Generates and verifies an explicit two-way OpenAgents relup"
22
  @switches [
23
    target: :string,
24
    from: :string,
25
    outdir: :string,
26
    from_ebin: :string,
27
    to_ebin: :string,
28
    from_state: :integer,
29
    to_state: :integer
30
  ]
13 31
14 32
  @impl true
15 33
  def run(arguments) do

@@ -21,7 +39,7 @@ defmodule Mix.Tasks.Openagents.Relup do

21 39
22 40
    target = required_path!(options, :target)
23 41
    from = required_path!(options, :from)
24
    outdir = required_directory!(options, :outdir)
42
    outdir = created_directory!(options, :outdir)
25 43
26 44
    paths =
27 45
      [target, from]

@@ -44,7 +62,17 @@ defmodule Mix.Tasks.Openagents.Relup do

44 62
           warnings_as_errors: true
45 63
         ) do
46 64
      {:ok, _relup, _module, []} ->
47
        Mix.shell().info("Generated #{Path.join(outdir, "relup")}")
65
        relup = Path.join(outdir, "relup")
66
67
        covered =
68
          Appup.verify_relup!(relup,
69
            from_ebin: required_directory!(options, :from_ebin),
70
            to_ebin: required_directory!(options, :to_ebin),
71
            from_state_version: Keyword.fetch!(options, :from_state),
72
            to_state_version: Keyword.fetch!(options, :to_state)
73
          )
74
75
        Mix.shell().info("Generated #{relup}, covering #{length(covered)} modules")
48 76
49 77
      {:ok, _relup, module, warnings} ->
50 78
        Mix.raise(

@@ -67,12 +95,20 @@ defmodule Mix.Tasks.Openagents.Relup do

67 95
      else: Mix.raise("--#{key} must name a release resource without the .rel suffix")
68 96
  end
69 97
70
  defp required_directory!(options, key) do
98
  defp created_directory!(options, key) do
71 99
    path = options |> Keyword.fetch!(key) |> Path.expand()
72 100
    File.mkdir_p!(path)
73 101
    path
74 102
  end
75 103
104
  defp required_directory!(options, key) do
105
    path = options |> Keyword.fetch!(key) |> Path.expand()
106
107
    if File.dir?(path),
108
      do: path,
109
      else: Mix.raise("--#{String.replace(to_string(key), "_", "-")} must name a directory")
110
  end
111
76 112
  defp format(module, function, value) do
77 113
    module
78 114
    |> apply(function, [value])
lib/openagents/forge/relup_node.ex modified +51 -13

@@ -37,28 +37,66 @@ defmodule OpenAgents.Forge.RelupNode do

37 37
    end
38 38
  end
39 39
40
  @doc "Restore the consumable tar and unpack it when needed."
40
  @doc """
41
  Restore the consumable tar and unpack it when needed.
42
43
  A release version already known to `release_handler` is only reused when it
44
  was unpacked from these exact artifact bytes, recorded next to the immutable
45
  cache. Re-cutting a version from a different revision therefore fails closed
46
  instead of installing whatever the node unpacked the first time.
47
  """
41 48
  def unpack(request, opts \\ []) do
42 49
    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)
50
      cond do
51
        not release_known?(request.to_version, opts) ->
52
          perform_unpack(request, opts)
53
54
        unpacked_digest(request, opts) == request.artifact_digest ->
55
          {:ok, %{"phase" => "unpacked", "restaged" => true}}
56
57
        true ->
58
          {:error, :unpacked_version_conflict}
59
      end
60
    end
61
  end
47 62
48
        case handler_call(opts, :unpack_release, [to_charlist(release_basename(request))]) do
49
          {:ok, ^expected_version} ->
50
            {:ok, %{"phase" => "unpacked", "restaged" => true}}
63
  defp perform_unpack(request, opts) do
64
    expected_version = to_charlist(request.to_version)
51 65
52
          {:error, reason} ->
53
            {:error, {:unpack_failed, safe_code(reason)}}
66
    # Record the intent before unpacking, so an interrupted unpack retries from
67
    # the same bytes instead of reading as a conflicting version.
68
    with :ok <- record_unpacked_digest(request, opts) do
69
      case handler_call(opts, :unpack_release, [to_charlist(release_basename(request))]) do
70
        {:ok, ^expected_version} ->
71
          {:ok, %{"phase" => "unpacked", "restaged" => true}}
54 72
55
          _other ->
56
            {:error, :unexpected_unpack_result}
57
        end
73
        {:error, reason} ->
74
          {:error, {:unpack_failed, safe_code(reason)}}
75
76
        _other ->
77
          {:error, :unexpected_unpack_result}
58 78
      end
59 79
    end
60 80
  end
61 81
82
  defp unpacked_digest(request, opts) do
83
    case File.read(unpacked_digest_path(request, opts)) do
84
      {:ok, digest} -> String.trim(digest)
85
      {:error, _reason} -> nil
86
    end
87
  end
88
89
  defp record_unpacked_digest(request, opts) do
90
    File.mkdir_p!(cache_dir(opts))
91
    File.write!(unpacked_digest_path(request, opts), request.artifact_digest)
92
    :ok
93
  rescue
94
    _error -> {:error, :release_directory_unavailable}
95
  end
96
97
  defp unpacked_digest_path(request, opts),
98
    do: Path.join(cache_dir(opts), "unpacked-#{request.to_version}")
99
62 100
  @doc "Generate runtime configuration and preflight the relup."
63 101
  def check_install(request, opts \\ []) do
64 102
    with :ok <- generate_config(request.to_version, opts) do
lib/openagents/release/appup.ex added +226

@@ -0,0 +1,226 @@

1
defmodule OpenAgents.Release.Appup do
2
  @moduledoc """
3
  Derives one release pair's appup instructions from the two builds' modules.
4
5
  `:systools.make_relup/4` consumes an appup verbatim. It never compares module
6
  contents, so a constant instruction list upgrades only the modules it names
7
  and leaves every other changed module running the old code while the node
8
  reports itself converged. This module reads both builds' compiled modules and
9
  emits one instruction per module that actually differs.
10
11
  Both directories must be application `ebin` directories from the same build
12
  stage — `_build/<env>/lib/openagents/ebin` on each side — so that protocol
13
  consolidation, which `mix release` performs later, cannot make the two sides
14
  disagree about modules neither revision changed.
15
16
  `OpenAgents.ReleaseState` always takes an advanced update carrying the target
17
  schema version, because the target schema belongs to the other release and a
18
  running module cannot infer it. `OpenAgents.ReleaseState.install_barrier/0`
19
  runs after the module instructions in both directions.
20
21
  Consolidated protocol modules live outside the application `ebin` directory
22
  and no appup covers them. A revision that changes protocol consolidation is a
23
  structural change and belongs on the rolling replacement path.
24
  """
25
26
  @state_module OpenAgents.ReleaseState
27
  @barrier {:apply, {OpenAgents.ReleaseState, :install_barrier, []}}
28
  @schema_versions [1, 2]
29
  @version_pattern ~r/\A\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/
30
31
  @doc """
32
  Build the complete appup term for one concrete release pair.
33
34
  Returns `{to_version, [{from_version, up}], [{from_version, down}]}` in the
35
  charlist shape `:systools` expects.
36
  """
37
  def build(options) do
38
    from_version = version!(options, :from_version)
39
    to_version = version!(options, :to_version)
40
41
    if from_version == to_version do
42
      raise ArgumentError, "from_version and to_version must differ"
43
    end
44
45
    {up, down} = instructions(options)
46
47
    {String.to_charlist(to_version), [{String.to_charlist(from_version), up}],
48
     [{String.to_charlist(from_version), down}]}
49
  end
50
51
  @doc """
52
  Return `{up, down}` instruction lists covering every module that differs.
53
54
  Options:
55
56
    * `:from_ebin` — the older build's application `ebin` directory
57
    * `:to_ebin` — the newer build's application `ebin` directory
58
    * `:from_state_version` — the older build's `ReleaseState` schema
59
    * `:to_state_version` — the newer build's `ReleaseState` schema
60
  """
61
  def instructions(options) do
62
    from = read_modules(Keyword.fetch!(options, :from_ebin))
63
    to = read_modules(Keyword.fetch!(options, :to_ebin))
64
    from_schema = schema!(options, :from_state_version)
65
    to_schema = schema!(options, :to_state_version)
66
67
    added = Enum.sort(Map.keys(to) -- Map.keys(from))
68
    removed = Enum.sort(Map.keys(from) -- Map.keys(to))
69
70
    changed =
71
      for {module, entry} <- to,
72
          previous = Map.get(from, module),
73
          previous != nil,
74
          previous.digest != entry.digest,
75
          do: module
76
77
    migrated =
78
      changed
79
      |> with_state_module(from, to)
80
      |> Enum.sort()
81
82
    up =
83
      Enum.map(added, &{:add_module, &1}) ++
84
        Enum.map(migrated, &instruction(&1, to, to_schema)) ++
85
        [@barrier] ++
86
        Enum.map(removed, &{:delete_module, &1})
87
88
    down =
89
      Enum.map(removed, &{:add_module, &1}) ++
90
        Enum.map(migrated, &instruction(&1, from, from_schema)) ++
91
        [@barrier] ++
92
        Enum.map(added, &{:delete_module, &1})
93
94
    {up, down}
95
  end
96
97
  @doc """
98
  Raise unless the generated relup names every module the two builds differ in.
99
100
  `:systools` reads the appup from the candidate's `ebin` directory, so a stale
101
  appup, missing build environment, or dropped instruction produces a relup
102
  that installs fewer modules than the revisions changed. This recomputes the
103
  instruction list from the same two `ebin` directories and checks the relup
104
  against it in both directions. It proves the packaging pipeline carried the
105
  instructions through; it cannot prove that loading a module is the right
106
  instruction for it.
107
108
  Returns the sorted list of modules the upgrade direction covers.
109
  """
110
  def verify_relup!(path, options) do
111
    {up, down} = instructions(options)
112
113
    case :file.consult(String.to_charlist(path)) do
114
      {:ok, [{_to_version, up_entries, down_entries}]} ->
115
        check_direction!(path, "upgrade", up, up_entries)
116
        check_direction!(path, "downgrade", down, down_entries)
117
        up |> Enum.flat_map(&instruction_module/1) |> Enum.sort()
118
119
      other ->
120
        raise ArgumentError, "#{path} is not a relup file: #{inspect(other)}"
121
    end
122
  end
123
124
  @doc "Return every atom a relup or appup instruction term names."
125
  def named_atoms(term) when is_tuple(term), do: term |> Tuple.to_list() |> named_atoms()
126
  def named_atoms(term) when is_list(term), do: Enum.flat_map(term, &named_atoms/1)
127
  def named_atoms(term) when is_atom(term), do: [term]
128
  def named_atoms(_term), do: []
129
130
  defp check_direction!(path, direction, expected_instructions, entries) do
131
    expected = expected_instructions |> Enum.flat_map(&instruction_module/1) |> MapSet.new()
132
    named = entries |> named_atoms() |> MapSet.new()
133
    missing = expected |> MapSet.difference(named) |> Enum.sort()
134
135
    if missing != [] do
136
      raise "#{path} carries no #{direction} instruction for: #{Enum.map_join(missing, ", ", &inspect/1)}"
137
    end
138
  end
139
140
  defp instruction_module({:add_module, module}), do: [module]
141
  defp instruction_module({:delete_module, module}), do: [module]
142
  defp instruction_module({:load_module, module}), do: [module]
143
  defp instruction_module({:update, module, _change}), do: [module]
144
  defp instruction_module(_instruction), do: []
145
146
  defp with_state_module(changed, from, to) do
147
    if Map.has_key?(from, @state_module) and Map.has_key?(to, @state_module) do
148
      Enum.uniq([@state_module | changed])
149
    else
150
      changed
151
    end
152
  end
153
154
  defp instruction(@state_module, _modules, schema),
155
    do: {:update, @state_module, {:advanced, [schema_version: schema]}}
156
157
  defp instruction(module, modules, _schema) do
158
    if modules[module].supervisor?,
159
      do: {:update, module, :supervisor},
160
      else: {:load_module, module}
161
  end
162
163
  defp read_modules(directory) when is_binary(directory) do
164
    beams = directory |> Path.join("*.beam") |> Path.wildcard()
165
166
    if beams == [] do
167
      raise ArgumentError, "no compiled modules found in #{directory}"
168
    end
169
170
    Map.new(beams, &read_module/1)
171
  end
172
173
  defp read_module(path) do
174
    charlist = String.to_charlist(path)
175
176
    case :beam_lib.md5(charlist) do
177
      {:ok, {module, digest}} ->
178
        {module, %{digest: digest, supervisor?: supervisor?(charlist)}}
179
180
      {:error, :beam_lib, reason} ->
181
        raise ArgumentError, "cannot read #{path}: #{inspect(reason)}"
182
    end
183
  end
184
185
  # `use Supervisor` records the Elixir behaviour, but the process it starts is
186
  # an `:supervisor` process registered under the callback module, so the
187
  # supervisor instruction is the one that reaches it. Anything else takes
188
  # `load_module`, which is correct whenever the process state shape did not
189
  # change; a revision that changes a running process's state shape is a
190
  # structural change and belongs on the rolling replacement path.
191
  defp supervisor?(charlist) do
192
    case :beam_lib.chunks(charlist, [:attributes]) do
193
      {:ok, {_module, [attributes: attributes]}} ->
194
        attributes
195
        |> Enum.flat_map(fn
196
          {:behaviour, behaviours} when is_list(behaviours) -> behaviours
197
          _other -> []
198
        end)
199
        |> Enum.any?(&(&1 in [:supervisor, Supervisor]))
200
201
      _other ->
202
        false
203
    end
204
  end
205
206
  defp version!(options, key) do
207
    version = Keyword.fetch!(options, key)
208
209
    if is_binary(version) and Regex.match?(@version_pattern, version) do
210
      version
211
    else
212
      raise ArgumentError, "#{key} must be an X.Y.Z version, got: #{inspect(version)}"
213
    end
214
  end
215
216
  defp schema!(options, key) do
217
    schema = Keyword.fetch!(options, key)
218
219
    if schema in @schema_versions do
220
      schema
221
    else
222
      raise ArgumentError,
223
            "#{key} must be one of #{inspect(@schema_versions)}, got: #{inspect(schema)}"
224
    end
225
  end
226
end
lib/openagents/release_state.ex modified +32 -10

@@ -2,10 +2,15 @@ defmodule OpenAgents.ReleaseState do

2 2
  @moduledoc """
3 3
  Holds bounded runtime observations across supported release upgrades.
4 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.
5
  The process uses an explicitly versioned state struct whose schema is
6
  compiled into each release from `OPENAGENTS_RELUP_STATE_VERSION`. A two-way
7
  relup calls `code_change/3`, which preserves the PID and observations while
8
  adding or removing schema 2's integrity field.
9
10
  The target schema is explicit, not positional: `OpenAgents.Release.Appup`
11
  puts the installing release's schema in the appup's `extra` term for each
12
  direction. A pair whose schemas match on both sides therefore keeps its
13
  schema through a downgrade instead of being forced back to schema 1.
9 14
  """
10 15
11 16
  use GenServer

@@ -67,18 +72,35 @@ defmodule OpenAgents.ReleaseState do

67 72
  end
68 73
69 74
  @impl true
70
  def code_change({:down, _from_version}, %State{} = state, _extra) do
71
    {:ok, state_for(1, state.observations)}
75
  def code_change({:down, _from_version}, %State{} = state, extra) do
76
    # A downgrade runs this clause in the new module before the old code is
77
    # loaded, so the target schema belongs to the release being installed and
78
    # this module cannot infer it. The appup carries it in `extra`; without it
79
    # the migration refuses rather than guessing a schema.
80
    case target_schema(extra) do
81
      {:ok, schema} -> {:ok, state_for(schema, state.observations)}
82
      :error -> {:error, :missing_downgrade_schema_version}
83
    end
72 84
  end
73 85
74
  def code_change(_from_version, %State{schema_version: 1} = state, _extra) do
75
    {:ok, state_for(2, state.observations)}
86
  def code_change(_from_version, %State{} = state, extra) do
87
    # An upgrade runs in the release being installed, so this release's own
88
    # compiled schema is the target when the appup names none.
89
    case target_schema(extra) do
90
      {:ok, schema} -> {:ok, state_for(schema, state.observations)}
91
      :error -> {:ok, state_for(@current_schema, state.observations)}
92
    end
76 93
  end
77 94
78
  def code_change(_from_version, %State{} = state, _extra) do
79
    {:ok, state_for(@current_schema, state.observations)}
95
  defp target_schema(extra) when is_list(extra) do
96
    Enum.find_value(extra, :error, fn
97
      {:schema_version, schema} when schema in [1, 2] -> {:ok, schema}
98
      _other -> nil
99
    end)
80 100
  end
81 101
102
  defp target_schema(_extra), do: :error
103
82 104
  defp state_for(1, observations) do
83 105
    %State{schema_version: 1, observations: observations, integrity: nil}
84 106
  end
mix.exs modified +4 -1

@@ -24,7 +24,10 @@ defmodule OpenAgents.MixProject do

24 24
        ]
25 25
      ],
26 26
      deps: deps(),
27
      compilers: [:appup, :phoenix_live_view] ++ Mix.compilers(),
27
      # `:appup` runs last because rel/openagents.appup.exs derives its
28
      # instruction list from this build's compiled modules through
29
      # OpenAgents.Release.Appup, which only exists once :elixir has run.
30
      compilers: [:phoenix_live_view] ++ Mix.compilers() ++ [:appup],
28 31
      appup: "rel/openagents.appup.exs",
29 32
      releases: releases(),
30 33
      listeners: [Phoenix.CodeReloader]
ops/forge/package-relup.sh modified +22 -1

@@ -115,6 +115,14 @@ env -u RELUP_FROM -u RELUP_TO -u OPENAGENTS_RELUP_PATH \

115 115
  OPENAGENTS_RELUP_STATE_VERSION="$from_state" \
116 116
  sh -c 'cd "$1" && mix do compile --force --warnings-as-errors + release --overwrite' sh "$build_root/from"
117 117
118
# The appup derives its instruction list from these two module sets, so every
119
# module that differs between the revisions is covered. Both sides must be
120
# _build ebin directories: protocol consolidation happens later, in `mix
121
# release`, and comparing a release ebin against a _build ebin would report
122
# modules as added or deleted that neither revision touched.
123
from_ebin="$build_root/from/_build/prod/lib/openagents/ebin"
124
[ -d "$from_ebin" ] || { echo "missing from-build ebin: $from_ebin" >&2; exit 1; }
125
118 126
echo "Building $to_version release resource"
119 127
env -u OPENAGENTS_RELUP_PATH \
120 128
  MIX_ENV=prod \

@@ -123,13 +131,23 @@ env -u OPENAGENTS_RELUP_PATH \

123 131
  OPENAGENTS_RELUP_STATE_VERSION="$to_state" \
124 132
  RELUP_FROM="$from_version" \
125 133
  RELUP_TO="$to_version" \
134
  RELUP_FROM_EBIN="$from_ebin" \
135
  RELUP_FROM_STATE="$from_state" \
136
  RELUP_TO_STATE="$to_state" \
126 137
  sh -c 'cd "$1" && mix do compile --force --warnings-as-errors + release --overwrite' sh "$build_root/to"
127 138
139
to_ebin="$build_root/to/_build/prod/lib/openagents/ebin"
140
[ -d "$to_ebin" ] || { echo "missing to-build ebin: $to_ebin" >&2; exit 1; }
141
128 142
echo "Generating the two-way relup"
129 143
(cd "$build_root/to" && MIX_ENV=prod mix openagents.relup \
130 144
  --target "$build_root/to/release/releases/$to_version/openagents" \
131 145
  --from "$build_root/from/release/releases/$from_version/openagents" \
132
  --outdir "$build_root")
146
  --outdir "$build_root" \
147
  --from-ebin "$from_ebin" \
148
  --to-ebin "$to_ebin" \
149
  --from-state "$from_state" \
150
  --to-state "$to_state")
133 151
134 152
echo "Reassembling $to_version with the embedded relup"
135 153
env \

@@ -140,6 +158,9 @@ env \

140 158
  OPENAGENTS_RELUP_STATE_VERSION="$to_state" \
141 159
  RELUP_FROM="$from_version" \
142 160
  RELUP_TO="$to_version" \
161
  RELUP_FROM_EBIN="$from_ebin" \
162
  RELUP_FROM_STATE="$from_state" \
163
  RELUP_TO_STATE="$to_state" \
143 164
  sh -c 'cd "$1" && mix release --overwrite' sh "$build_root/to"
144 165
145 166
tar -tzf "$build_root/to/release/openagents-$to_version.tar.gz" |
ops/relup-proof/install-proof.sh modified +159 -54

@@ -1,37 +1,33 @@

1 1
#!/bin/sh
2 2
# Installs an already-packaged two-way relup against a live single-node
3 3
# release, proving forward upgrade, reverse rollback, and re-upgrade with
4
# ReleaseState retention. Consumes the output of ops/forge/package-relup.sh.
4
# ReleaseState retention. Consumes the output of ops/forge/package-relup.sh,
5
# including its versions and state schemas, so the assertions describe the
6
# package actually under test rather than a hardcoded pair.
7
#
8
# This script creates and drops a database. It refuses to run unless
9
# OPENAGENTS_RELUP_PROOF_DISPOSABLE=1, the URL host is loopback, and the
10
# database name contains `proof`, `smoke`, or `test`. It performs every one of
11
# those checks before it arms its cleanup trap, so a refused run destroys
12
# nothing.
5 13
#
6 14
# Required environment: OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 and
7
# OPENAGENTS_RELUP_PROOF_DATABASE_URL (a disposable PostgreSQL URL),
8
# matching the contract of ops/relup-proof/run.sh.
15
# OPENAGENTS_RELUP_PROOF_DATABASE_URL.
9 16
#
10 17
# Optional: RELUP_PROOF_PACKAGE_DIR, RELUP_PROOF_NODE, RELUP_PROOF_PORT.
11
# Install proof for a generalized relup pair: 0.2.0 -> 0.3.0 -> reverse ->
12
# re-upgrade, using the tarballs produced by ops/forge/package-relup.sh.
13 18
#
14
# Assertions check observable effects (directories, running version, retained
15
# state) rather than release_handler return values relayed through the shell
16
# rpc channel, whose IO can drop mid-call while release_handler runs.
19
# Every release_handler call records its result to a file on the node and the
20
# assertion reads that file back. The rpc channel's IO can drop mid-call while
21
# release_handler suspends processes, so the shell cannot trust what it saw on
22
# stdout; it can trust what the node wrote. The reverse leg asserts exactly
23
# what OpenAgents.Forge.RelupNode.verify_reverse_health/2 asserts: the from
24
# release is current or permanent, the node reports ready, and the process
25
# state carries the packaged from_state_version.
17 26
set -eu
18 27
19 28
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
20 29
pkg="${RELUP_PROOF_PACKAGE_DIR:-$repo_root/.git/openagents/relup-package}"
21
runtime_root=$(mktemp -d /tmp/openagents-relup-runtime.XXXXXX)
22
release_log="$runtime_root/release.log"
23 30
node_name="${RELUP_PROOF_NODE:-openagents-relup-proof@127.0.0.1}"
24
db="${RELUP_PROOF_DB:-openagents_relup_proof}"
25
26
cleanup() {
27
  if [ -n "${release_pid:-}" ]; then
28
    profile "$runtime_root/bin/openagents" stop >/dev/null 2>&1 || true
29
    kill -TERM "$release_pid" 2>/dev/null || true
30
  fi
31
  [ -d "$runtime_root" ] && find "$runtime_root" -depth -delete
32
  dropdb -h localhost --if-exists "$db" >/dev/null 2>&1 || true
33
}
34
trap cleanup EXIT INT TERM
35 31
36 32
if [ "${OPENAGENTS_RELUP_PROOF_DISPOSABLE:-}" != "1" ]; then
37 33
  echo "set OPENAGENTS_RELUP_PROOF_DISPOSABLE=1 for a disposable database" >&2

@@ -39,20 +35,82 @@ if [ "${OPENAGENTS_RELUP_PROOF_DISPOSABLE:-}" != "1" ]; then

39 35
fi
40 36
41 37
proof_database_url=${OPENAGENTS_RELUP_PROOF_DATABASE_URL:-}
42
[ -n "$proof_database_url" ] || {
38
if [ -z "$proof_database_url" ]; then
43 39
  echo "OPENAGENTS_RELUP_PROOF_DATABASE_URL is required" >&2
44 40
  exit 1
41
fi
42
43
authority=${proof_database_url#*://}
44
db_path=${authority#*/}
45
authority=${authority%%/*}
46
host_port=${authority##*@}
47
db_host=${host_port%%:*}
48
db_name=${db_path%%\?*}
49
50
case "$host_port" in
51
  *:*) db_port=${host_port##*:} ;;
52
  *) db_port=5432 ;;
53
esac
54
55
case "$db_host" in
56
  localhost | 127.0.0.1) : ;;
57
  *)
58
    echo "refusing: the proof database host must be loopback, got $db_host" >&2
59
    exit 1
60
    ;;
61
esac
62
63
case "$db_name" in
64
  *proof* | *smoke* | *test*) : ;;
65
  *)
66
    echo "refusing: the proof database name must contain proof, smoke, or test" >&2
67
    exit 1
68
    ;;
69
esac
70
71
if [ ! -f "$pkg/package.json" ]; then
72
  echo "missing relup package: $pkg/package.json" >&2
73
  echo "run ops/forge/package-relup.sh --out-dir $pkg first" >&2
74
  exit 1
75
fi
76
77
package_field() {
78
  sed -n "s/^[[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\}[[:space:]]*$/\1/p" \
79
    "$pkg/package.json" | head -1
45 80
}
46 81
82
from_version=$(package_field from_version)
83
to_version=$(package_field to_version)
84
from_state=$(package_field from_state_version)
85
to_state=$(package_field to_state_version)
86
87
for field in "$from_version" "$to_version" "$from_state" "$to_state"; do
88
  [ -n "$field" ] || { echo "package.json is missing a required field" >&2; exit 1; }
89
done
90
91
runtime_root=$(mktemp -d /tmp/openagents-relup-runtime.XXXXXX)
92
release_log="$runtime_root/release.log"
93
result_file="$runtime_root/handler-result"
94
release_pid=
95
96
cleanup() {
97
  if [ -n "${release_pid:-}" ]; then
98
    profile "$runtime_root/bin/openagents" stop >/dev/null 2>&1 || true
99
    kill -TERM "$release_pid" 2>/dev/null || true
100
  fi
101
  [ -d "$runtime_root" ] && find "$runtime_root" -depth -delete
102
  dropdb -h "$db_host" -p "$db_port" --if-exists "$db_name" >/dev/null 2>&1 || true
103
}
104
trap cleanup EXIT INT TERM
105
47 106
pkill -f "$node_name" 2>/dev/null || true
48 107
sleep 1
49 108
50
db_name=$(printf '%s' "$proof_database_url" | sed -E 's|.*/||')
51
dropdb -h localhost --if-exists "$db_name" >/dev/null 2>&1 || true
52
createdb -h localhost "$db_name"
109
dropdb -h "$db_host" -p "$db_port" --if-exists "$db_name" >/dev/null 2>&1 || true
110
createdb -h "$db_host" -p "$db_port" "$db_name"
53 111
54
tar -xzf "$pkg/openagents-0.2.0.tar.gz" -C "$runtime_root"
55
cp "$pkg/openagents-0.3.0.tar.gz" "$runtime_root/releases/openagents-0.3.0.tar.gz"
112
tar -xzf "$pkg/openagents-$from_version.tar.gz" -C "$runtime_root"
113
cp "$pkg/openagents-$to_version.tar.gz" "$runtime_root/releases/openagents-$to_version.tar.gz"
56 114
57 115
secret=$(openssl rand -base64 64 | tr -d '\n')
58 116
token_key=$(openssl rand -base64 32 | tr -d '\n')

@@ -77,12 +135,42 @@ profile() {

77 135
fail() {
78 136
  echo "PROOF FAILED: $1"
79 137
  tail -40 "$runtime_root/log" 2>/dev/null || true
138
  tail -40 "$release_log" 2>/dev/null || true
80 139
  exit 1
81 140
}
82 141
83 142
bin="$runtime_root/bin/openagents"
84 143
85
echo "starting 0.2.0"
144
# Runs one expression on the node and returns its inspected result through a
145
# file, so a dropped rpc channel cannot be mistaken for a successful call.
146
handler_result=""
147
148
record() {
149
  rm -f "$result_file"
150
  profile "$bin" rpc "File.write!(\"$result_file\", inspect($1))" >/dev/null 2>&1 || true
151
152
  attempt=0
153
  until [ -f "$result_file" ]; do
154
    attempt=$((attempt + 1))
155
    [ "$attempt" -ge 240 ] && fail "the node recorded no result for: $1"
156
    kill -0 "$release_pid" 2>/dev/null || fail "the node exited during: $1"
157
    sleep 0.5
158
  done
159
160
  handler_result=$(cat "$result_file")
161
}
162
163
assert_result() {
164
  record "$1"
165
166
  case "$handler_result" in
167
    *"$2"*) echo "  $3: $handler_result" ;;
168
    *) fail "$3 returned $handler_result, expected $2" ;;
169
  esac
170
}
171
172
echo "proving $from_version -> $to_version (state $from_state -> $to_state)"
173
echo "starting $from_version"
86 174
profile "$bin" start >"$release_log" 2>&1 &
87 175
release_pid=$!
88 176

@@ -94,42 +182,59 @@ until profile "$bin" rpc 'if Process.whereis(OpenAgents.ReleaseState), do: IO.pu

94 182
  sleep 0.5
95 183
done
96 184
97
echo "recording state on 0.2.0"
98
out=$(profile "$bin" rpc 'OpenAgents.ReleaseState.observe("retained-through-0.3.0"); IO.puts("observed")' 2>/dev/null)
99
echo "$out" | grep -q observed || fail "observe failed: $out"
185
echo "recording state on $from_version"
186
assert_result "(OpenAgents.ReleaseState.observe(\"retained-through-$to_version\"))" ":ok" "observe"
187
188
echo "unpacking $to_version"
189
assert_result ":release_handler.unpack_release(~c\"openagents-$to_version\")" \
190
  "{:ok, ~c\"$to_version\"}" "unpack_release"
100 191
101
echo "unpacking 0.3.0"
102
profile "$bin" rpc ':release_handler.unpack_release(:binary.bin_to_list("openagents-0.3.0"))' >/dev/null 2>&1 || true
103
[ -d "$runtime_root/releases/0.3.0" ] || fail "unpack produced no releases/0.3.0 directory"
192
echo "installing $to_version"
193
assert_result "(Castle.generate(\"$to_version\"); :release_handler.install_release(~c\"$to_version\"))" \
194
  "{:ok, ~c\"$from_version\"" "install_release"
104 195
105
echo "installing 0.3.0"
106
profile "$bin" rpc 'Castle.generate("0.3.0"); :release_handler.install_release(:binary.bin_to_list("0.3.0"))' >/dev/null 2>&1 || true
196
assert_result "to_string(Application.spec(:openagents, :vsn))" "\"$to_version\"" "running version"
197
assert_result "OpenAgents.ReleaseState.snapshot().schema_version" "$to_state" "forward state schema"
198
assert_result "Enum.member?(OpenAgents.ReleaseState.snapshot().observations, \"retained-through-$to_version\")" \
199
  "true" "forward observations"
200
assert_result "OpenAgents.Cluster.local_report()[\"ready\"]" "true" "forward health"
107 201
108
out=$(profile "$bin" rpc 'IO.puts(to_string(Application.spec(:openagents, :vsn)))' 2>/dev/null)
109
echo "$out" | grep -q "0.3.0" || fail "running version did not become 0.3.0: $out"
202
assert_result ":release_handler.make_permanent(~c\"$to_version\")" ":ok" "make_permanent"
203
assert_result "Enum.find_value(:release_handler.which_releases(), fn {_, v, _, s} -> if to_string(v) == \"$to_version\", do: s end)" \
204
  ":permanent" "forward permanence"
110 205
111
out=$(profile "$bin" rpc 'IO.puts(inspect({OpenAgents.ReleaseState.snapshot().schema_version, Enum.any?(OpenAgents.ReleaseState.snapshot().observations, &(&1 == "retained-through-0.3.0"))}))' 2>/dev/null)
112
echo "$out" | grep -qF "{2, true}" || fail "state did not survive the upgrade: $out"
206
echo "reversing to $from_version"
207
# RelupNode.reverse/2 does not regenerate configuration for the from release,
208
# so neither does this leg.
209
assert_result ":release_handler.install_release(~c\"$from_version\")" \
210
  "{:ok, " "reverse install_release"
113 211
114
profile "$bin" rpc ':release_handler.make_permanent(:binary.bin_to_list("0.3.0"))' >/dev/null 2>&1 || true
115
echo "0.3.0 installed live and made permanent; observations survived"
212
# The three checks RelupNode.verify_reverse_health/2 makes, in its order.
213
record "Enum.find_value(:release_handler.which_releases(), fn {_, v, _, s} -> if to_string(v) == \"$from_version\", do: s end)"
116 214
117
echo "reversing to 0.2.0"
118
profile "$bin" rpc ':release_handler.install_release(:binary.bin_to_list("0.2.0")); :release_handler.make_permanent(:binary.bin_to_list("0.2.0"))' >/dev/null 2>&1 || true
215
case "$handler_result" in
216
  :current | :permanent) echo "  reverse release status: $handler_result" ;;
217
  *) fail "reverse left $from_version at $handler_result, expected :current or :permanent" ;;
218
esac
119 219
120
out=$(profile "$bin" rpc 'IO.puts(to_string(Application.spec(:openagents, :vsn)))' 2>/dev/null)
121
echo "$out" | grep -q "0.2.0" || fail "reverse did not restore 0.2.0: $out"
220
assert_result "OpenAgents.Cluster.local_report()[\"ready\"]" "true" "reverse health"
221
assert_result "OpenAgents.ReleaseState.snapshot().schema_version" "$from_state" "reverse state schema"
222
assert_result "Enum.member?(OpenAgents.ReleaseState.snapshot().observations, \"retained-through-$to_version\")" \
223
  "true" "reverse observations"
122 224
123
out=$(profile "$bin" rpc 'IO.puts(inspect(Enum.any?(OpenAgents.ReleaseState.snapshot().observations, &(&1 == "retained-through-0.3.0"))))' 2>/dev/null)
124
echo "$out" | grep -qF "true" || fail "observations did not survive the reverse"
225
assert_result ":release_handler.make_permanent(~c\"$from_version\")" ":ok" "reverse make_permanent"
226
assert_result "Enum.find_value(:release_handler.which_releases(), fn {_, v, _, s} -> if to_string(v) == \"$from_version\", do: s end)" \
227
  ":permanent" "reverse permanence"
125 228
126
echo "re-upgrading to 0.3.0"
127
profile "$bin" rpc ':release_handler.install_release(:binary.bin_to_list("0.3.0")); :release_handler.make_permanent(:binary.bin_to_list("0.3.0"))' >/dev/null 2>&1 || true
229
echo "re-upgrading to $to_version"
230
assert_result "(Castle.generate(\"$to_version\"); :release_handler.install_release(~c\"$to_version\"))" \
231
  "{:ok, ~c\"$from_version\"" "re-upgrade install_release"
128 232
129
out=$(profile "$bin" rpc 'IO.puts(to_string(Application.spec(:openagents, :vsn)))' 2>/dev/null)
130
echo "$out" | grep -q "0.3.0" || fail "re-upgrade did not restore 0.3.0: $out"
233
assert_result "to_string(Application.spec(:openagents, :vsn))" "\"$to_version\"" "re-upgrade running version"
234
assert_result "OpenAgents.ReleaseState.snapshot().schema_version" "$to_state" "re-upgrade state schema"
235
assert_result ":release_handler.make_permanent(~c\"$to_version\")" ":ok" "re-upgrade make_permanent"
131 236
132 237
echo ""
133 238
echo "GENERALIZED RELUP PROOF PASSED"
134
echo "forward 0.2.0->0.3.0, reverse, and re-upgrade all installed hot;"
135
echo "ReleaseState observations survived every transition."
239
echo "forward $from_version->$to_version, reverse, and re-upgrade all installed hot;"
240
echo "each release_handler result and both state schemas were asserted."
ops/relup-proof/run.sh modified +19 -1

@@ -41,6 +41,11 @@ env -u RELUP_FROM -u RELUP_TO -u OPENAGENTS_RELUP_PATH \

41 41
  OPENAGENTS_RELUP_STATE_VERSION="1" \
42 42
  mix do compile --force --warnings-as-errors + release --overwrite
43 43
44
# The 0.2.0 appup diffs the two builds' compiled modules. Both builds share
45
# this checkout's _build, so copy 0.1.0's ebin out before 0.2.0 overwrites it.
46
from_ebin="$build_root/ebin-0.1.0"
47
cp -R "$repo_root/_build/prod/lib/openagents/ebin" "$from_ebin"
48
44 49
echo "Building explicit 0.2.0 release resource"
45 50
env -u OPENAGENTS_RELUP_PATH \
46 51
  MIX_ENV=prod \

@@ -49,13 +54,23 @@ env -u OPENAGENTS_RELUP_PATH \

49 54
  OPENAGENTS_RELUP_STATE_VERSION="2" \
50 55
  RELUP_FROM="0.1.0" \
51 56
  RELUP_TO="0.2.0" \
57
  RELUP_FROM_EBIN="$from_ebin" \
58
  RELUP_FROM_STATE="1" \
59
  RELUP_TO_STATE="2" \
52 60
  mix do compile --force --warnings-as-errors + release --overwrite
53 61
62
to_ebin="$build_root/ebin-0.2.0"
63
cp -R "$repo_root/_build/prod/lib/openagents/ebin" "$to_ebin"
64
54 65
echo "Generating forward and reverse relup"
55 66
MIX_ENV=prod mix openagents.relup \
56 67
  --target "$build_root/release-0.2.0/releases/0.2.0/openagents" \
57 68
  --from "$build_root/release-0.1.0/releases/0.1.0/openagents" \
58
  --outdir "$build_root"
69
  --outdir "$build_root" \
70
  --from-ebin "$from_ebin" \
71
  --to-ebin "$to_ebin" \
72
  --from-state 1 \
73
  --to-state 2
59 74
60 75
echo "Reassembling 0.2.0 with the generated relup"
61 76
env \

@@ -66,6 +81,9 @@ env \

66 81
  OPENAGENTS_RELUP_STATE_VERSION="2" \
67 82
  RELUP_FROM="0.1.0" \
68 83
  RELUP_TO="0.2.0" \
84
  RELUP_FROM_EBIN="$from_ebin" \
85
  RELUP_FROM_STATE="1" \
86
  RELUP_TO_STATE="2" \
69 87
  mix release --overwrite
70 88
71 89
cp "$build_root/release-0.1.0/openagents-0.1.0.tar.gz" "$publish_root/openagents-0.1.0.tar.gz"
rel/openagents.appup.exs modified +34 -21

@@ -1,35 +1,48 @@

1
# Supported hot-upgrade instructions for the :openagents app.
1
# Hot-upgrade instructions for the :openagents app, for one concrete pair.
2 2
#
3
# The instruction set applies to any concrete from/to version pair: the
4
# advanced update drives `ReleaseState.code_change/3`, which handles upgrade,
5
# downgrade, and same-schema transitions, and the barrier makes interruption
6
# recovery deterministic without affecting normal installs. Whether a specific
7
# pair is admissible is decided where the truth lives — the packaged releases
8
# and `release_handler` refuse at check_install when no relup can be produced.
3
# `:systools.make_relup/4` copies an appup into the relup verbatim and never
4
# compares module contents, so a constant instruction list would install only
5
# the modules it names and leave the rest of the node on the old revision while
6
# `BuildInfo.revision/0` reported the new one. `OpenAgents.Release.Appup`
7
# therefore derives the instruction list from the two builds' compiled modules.
9 8
#
10
# Build the candidate with both variables set; build a plain release with both
11
# unset.
9
# Set RELUP_FROM, RELUP_TO, RELUP_FROM_EBIN, RELUP_FROM_STATE, and
10
# RELUP_TO_STATE to build a relup candidate. RELUP_FROM_EBIN must be a copy of
11
# the older build's `_build/<env>/lib/openagents/ebin` directory, taken before
12
# the newer build overwrites it; RELUP_TO_EBIN defaults to this build's own
13
# compile path. Leave RELUP_FROM and RELUP_TO unset to build a plain release.
14
# Anything in between fails the build rather than emitting an instruction list
15
# that cannot be trusted.
12 16
(fn ->
13 17
   to = System.get_env("RELUP_TO")
14 18
   from = System.get_env("RELUP_FROM")
15
   vsn = ~r/\A\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/
16
17
   steps = [
18
     {:update, OpenAgents.ReleaseState, {:advanced, []}},
19
     {:apply, {OpenAgents.ReleaseState, :install_barrier, []}},
20
     {:load_module, OpenAgents.BuildInfo}
21
   ]
22 19
23 20
   cond do
24
     is_binary(to) and is_binary(from) and Regex.match?(vsn, to) and
25
         Regex.match?(vsn, from) and from != to ->
26
       {String.to_charlist(to), [{String.to_charlist(from), steps}],
27
        [{String.to_charlist(from), steps}]}
21
     is_binary(to) and is_binary(from) ->
22
       from_ebin =
23
         System.get_env("RELUP_FROM_EBIN") ||
24
           raise "RELUP_FROM_EBIN must name the from-build's openagents ebin directory"
25
26
       schema = fn name ->
27
         case System.get_env(name) do
28
           value when value in ["1", "2"] -> String.to_integer(value)
29
           _other -> raise "#{name} must be 1 or 2"
30
         end
31
       end
32
33
       OpenAgents.Release.Appup.build(
34
         from_version: from,
35
         to_version: to,
36
         from_ebin: Path.expand(from_ebin),
37
         to_ebin: System.get_env("RELUP_TO_EBIN", Mix.Project.compile_path()),
38
         from_state_version: schema.("RELUP_FROM_STATE"),
39
         to_state_version: schema.("RELUP_TO_STATE")
40
       )
28 41
29 42
     is_nil(to) and is_nil(from) ->
30 43
       {String.to_charlist(System.get_env("OPENAGENTS_RELEASE_VSN", "0.2.0")), [], []}
31 44
32 45
     true ->
33
       raise "RELUP_FROM and RELUP_TO must be distinct X.Y.Z versions"
46
       raise "RELUP_FROM and RELUP_TO must be set together"
34 47
   end
35 48
 end).()
test/openagents/cluster/code_change_test.exs modified +36 -9

@@ -4,6 +4,10 @@ defmodule OpenAgents.Cluster.CodeChangeTest do

4 4
  live process's state from an old shape to the current one **in place** — same
5 5
  pid, no restart, state carried across. This is what lets a relup swap code
6 6
  under running turns/jobs/voice instead of dropping them.
7
8
  The target schema is the installing release's, not a function of direction.
9
  A downgrade runs `code_change/3` in the new module before the old code loads,
10
  so the appup names the schema explicitly and this module refuses to guess.
7 11
  """
8 12
  use ExUnit.Case, async: true
9 13

@@ -18,27 +22,50 @@ defmodule OpenAgents.Cluster.CodeChangeTest do

18 22
      %State{state | schema_version: 1, integrity: nil}
19 23
    end)
20 24
21
    :ok = :sys.suspend(pid)
22
    :ok = :sys.change_code(pid, ReleaseState, ~c"0.1.0", [])
23
    :ok = :sys.resume(pid)
25
    change_code(pid, ~c"0.1.0", schema_version: 2)
24 26
25 27
    assert %State{schema_version: 2, observations: ["retained"], integrity: integrity} =
26 28
             ReleaseState.snapshot(pid)
27 29
28 30
    assert is_binary(integrity)
29 31
30
    :ok = :sys.suspend(pid)
31
    :ok = :sys.change_code(pid, ReleaseState, {:down, ~c"0.2.0"}, [])
32
    :ok = :sys.resume(pid)
32
    change_code(pid, {:down, ~c"0.2.0"}, schema_version: 1)
33 33
34 34
    assert %State{schema_version: 1, observations: ["retained"], integrity: nil} =
35 35
             ReleaseState.snapshot(pid)
36 36
37
    :ok = :sys.suspend(pid)
38
    :ok = :sys.change_code(pid, ReleaseState, ~c"0.1.0", [])
39
    :ok = :sys.resume(pid)
37
    change_code(pid, ~c"0.1.0", schema_version: 2)
40 38
41 39
    assert %State{schema_version: 2, observations: ["retained"]} = ReleaseState.snapshot(pid)
42 40
    assert %State{} = :sys.get_state(pid)
43 41
  end
42
43
  test "a same-schema pair keeps its schema through a downgrade" do
44
    pid = start_supervised!({ReleaseState, name: nil})
45
    :ok = ReleaseState.observe("retained", pid)
46
47
    change_code(pid, ~c"0.2.0", schema_version: 2)
48
    change_code(pid, {:down, ~c"0.3.0"}, schema_version: 2)
49
50
    assert %State{schema_version: 2, observations: ["retained"], integrity: integrity} =
51
             ReleaseState.snapshot(pid)
52
53
    assert is_binary(integrity)
54
  end
55
56
  test "a downgrade without an explicit target schema refuses" do
57
    pid = start_supervised!({ReleaseState, name: nil})
58
    :ok = :sys.suspend(pid)
59
60
    refused = :sys.change_code(pid, ReleaseState, {:down, ~c"0.3.0"}, [])
61
    :ok = :sys.resume(pid)
62
63
    assert {:error, {:error, :missing_downgrade_schema_version}} = refused
64
  end
65
66
  defp change_code(pid, version, extra) do
67
    :ok = :sys.suspend(pid)
68
    :ok = :sys.change_code(pid, ReleaseState, version, extra)
69
    :ok = :sys.resume(pid)
70
  end
44 71
end
test/openagents/forge/relup_deployment_test.exs modified +98 -1

@@ -1,7 +1,12 @@

1 1
defmodule OpenAgents.Forge.RelupDeploymentTest do
2
  use ExUnit.Case, async: true
2
  # Not async: the reverse-direction case drives the real RelupNode against the
3
  # singleton test release handler.
4
  use ExUnit.Case, async: false
3 5
4 6
  alias OpenAgents.Forge.RelupDeployment
7
  alias OpenAgents.ReleaseState
8
  alias OpenAgents.ReleaseState.State
9
  alias OpenAgents.Test.ReleaseHandler
5 10
6 11
  @sha String.duplicate("a", 40)
7 12
  @digest String.duplicate("b", 64)

@@ -182,6 +187,98 @@ defmodule OpenAgents.Forge.RelupDeploymentTest do

182 187
    end
183 188
  end
184 189
190
  describe "the reverse direction on a same-schema pair" do
191
    test "restores the from release, its permanence, and the process state" do
192
      state = start_supervised!({ReleaseState, name: nil})
193
      :ok = ReleaseState.observe("retained", state)
194
195
      installer = fn
196
        "0.3.0" -> migrate(state, ~c"0.2.0", schema_version: 2)
197
        "0.2.0" -> migrate(state, {:down, ~c"0.3.0"}, schema_version: 2)
198
      end
199
200
      start_supervised!(
201
        {ReleaseHandler,
202
         %{
203
           releases: [{~c"openagents", ~c"0.2.0", [], :permanent}],
204
           pair: {"0.2.0", "0.3.0"},
205
           on_install: installer
206
         }}
207
      )
208
209
      artifact = "immutable 0.3.0 artifact"
210
211
      root =
212
        Path.join(
213
          System.tmp_dir!(),
214
          "openagents-relup-fleet-#{System.unique_integer([:positive])}"
215
        )
216
217
      File.mkdir_p!(Path.join(root, "releases"))
218
      on_exit(fn -> File.rm_rf!(root) end)
219
220
      node_opts = [
221
        release_root: root,
222
        release_handler: ReleaseHandler,
223
        generate_config: fn _version -> :ok end,
224
        health: &unready_once/0,
225
        state: fn -> ReleaseState.snapshot(state) end
226
      ]
227
228
      request =
229
        request()
230
        |> Map.merge(%{
231
          from_version: "0.2.0",
232
          to_version: "0.3.0",
233
          from_state_version: 2,
234
          to_state_version: 2,
235
          artifact_bytes: artifact,
236
          artifact_digest: sha256(artifact),
237
          expected_nodes: [Node.self()],
238
          expected_fleet_size: 1
239
        })
240
241
      assert {:error, %{status: "failed"}} =
242
               RelupDeployment.run(request,
243
                 members: fn -> [Node.self()] end,
244
                 gate_verifier: fn @sha -> {:ok, %{}} end,
245
                 rpc: fn _node, module, function, arguments, _timeout ->
246
                   apply(module, function, arguments ++ [node_opts])
247
                 end
248
               )
249
250
      assert %State{schema_version: 2, observations: ["retained"], integrity: integrity} =
251
               ReleaseState.snapshot(state)
252
253
      assert is_binary(integrity)
254
255
      assert Enum.any?(ReleaseHandler.which_releases(), fn {_name, version, _apps, status} ->
256
               to_string(version) == "0.2.0" and status == :permanent
257
             end)
258
    end
259
  end
260
261
  # The post-install health check fails once, which is what sends the node down
262
  # the reverse path; every later check reports ready.
263
  defp unready_once do
264
    case Process.get(:relup_health_calls, 0) do
265
      0 ->
266
        Process.put(:relup_health_calls, 1)
267
        %{"ready" => false}
268
269
      _later ->
270
        %{"ready" => true}
271
    end
272
  end
273
274
  defp migrate(pid, version, extra) do
275
    :ok = :sys.suspend(pid)
276
    :ok = :sys.change_code(pid, ReleaseState, version, extra)
277
    :ok = :sys.resume(pid)
278
  end
279
280
  defp sha256(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)
281
185 282
  defp drain_messages(acc) do
186 283
    receive do
187 284
      event -> drain_messages([event | acc])
test/openagents/forge/relup_node_test.exs modified +168 -52

@@ -2,69 +2,185 @@ defmodule OpenAgents.Forge.RelupNodeTest do

2 2
  use ExUnit.Case, async: false
3 3
4 4
  alias OpenAgents.Forge.RelupNode
5
  alias OpenAgents.ReleaseState
6
  alias OpenAgents.ReleaseState.State
5 7
  alias OpenAgents.Test.ReleaseHandler
6 8
7
  setup do
8
    start_supervised!(
9
      {ReleaseHandler, %{releases: [{~c"openagents", ~c"0.1.0", [], :permanent}]}}
10
    )
9
  describe "a schema 1 to schema 2 upgrade" do
10
    setup do
11
      start_supervised!(
12
        {ReleaseHandler, %{releases: [{~c"openagents", ~c"0.1.0", [], :permanent}]}}
13
      )
11 14
12
    root =
13
      Path.join(System.tmp_dir!(), "openagents-relup-node-#{System.unique_integer([:positive])}")
15
      root = release_root()
14 16
15
    File.mkdir_p!(Path.join(root, "releases"))
16
    on_exit(fn -> File.rm_rf!(root) end)
17
      artifact = "immutable release artifact"
18
19
      request = %{
20
        release_name: "openagents",
21
        from_version: "0.1.0",
22
        to_version: "0.2.0",
23
        from_state_version: 1,
24
        to_state_version: 2,
25
        artifact_bytes: artifact,
26
        artifact_digest: sha256(artifact)
27
      }
28
29
      opts = [
30
        release_root: root,
31
        release_handler: ReleaseHandler,
32
        generate_config: fn _version -> :ok end,
33
        health: fn -> %{"ready" => true} end,
34
        state: fn -> %{schema_version: 2} end
35
      ]
36
37
      %{root: root, request: request, opts: opts}
38
    end
39
40
    test "restages the consumed artifact from immutable cache", %{
41
      root: root,
42
      request: request,
43
      opts: opts
44
    } do
45
      assert {:ok, %{"phase" => "staged"}} = RelupNode.stage(request, opts)
46
      assert {:ok, %{"phase" => "stage_verified"}} = RelupNode.verify_stage(request, opts)
47
      assert {:ok, %{"phase" => "unpacked"}} = RelupNode.unpack(request, opts)
17 48
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}
49
      staged = Path.join([root, "releases", "openagents-0.2.0.tar.gz"])
50
      File.rm!(staged)
51
52
      assert {:ok, %{"phase" => "unpacked", "restaged" => true}} = RelupNode.unpack(request, opts)
53
      assert File.read!(staged) == request.artifact_bytes
54
    end
55
56
    test "checks, installs, verifies, and makes the release permanent", %{
57
      request: request,
58
      opts: opts
59
    } do
60
      assert {:ok, _result} = RelupNode.stage(request, opts)
61
      assert {:ok, _result} = RelupNode.unpack(request, opts)
62
      assert {:ok, %{"phase" => "checked"}} = RelupNode.check_install(request, opts)
63
      assert {:ok, %{"phase" => "installed"}} = RelupNode.install(request, opts)
64
      assert {:ok, %{"phase" => "verified"}} = RelupNode.verify(request, :current, opts)
65
      assert {:ok, %{"phase" => "permanent"}} = RelupNode.make_permanent(request, opts)
66
      assert {:ok, %{"phase" => "verified"}} = RelupNode.verify(request, :permanent, opts)
67
    end
68
69
    test "refuses a candidate whose staged bytes are not the requested artifact", %{
70
      request: request,
71
      opts: opts
72
    } do
73
      assert {:error, :artifact_digest_mismatch} =
74
               RelupNode.stage(%{request | artifact_bytes: "other bytes"}, opts)
75
    end
76
77
    test "refuses a version already unpacked from different bytes", %{
78
      request: request,
79
      opts: opts
80
    } do
81
      assert {:ok, _result} = RelupNode.stage(request, opts)
82
      assert {:ok, _result} = RelupNode.unpack(request, opts)
83
84
      recut = "0.2.0 rebuilt from another revision"
85
      recut = %{request | artifact_bytes: recut, artifact_digest: sha256(recut)}
86
87
      assert {:ok, _result} = RelupNode.stage(recut, opts)
88
      assert {:error, :unpacked_version_conflict} = RelupNode.unpack(recut, opts)
89
    end
39 90
  end
40 91
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)
92
  describe "a same-schema pair through forward, reverse, and forward again" do
93
    setup do
94
      state = start_supervised!({ReleaseState, name: nil})
95
      :ok = ReleaseState.observe("retained", state)
96
97
      # The instructions the appup emits for a 2 -> 2 pair. `AppupTest` proves
98
      # the generator emits exactly these extras in each direction.
99
      installer = fn
100
        "0.3.0" -> migrate(state, ~c"0.2.0", schema_version: 2)
101
        "0.2.0" -> migrate(state, {:down, ~c"0.3.0"}, schema_version: 2)
102
      end
103
104
      start_supervised!(
105
        {ReleaseHandler,
106
         %{
107
           releases: [{~c"openagents", ~c"0.2.0", [], :permanent}],
108
           pair: {"0.2.0", "0.3.0"},
109
           on_install: installer
110
         }}
111
      )
49 112
50
    staged = Path.join([root, "releases", "openagents-0.2.0.tar.gz"])
51
    File.rm!(staged)
113
      artifact = "immutable 0.3.0 artifact"
52 114
53
    assert {:ok, %{"phase" => "unpacked", "restaged" => true}} = RelupNode.unpack(request, opts)
54
    assert File.read!(staged) == request.artifact_bytes
115
      request = %{
116
        release_name: "openagents",
117
        from_version: "0.2.0",
118
        to_version: "0.3.0",
119
        from_state_version: 2,
120
        to_state_version: 2,
121
        artifact_bytes: artifact,
122
        artifact_digest: sha256(artifact)
123
      }
124
125
      opts = [
126
        release_root: release_root(),
127
        release_handler: ReleaseHandler,
128
        generate_config: fn _version -> :ok end,
129
        health: fn -> %{"ready" => true} end,
130
        state: fn -> ReleaseState.snapshot(state) end
131
      ]
132
133
      %{state: state, request: request, opts: opts}
134
    end
135
136
    test "verifies forward, reverses without corrupting state, and re-upgrades", %{
137
      state: state,
138
      request: request,
139
      opts: opts
140
    } do
141
      assert {:ok, _result} = RelupNode.stage(request, opts)
142
      assert {:ok, _result} = RelupNode.verify_stage(request, opts)
143
      assert {:ok, _result} = RelupNode.unpack(request, opts)
144
      assert {:ok, _result} = RelupNode.check_install(request, opts)
145
      assert {:ok, _result} = RelupNode.install(request, opts)
146
      assert {:ok, %{"phase" => "verified"}} = RelupNode.verify(request, :current, opts)
147
      assert {:ok, _result} = RelupNode.make_permanent(request, opts)
148
      assert {:ok, %{"phase" => "verified"}} = RelupNode.verify(request, :permanent, opts)
149
150
      # `reverse/2` installs 0.2.0, checks health and schema, then restores
151
      # permanence. Before the target schema became explicit this left the
152
      # process on schema 1, so verify_reverse_health/2 failed with
153
      # :reverse_state_version_mismatch and permanence was never restored.
154
      assert {:ok, %{"phase" => "reversed", "restored" => true}} =
155
               RelupNode.reverse(request, opts)
156
157
      assert %State{schema_version: 2, observations: ["retained"], integrity: integrity} =
158
               ReleaseState.snapshot(state)
159
160
      assert is_binary(integrity)
161
162
      assert {:ok, _result} = RelupNode.install(request, opts)
163
      assert {:ok, %{"phase" => "verified"}} = RelupNode.verify(request, :current, opts)
164
      assert {:ok, _result} = RelupNode.make_permanent(request, opts)
165
      assert {:ok, %{"phase" => "verified"}} = RelupNode.verify(request, :permanent, opts)
166
167
      assert %State{schema_version: 2, observations: ["retained"]} = ReleaseState.snapshot(state)
168
    end
55 169
  end
56 170
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)
171
  defp migrate(pid, version, extra) do
172
    :ok = :sys.suspend(pid)
173
    :ok = :sys.change_code(pid, ReleaseState, version, extra)
174
    :ok = :sys.resume(pid)
175
  end
176
177
  defp release_root do
178
    root =
179
      Path.join(System.tmp_dir!(), "openagents-relup-node-#{System.unique_integer([:positive])}")
180
181
    File.mkdir_p!(Path.join(root, "releases"))
182
    on_exit(fn -> File.rm_rf!(root) end)
183
    root
68 184
  end
69 185
70 186
  defp sha256(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower)
test/openagents/release/appup_test.exs added +227

@@ -0,0 +1,227 @@

1
defmodule OpenAgents.Release.AppupTest do
2
  @moduledoc """
3
  Proves the packaged appup describes the two revisions it was built from.
4
5
  `:systools.make_relup/4` copies the appup into the relup verbatim, so an
6
  instruction list that names a fixed set of modules installs only those
7
  modules. A node would then run an interleaved mixture of two revisions while
8
  `verify` passed and `BuildInfo.revision/0` reported the new SHA. These tests
9
  drive `rel/openagents.appup.exs`, the file the release actually ships.
10
  """
11
  use ExUnit.Case, async: false
12
13
  alias OpenAgents.Release.Appup
14
15
  @appup_file Path.expand("../../../rel/openagents.appup.exs", __DIR__)
16
  @environment [
17
    "RELUP_FROM",
18
    "RELUP_TO",
19
    "RELUP_FROM_EBIN",
20
    "RELUP_TO_EBIN",
21
    "RELUP_FROM_STATE",
22
    "RELUP_TO_STATE"
23
  ]
24
25
  setup do
26
    previous = Map.new(@environment, &{&1, System.get_env(&1)})
27
28
    on_exit(fn ->
29
      Enum.each(previous, fn
30
        {name, nil} -> System.delete_env(name)
31
        {name, value} -> System.put_env(name, value)
32
      end)
33
    end)
34
35
    :ok
36
  end
37
38
  test "covers a changed module the fixed proof set never named" do
39
    from = ebin([widget(1), only_in_from()])
40
    to = ebin([widget(2), only_in_to()])
41
42
    {~c"0.3.0", [{~c"0.2.0", up}], [{~c"0.2.0", down}]} =
43
      generate(from, to, "0.2.0", "0.3.0", 2, 2)
44
45
    assert {:load_module, Relup.Fixture.Widget} in up
46
    assert {:load_module, Relup.Fixture.Widget} in down
47
48
    assert {:add_module, Relup.Fixture.OnlyInTo} in up
49
    assert {:delete_module, Relup.Fixture.OnlyInFrom} in up
50
    assert {:add_module, Relup.Fixture.OnlyInFrom} in down
51
    assert {:delete_module, Relup.Fixture.OnlyInTo} in down
52
53
    assert {:apply, {OpenAgents.ReleaseState, :install_barrier, []}} in up
54
    assert {:apply, {OpenAgents.ReleaseState, :install_barrier, []}} in down
55
  end
56
57
  test "leaves untouched modules alone" do
58
    from = ebin([widget(1), stable()])
59
    to = ebin([widget(2), stable()])
60
61
    {_version, [{_from, up}], _down} = generate(from, to, "0.2.0", "0.3.0", 2, 2)
62
63
    assert {:load_module, Relup.Fixture.Widget} in up
64
    refute {:load_module, Relup.Fixture.Stable} in up
65
  end
66
67
  test "carries each direction's target state schema in the advanced update" do
68
    from = ebin([widget(1)])
69
    to = ebin([widget(2)])
70
71
    {_version, [{_from, up}], [{_to, down}]} = generate(from, to, "0.1.0", "0.2.0", 1, 2)
72
73
    assert {:update, OpenAgents.ReleaseState, {:advanced, [schema_version: 2]}} in up
74
    assert {:update, OpenAgents.ReleaseState, {:advanced, [schema_version: 1]}} in down
75
  end
76
77
  test "keeps a same-schema pair on its schema in both directions" do
78
    from = ebin([widget(1)])
79
    to = ebin([widget(2)])
80
81
    {_version, [{_from, up}], [{_to, down}]} = generate(from, to, "0.2.0", "0.3.0", 2, 2)
82
83
    assert {:update, OpenAgents.ReleaseState, {:advanced, [schema_version: 2]}} in up
84
    assert {:update, OpenAgents.ReleaseState, {:advanced, [schema_version: 2]}} in down
85
  end
86
87
  test "updates a supervisor rather than loading it" do
88
    from = ebin([supervisor(1)])
89
    to = ebin([supervisor(2)])
90
91
    {_version, [{_from, up}], _down} = generate(from, to, "0.2.0", "0.3.0", 2, 2)
92
93
    assert {:update, Relup.Fixture.Tree, :supervisor} in up
94
  end
95
96
  test "refuses to emit an instruction list without the from build" do
97
    to = ebin([widget(2)])
98
99
    System.put_env("RELUP_FROM", "0.2.0")
100
    System.put_env("RELUP_TO", "0.3.0")
101
    System.put_env("RELUP_TO_EBIN", to)
102
    System.put_env("RELUP_FROM_STATE", "2")
103
    System.put_env("RELUP_TO_STATE", "2")
104
    System.delete_env("RELUP_FROM_EBIN")
105
106
    assert_raise RuntimeError, ~r/RELUP_FROM_EBIN/, fn -> Code.eval_file(@appup_file) end
107
  end
108
109
  test "refuses one version without the other" do
110
    System.put_env("RELUP_TO", "0.3.0")
111
    System.delete_env("RELUP_FROM")
112
113
    assert_raise RuntimeError, ~r/must be set together/, fn -> Code.eval_file(@appup_file) end
114
  end
115
116
  test "verify_relup! rejects a relup that omits a changed module" do
117
    from = ebin([widget(1)])
118
    to = ebin([widget(2)])
119
120
    path = Path.join(directory(), "relup")
121
122
    File.write!(path, ~s|{"0.3.0",[{"0.2.0","",[]}],[{"0.2.0","",[]}]}.\n|)
123
124
    assert_raise RuntimeError, ~r/Relup.Fixture.Widget/, fn ->
125
      Appup.verify_relup!(path,
126
        from_ebin: from,
127
        to_ebin: to,
128
        from_state_version: 2,
129
        to_state_version: 2
130
      )
131
    end
132
  end
133
134
  test "verify_relup! accepts a relup that names every covered module" do
135
    from = ebin([widget(1)])
136
    to = ebin([widget(2)])
137
138
    {_version, [{_from, up}], [{_to, down}]} = generate(from, to, "0.2.0", "0.3.0", 2, 2)
139
    path = Path.join(directory(), "relup")
140
141
    File.write!(
142
      path,
143
      :io_lib.format(~c"~tp.~n", [{~c"0.3.0", [{~c"0.2.0", ~c"", up}], [{~c"0.2.0", ~c"", down}]}])
144
    )
145
146
    assert OpenAgents.ReleaseState in Appup.verify_relup!(path,
147
             from_ebin: from,
148
             to_ebin: to,
149
             from_state_version: 2,
150
             to_state_version: 2
151
           )
152
  end
153
154
  defp generate(from_ebin, to_ebin, from_version, to_version, from_state, to_state) do
155
    System.put_env("RELUP_FROM", from_version)
156
    System.put_env("RELUP_TO", to_version)
157
    System.put_env("RELUP_FROM_EBIN", from_ebin)
158
    System.put_env("RELUP_TO_EBIN", to_ebin)
159
    System.put_env("RELUP_FROM_STATE", to_string(from_state))
160
    System.put_env("RELUP_TO_STATE", to_string(to_state))
161
162
    {appup, []} = Code.eval_file(@appup_file)
163
    appup
164
  end
165
166
  defp ebin(sources) do
167
    directory = directory()
168
169
    state_beam = OpenAgents.ReleaseState |> :code.which() |> to_string()
170
    File.cp!(state_beam, Path.join(directory, Path.basename(state_beam)))
171
172
    Enum.each(sources, &compile_into(directory, &1))
173
    directory
174
  end
175
176
  defp directory do
177
    path =
178
      Path.join(
179
        System.tmp_dir!(),
180
        "openagents-appup-#{System.unique_integer([:positive])}"
181
      )
182
183
    File.mkdir_p!(path)
184
    on_exit(fn -> File.rm_rf!(path) end)
185
    path
186
  end
187
188
  defp compile_into(directory, source) do
189
    previous = Code.compiler_options(ignore_module_conflict: true)
190
191
    try do
192
      for {module, binary} <- Code.compile_string(source) do
193
        File.write!(Path.join(directory, "#{module}.beam"), binary)
194
        :code.purge(module)
195
        :code.delete(module)
196
      end
197
    after
198
      Code.compiler_options(previous)
199
    end
200
  end
201
202
  defp widget(value) do
203
    "defmodule Relup.Fixture.Widget do def value, do: #{value} end"
204
  end
205
206
  defp stable do
207
    "defmodule Relup.Fixture.Stable do def value, do: :stable end"
208
  end
209
210
  defp only_in_from do
211
    "defmodule Relup.Fixture.OnlyInFrom do def value, do: :gone end"
212
  end
213
214
  defp only_in_to do
215
    "defmodule Relup.Fixture.OnlyInTo do def value, do: :new end"
216
  end
217
218
  defp supervisor(value) do
219
    """
220
    defmodule Relup.Fixture.Tree do
221
      use Supervisor
222
      @impl true
223
      def init(_arguments), do: Supervisor.init([], strategy: :one_for_one, max_restarts: #{value})
224
    end
225
    """
226
  end
227
end
test/support/openagents/test/release_handler.ex modified +9 -1

@@ -29,6 +29,13 @@ defmodule OpenAgents.Test.ReleaseHandler do

29 29
  def install_release(version) do
30 30
    version = to_string(version)
31 31
32
    # Stands in for the relup's own instructions, which run inside
33
    # install_release: the module updates and the ReleaseState code change.
34
    case Agent.get(__MODULE__, &Map.get(&1, :on_install)) do
35
      nil -> :ok
36
      installer -> installer.(version)
37
    end
38
32 39
    Agent.update(__MODULE__, fn state ->
33 40
      releases =
34 41
        Enum.map(state.releases, fn {name, found, applications, status} ->

@@ -65,6 +72,7 @@ defmodule OpenAgents.Test.ReleaseHandler do

65 72
  end
66 73
67 74
  defp other_version(version) do
68
    if to_string(version) == "0.2.0", do: ~c"0.1.0", else: ~c"0.2.0"
75
    {from, to} = Agent.get(__MODULE__, &Map.get(&1, :pair, {"0.1.0", "0.2.0"}))
76
    if to_string(version) == to, do: to_charlist(from), else: to_charlist(to)
69 77
  end
70 78
end

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