@@ -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
396431on 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
399434asserted against the record the real ` :release_handler ` read from the OTP
400435installation'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
404452peer would prove nothing about the one thing it exists to do, which is to run a
0 commit comments