Generalize relup version admission and packaging

352448a86c97 · AtlantisPleb · · parent 3b44f02b3448

Generalize relup version admission and packaging

RelupDeployment now admits any distinct X.Y.Z pair whose state
versions stay within the supported set and never regress, matching
what RelupNode already enforces per node; the packaged appup on the
target remains the real gate at check_install.

The appup source generates forward and reverse instructions for any
admitted pair instead of raising outside the proof transition, and a
new ops/forge/package-relup.sh assembles two release tarballs plus the
generated relup for an arbitrary revision pair in isolated worktrees,
emitting a digest-addressed package.json for deployment requests.

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 lib/openagents/forge/relup_deployment.ex
  • added ops/forge/package-relup.sh
  • modified rel/openagents.appup.exs
  • modified test/openagents/forge/relup_deployment_test.exs

Diff

4 files changed, +303 -21

lib/openagents/forge/relup_deployment.ex modified +23 -6

@@ -12,6 +12,12 @@ defmodule OpenAgents.Forge.RelupDeployment do

12 12
13 13
  @sha_pattern ~r/\A[0-9a-f]{40}\z/
14 14
  @digest_pattern ~r/\A[0-9a-f]{64}\z/
15
  # Same admission the per-node release-handler transaction enforces
16
  # (RelupNode): any concrete X.Y.Z[-+suffix] pair. The real gate for a
17
  # transition is the packaged appup on the node itself; check_install refuses
18
  # honestly when no relup exists between the two versions.
19
  @version_pattern ~r/\A[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?\z/
20
  @supported_state_versions [1, 2]
15 21
  @default_timeout_ms 120_000
16 22
17 23
  @doc "Deploy one two-way relup across the exact expected fleet."

@@ -169,6 +175,9 @@ defmodule OpenAgents.Forge.RelupDeployment do

169 175
  end
170 176
171 177
  defp validate_request(request) when is_map(request) do
178
    from_version = Map.get(request, :from_version)
179
    to_version = Map.get(request, :to_version)
180
172 181
    cond do
173 182
      not Regex.match?(@sha_pattern, Map.get(request, :sha, "")) ->
174 183
        {:error, :invalid_git_sha}

@@ -182,18 +191,24 @@ defmodule OpenAgents.Forge.RelupDeployment do

182 191
      Map.get(request, :release_name) != "openagents" ->
183 192
        {:error, :invalid_release_name}
184 193
185
      Map.get(request, :from_version) != "0.1.0" ->
186
        {:error, :unsupported_from_version}
194
      not version?(from_version) ->
195
        {:error, :invalid_from_version}
196
197
      not version?(to_version) ->
198
        {:error, :invalid_to_version}
187 199
188
      Map.get(request, :to_version) != "0.2.0" ->
189
        {:error, :unsupported_to_version}
200
      from_version == to_version ->
201
        {:error, :degenerate_version_transition}
190 202
191
      Map.get(request, :from_state_version) != 1 ->
203
      Map.get(request, :from_state_version) not in @supported_state_versions ->
192 204
        {:error, :unsupported_from_state_version}
193 205
194
      Map.get(request, :to_state_version) != 2 ->
206
      Map.get(request, :to_state_version) not in @supported_state_versions ->
195 207
        {:error, :unsupported_to_state_version}
196 208
209
      Map.get(request, :to_state_version) < Map.get(request, :from_state_version) ->
210
        {:error, :state_version_regression}
211
197 212
      not is_list(Map.get(request, :expected_nodes)) ->
198 213
        {:error, :invalid_expected_nodes}
199 214

@@ -210,6 +225,8 @@ defmodule OpenAgents.Forge.RelupDeployment do

210 225
211 226
  defp validate_request(_request), do: {:error, :invalid_request}
212 227
228
  defp version?(version), do: is_binary(version) and Regex.match?(@version_pattern, version)
229
213 230
  defp public_result(request, status, node_results, error_code) do
214 231
    %{
215 232
      schema: "openagents.relup-deployment.v1",
ops/forge/package-relup.sh added +181

@@ -0,0 +1,181 @@

1
#!/bin/sh
2
# Package one two-way relup transition: two production release tarballs plus
3
# the generated relup, digest-addressed and ready for RelupDeployment.
4
#
5
# Usage:
6
#   ops/forge/package-relup.sh --from-version 0.2.0 --to-version 0.3.0 \
7
#     [--from-rev <sha|ref>] [--to-rev <sha|ref>] \
8
#     [--from-state 2] [--to-state 2] [--out-dir DIR]
9
#
10
# Builds each revision in an isolated temporary worktree, so the working
11
# checkout is untouched. The steps mirror ops/relup-proof/run.sh, which is the
12
# proven recipe; this script parameterizes it for real version pairs. Whether
13
# a specific pair installs is still decided by the packaged appup and
14
# `release_handler` on the target nodes at check_install time.
15
16
set -eu
17
18
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
19
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
20
21
usage() {
22
  echo "usage: $0 --from-version V --to-version V [--from-rev REF] [--to-rev REF] [--from-state N] [--to-state N] [--out-dir DIR]" >&2
23
  exit 2
24
}
25
26
from_version=""
27
to_version=""
28
from_rev="HEAD"
29
to_rev="HEAD"
30
from_state="2"
31
to_state="2"
32
out_dir=""
33
34
while [ $# -gt 0 ]; do
35
  case "$1" in
36
    --from-version) from_version="$2"; shift 2 ;;
37
    --to-version) to_version="$2"; shift 2 ;;
38
    --from-rev) from_rev="$2"; shift 2 ;;
39
    --to-rev) to_rev="$2"; shift 2 ;;
40
    --from-state) from_state="$2"; shift 2 ;;
41
    --to-state) to_state="$2"; shift 2 ;;
42
    --out-dir) out_dir="$2"; shift 2 ;;
43
    *) usage ;;
44
  esac
45
done
46
47
[ -n "$from_version" ] || usage
48
[ -n "$to_version" ] || usage
49
50
version_ok() {
51
  echo "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'
52
}
53
54
state_ok() {
55
  echo "$1" | grep -Eq '^[12]$'
56
}
57
58
version_ok "$from_version" || { echo "from-version must be X.Y.Z" >&2; exit 2; }
59
version_ok "$to_version" || { echo "to-version must be X.Y.Z" >&2; exit 2; }
60
state_ok "$from_state" || { echo "from-state must be 1 or 2" >&2; exit 2; }
61
state_ok "$to_state" || { echo "to-state must be 1 or 2" >&2; exit 2; }
62
63
if [ "$from_version" = "$to_version" ]; then
64
  echo "from-version and to-version must differ" >&2
65
  exit 2
66
fi
67
68
if [ "$to_state" -lt "$from_state" ]; then
69
  echo "to-state must not regress below from-state" >&2
70
  exit 2
71
fi
72
73
from_sha=$(git -C "$repo_root" rev-parse --verify "${from_rev}^{commit}")
74
to_sha=$(git -C "$repo_root" rev-parse --verify "${to_rev}^{commit}")
75
76
build_root=$(mktemp -d "${TMPDIR:-/tmp}/openagents-relup-package.XXXXXX")
77
assets_digested=0
78
79
cleanup() {
80
  git -C "$repo_root" worktree remove --force "$build_root/from" >/dev/null 2>&1 || true
81
  git -C "$repo_root" worktree remove --force "$build_root/to" >/dev/null 2>&1 || true
82
83
  if [ "$assets_digested" = "1" ]; then
84
    (git -C "$repo_root" worktree list --porcelain >/dev/null 2>&1) || true
85
  fi
86
87
  rm -rf "$build_root"
88
}
89
90
trap cleanup EXIT INT TERM
91
92
git -C "$repo_root" worktree add --detach "$build_root/from" "$from_sha" >/dev/null
93
git -C "$repo_root" worktree add --detach "$build_root/to" "$to_sha" >/dev/null
94
95
echo "Fetching dependencies (from tree)"
96
(cd "$build_root/from" && MIX_ENV=prod mix deps.get >/dev/null)
97
(cd "$build_root/from" && npm install --prefix assets --no-audit --no-fund >/dev/null 2>&1)
98
99
echo "Fetching dependencies (to tree)"
100
(cd "$build_root/to" && MIX_ENV=prod mix deps.get >/dev/null)
101
(cd "$build_root/to" && npm install --prefix assets --no-audit --no-fund >/dev/null 2>&1)
102
103
echo "Building production assets (from tree)"
104
(cd "$build_root/from" && MIX_ENV=prod mix assets.deploy)
105
assets_digested=1
106
107
echo "Building production assets (to tree)"
108
(cd "$build_root/to" && MIX_ENV=prod mix assets.deploy)
109
110
echo "Building $from_version release"
111
env -u RELUP_FROM -u RELUP_TO -u OPENAGENTS_RELUP_PATH \
112
  MIX_ENV=prod \
113
  OPENAGENTS_RELEASE_PATH="$build_root/from/release" \
114
  OPENAGENTS_RELEASE_VSN="$from_version" \
115
  OPENAGENTS_RELUP_STATE_VERSION="$from_state" \
116
  sh -c 'cd "$1" && mix do compile --force --warnings-as-errors + release --overwrite' sh "$build_root/from"
117
118
echo "Building $to_version release resource"
119
env -u OPENAGENTS_RELUP_PATH \
120
  MIX_ENV=prod \
121
  OPENAGENTS_RELEASE_PATH="$build_root/to/release" \
122
  OPENAGENTS_RELEASE_VSN="$to_version" \
123
  OPENAGENTS_RELUP_STATE_VERSION="$to_state" \
124
  RELUP_FROM="$from_version" \
125
  RELUP_TO="$to_version" \
126
  sh -c 'cd "$1" && mix do compile --force --warnings-as-errors + release --overwrite' sh "$build_root/to"
127
128
echo "Generating the two-way relup"
129
(cd "$build_root/to" && MIX_ENV=prod mix openagents.relup \
130
  --target "$build_root/to/release/releases/$to_version/openagents" \
131
  --from "$build_root/from/release/releases/$from_version/openagents" \
132
  --outdir "$build_root")
133
134
echo "Reassembling $to_version with the embedded relup"
135
env \
136
  MIX_ENV=prod \
137
  OPENAGENTS_RELEASE_PATH="$build_root/to/release" \
138
  OPENAGENTS_RELEASE_VSN="$to_version" \
139
  OPENAGENTS_RELUP_PATH="$build_root/relup" \
140
  OPENAGENTS_RELUP_STATE_VERSION="$to_state" \
141
  RELUP_FROM="$from_version" \
142
  RELUP_TO="$to_version" \
143
  sh -c 'cd "$1" && mix release --overwrite' sh "$build_root/to"
144
145
tar -tzf "$build_root/to/release/openagents-$to_version.tar.gz" |
146
  grep -Fxq "releases/$to_version/relup"
147
148
publish_root="$out_dir"
149
if [ -z "$publish_root" ]; then
150
  publish_root="$repo_root/artifacts/relup/$from_version-to-$to_version"
151
fi
152
153
from_digest=$(sha256sum "$build_root/from/release/openagents-$from_version.tar.gz" | cut -d ' ' -f 1)
154
to_digest=$(sha256sum "$build_root/to/release/openagents-$to_version.tar.gz" | cut -d ' ' -f 1)
155
relup_digest=$(sha256sum "$build_root/relup" | cut -d ' ' -f 1)
156
157
mkdir -p "$publish_root"
158
cp "$build_root/from/release/openagents-$from_version.tar.gz" "$publish_root/"
159
cp "$build_root/to/release/openagents-$to_version.tar.gz" "$publish_root/"
160
cp "$build_root/relup" "$publish_root/"
161
162
cat >"$publish_root/package.json" <<EOF
163
{
164
  "schema": "openagents.relup-package.v1",
165
  "release_name": "openagents",
166
  "from_revision": "$from_sha",
167
  "to_revision": "$to_sha",
168
  "from_version": "$from_version",
169
  "to_version": "$to_version",
170
  "from_state_version": $from_state,
171
  "to_state_version": $to_state,
172
  "from_artifact_digest": "$from_digest",
173
  "to_artifact_digest": "$to_digest",
174
  "relup_digest": "$relup_digest"
175
}
176
EOF
177
178
echo "Relup package ready: $publish_root"
179
echo "  openagents-$from_version.tar.gz  sha256:$from_digest"
180
echo "  openagents-$to_version.tar.gz    sha256:$to_digest"
181
echo "  relup                            sha256:$relup_digest"
rel/openagents.appup.exs modified +25 -15

@@ -1,25 +1,35 @@

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.
1
# Supported hot-upgrade instructions for the :openagents app.
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.
9
#
10
# Build the candidate with both variables set; build a plain release with both
11
# unset.
5 12
(fn ->
6 13
   to = System.get_env("RELUP_TO")
7 14
   from = System.get_env("RELUP_FROM")
15
   vsn = ~r/\A\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\z/
8 16
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
       ]
17
   steps = [
18
     {:update, OpenAgents.ReleaseState, {:advanced, []}},
19
     {:apply, {OpenAgents.ReleaseState, :install_barrier, []}},
20
     {:load_module, OpenAgents.BuildInfo}
21
   ]
16 22
17
       {~c"0.2.0", [{~c"0.1.0", steps}], [{~c"0.1.0", steps}]}
23
   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}]}
18 28
19
     {nil, nil} ->
29
     is_nil(to) and is_nil(from) ->
20 30
       {String.to_charlist(System.get_env("OPENAGENTS_RELEASE_VSN", "0.2.0")), [], []}
21 31
22
     _unsupported ->
23
       raise "RELUP_FROM and RELUP_TO must select the supported 0.1.0 to 0.2.0 transition"
32
     true ->
33
       raise "RELUP_FROM and RELUP_TO must be distinct X.Y.Z versions"
24 34
   end
25 35
 end).()
test/openagents/forge/relup_deployment_test.exs modified +74

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

108 108
    }
109 109
  end
110 110
111
  describe "version admission" do
112
    test "admits an arbitrary forward semver transition, not only the proof pair" do
113
      parent = self()
114
115
      rpc = fn node, _module, function, _arguments, _timeout ->
116
        send(parent, {:phase, node, function})
117
        {:ok, %{}}
118
      end
119
120
      request =
121
        request()
122
        |> Map.merge(%{from_version: "0.2.0", to_version: "0.3.0", from_state_version: 2})
123
124
      assert {:ok, %{status: "live", from_version: "0.2.0", to_version: "0.3.0"}} =
125
               RelupDeployment.run(request,
126
                 members: fn -> @nodes end,
127
                 gate_verifier: fn @sha -> {:ok, %{}} end,
128
                 rpc: rpc
129
               )
130
131
      assert length(drain_messages([])) == 24
132
    end
133
134
    test "refuses a degenerate transition" do
135
      request = request() |> Map.put(:to_version, request().from_version)
136
137
      assert {:error, :degenerate_version_transition} =
138
               RelupDeployment.run(request,
139
                 members: fn -> @nodes end,
140
                 gate_verifier: fn @sha -> {:ok, %{}} end,
141
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
142
               )
143
    end
144
145
    test "refuses malformed versions" do
146
      assert {:error, :invalid_from_version} =
147
               RelupDeployment.run(request() |> Map.put(:from_version, "zero-point-twelve"),
148
                 members: fn -> @nodes end,
149
                 gate_verifier: fn @sha -> {:ok, %{}} end,
150
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
151
               )
152
153
      assert {:error, :invalid_to_version} =
154
               RelupDeployment.run(request() |> Map.put(:to_version, "0.2"),
155
                 members: fn -> @nodes end,
156
                 gate_verifier: fn @sha -> {:ok, %{}} end,
157
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
158
               )
159
    end
160
161
    test "refuses state version regression and unsupported state versions" do
162
      regression =
163
        request()
164
        |> Map.merge(%{from_version: "0.2.0", to_version: "0.3.0", from_state_version: 2})
165
        |> Map.put(:to_state_version, 1)
166
167
      assert {:error, :state_version_regression} =
168
               RelupDeployment.run(regression,
169
                 members: fn -> @nodes end,
170
                 gate_verifier: fn @sha -> {:ok, %{}} end,
171
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
172
               )
173
174
      unsupported = request() |> Map.put(:to_state_version, 3)
175
176
      assert {:error, :unsupported_to_state_version} =
177
               RelupDeployment.run(unsupported,
178
                 members: fn -> @nodes end,
179
                 gate_verifier: fn @sha -> {:ok, %{}} end,
180
                 rpc: fn _, _, _, _, _ -> {:ok, %{}} end
181
               )
182
    end
183
  end
184
111 185
  defp drain_messages(acc) do
112 186
    receive do
113 187
      event -> drain_messages([event | acc])

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