Handle rolling reboot transitions

2a2aa0daf679 · AtlantisPleb · · parent 733cb7c60347

Handle rolling reboot transitions

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified docs/operations/release-deployment-fallbacks.md
  • modified lib/openagents/forge/rolling_provider.ex
  • modified lib/openagents/forge/rolling_provider/gcp.ex
  • modified lib/openagents/forge/rolling_replacement.ex
  • modified test/openagents/forge/rolling_provider/gcp_test.exs
  • modified test/openagents/forge/rolling_replacement_test.exs
  • modified test/support/openagents/test/rolling_provider.ex

Diff

7 files changed, +95 -7

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

@@ -137,6 +137,12 @@ not deployment identity. Pass only the digest to the replacement provider.

137 137
infrastructure. Keep machine inventory, credentials, addresses, and provider
138 138
resource names outside the repository.
139 139
140
The provider reports its exact connected infrastructure inventory through
141
`members/0`. A hidden controller must not include itself or a temporary RPC
142
client in that inventory. The GCP provider intersects connected Erlang nodes
143
with its configured three-node instance map, so every membership check uses
144
the same bounded fleet identity.
145
140 146
For each node, the coordinator performs this sequence:
141 147
142 148
1. Verify the exact-SHA release receipt and exact initial member set.

@@ -149,6 +155,11 @@ For each node, the coordinator performs this sequence:

149 155
   access, source SHA, and image digest.
150 156
7. Recheck exact fleet membership before selecting another node.
151 157
158
An Erlang distribution disconnect is an expected transient state while a VM
159
reboots. The GCP provider reports that node as unavailable so the coordinator
160
continues its bounded readiness polling. Other RPC errors fail the rollout
161
closed.
162
152 163
If a node does not rejoin, the coordinator asks the provider to restore the
153 164
last-known-good SHA and digest, waits for that node's full health, records the
154 165
recovery result, and aborts. It never replaces a second node while the first is
lib/openagents/forge/rolling_provider.ex modified +1

@@ -16,6 +16,7 @@ defmodule OpenAgents.Forge.RollingProvider do

16 16
        }
17 17
18 18
  @callback remove_readiness(node(), context()) :: :ok | {:error, term()}
19
  @callback members() :: [node()]
19 20
  @callback restore_readiness(node(), context()) :: :ok | {:error, term()}
20 21
  @callback drain(node(), context()) :: {:ok, non_neg_integer()} | {:error, term()}
21 22
  @callback capacity([node()], context()) ::
lib/openagents/forge/rolling_provider/gcp.ex modified +31

@@ -15,6 +15,21 @@ defmodule OpenAgents.Forge.RollingProvider.Gcp do

15 15
  alias OpenAgents.Forge.RollingNodeProbe
16 16
  alias OpenAgents.Forge.RollingProvider.Gcp.Compute
17 17
18
  @impl true
19
  def members do
20
    with {:ok, config} <- config() do
21
      configured = config |> Keyword.fetch!(:instances) |> Map.keys() |> MapSet.new()
22
23
      config
24
      |> Keyword.get(:node_list, &connected_nodes/0)
25
      |> then(& &1.())
26
      |> Enum.filter(&MapSet.member?(configured, to_string(&1)))
27
      |> Enum.sort()
28
    else
29
      {:error, _reason} -> []
30
    end
31
  end
32
18 33
  @impl true
19 34
  def remove_readiness(node, _context) do
20 35
    with {:ok, config} <- config(),

@@ -121,11 +136,24 @@ defmodule OpenAgents.Forge.RollingProvider.Gcp do

121 136
  defp probe(config, node, expected_fleet_size) do
122 137
    case rpc(config, node, RollingNodeProbe, :status, [expected_fleet_size]) do
123 138
      %{member: true} = result -> {:ok, result}
139
      {:error, :noconnection} -> {:ok, unavailable_probe()}
124 140
      {:error, reason} -> {:error, reason}
125 141
      other -> {:error, {:invalid_node_probe, other}}
126 142
    end
127 143
  end
128 144
145
  defp unavailable_probe do
146
    %{
147
      member: false,
148
      ready: false,
149
      boot_converged: false,
150
      database_ready: false,
151
      sha: nil,
152
      image_digest: nil,
153
      ra_quorum: false
154
    }
155
  end
156
129 157
  defp rpc(config, node, module, function, arguments, call_timeout \\ nil) do
130 158
    rpc = Keyword.get(config, :rpc, &:erpc.call/5)
131 159

@@ -135,6 +163,8 @@ defmodule OpenAgents.Forge.RollingProvider.Gcp do

135 163
    end
136 164
  catch
137 165
    :exit, reason -> {:error, {:rpc_exit, reason}}
166
    :error, {:erpc, :noconnection} -> {:error, :noconnection}
167
    :error, reason -> {:error, {:rpc_error, reason}}
138 168
  end
139 169
140 170
  defp instance(config, node) do

@@ -145,6 +175,7 @@ defmodule OpenAgents.Forge.RollingProvider.Gcp do

145 175
  end
146 176
147 177
  defp driver(config), do: Keyword.get(config, :driver, Compute)
178
  defp connected_nodes, do: Enum.uniq(Node.list() ++ Node.list(:hidden))
148 179
  defp timeout(config), do: Keyword.get(config, :rpc_timeout_ms, 5_000)
149 180
  defp compute_timeout(config), do: Keyword.get(config, :compute_timeout_ms, 300_000)
150 181
lib/openagents/forge/rolling_replacement.ex modified +8 -7

@@ -21,7 +21,7 @@ defmodule OpenAgents.Forge.RollingReplacement do

21 21
    with :ok <- validate_request(request),
22 22
         {:ok, _receipt} <- gate_verify(request.sha, opts),
23 23
         {:ok, provider} <- provider(opts),
24
         :ok <- initial_membership(request, opts) do
24
         :ok <- initial_membership(request, provider, opts) do
25 25
      replace_nodes(request.expected_nodes, request, provider, opts, %{})
26 26
    end
27 27
  end

@@ -83,7 +83,7 @@ defmodule OpenAgents.Forge.RollingReplacement do

83 83
  defp replace_drained_node(node, request, provider, context, opts) do
84 84
    with :ok <- provider.replace(node, request.image_digest, context),
85 85
         :ok <- wait_for_target(provider, node, request, context, opts),
86
         :ok <- exact_membership(request, opts) do
86
         :ok <- exact_membership(request, provider, opts) do
87 87
      {:ok, "ready"}
88 88
    else
89 89
      {:error, reason} ->

@@ -234,16 +234,17 @@ defmodule OpenAgents.Forge.RollingReplacement do

234 234
    end
235 235
  end
236 236
237
  defp initial_membership(request, opts), do: exact_membership(request, opts)
237
  defp initial_membership(request, provider, opts),
238
    do: exact_membership(request, provider, opts)
238 239
239
  defp exact_membership(request, opts) do
240
    if members(opts) == request.expected_nodes,
240
  defp exact_membership(request, provider, opts) do
241
    if members(provider, opts) == request.expected_nodes,
241 242
      do: :ok,
242 243
      else: {:error, :fleet_membership_mismatch}
243 244
  end
244 245
245
  defp members(opts) do
246
    Keyword.get(opts, :members, &OpenAgents.Cluster.members/0).()
246
  defp members(provider, opts) do
247
    Keyword.get(opts, :members, &provider.members/0).()
247 248
    |> Enum.sort()
248 249
  end
249 250
test/openagents/forge/rolling_provider/gcp_test.exs modified +28

@@ -105,6 +105,34 @@ defmodule OpenAgents.Forge.RollingProvider.GcpTest do

105 105
    assert {:ok, %{ready: 2, quorum: false}} = Gcp.capacity(tl(@nodes), context())
106 106
  end
107 107
108
  test "reports a rebooting node as unavailable when Erlang distribution disconnects" do
109
    rpc = fn _node, RollingNodeProbe, :status, [_expected], _timeout ->
110
      :erlang.error({:erpc, :noconnection})
111
    end
112
113
    put_config(rpc)
114
115
    assert {:ok,
116
            %{
117
              member: false,
118
              ready: false,
119
              boot_converged: false,
120
              database_ready: false,
121
              sha: nil,
122
              image_digest: nil
123
            }} = Gcp.status(hd(@nodes), context())
124
  end
125
126
  test "reports only connected nodes in the configured fleet inventory" do
127
    rpc = fn _node, _module, _function, _arguments, _timeout -> :ok end
128
129
    put_config(rpc,
130
      node_list: fn -> [hd(@nodes), :unknown@fleet, Enum.at(@nodes, 2)] end
131
    )
132
133
    assert Gcp.members() == [hd(@nodes), Enum.at(@nodes, 2)]
134
  end
135
108 136
  defp context do
109 137
    %{
110 138
      sha: @sha,
test/openagents/forge/rolling_replacement_test.exs modified +13

@@ -26,6 +26,18 @@ defmodule OpenAgents.Forge.RollingReplacementTest do

26 26
           ]
27 27
  end
28 28
29
  test "uses the provider's exact infrastructure inventory by default" do
30
    start_provider(nil)
31
32
    assert {:ok, %{status: "live"}} =
33
             RollingReplacement.run(request(),
34
               provider: RollingProvider,
35
               gate_verifier: fn @sha -> {:ok, %{}} end,
36
               wait_attempts: 1,
37
               wait_interval_ms: 0
38
             )
39
  end
40
29 41
  test "restores the prior image and aborts before another replacement when rejoin fails" do
30 42
    start_provider(:second@local)
31 43

@@ -88,6 +100,7 @@ defmodule OpenAgents.Forge.RollingReplacementTest do

88 100
         capacity: capacity,
89 101
         events: [],
90 102
         fail_node: fail_node,
103
         members: @nodes,
91 104
         previous_sha: @previous_sha,
92 105
         rolled_back: MapSet.new(),
93 106
         digests: Map.new(@nodes, &{&1, @previous})
test/support/openagents/test/rolling_provider.ex modified +3

@@ -11,6 +11,9 @@ defmodule OpenAgents.Test.RollingProvider do

11 11
12 12
  def events, do: Agent.get(__MODULE__, &Enum.reverse(&1.events))
13 13
14
  @impl true
15
  def members, do: Agent.get(__MODULE__, & &1.members)
16
14 17
  @impl true
15 18
  def remove_readiness(node, _context) do
16 19
    event({:remove_readiness, node})

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