Skip to content

Commit 22609f7

Browse files
ausimianclaude
andcommitted
fix: check the release record in the call that acts on it
upgradable/0 was a gate bin/castle asked for in an rpc of its own, before the rpc that unpacked or installed. Two rpcs are two moments and possibly two node instances: a node could answer on the strength of the record it read at boot, restart before the second call arrived, and synthesise an empty record on the way back up - and the unpack or the install would then go ahead on an answer that no longer held, which is the silent stale-code failure the gate exists to prevent. A check whose answer can be invalidated between asking and acting is not a check. So Commands.unpack/2 and Commands.install/2 make it themselves, before :release_handler is asked for anything. One call, one record, one decision, and nothing to reintroduce in the shell. install is checked because that is where the damage is. unpack is checked because it is the one other operation that writes release records: do_unpack_release/4 ends in write_releases/3 over the records the handler holds, so an unpack on such a node puts the synthesised record into RELEASES, the next boot reads it back, and make_releases/2 does nothing when the file is there - which takes away the restart the refusal names as the remedy and leaves the system with no way out. commit, remove and releases stay unchecked, and that is measured rather than assumed: do_make_permanent/2 returns early for a release that is already permanent and errors for every other status, do_remove_release/4 refuses the permanent release outright, and releases only reads, so none of them can write that record back, while refusing them could strand a version already installed and waiting to be committed. The refusal now names the operation that did not happen rather than reading as a precondition an operator failed to satisfy: "Cannot install 1.2.3: 1.2.2 is running from a release record OTP built from the boot script ...". upgradable/0 stays, as a query rather than a gate. Nothing has to call it, but the state it reports is invisible otherwise - the file can be present while the record the node works from was synthesised - so an operator needs some way to ask that does not unpack or install anything. Whether it belongs in the documented API surface is #11's. The new tests are about ordering, so they are written the way materialise/2's are: the stub is handed a reply that would have the operation succeed and the assertion is that it was never asked for it, because the refusal looks the same whether it came before the mutation or after. Two more assert which_releases was called in the call that acted, which no version asking it elsewhere can pass, and commit's guard is the mirror image - a synthesised record, and which_releases never called. Forecastle drops the separate upgradable rpc from bin/castle: nothing replaces it, because the operations refuse for themselves. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
1 parent 5c5e9e0 commit 22609f7

6 files changed

Lines changed: 312 additions & 83 deletions

File tree

AGENTS.md

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -284,30 +284,65 @@ Castle's job is configuration and release management on a running node.
284284
to create a relocatable RELEASES file", in OTP's own words. Passing the root
285285
would bake this machine's paths into a file whose point is that it can be
286286
moved, and no end-state test would see it.
287-
- **`Castle.upgradable/0`**succeeds when the running release can be upgraded
288-
from, and refuses when `:release_handler` is working from the record it
289-
synthesises for itself. It reads `RELEASES` once, in `init/1`, and when it
290-
cannot it builds a record out of the boot script's name and version with the
291-
`libs` field left at `[]`. Nothing can replace that afterwards, and creating
292-
the file later does not: the first operation that changes anything writes the
293-
in-memory record back over it. Upgrading from it is silently wrong rather than
294-
refused — the relup's `point_of_no_return` switches code paths for
295-
`get_new_libs(Current, New)`, which folds over the *current* release's
296-
applications and so yields nothing at all, leaving any application whose
297-
version changed but whose code the relup does not load running from the
287+
- **The release record check**`unpack/1` and `install/1` refuse a system whose
288+
release record `:release_handler` synthesised for itself, and they refuse it
289+
from *inside* the operation. `:release_handler` reads `RELEASES` once, in
290+
`init/1`, and when it cannot it builds a record out of the boot script's name
291+
and version with the `libs` field left at `[]`. Nothing can replace that
292+
afterwards, and creating the file later does not: the first operation that
293+
changes anything writes the in-memory record back over it. Upgrading from it is
294+
silently wrong rather than refused — the relup's `point_of_no_return` switches
295+
code paths for `get_new_libs(Current, New)`, which folds over the *current*
296+
release's applications and so yields nothing at all, leaving any application
297+
whose version changed but whose code the relup does not load running from the
298298
directory of the release being replaced. The discriminator is that empty
299299
application list, and it is exact: `which_releases/0` reports
300300
`mk_lib_name(Libs)`, `mk_lib_name([]) -> []`, and a record read from a
301-
`RELEASES` file names at least `kernel` and `stdlib`. It has to be asked of the
302-
node rather than of the filesystem, which is why this is here and not in
303-
`bin/castle`: a file that appeared *after* the boot that looked for it passes
304-
a shell test for the file and still leaves the node on the synthesised record.
305-
The remedy the message names is a restart, because that is the only thing that
306-
changes the answer.
301+
`RELEASES` file names at least `kernel` and `stdlib`. The remedy the message
302+
names is a restart, because that is the only thing that changes the answer.
303+
304+
It has to be asked of the node rather than of the filesystem — a file that
305+
appeared *after* the boot that looked for it passes a shell test and still
306+
leaves the node on the synthesised record — and it has to be asked *in the call
307+
that acts*. It was a separate rpc from `bin/castle` while #13 was being built,
308+
and that was wrong: two rpcs are two moments and possibly two node instances,
309+
so a node could pass the check on the record it read at boot, restart onto a
310+
synthesised one, and have the unpack or the install arrive afterwards and go
311+
ahead on an answer that no longer held. **Do not reintroduce a separate check
312+
in front of these operations**, in `bin/castle` or anywhere else. It is
313+
`Castle.Commands.ensure_upgradable/2`, and both operations make it themselves
314+
before `:release_handler` is asked for anything.
315+
316+
`install` is checked because that is where the silent damage happens. `unpack`
317+
is checked because it is the one other operation that *writes* release records:
318+
`do_unpack_release/4` ends in `write_releases/3` over the records the handler
319+
holds, so an unpack puts the synthesised record into `RELEASES`, the next boot
320+
reads it back as though it had always been there, and `Castle.make_releases/0`
321+
does nothing when the file exists — so an unpack allowed through takes away the
322+
restart the refusal names. `commit`, `remove` and `releases` are deliberately
323+
*not* checked, and that is measured rather than assumed: `do_make_permanent/2`
324+
returns early for a release that is already permanent and errors for every
325+
other status, `do_remove_release/4` refuses the permanent release outright, and
326+
`releases` only reads — none of them can write that record back, while refusing
327+
them could strand an upgrade already under way, a version installed and waiting
328+
to be committed that the next restart would take back.
329+
- **`Castle.upgradable/0`** — the same question asked on its own, and nothing
330+
more: a diagnostic, not a gate, and nothing has to call it. It stays because
331+
the state it reports is otherwise invisible — the file can be present while the
332+
record the node works from was synthesised — so an operator needs a way to ask
333+
that does not unpack or install anything. Whether it belongs in the documented
334+
API surface is [#11](https://github.com/ausimian/castle/issues/11)'s to settle.
307335
- **`unpack/1`, `install/1`, `commit/1`, `remove/1`, `releases/0`** — wrappers
308336
over `:release_handler`, with the target version's configuration materialised
309-
ahead of `install` and `commit` so that it exists before the version is
310-
booted.
337+
ahead of `install` and `commit` so that it exists before the version is booted,
338+
and the record check inside `unpack` and `install`. The boundary composes
339+
materialise-then-install, so a node that will be refused for its record
340+
materialises the target's configuration before it hears so. That is what the
341+
check costs by living inside the operation instead of in front of it, and it is
342+
only work: materialising writes into the target's version directory, never to
343+
the running system and never to a release record, and it is idempotent. Both
344+
refusals fall before `install_release/1` is asked for anything, which is the
345+
line that matters.
311346
- **`Castle.running/1`** — succeeds when the version it is given is the release
312347
the system is running. `install_release/1`'s reply says only that the upgrade
313348
was accepted: a transition that restarts the emulator is replied to and then
@@ -394,11 +429,24 @@ directory, which is what lets them all run async.
394429
`test/castle_test.exs` drives the boundary itself against the real
395430
`:release_handler` — which is running under `mix test`, because castle depends
396431
on sasl — and the real `:init`, naming releases that do not exist. One test
397-
there is not about the boundary: `upgradable/0` rests on a claim about OTP's own
398-
data, that a record read from a `RELEASES` file names applications, so it is
432+
there is not about the boundary: the record check rests on a claim about OTP's
433+
own data, that a record read from a `RELEASES` file names applications, so it is
399434
asserted against the record the real `:release_handler` read from the OTP
400435
installation's own file rather than against a stub. It fails, loudly and with
401-
the reason visible, on an installation that has no `releases/RELEASES`.
436+
the reason visible, on an installation that has no `releases/RELEASES` — and so
437+
does the boundary's `unpack/1` test, now that `unpack` makes the same check.
438+
439+
The record check's discriminators are about *ordering*, so they are written the
440+
way `materialise/2`'s are: the stub is given a reply that would have the
441+
operation succeed, and the assertion is that it was never asked for it —
442+
`Stub.calls(:unpack_release) == []`, `Stub.calls(:install_release) == []`. An
443+
end-state test cannot tell a refusal that came first from one that came after,
444+
because the refusal is the same either way. Two more assert
445+
`Stub.calls(:which_releases) == [[]]` on the successful path: the check happened
446+
*in* the call that acted, which is the whole of what this fixed, and a version
447+
that asked it somewhere else would pass every other assertion here.
448+
`commit/2`'s regression guard is the mirror image — a synthesised record, and
449+
`Stub.calls(:which_releases) == []`, because commit must *not* acquire the check.
402450

403451
`test/castle/peer_test.exs` is the exception: it starts real peers. Stubbing the
404452
peer would prove nothing about the one thing it exists to do, which is to run a

RELEASE.md

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,39 @@
4444
This is how every release is configured now, and the only way: the path that
4545
read a `build.config` is gone, along with the build-time interception that
4646
produced one — see *Removed* below.
47-
- `Castle.upgradable/0`, which succeeds when the release the system is running
48-
can be upgraded from, and fails when it cannot. `:release_handler` reads
49-
`releases/RELEASES` once, as it starts, and when the file is not there it makes
50-
a release record up out of the boot script's name and version — a record that
51-
names no applications at all. Upgrading a system in that state is worse than
52-
being stopped: the install reports success, and every application whose version
53-
changed but whose code the upgrade does not explicitly load goes on running its
54-
old code out of the directory of the release that was just replaced, until a
55-
later `remove` deletes it. Nothing can repair the running system afterwards,
56-
because creating the file changes no record the node holds — so what the
57-
failure says is to restart the system before upgrading it, which is the one
58-
thing that does. The question is asked of the node's own records rather than of
59-
the filesystem, which is the only way to see the case where the file exists but
60-
the boot that went looking for it was earlier.
47+
- `Castle.unpack/1` and `Castle.install/1` now refuse a system that cannot be
48+
upgraded from, and refuse it in the same call that would otherwise have done
49+
the work. `:release_handler` reads `releases/RELEASES` once, as it starts, and
50+
when the file is not there it makes a release record up out of the boot
51+
script's name and version — a record that names no applications at all.
52+
Upgrading a system in that state is worse than being stopped: the install
53+
reports success, and every application whose version changed but whose code the
54+
upgrade does not explicitly load goes on running its old code out of the
55+
directory of the release that was just replaced, until a later `remove` deletes
56+
it. Nothing can repair the running system afterwards, because creating the file
57+
changes no record the node holds — so what the refusal says is to restart,
58+
which is the one thing that does: the release creates the file before it
59+
starts.
60+
61+
The question is asked of the node's own records rather than of the filesystem,
62+
which is the only way to see the case where the file exists but the boot that
63+
went looking for it was earlier — and it is asked by the operation, rather than
64+
of the operator beforehand. A check made in one call and acted on in another is
65+
a check about a moment that has passed: the node can restart in between, and
66+
the node that comes back makes a fresh record up, so the operation would go
67+
ahead on an answer that no longer held.
68+
69+
Unpacking is refused as well as installing because it is the one other
70+
operation that *writes* release records: an unpack on such a node would put the
71+
made-up record into `releases/RELEASES`, where the next boot would read it back
72+
as though it belonged there — which takes away the restart that is the way out,
73+
since the file is only created when it is missing. Committing and removing are
74+
unaffected: neither can write that record back, and refusing them could strand
75+
a version that was already installed.
76+
- `Castle.upgradable/0`, which answers the same question on its own, for an
77+
operator who wants to know whether a system can be upgraded from without
78+
unpacking or installing anything. Nothing has to call it first — the operations
79+
that need the answer get it for themselves.
6180
- `Castle.Error`, the exception raised by a release-management command that did
6281
not succeed.
6382
- `Castle.running/1`, which succeeds when the version it is given is the

lib/castle.ex

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ defmodule Castle do
2626
report!(Commands.make_releases(rel_dir()))
2727
end
2828

29+
# A question, and not a gate anything has to ask: `unpack/1` and `install/1`
30+
# make the same check themselves, inside the operation, where nothing can
31+
# happen between the answer and the act. This is how an operator asks without
32+
# acting - the state it reports is invisible otherwise, because the file can be
33+
# there while the record the node works from was synthesised. Do not put it
34+
# back in front of them: a check in a call of its own is a check about a moment
35+
# that has passed, and `bin/castle` sends each of these as a separate rpc.
2936
def upgradable do
3037
report!(Commands.upgradable())
3138
end
@@ -58,11 +65,22 @@ defmodule Castle do
5865

5966
# Makes sure the target version's configuration exists before the version is
6067
# handed to `:release_handler`, and fails here if it cannot be made to. It
61-
# runs ahead of both operations that need it, and everything that can refuse
62-
# to go on - a peer that will not start, a boot script that is not there, a
63-
# provider that raises - refuses from inside this call, which is to say before
64-
# `install_release/1` has been asked for anything. Nothing after that point
65-
# may fail without saying that an install happened.
68+
# runs ahead of both operations that need it, and everything about the target
69+
# that can refuse to go on - a peer that will not start, a boot script that is
70+
# not there, a provider that raises - refuses from inside this call.
71+
#
72+
# `Commands.install/2` then refuses a running node whose release record OTP
73+
# synthesised, which is a fact about this node rather than about the target,
74+
# and so cannot be answered here. Both refusals are before `install_release/1`
75+
# has been asked for anything, which is the line that matters: nothing after
76+
# that point may fail without saying that an install happened.
77+
#
78+
# The order means a node that will be refused for its record materialises the
79+
# target's configuration before it hears so. That is what the record check
80+
# costs by living inside the operation instead of in front of it, and it is
81+
# only work: materialising writes into the target's version directory, never to
82+
# the running system and never to a release record, and it is idempotent, so
83+
# the refusal still leaves the system exactly as it was.
6684
defp materialise(vsn), do: report!(Commands.materialise(rel_vsn_dir(vsn)))
6785

6886
# The release directory, and the version directory of the release being

0 commit comments

Comments
 (0)