Skip to content

Commit cc53c5d

Browse files
authored
Merge pull request #17 from ausimian/issue/10-error-returns
fix: report release-management failures, and confirm an upgrade took effect
2 parents 00c31eb + f70fc02 commit cc53c5d

12 files changed

Lines changed: 960 additions & 82 deletions

AGENTS.md

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,50 @@ Castle's job is configuration and release management on a running node.
2121
- **`unpack/1`, `install/1`, `commit/1`, `remove/1`, `releases/0`** — wrappers
2222
over `:release_handler`, with `generate/1` called ahead of `install` and
2323
`commit` so the target version's configuration exists before it is booted.
24+
- **`Castle.running/1`** — succeeds when the version it is given is the release
25+
the system is running. `install_release/1`'s reply says only that the upgrade
26+
was accepted: a transition that restarts the emulator is replied to and then
27+
rebooted, and an emulator upgrade finishes on the way back up, where it can
28+
still roll back. So Castle answers the question and leaves the asking to
29+
Forecastle: `bin/castle install` repeats it rather than trusting the reply,
30+
from Forecastle 1.0.0 — the revision pinned in this project's `mix.lock`
31+
installs with a single rpc and never calls this, so do not describe the
32+
polling as something Castle's own integrated state does. Two conditions. The
33+
version is the running release: the
34+
`current` one, or the `permanent` one when none is current — `install` leaves
35+
its target `current` and `commit` promotes it, so both count; `unpacked` (a
36+
rolled-back continuation) and `tmp_current` (written before the reboot) do
37+
not. And its boot has finished, which is `:init.get_status/0`'s *provided*
38+
status being `:started`. Do not gate on the internal status: it stays
39+
`:starting` for the life of a release started by its boot script, so a booted
40+
node reports `{:starting, :started}`. The provided status is what the script's
41+
`{progress, _}` instructions move along, and `started` is its last one — after
42+
the applications have started, and after `new_emulator_upgrade/2` in the
43+
hybrid script that continues an emulator upgrade. Without that second
44+
condition a poll can confirm a node that is still booting, and automation
45+
that commits straight after installing would make a version that cannot boot
46+
the permanent one.
47+
48+
The marker is the whole of the evidence, so it inherits whatever the selected
49+
boot script does with it. `RELEASE_BOOT_SCRIPT` naming a hand-written script
50+
that never reaches `{progress, started}` will never be confirmed — `install`
51+
waits and then fails, and the refusal names the progress the node did reach,
52+
so it is diagnosable and never a false success — and one that emits the marker
53+
before its applications start defeats the check. Both are documented rather
54+
than validated: Mix generates the boot scripts and offers no `rel/` template
55+
for them, so reaching either state takes deliberate work. (An earlier note
56+
here claimed `systools_make:add_apply_upgrade/2`'s hard match on the trailing
57+
marker ruled this out. It does not: that builds the hybrid script for an
58+
emulator upgrade and says nothing about a script an operator supplies.)
59+
60+
Every one of them is a command entry point, so `Castle` is the command
61+
boundary: an operation that fails raises `Castle.Error` there, which is what
62+
leaves a non-zero exit status behind for the shell that asked for it. Raising,
63+
not halting — the expression runs on the *running* node, so halting would take
64+
down the system under management; `Kernel.CLI` catches on the node and
65+
re-raises in the calling VM, and only that VM exits. `Castle.Commands` holds
66+
the operations themselves, returning their outcome instead of acting on the
67+
process, which is what makes them testable.
2468

2569
Forecastle is what arranges for these to be reachable: it renames `sys.config`
2670
to `build.config` at assembly time, adds a `:preboot` script that starts
@@ -31,7 +75,10 @@ into this module.
3175

3276
| Path | Purpose |
3377
| --- | --- |
34-
| `lib/castle.ex` | The whole of the runtime logic |
78+
| `lib/castle.ex` | The command boundary: print the outcome, or raise |
79+
| `lib/castle/commands.ex` | The commands themselves, returning their outcome |
80+
| `lib/castle/error.ex` | The exception a failed command raises |
81+
| `test/support/` | Stubs for `:release_handler`, `:init` and a config provider |
3582

3683
## Working on this project
3784

@@ -50,27 +97,32 @@ into this module.
5097

5198
## Tests
5299

53-
There is no test coverage yet. `test/castle_test.exs` is a `doctest` stub.
100+
`mix test` covers `Castle.Commands` as units. `:release_handler` and `:init` are
101+
reached through module arguments that default to them, so the tests hand them
102+
`Castle.ReleaseHandlerStub` and `Castle.InitStub` instead; `generate/1` takes
103+
the version directory it writes to, so the tests give it a `tmp_dir` holding a
104+
synthetic `build.config`. `test/castle_test.exs` drives the boundary itself
105+
against the real `:release_handler` — which is running under `mix test`, because
106+
castle depends on sasl — and the real `:init`, naming releases that do not
107+
exist.
54108

55-
Every function here talks to `:release_handler` against a real installed
56-
release, and `generate/1` resolves paths from `:code.root_dir()`, so none of it
57-
is reachable from a plain `mix test`. Testing it needs a release fixture booted
58-
in a workspace, the way Forecastle's `:e2e` suite does — tracked in
59-
[#8](https://github.com/ausimian/castle/issues/8). Forecastle's
60-
`test/forecastle/upgrade_test.exs` exercises this code end to end in the
61-
meantime.
109+
What is *not* covered here is a booted release: the upgrade of a running
110+
system, and the exit statuses `bin/castle` returns, belong to Forecastle's
111+
`:e2e` suite ([#8](https://github.com/ausimian/castle/issues/8)), which
112+
exercises this code against a real release and asserts on the success messages
113+
each command prints. Those strings — `Unpacked <vsn> ok`,
114+
`Now running <vsn> (previously <other>).`, `Committed <vsn>. …` and the
115+
`releases/0` table — are a contract with that suite. Failure messages are not.
62116

63117
## Known limitations
64118

65-
- **Failed operations exit 0.** Every command catches the `:release_handler`
66-
error, prints it and returns normally, so `bin/castle` cannot tell a failed
67-
unpack/install/commit/remove from a successful one. Tracked in
68-
[#10](https://github.com/ausimian/castle/issues/10), together with letting
69-
`generate/1` take a caller-chosen destination path
70-
([#15](https://github.com/ausimian/castle/issues/15)).
71119
- **Concurrent boots race on `sys.config`.** `generate/1` writes into the
72120
version directory, so simultaneous `start`/`daemon`/`eval` invocations with
73-
differing environments overwrite each other's configuration. Same issue.
121+
differing environments overwrite each other's configuration. Do not fix this
122+
by letting callers choose where the configuration is written: it goes away
123+
with [#13](https://github.com/ausimian/castle/issues/13), which materialises
124+
the target release's configuration in a `:peer` running its own config
125+
providers, and takes `Castle.generate/1` with it.
74126
- **The public API is undocumented.** `@moduledoc` is still the generated
75127
placeholder and there are no `@doc` or `@spec` annotations
76128
([#11](https://github.com/ausimian/castle/issues/11)).

RELEASE.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,65 @@
1+
### Added
2+
3+
- `Castle.Error`, the exception raised by a release-management command that did
4+
not succeed.
5+
- `Castle.running/1`, which succeeds when the version it is given is the
6+
release the system is running, and fails otherwise. `install/1` reports what
7+
`:release_handler` replied, and that reply says only that the upgrade was
8+
accepted: a transition that restarts the emulator is replied to and *then*
9+
rebooted, and for an emulator upgrade the instructions run on the way back
10+
up, where they can still fail and roll back. Completion therefore has to be
11+
observed rather than inferred, and this is what makes it observable: a caller
12+
that repeats the question until it is answered - which is what Forecastle's
13+
`bin/castle install` does, from its own 1.0.0 - can tell an upgrade that took
14+
effect from one that did not. Castle supplies the answer; it does not do the
15+
asking, and the Forecastle this release is built against does not yet ask.
16+
17+
Confirmation needs two things: the version is the release the system is
18+
running - the one
19+
whose status is `current`, or the `permanent` one if none is current, so a
20+
version is confirmed both before and after `commit` - *and* its boot has
21+
finished. The second is not redundant. A node that restarted into the new
22+
version is reachable long before it has finished booting: `release_handler`
23+
makes the version `current` while `sasl` starts, and distribution answers
24+
from `kernel` onwards, so an application started after `sasl` can still fail
25+
and take the system back to the version that was permanent. Confirming
26+
earlier than that would let automation `commit` a release that cannot boot.
27+
28+
"Finished booting" means the boot script reached its `{progress, started}`
29+
instruction, which is the last thing every boot script Mix generates does.
30+
A release booted with a `RELEASE_BOOT_SCRIPT` that names a hand-written
31+
script without that marker will therefore never be confirmed: `install` waits
32+
and then fails, and the refusal names the progress the node did reach, so it
33+
says what is wrong rather than failing silently - but it will not succeed. A
34+
script that emits the marker before its applications are started defeats the
35+
check instead, since the marker is all there is to go on.
36+
37+
Nothing can build a relup that restarts the emulator until
38+
[forecastle#4](https://github.com/ausimian/forecastle/issues/4), so the
39+
restart transitions this addresses cannot be exercised end to end yet. The
40+
hot-upgrade path is covered by Forecastle's end-to-end suite; the statuses
41+
themselves are covered by unit tests here.
42+
143
### Changed
244

345
- Raised the minimum Elixir requirement to 1.18.
46+
- `unpack/1`, `install/1`, `commit/1`, `remove/1`, `generate/1` and
47+
`make_releases/0` now fail when the operation fails, instead of printing the
48+
reason and returning normally. These are invoked over `bin/castle`, which
49+
reaches them by `rpc`, and by the launcher's preboot `eval`, so the reason
50+
now arrives on the caller's standard error and the command exits non-zero:
51+
`bin/castle unpack "$VSN" && bin/castle install "$VSN"` stops at the step
52+
that failed, and an operation the system refused no longer exits 0. The
53+
running system is unaffected - the failure is raised on the
54+
node, re-raised in the short-lived VM that made the call, and it is that VM
55+
which exits. What a successful command reports is unchanged.
56+
57+
### Fixed
58+
59+
- `install/1` reports the emulator restart that an upgrade to a new emulator,
60+
or to a new kernel, stdlib or sasl, needs - rather than failing with a
61+
`CaseClauseError` while the upgrade proceeds.
62+
- `releases/0` reports nothing at all, rather than raising `Enum.EmptyError`,
63+
when no releases are installed.
64+
- `generate/1` and `make_releases/0` say what went wrong - which file could not
65+
be read or written, and why - rather than raising `MatchError`.

lib/castle.ex

Lines changed: 35 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,95 +3,64 @@ defmodule Castle do
33
Documentation for `Castle`.
44
"""
55

6-
@app Mix.Project.config()[:app]
6+
alias Castle.Commands
7+
8+
# Every function in this module is a command entry point: `bin/castle` sends
9+
# each one to the running node over `bin/<release> rpc`, and the launcher's
10+
# env.sh fragment evaluates generate/1 and make_releases/0 in the preboot VM.
11+
# There is no separate CLI layer to carry the process status, so these
12+
# functions are the command boundary, and it is here that a failure raises.
13+
#
14+
# Raising, rather than halting or returning: the rpc expression runs on the
15+
# running release node, so halting there would halt the system under
16+
# management rather than the caller. `Kernel.CLI.rpc_eval/1` catches on the
17+
# node and the local VM re-raises, printing the reason and exiting non-zero
18+
# while the running node is left untouched. A returned error value would be
19+
# discarded - `Kernel.CLI` only inspects the result of a command for `:ok`.
20+
#
21+
# `Castle.Commands` holds the operations themselves, returning their outcome
22+
# rather than acting on the process, so that they can be tested.
723

824
def make_releases do
9-
reldir = "releases"
10-
releases_file = Path.join(reldir, "RELEASES")
11-
12-
unless File.exists?(releases_file) do
13-
{:ok, _} = Application.ensure_all_started(:sasl)
14-
[{name, vsn, _, _}] = :release_handler.which_releases(:permanent)
15-
relfile = Path.join([reldir, vsn, "#{name}.rel"])
16-
:ok = :release_handler.create_RELEASES(to_charlist(reldir), relfile, [])
17-
end
25+
report!(Commands.make_releases())
1826
end
1927

2028
def generate(vsn) do
21-
rel_vsn_dir = Path.join([:code.root_dir(), "releases", vsn])
22-
# Read the build time config from build.config
23-
{:ok, [build_config]} = :file.consult(to_charlist(Path.join(rel_vsn_dir, "build.config")))
24-
# Generate the sys.config by running the config providers
25-
sys_config =
26-
build_config
27-
|> Keyword.get(@app, [])
28-
|> Keyword.get(:config_providers, [])
29-
|> Enum.reduce(build_config, fn {mod, arg}, cfg -> mod.load(cfg, arg) end)
30-
31-
File.write!(
32-
Path.join(rel_vsn_dir, "sys.config"),
33-
:io_lib.format(~c"%% coding: utf-8~n~tp.~n", [sys_config])
34-
)
29+
report!(Commands.generate(rel_vsn_dir(vsn)))
3530
end
3631

3732
def unpack(name) when is_binary(name) do
38-
case :release_handler.unpack_release(to_charlist(name)) do
39-
{:ok, vsn} ->
40-
IO.puts("Unpacked #{vsn} ok")
41-
42-
{:error, reason} ->
43-
IO.puts("Failed to unpack #{name}. #{inspect(reason)}")
44-
end
33+
report!(Commands.unpack(name))
4534
end
4635

4736
def install(vsn) when is_binary(vsn) do
4837
generate(vsn)
38+
report!(Commands.install(vsn))
39+
end
4940

50-
case :release_handler.install_release(to_charlist(vsn)) do
51-
{:ok, other_vsn, _} ->
52-
IO.puts("Now running #{vsn} (previously #{other_vsn}).")
53-
54-
{:error, reason} ->
55-
IO.puts("Install of #{vsn} failed. #{inspect(reason)}")
56-
end
41+
def running(vsn) when is_binary(vsn) do
42+
report!(Commands.running(vsn))
5743
end
5844

5945
def commit(vsn) when is_binary(vsn) do
6046
generate(vsn)
61-
62-
case :release_handler.make_permanent(to_charlist(vsn)) do
63-
:ok ->
64-
IO.puts("Committed #{vsn}. System restarts will now boot into this version.")
65-
66-
{:error, reason} ->
67-
IO.puts("Commit of #{vsn} failed. #{inspect(reason)}")
68-
end
47+
report!(Commands.commit(vsn))
6948
end
7049

7150
def remove(vsn) when is_binary(vsn) do
72-
case :release_handler.remove_release(to_charlist(vsn)) do
73-
:ok ->
74-
IO.puts("Removed #{vsn}.")
75-
76-
{:error, reason} ->
77-
IO.puts("Removal of #{vsn} failed. #{inspect(reason)}")
78-
end
51+
report!(Commands.remove(vsn))
7952
end
8053

8154
def releases do
82-
vsns =
83-
for {_, vsn, _, status} <- :release_handler.which_releases() do
84-
{to_string(vsn), to_string(status)}
85-
end
55+
report!(Commands.releases())
56+
end
8657

87-
width =
88-
vsns
89-
|> Enum.map(&elem(&1, 0))
90-
|> Enum.map(&String.length/1)
91-
|> Enum.max()
58+
# The version directory of the running release. Where the configuration is
59+
# written is derived from the release that is running, never chosen by the
60+
# caller - see castle#13, which materialises target configuration in a peer
61+
# rather than extending this path.
62+
defp rel_vsn_dir(vsn), do: Path.join([:code.root_dir(), "releases", vsn])
9263

93-
Enum.each(vsns, fn {vsn, status} ->
94-
IO.puts("#{String.pad_trailing(vsn, width + 2)}#{status}")
95-
end)
96-
end
64+
defp report!({:ok, lines}), do: Enum.each(lines, &IO.puts/1)
65+
defp report!({:error, message}), do: raise(Castle.Error, message)
9766
end

0 commit comments

Comments
 (0)