Skip to content

Commit 44a804c

Browse files
ausimianclaude
andcommitted
fix: confirm a release only once its boot has finished
running/1 asked only whether the version was the current or permanent release, and that can be true of a node that is still booting. After a restart_emulator reboot, release_handler's transform_release/3 makes the target current while sasl starts, and distribution has been answering since kernel came up - so a poll landing in that window was confirmed, install exited 0, and an application started after sasl could still fail the boot and take the system back to the release that was permanent. Automation that commits after a successful install would then have made a version that cannot boot the permanent one. So the boot has to have finished as well. That is init:get_status/0's *provided* status being :started, and only that element is usable: the internal one stays :starting while the boot process lives, which is the whole life of a release started by its boot script, so a booted node reports {:starting, :started} and waiting for {:started, _} would wait for ever. Checked against a running VM, and against init.erl:687-700, where each {progress, Info} instruction sets the provided status. :started is the last of those instructions in every boot script Mix generates, after the one that starts the release's applications - and systools_make:add_apply_upgrade/2 splices release_handler:new_emulator_upgrade/2 in just before that same marker, so on the hybrid boot of an emulator upgrade the status reaches :started only once the continuation has run. One condition therefore covers both restart transitions. :init is reached through a module argument defaulting to it, as :release_handler already is, so both sides are testable: a target that is current but mid-boot is refused, and the same target once booted is accepted. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
1 parent 1948ca0 commit 44a804c

6 files changed

Lines changed: 135 additions & 26 deletions

File tree

AGENTS.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,20 @@ Castle's job is configuration and release management on a running node.
2626
was accepted: a transition that restarts the emulator is replied to and then
2727
rebooted, and an emulator upgrade finishes on the way back up, where it can
2828
still roll back. `bin/castle install` therefore polls this rather than
29-
trusting the reply. The running release is the `current` one, or the
30-
`permanent` one when none is current — `install` leaves its target `current`
31-
and `commit` promotes it, so both count; `unpacked` (a rolled-back
32-
continuation) and `tmp_current` (written before the reboot) do not.
29+
trusting the reply. Two conditions. The version is the running release: the
30+
`current` one, or the `permanent` one when none is current — `install` leaves
31+
its target `current` and `commit` promotes it, so both count; `unpacked` (a
32+
rolled-back continuation) and `tmp_current` (written before the reboot) do
33+
not. And its boot has finished, which is `:init.get_status/0`'s *provided*
34+
status being `:started`. Do not gate on the internal status: it stays
35+
`:starting` for the life of a release started by its boot script, so a booted
36+
node reports `{:starting, :started}`. The provided status is what the script's
37+
`{progress, _}` instructions move along, and `started` is its last one — after
38+
the applications have started, and after `new_emulator_upgrade/2` in the
39+
hybrid script that continues an emulator upgrade. Without that second
40+
condition a poll can confirm a node that is still booting, and automation
41+
that commits straight after installing would make a version that cannot boot
42+
the permanent one.
3343

3444
Every one of them is a command entry point, so `Castle` is the command
3545
boundary: an operation that fails raises `Castle.Error` there, which is what
@@ -52,7 +62,7 @@ into this module.
5262
| `lib/castle.ex` | The command boundary: print the outcome, or raise |
5363
| `lib/castle/commands.ex` | The commands themselves, returning their outcome |
5464
| `lib/castle/error.ex` | The exception a failed command raises |
55-
| `test/support/` | Stubs for `:release_handler` and a config provider |
65+
| `test/support/` | Stubs for `:release_handler`, `:init` and a config provider |
5666

5767
## Working on this project
5868

@@ -71,13 +81,14 @@ into this module.
7181

7282
## Tests
7383

74-
`mix test` covers `Castle.Commands` as units. `:release_handler` is reached
75-
through a module argument that defaults to it, so the tests hand it
76-
`Castle.ReleaseHandlerStub` instead; `generate/1` takes the version directory
77-
it writes to, so the tests give it a `tmp_dir` holding a synthetic
78-
`build.config`. `test/castle_test.exs` drives the boundary itself against the
79-
real `:release_handler` — which is running under `mix test`, because castle
80-
depends on sasl — naming releases that do not exist.
84+
`mix test` covers `Castle.Commands` as units. `:release_handler` and `:init` are
85+
reached through module arguments that default to them, so the tests hand them
86+
`Castle.ReleaseHandlerStub` and `Castle.InitStub` instead; `generate/1` takes
87+
the version directory it writes to, so the tests give it a `tmp_dir` holding a
88+
synthetic `build.config`. `test/castle_test.exs` drives the boundary itself
89+
against the real `:release_handler` — which is running under `mix test`, because
90+
castle depends on sasl — and the real `:init`, naming releases that do not
91+
exist.
8192

8293
What is *not* covered here is a booted release: the upgrade of a running
8394
system, and the exit statuses `bin/castle` returns, belong to Forecastle's

RELEASE.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@
88
accepted: a transition that restarts the emulator is replied to and *then*
99
rebooted, and for an emulator upgrade the instructions run on the way back
1010
up, where they can still fail and roll back. Completion therefore has to be
11-
observed, and `bin/castle install` polls this to observe it. The running
12-
release is the one whose status is `current`, or the `permanent` one if none
13-
is current, so a version is confirmed both before and after `commit`.
11+
observed, and `bin/castle install` polls this to observe it. Confirmation
12+
needs two things: the version is the release the system is running - the one
13+
whose status is `current`, or the `permanent` one if none is current, so a
14+
version is confirmed both before and after `commit` - *and* its boot has
15+
finished. The second is not redundant. A node that restarted into the new
16+
version is reachable long before it has finished booting: `release_handler`
17+
makes the version `current` while `sasl` starts, and distribution answers
18+
from `kernel` onwards, so an application started after `sasl` can still fail
19+
and take the system back to the version that was permanent. Confirming
20+
earlier than that would let automation `commit` a release that cannot boot.
1421
Nothing can build a relup that restarts the emulator until
1522
[forecastle#4](https://github.com/ausimian/forecastle/issues/4), so the
1623
restart transitions this addresses cannot be exercised end to end yet. The

lib/castle/commands.ex

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,50 @@ defmodule Castle.Commands do
166166
is - notably `:unpacked`, which is what a rolled-back continuation leaves the
167167
target as, and `:tmp_current`, which is written before the reboot a restart
168168
transition has yet to make.
169+
170+
Being the running release is necessary but not sufficient, because a node
171+
that restarted into it can be seen part-way up. `release_handler` records the
172+
new version as `:current` while `sasl` starts, and distribution is already
173+
answering by then - so a reply is available before the applications after
174+
`sasl` have started, and one of them can still fail the boot and take the
175+
system back to the previous permanent release. Committing on the strength of
176+
that would make a version that cannot boot the permanent one. So the boot has
177+
to have finished too, which is what the second condition below is for.
169178
"""
170-
@spec running(String.t(), module()) :: result()
171-
def running(vsn, handler \\ :release_handler) do
179+
@spec running(String.t(), module(), module()) :: result()
180+
def running(vsn, handler \\ :release_handler, init \\ :init) do
172181
case running_release(handler) do
173-
^vsn -> {:ok, []}
182+
^vsn -> booted(vsn, init)
174183
nil -> {:error, "#{vsn} is not the running release. No release is running."}
175184
other -> {:error, "#{vsn} is not the running release. #{other} is."}
176185
end
177186
end
178187

188+
# `init:get_status/0` answers `{InternalStatus, ProvidedStatus}`, and only the
189+
# second element is any use here. The internal one stays `:starting` for as
190+
# long as the boot process is alive, which is the whole life of a release
191+
# started by its boot script - a booted node reports `{:starting, :started}`,
192+
# so waiting for `{:started, _}` would wait forever.
193+
#
194+
# The provided status is what the boot script's `{progress, _}` instructions
195+
# set (init.erl:692), and `:started` is the last of them. Every boot script
196+
# Mix generates ends with `{progress, started}`, after the instruction that
197+
# starts the release's own applications; the hybrid script that continues an
198+
# emulator upgrade has `release_handler:new_emulator_upgrade/2` applied just
199+
# before that same marker (systools_make.erl:336). So a provided status of
200+
# `:started` means the script ran to the end: applications up, and any
201+
# continuation of the upgrade finished.
202+
defp booted(vsn, init) do
203+
case init.get_status() do
204+
{_internal, :started} ->
205+
{:ok, []}
206+
207+
{_internal, progress} ->
208+
{:error,
209+
"#{vsn} is the running release but has not finished booting: #{inspect(progress)}."}
210+
end
211+
end
212+
179213
defp running_release(handler) do
180214
releases = for {_, vsn, _, status} <- handler.which_releases(), do: {to_string(vsn), status}
181215

test/castle/commands_test.exs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ defmodule Castle.CommandsTest do
33

44
alias Castle.Commands
55
alias Castle.ConfigProviderStub
6+
alias Castle.InitStub
67
alias Castle.ReleaseHandlerStub, as: Stub
78

89
describe "generate/1" do
@@ -122,15 +123,15 @@ defmodule Castle.CommandsTest do
122123
end
123124
end
124125

125-
describe "running/2" do
126+
describe "running/3" do
126127
test "confirms the version an install has made current" do
127128
handler =
128129
Stub.stub(:which_releases, [
129130
{~c"sample", ~c"1.2.3", [], :current},
130131
{~c"sample", ~c"1.2.2", [], :permanent}
131132
])
132133

133-
assert Commands.running("1.2.3", handler) == {:ok, []}
134+
assert Commands.running("1.2.3", handler, booted()) == {:ok, []}
134135
end
135136

136137
test "confirms the version a commit has made permanent" do
@@ -140,7 +141,7 @@ defmodule Castle.CommandsTest do
140141
{~c"sample", ~c"1.2.2", [], :old}
141142
])
142143

143-
assert Commands.running("1.2.3", handler) == {:ok, []}
144+
assert Commands.running("1.2.3", handler, booted()) == {:ok, []}
144145
end
145146

146147
test "refuses the permanent version while another one is current" do
@@ -152,7 +153,7 @@ defmodule Castle.CommandsTest do
152153
{~c"sample", ~c"1.2.2", [], :permanent}
153154
])
154155

155-
assert Commands.running("1.2.2", handler) ==
156+
assert Commands.running("1.2.2", handler, booted()) ==
156157
{:error, "1.2.2 is not the running release. 1.2.3 is."}
157158
end
158159

@@ -163,7 +164,7 @@ defmodule Castle.CommandsTest do
163164
{~c"sample", ~c"1.2.2", [], :permanent}
164165
])
165166

166-
assert Commands.running("1.2.3", handler) ==
167+
assert Commands.running("1.2.3", handler, booted()) ==
167168
{:error, "1.2.3 is not the running release. 1.2.2 is."}
168169
end
169170

@@ -176,21 +177,47 @@ defmodule Castle.CommandsTest do
176177
{~c"sample", ~c"1.2.2", [], :permanent}
177178
])
178179

179-
assert Commands.running("1.2.3", handler) ==
180+
assert Commands.running("1.2.3", handler, booted()) ==
180181
{:error, "1.2.3 is not the running release. 1.2.2 is."}
181182
end
182183

183184
test "refuses a version the system has never heard of" do
184185
handler = Stub.stub(:which_releases, [{~c"sample", ~c"1.2.2", [], :permanent}])
185186

186-
assert Commands.running("9.9.9", handler) ==
187+
assert Commands.running("9.9.9", handler, booted()) ==
187188
{:error, "9.9.9 is not the running release. 1.2.2 is."}
188189
end
189190

191+
test "refuses a version whose boot has not finished" do
192+
# A node that restarted into the new version answers rpc from the moment
193+
# kernel is up, and release_handler has made the version current by the
194+
# time sasl has started - so it can be seen like this, with applications
195+
# still to start and the boot still able to fail back to the release that
196+
# was permanent before.
197+
handler = Stub.stub(:which_releases, [{~c"sample", ~c"1.2.3", [], :current}])
198+
init = InitStub.stub({:starting, :applications_loaded})
199+
200+
assert Commands.running("1.2.3", handler, init) ==
201+
{:error,
202+
"1.2.3 is the running release but has not finished booting: :applications_loaded."}
203+
end
204+
205+
test "confirms that same version once its boot has finished" do
206+
handler = Stub.stub(:which_releases, [{~c"sample", ~c"1.2.3", [], :current}])
207+
208+
# The internal status says nothing: it stays :starting for as long as the
209+
# boot process lives, which is the whole life of a release started by its
210+
# boot script. Only the provided status, which the script's last
211+
# {progress, _} sets, answers the question.
212+
for status <- [{:starting, :started}, {:started, :started}] do
213+
assert Commands.running("1.2.3", handler, InitStub.stub(status)) == {:ok, []}
214+
end
215+
end
216+
190217
test "refuses everything when nothing is running" do
191218
handler = Stub.stub(:which_releases, [{~c"sample", ~c"1.2.3", [], :unpacked}])
192219

193-
assert Commands.running("1.2.3", handler) ==
220+
assert Commands.running("1.2.3", handler, booted()) ==
194221
{:error, "1.2.3 is not the running release. No release is running."}
195222
end
196223
end
@@ -249,6 +276,9 @@ defmodule Castle.CommandsTest do
249276
end
250277
end
251278

279+
# What a node reports once its boot script has run to the end.
280+
defp booted, do: InitStub.stub({:starting, :started})
281+
252282
defp write_build_config(dir, config) do
253283
File.write!(
254284
Path.join(dir, "build.config"),

test/castle_test.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ defmodule CastleTest do
3535
test "confirms the release that is running, silently" do
3636
[{_, vsn, _, _}] = :release_handler.which_releases(:permanent)
3737

38+
# Through the real :init as well as the real :release_handler: a VM that
39+
# has finished booting reports {:starting, :started}, which is the shape
40+
# the confirmation has to accept.
41+
assert {_internal, :started} = :init.get_status()
3842
assert capture_io(fn -> assert Castle.running(to_string(vsn)) == :ok end) == ""
3943
end
4044

test/support/init_stub.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule Castle.InitStub do
2+
@moduledoc false
3+
4+
# A stand-in for `:init`, so that a node seen part-way through its boot can be
5+
# tested. Answers from the calling process's dictionary, like
6+
# `Castle.ReleaseHandlerStub`, and for the same reason.
7+
8+
@doc """
9+
Registers the status `get_status/0` replies with, and returns this module so
10+
that it can be passed straight to the function under test.
11+
"""
12+
def stub(status) do
13+
Process.put(__MODULE__, status)
14+
__MODULE__
15+
end
16+
17+
def get_status do
18+
case Process.get(__MODULE__, :unstubbed) do
19+
:unstubbed -> raise "get_status/0 was called without a registered reply"
20+
status -> status
21+
end
22+
end
23+
end

0 commit comments

Comments
 (0)