Runtime support for hot-code upgrades in Elixir releases. Castle is the runtime half of a pair: Forecastle is the build-time half. Consumers depend on Castle, which pulls Forecastle in as a build-time dependency.
Castle's job is configuration and release management on a running node.
-
Materialising the target's configuration, which
install/1andcommit/1do before they hand a version to:release_handler. This is the whole reason the pair exists: Mix expands runtime configuration once, at boot, from the version it booted; Castle expands it for the version being upgraded to, before the relup runs.There is one way it happens, and there used to be two. The other read a
build.config— thesys.configForecastle renamed at assembly time, having stripped the providers out and stashed their initialised state under:castle— and folded that state over it in the running node, which isCastle.generate/1. Both halves of that are gone: forecastle#6 stopped intercepting configuration at build time, and the third step of #13 deleted the path that read it. Do not reintroduce either. A release whose providers ran in the version that happens to be running was configured by the wrong code.What is left is
Castle.Peer: a:peerreached over a loopback socket — so no epmd, cookie, node name or distribution; the peer reportsnonode@nohostandis_alive() == false— booted on the target's ownprebootscript and its own emulator, which runsConfig.Provider.boot/1over the target's own provider modules and hands the resolved configuration back to be written. Castle does not fold providers itself, and must not acquire the ability to: the point of #13 is that Elixir's pipeline stays the only implementation of it. What Castle arranges is that the pipeline writes rather than configuring the VM it happens to be in, which is one field of the provider state and a reboot function that does nothing.A provider module can differ between the version that is running and the version being installed, which is why this cannot be done on the running node, and is what the peer earns.
Six things about it are load-bearing.
Every evaluation starts from the configuration Mix wrote, and never from the result of the last one. Providers are not obliged to be idempotent and the ones people write are not —
if System.get_env("FEATURE"), do: config …in aruntime.exssets a key on a run where the variable is set and says nothing about it on a run where it is not — so resolving over the previous result would leave that key behind, and the version an operator commits would be configured differently from the way it boots.sys.configcannot be the base, because that is the file:release_handlerreads and so the file the resolved result has to land in; the first materialisation therefore copies it tosys.config.pristineand every later one seeds from there.That copy is staged in the working directory below, given the mode
sys.confighas, and published by hard link. Not written to its final name, and an exclusive create is not enough either: exclusivity makes creation atomic, not publication, so the file exists and is empty between the open and the write — long enough for a racing reader to see something that is not a configuration, and, if the install died there, long enough to leave a truncated base that every later evaluation would prefer to the original still insys.config. A link publishes a file that is already complete, and refuses rather than replaces, so the loser of a race reads what the winner published instead of its own copy. Staging that never gets published is left where it is: an install cannot tell its own leftovers from another install's work in progress, so it does not try, and nothing reads that name. Do not "tidy up" straycastle-*names — files or directories — in code for the same reason.Castle.Peerremoves the working directory it made, on every way out, and nothing else.Both files this module creates are made inside an owner-only working directory and moved out of it, and a new one must be too. Each holds a release's configuration — the base, and the scratch copy the providers resolve into — so none may be readable by anyone the
sys.configit came from or is about to become would exclude: an operator who restricts that file has said something, and it has to hold for the copies.The protection is the directory, not the file's own mode, and it has to be: OTP cannot create a file with a mode.
:file.open/2's modes say how a file is to be read and written and nothing about the permissions it is created with — kernel'smode()type is the whole list — and an unrecognised option is ignored rather than refused, so{:mode, 0o600}is accepted and does nothing. The inode is created 0666 against the umask whichever way in you take. So a mode can only be applied to a file that already exists, andchmoddoes not revoke a descriptor somebody already holds: a reader who opened the path while it was 0644 goes on reading everything written afterwards. That is a standing read channel, not a blink, and no amount of care at the call site closes it. Do not go looking for an atomic create-with-mode; there isn't one.What works is
Castle.Peer.work_dir/1:mkdira directory in the version directory, chmod it 0700, and then check that it is still empty, refusing and removing it if it is not.plan/2is the one place the two file paths are decided, and it puts both inside that directory under the names they will have when they leave. Each is created exclusively, so a name already at the path is refused rather than followed or truncated, and each is written through the handle that exclusive open returned.Never reopen a name to place content. The exclusive open's whole value is that it establishes the name did not exist; closing the handle and reopening the same name by path throws that away, and anything able to create the name in between is handed the configuration.
write_private/2therefore creates and fills in one movement — there is deliberately no way here to bring one of these files into existence without also placing its content, because separating the two is what let a reopen back in.The
chmodis the one step that still goes by path, because OTP has nothing that sets a mode on an open file::file.change_mode/2and:file.write_file_info/2take a name and reject a handle (:badargand:function_clause). That is an accepted asymmetry rather than an oversight. By the time it runs the content is already committed to the inode the open created, so a name swapped underneath it does not receive the configuration — it gets narrowed, which is Castle setting some other file to 0600 inside a directory it verified empty and made 0700. A nuisance, not a disclosure.The rule is create, narrow, then verify — and write through the handle you created. The verification is not decoration and it is not defence in depth: it is there because five successive attempts to reason about whether a window was harmless were all wrong, and a sixth judgement of the same kind is worth nothing. Do not remove it on the grounds that you can see why the window is safe. That is precisely the sentence that preceded each of the previous five findings.
The particular argument it replaced, so nobody reconstructs it: an empty directory has nothing behind the window, and permission to traverse a directory is checked on every lookup rather than captured at open the way permission to read a file is, so a stale directory descriptor grants nothing once the chmod has happened. Both halves are true, and both are about reading. A directory the umask left group-writable — 0002 is an ordinary umask and 0000 exists — can be written into during that window, and the child names are predictable, so an interloper needs no descriptor at all: it plants
sys.configas a symlink to a file it can read and waits for the configuration to arrive through it. Empty is safe to read. It is not safe to write into.Exclusivity and privacy are separate properties and neither substitutes for the other.
:exclusiveon the open says nothing about the permissions the inode arrives with, which is why it is no answer to the paragraph above — that was measured, and{:mode, _}alongside it is silently ignored. A private directory says nothing about what a name already inside it would do, which is why it is no answer to a planted symlink:File.write/2follows one, truncates what it points at, chmods that to 0600, fills it with the configuration, and creates the target outright if the link dangles. All measured, all refused by:exclusive, which returns:eexistfor a regular file, a symlink and a dangling symlink alike.File.mkdir/1refuses all three too, which is what stops the working directory's own name being taken first.It does not defend a version directory other accounts can write to: whoever can create a name there can replace
sys.configitself, so that release is compromised before Castle is asked to configure it. The case defended is the ordinary one, a version directory anyone may traverse and read.write_private/2refuses to create a file in a directory that grants anything to group or other, rather than trusting the caller to have picked a path inside the working directory — the invariant is in the primitive because remembering it at the call sites is what failed, four times over. It is a guard against the next call site rather than against an attacker — a directory can be chmodded between the check and the create, which is the gap the exclusive create covers — and it also catches a filesystem that took themkdirand ignored thechmod, where none of this can be honoured and the operator's own mode onsys.configwould not be either.The file is given 0600 on creation and the model's mode last —
write_like/3iswrite_private/2plus that, for a file written once. The ordering matters for the writes that come after, not for the first one: asys.configat 0440 is an operator declaring their configuration read-only, and the scratch is written twice more after Castle creates it — the peer's pipeline writes the resolved configuration over it, then this module writes it again — with both of those reopening the name, and a file at 0440 cannot be reopened for writing. So the model's mode goes on last of all, immediately before the rename, and a failure part-way leaves the file narrower than intended rather than wider. Do not "simplify" the ordering back.The scratch's later writes rest on the directory, not on the handle. One of them is
Config.Provider.write_config!, which isFile.write/2in Elixir's own code, in the peer's VM — driving Elixir's pipeline is what this module is for, so that is not ours to change and must not be worked around. Nor may the creation handle be held open across the peer's run to cover them: the peer writes by name in a VM of its own, and a handle kept here would go on pointing at whichever inode the name had when it was opened. Elixir truncates the same inode today; one that wrote a temporary file and renamed it would leave the handle on an orphan and the configuration written through it would silently be nobody's. What protects those writes is that the directory was verified empty, is 0700, and holds only names Castle created.The two operations that move one of these files into place, the link that publishes the base and the rename that replaces
sys.config, need permission on the directories rather than on the file, so a restrictive mode never has to be relaxed again; nor does removing what is left in the working directory afterwards.File.write/2creates with the process umask and never looks at a mode.File.cp/2does carry the mode — and was adopted here for that reason — but it writes the whole file first and narrows it afterwards (:file.copy, thencopy_file_mode/2atfile.ex:1285), which is the same exposure with a shorter window. The end state is identical either way, which is exactly why no test of the end state caught any of it. Do not add another way to write one of these files, and do not create one next tosys.confighowever carefully; extend the primitive, and put the file in the working directory.Ownership and group are not reproduced — only the mode bits are. This is a property of the design, not an oversight. Reproducing them needs
chown, which needs privileges a release account does not have, and where Castle could chown it is running as root, which is a worse problem than the one being solved. Applying the model's numeric mode is what the operator asked for; that the process's default group differs from the model's is an environmental fact Castle cannot correct. So a deployment that restrictssys.configthrough group ownership —root:secretsat 0640, say — needs the release account's default group to be right for the version directory, because the base and the scratch will be created with that group and the mode bits will be honoured against it.A base that cannot be read as a configuration is refused, naming the remedy, rather than resolved from: it is preferred to
sys.configby definition, so failing loudly is the only safe thing left.This is permanent design: the path this replaced always had a pristine base —
build.configwas one, and nothing ever wrote it — and this is what carries that property forward now that it is gone.sys.configgains aCASTLE_MATERIALISEDcomment line, which makes the invariant checkable: written by Castle, so a base must exist. A version that says that and has no base beside it is refused, with the remedy (unpack it again) named, rather than having a once-resolved configuration captured as though it were the original.With a pristine base, materialising at
commitis not merely harmless but right: it produces what a boot at commit time would produce, which is the point of doing it there.The peer is started linked and stopped on every path out, including the failing ones;
wait_bootand the call both have deadlines, so a peer that never answers cannot hold an install open. Everything that can refuse — a missing boot script, an emulator that is not there, a provider that raises, a compile environment that does not agree — refuses beforeinstall_release/1is called. The resolved configuration is assembled in the working directory and renamed ontosys.config, so a version never holds half a configuration.The control connection is a socket rather than
connection: :standard_io, which is what the issue suggested. Standard IO multiplexes the peer's console output with the frames carrying the call over one byte stream and reserves sixteen byte values for the framing, every one of them a UTF-8 lead byte — so a provider, or a NIF under it, writing an accented character straight to a file descriptor fails the frame's checksum and takes the control process down, refusing an install that was about to succeed. Nothing outside:peercan harden that; the shared stream is the mechanism. The socket costs the diagnosis of a peer that cannot boot: a detached peer says nothing on its way down and the origin holds no handle on it, so a failed boot is noticed when the deadline expires rather than at once and with the emulator's reason. That was the trade, and it went the way it did because a broken release is broken either way while a working one must not be refused.Because a detached peer's descriptors are the null device, its standard error is relayed through its
userprocess — which is what makes Elixir's account of a provider that raised reach the operator at all. A raw write still goes nowhere, which is the safe direction.Castle.Peer.resolve/1is called in the target release, so{Castle.Peer, :resolve, 1}is a contract between one version of Castle and the next. A target too old to have it fails the call and the install is refused.Finally,
Castle.Peermakes the compile-environment check Elixir would have made, with Elixir's own validator. Elixir makes it in the branch that applies a resolved configuration, and again on the boot that follows the branch that writes one; this drives the writing branch and nothing boots afterwards, so without it a release Elixir considers unbootable would be installed and the problem found on the way up, where the only way out is a rollback. Do not weaken it into something that skips when it does not recognise what it was given: it refuses instead, because a check that silently passes everything looks exactly like a check that works. -
Castle.make_releases/0— creates theRELEASESfile from the running permanent release if it does not already exist, so a release assembled by Mix can manage its own upgrades. The directory is derived fromcode:root_dir(), which no caller has to change directory to reach and none should: the working directory was only ever visible to theFile.exists?/1guard, which is what let the file this looked for and the file OTP wrote be different ones.That derivation is right for the default Mix configuration and only for it.
:release_handlerresolves its relative paths againstcode:root_dir()(consult/2isfile:consult(root_dir_relative_path(File)), anddo_write_release/3the same), but the releases directory is not one of them:init/1takes it from{sasl, releases_dir}, thenRELDIR, and only theninit:get_argument(root). Mix sets neither, so on a Mix release the two coincide — but a deployment that sets either has Castle writingRELEASESwhere the handler will not read it, and then the record check refuses with a message naming a restart as the remedy, which a restart does not fix. That is #23, not something to leave implied here: the claim that this directory is "the one OTP writes" is true by default and false under configuration OTP documents.It calls
create_RELEASES/3, never/4with the root supplied:/3iscreate_RELEASES("", RelDir, RelFile, LibDirs), andcheck_rel_data/4stores library directories aslib/<app>-<vsn>when the root is empty and as absolute paths under it when it is not — "to make it easy to create a relocatable RELEASES file", in OTP's own words. Passing the root would bake this machine's paths into a file whose point is that it can be moved, and no end-state test would see it. -
The ERTS guard —
include_erts: falseis fundamentally incompatible withrelease_handler-based hot upgrades in a Mix release, and Castle refuses such a deployment rather than serving it.Castle.Commands.ensure_own_erts/2is the reference account; this is the shape of it.Mix.Release.copy_erts/1has a clause for%{erts_source: nil}that copies nothing, and only the other clause writes theerlshim that rewritesROOTDIRto the release root.include_erts: falseis what setserts_sourceto nil, andreleases/<vsn>/elixirkeeps itsERTS_BIN="$ERTS_BIN"line unrewritten, so the launcher runs whichevererlis on the path andcode:root_dir()is the shared Erlang installation rather than the deployment.The root is not Castle's to choose differently, and deriving it from
RELEASE_ROOTwould be worse rather than better. It isrelease_handler's own anchor:root_dir_relative_path/1isfilename:join(code:root_dir(), Pathname), andcreate_RELEASES/3stores library directories relatively —filename:join("lib", LibName), so the file stays relocatable — so everylib/<app>-<vsn>the handler reads, writes or deletes resolves there, as does theextract_tar(Root, Tar)an unpack goes through and theerts-<vsn>a removal deletes. So a Castle that wrote to$RELEASE_ROOTwould put the configuration where the handler never looks, and an upgrade would go on using applications under the installation — a silent divergence in place of a loud failure. Do not "fix" the guard that way.The release records are the exception, and saying otherwise is the mistake this file made first.
releases/RELEASESandreleases/<vsn>/…are not anchored to the root:init/1takes the releases directory from{sasl, releases_dir}, thenRELDIR, and only theninit:get_argument(root). Mix sets neither, so on a Mix release they land under the root by default — but "by default" and "necessarily" are different claims, and the second one is false. It changes nothing about the guard, because the handler keeps the root and the releases directory as separate state and only the second followsRELDIR: relocating the records moves the bookkeeping and leaves the applications being extracted into, resolved against and deleted out of the root. That is whyRELDIRis not a way out, and why the refusal says so.The question is asked of the node, and there is exactly one implementation of it. The shell-side gate in Forecastle's
env.shwas considered and refused. It would have saved the deployment a preboot VM and a refusal on every start —RELEASESnever appears in the deployment, so the hook's local absence check invokes it again every time — and that cost is accepted deliberately, because a shell test can only approximate what the node knows, which is the class of bug #13's third step removed ("It has to be asked of the node rather than of the filesystem"), and a second implementation of the rule can drift from the first. Do not add it later thinking it was an oversight.The evidence is that every launcher
mix releasegenerates exportsRELEASE_ROOTfrom its own location before it sourcesenv.sh, so a setRELEASE_ROOTnaming a directory other thancode:root_dir()is exact and needs no globbing. Nothing else sets the variable, so outside a release — undermix test, in a VM started by hand — there is nothing to compare and the guard is inert, which is what makes it safe in front of every mutating operation. The two are the same string on an ordinary release, both beingpwd -Poutput in scripts Mix generates, soPath.expand/1settles it; astaton device and inode is the fallback, because refusing a deployment that does bring its own ERTS — one spelled through acurrentsymlink, say — is the one failure here an operator cannot work around.What it detects is the divergence, not the missing ERTS, and the message asserts no cause at all.
include_erts: falseis the cause in almost every case but it is not the only one: theerlshim Mix writes isROOTDIR="${ERL_ROOTDIR:-…}", so anERL_ROOTDIRin the environment diverges the two on a release that did bring its ERTS. Two directories are the whole of the evidence, and nothing here can tell those apart or knows that they exhaust the possibilities, so both are offered as examples. This message has now been wrong twice in the same way — first asserting the missing ERTS, then assertingERL_ROOTDIRas the only alternative — so state the divergence and stop. A cause stated confidently from two directories is how a correct refusal comes to be read as a bug in the guard, and it sends an operator to rebuild something that was not the problem.Castle.Peer.emulator/2is the one that may still speak of ERTS in particular, because it looked for the emulator and it was not there.Do not say that everything
:release_handlertouches resolves under the emulator's root — the release records alone do not.init/1takes its releases directory from{sasl, releases_dir}, thenRELDIR, and only theninit:get_argument(root), so those two genuinely relocate it, and a message claiming otherwise is false. It does not rescue such a deployment, which is why the guard is still right: the handler holds the root and the releases directory as separate state and only the second followsRELDIR.do_unpack_release/4extracts throughextract_tar(Root, Tar),check_rel_data/4records library directories aslib/<app>-<vsn>for resolution againstcode:root_dir(), anddo_remove_release/4deletesfilename:join(Root, "erts-" ++ EVsn). Relocating the records moves the bookkeeping and leaves the applications themselves being extracted into, read from and deleted out of the emulator's root.The comparison has three answers, not two.
compare_dirs/2isPath.expand/1on both and then astaton device and inode, and it returns:same,:differentor{:indeterminate, why}. The third is the one to keep: by the time thestatruns the two have already failed to match as strings, so a catch-all folding every unusable result into:differentputs an:eacceson a parent, an:enoent, an:eloopand a filesystem with no inode numbers into the same branch as two directories that genuinely differ — and then the message asserts a difference that was never established. Both answers still refuse, so this changes no outcome; it changes what the operator is told, which is the part they act on. Do not fold them back together, and note that fixtures feel it: a test pointingRELEASE_ROOTat a path nothing created exercises the indeterminate refusal, not this one, so the directories in these tests have to exist.It gates
make_releases/0— before theFile.exists?check, not after, because an Erlang installation built by OTP has areleases/RELEASESof its own and looking first would find it, report success and never say anything — andunpack/1,install/1,commit/1andremove/1,removemost of all, sinceremove_releasedeletes paths resolved againstcode:root_dir(). It gatesmaterialise/3too, which is whatinstall/1andcommit/1do first, or the operator's first news would be that some version directory inside the Erlang installation holds nothing to configure.upgradable/0andreleases/0are deliberately outside it, for the reasoncommit/remove/releasesare outside the release-record check and one of its own: they only read, and an operator has to be able to ask what the node thinks it is running in order to make sense of the refusal. Gating a diagnostic on the condition it diagnoses leaves nothing to ask.Unlike the record check,
commitandremovedo carry this one, and that is not an inconsistency: the record check could strand an upgrade already under way, while this says the deployment could never have been upgraded at all, so there is nothing to strand.Castle.Peer.emulator/2's refusal stays, and points here rather than restating any of it. It is reached more narrowly — a release whose own release file names an ERTS that is not under the root it was unpacked into — and it is what is left if the guard is ever reached withRELEASE_ROOTunset. -
The release record check —
unpack/1andinstall/1refuse a system whose release record:release_handlersynthesised for itself, and they refuse it from inside the operation.:release_handlerreadsRELEASESonce, ininit/1, and when it cannot it builds a record out of the boot script's name and version with thelibsfield left at[]. Nothing can replace that afterwards, and creating the file later does not: the first operation that changes anything writes the in-memory record back over it. Upgrading from it is silently wrong rather than refused — the relup'spoint_of_no_returnswitches code paths forget_new_libs(Current, New), which folds over the current release's applications and so yields nothing at all, leaving any application whose version changed but whose code the relup does not load running from the directory of the release being replaced. The discriminator is that empty application list, and it is exact:which_releases/0reportsmk_lib_name(Libs),mk_lib_name([]) -> [], and a record read from aRELEASESfile names at leastkernelandstdlib. The remedy the message names is a restart, because that is the only thing that changes the answer.A restart is necessary and not always sufficient, so the message names the state the file has to be in rather than just saying "restart". The record is synthesised when
RELEASESwas missing or could not be read, and Forecastle'senv.shcreates it only when it is absent ([ ! -f ... ]). So a file that is present and unreadable is stepped over on every start: the node comes back on a freshly synthesised record, the refusal repeats, and an operator following a message that named only the restart would loop forever.State the required condition, not the boot-time cause. The obvious correction — branch the advice on why the record was synthesised, absent versus unreadable — is wrong in a third case, and that was the first attempt here. The file can have been absent at boot and been created, readably, since: the node keeps its synthesised record either way, so the refusal still fires, and an operator told to check whether the file is "absent" or "present and unreadable" finds it is neither and has no applicable advice. A plain restart is exactly right for them. So the message asks for
releases/RELEASESto be absent or consultable before the restart, which covers all three states and is shorter than the branch it replaced. Do not turn it back into a case analysis of the cause, and do not collapse it into a bare "restart the system" either.Name the authority, not the mechanism — and this is the lesson of five successive corrections to one sentence. Each named a property of the file and each was necessary but not sufficient, so each admitted a narrower counterexample: "restart" missed a file that was present and unreadable; "present or absent" missed one created readably since boot; "readable" missed malformed terms, because
init/1reads it withfile:consult/1; "consultable" missed a file of two valid terms, becauseinit/1accepts only{ok, [Term]}. There is no reason to think that series had ended, and the hook leaves an existing file alone whatever is in it, so every one of those states loops.The message therefore asks for a file
:release_handleraccepts, and says that no single property of the file is the test. That cannot be narrowed further because it does not claim a mechanism, and it is what an operator needs anyway: the handler is the thing that has to take the file. Do not "improve" it by substituting whichever internal criterion is current — that is the move that was wrong five times.And it must not name
releases/RELEASESunqualified, because that is the file the release creates, not necessarily the one the handler reads — seeCastle.Deployment.root_dir/0and #23. WhereRELDIRor{sasl, releases_dir}points elsewhere the two are different files, and the remedy is then genuinely harder rather than merely differently spelled: the hook creates one at the root that the handler will not read, so "absent" does not get the operator out either, and the file the handler does read has to be put there by hand. The message says so. When #23 lands and Castle follows those overrides, this paragraph and that sentence both need revisiting — the divergence is the thing being described, and it is the thing #23 removes.It has to be asked of the node rather than of the filesystem — a file that appeared after the boot that looked for it passes a shell test and still leaves the node on the synthesised record — and it has to be asked in the call that acts. It was a separate rpc from
bin/castlewhile #13 was being built, and that was wrong: two rpcs are two moments and possibly two node instances, so a node could pass the check on the record it read at boot, restart onto a synthesised one, and have the unpack or the install arrive afterwards and go ahead on an answer that no longer held. Do not reintroduce a separate check in front of these operations, inbin/castleor anywhere else. It isCastle.Commands.ensure_upgradable/2, and both operations make it themselves before:release_handleris asked for anything.installis checked because that is where the silent damage happens.unpackis checked because it is the one other operation that writes release records:do_unpack_release/4ends inwrite_releases/3over the records the handler holds, so an unpack puts the synthesised record intoRELEASES, the next boot reads it back as though it had always been there, andCastle.make_releases/0does nothing when the file exists — so an unpack allowed through takes away the restart the refusal names.commit,removeandreleasesare deliberately not checked, and that is measured rather than assumed:do_make_permanent/2returns early for a release that is already permanent and errors for every other status,do_remove_release/4refuses the permanent release outright, andreleasesonly reads — none of them can write that record back, while refusing them could strand an upgrade already under way, a version installed and waiting to be committed that the next restart would take back. -
Castle.upgradable/0— the same question asked on its own, and nothing more: a diagnostic, not a gate, and nothing has to call it. It stays because the state it reports is otherwise invisible — the file can be present while the record the node works from was synthesised — so an operator needs a way to ask that does not unpack or install anything. #11 settled that it belongs in the documented surface, and for that same reason:bin/castle upgradableis how an operator asks, and a diagnostic nobody is told about is one nobody thinks to ask. -
unpack/1,install/1,commit/1,remove/1,releases/0— wrappers over:release_handler, with the target version's configuration materialised beforeinstallandcommithand it over, the record check insideunpackandinstall, and the ERTS guard inside all of them butreleases/0.Castle.install/1composes nothing, and it used to. It calledCommands.materialise/3and thenCommands.install/4, and that composition was the bug: two callers both configured the target before either reached the lock. Materialising is now the third step insideCommands.install/5, after the record check and after the pending-marker refusal and before the marker is armed — so a node that will be refused, for its record or for a pending restart install, is refused without having configured anything. The note this replaces called materialising "only work" on the grounds that it writes into the target's version directory and is idempotent; it ends in a rename ontosys.config, so it is not. See the restart-marker section for the whole of it.And that claim is now tested at the boundary, which is what
Castle.install/2..5is for.installtakes the releases directory, the handler, the peer and the deployment as defaulted arguments, exactly asCommands.install/5does, for one reason: a concurrency test that drivesCommands.install/5cannot see anything composed inCastle.install/1, so the defect above was reintroducible with the whole suite green. One case incommands_test.exsruns two concurrent callers throughCastle.install/5instead, and fails if amaterialise/3reappears in front of the install. The arity-1 form is unchanged and is still whatbin/castlecalls overrpc; nothing about the deployment is chosen by a caller in a release, because nothing in a release passes the extra arguments.Castle.commit/1composes nothing either, and the asymmetry this used to describe is gone. It said an ERTS-less deployment hears "Cannot configure" fromcommitand "Cannot install" frominstall, and that the difference was exact rather than untidy. It was exact, and it was also the visible symptom of the same composition:commitmaterialised in front of the operation, so the configuration step's guard answered first.Commands.commit/5now materialises inside its own serialised region, so every command names itself.erts_guard_test.exspins that in the new direction — a "Cannot configure" reappearing there would mean a composition had come back at the boundary.Every refusal still falls before
install_release/1is asked for anything, which is the line that matters. -
Castle.running/1— succeeds when the version it is given is the release the system is running.install_release/1's reply says only that the upgrade was accepted: a transition that restarts the emulator is replied to and then rebooted, and an emulator upgrade finishes on the way back up, where it can still roll back. So Castle answers the question and leaves the asking to Forecastle:bin/castle installrepeats it rather than trusting the reply, from Forecastle 1.0.0 — so the polling is Forecastle's, and not something Castle's own state does. Two conditions. The version is the running release: thecurrentone, or thepermanentone when none is current —installleaves its targetcurrentandcommitpromotes it, so both count;unpacked(a rolled-back continuation) andtmp_current(written before the reboot) do not. And its boot has finished, which is:init.get_status/0's provided status being:started. Do not gate on the internal status: it stays:startingfor the life of a release started by its boot script, so a booted node reports{:starting, :started}. The provided status is what the script's{progress, _}instructions move along, andstartedis its last one — after the applications have started, and afternew_emulator_upgrade/2in the hybrid script that continues an emulator upgrade. Without that second condition a poll can confirm a node that is still booting, and automation that commits straight after installing would make a version that cannot boot the permanent one.A version the launcher booted provisionally, after a transition that restarted the emulator, arrives here as
currentand needs nothing of its own — which is measured rather than assumed.prepare_restart_new_emulator/7persists it astmp_currentbefore the reboot; on the boot that follows,transform_release/3writes that back asunpackedon disk whileset_current/2hands the handler a record in which it iscurrentin memory, becauseinit:script_id()names it. So the same two conditions answer the same question across a reboot, which is what letsbin/castle installpoll through one.The marker is the whole of the evidence, so it inherits whatever the selected boot script does with it.
RELEASE_BOOT_SCRIPTnaming a hand-written script that never reaches{progress, started}will never be confirmed —installwaits and then fails, and the refusal names the progress the node did reach, so it is diagnosable and never a false success — and one that emits the marker before its applications start defeats the check. Both are documented rather than validated: Mix generates the boot scripts and offers norel/template for them, so reaching either state takes deliberate work. (An earlier note here claimedsystools_make:add_apply_upgrade/2's hard match on the trailing marker ruled this out. It does not: that builds the hybrid script for an emulator upgrade and says nothing about a script an operator supplies.) -
The restart marker —
releases/castle-restart-pending, armed byinstall/5beforeinstall_release/1is asked for anything and cleared on every path where the install failed. Forecastle'senv.shfragment consumes it on the next start and boots the version it names. The two halves are useless apart and landed together (#14 and forecastle#10).Two files are the evidence, not one, and that is the point of this marker existing at all.
prepare_restart_new_emulator/7writesreleases/new_start_erl.databefore the reboot and nothing ever removes it —transform_release/3reconciles the release record and does not touch the file — so a preparation that failed after writing it leaves a file naming a version that was never installed. On its own that file is not a boot instruction. Castle's marker says a reboot was really asked for; the hook requires both, and requires them to name one version.Agreeing on a version is not enough, and the first version of this shipped believing it was. Two files that name the same version do not establish that one install produced them. The sequence that breaks it has no exotic step in it: an attempt to X fails after OTP's file is written, the operator retries X, the retry arms a fresh marker beside the stale file, and a manual or hard restart before the retry reaches
install_release/1then presents a matching pair. The launcher boots X, which nothing installed, and OTP's records call itunpacked— the node runs one release whilewhich_releases/0reports another. Back-to-back or concurrent installs broke it the other way, by overwriting and disarming each other's marker.So the pair is owned by an install attempt, and four things make it so.
unclaimed/3andarm/4are three of them and the order is the protocol; the fourth is that there is only ever one caller in the install at all.- One pending restart install at a time. A marker already at the path
refuses the install rather than being adopted or replaced, which is what
keeps step 2 from clearing the
new_start_erl.datathat attempt's preparation wrote.publish/2decides it a second time over, by refusing rather than replacing. - OTP's file is cleared before the marker is armed. That is what closes
the window above: after it,
new_start_erl.dataexisting means this attempt's preparation wrote it. Removing it is safe —write_new_start_erl/3goes throughfile:write_file/2, which creates the file when it is absent. The order matters and must not be reversed: an attempt that refused after clearing would take an already-requested reboot away silently. - The marker names the attempt that armed it, on a second line, and
disarm/3removes it only if it still does. The marker's name is shared and the marker is short-lived — anystartordaemonof the deployment consumes it, whether or not that start goes on to boot — so removing it by name would take a later attempt's marker away. The attempt is the operating system pid, the wall clock in nanoseconds and a serial: unique within a node and across its restarts, which is as far as ownership has to reach, because the marker never outlives the next start. It is not a secret and does not need to be — anything able to forge it can write in the releases directory, where it could write the marker itself. What it defends against is confusion, not forgery. The hook never reads it: the version is the first line, which is whathead -n 1gives it, so the file carries this without the shell parsing anything it did not before. - One caller in the install at a time, which is
Castle.Commands.serialised/2and the whole of what makes the first three mean anything across processes.
Steps 1 to 3 are one caller's sequence, and the first version of this believed that
release_handlerserialisinginstall_release/1was enough to make them a protocol. It is not: that serialisation is downstream of all of them. Two callers both read the running release, both classify it and both pass step 1, because none of that has published anything yet. Step 1 reversed with step 2 is then no protection at all — the loser reaches step 2 after the winner'sinstall_release/1has writtennew_start_erl.data, deletes it, and the winner's reboot comes back on the permanent release while the loser reports that nothing has been changed. An operator sees a timeout and a false reassurance.Do not answer this by reordering the protocol. Publishing before clearing leaves a window in which the marker pairs with a stale
new_start_erl.data, and the hook then boots a version nothing installed — which is worse than losing a reboot, and is what step 2's position exists to prevent. The order is right for one caller; the fix is that there is one caller.The serialised region is the whole install, not just the arming, because the classification is a prediction about the running release: an install that completes between
restart_planned?/3andinstall_release/1moves the from-version, sodo_get_rh_script/4evaluates a different relup entry and the armed state disagrees with the transition OTP selects. So the running-release read, the classification, the arming,install_release/1and the disarming are all inside it. The ERTS guard is the one part deliberately outside — it reads two directories and refuses without touching anything, and a refusal has no reason to wait.It is
:global.trans/3over[node()], and the mechanism was chosen rather than assumed.global_name_serveris a kernel process running whether or not distribution is, andset_lock/2restricted to[node()]talks to the local one only — so this works on a node withis_alive() == false, which is the ordinary case for a release that configures no distribution, and is the case it was measured on.trans/3releases the lock in anafterandglobalmonitors the holder besides, so a caller that dies releases it instead of wedging every later install; retries areinfinity, so there is noabortedto have to mean something by. The alternative was a process of Castle's own, and it is a worse trade: these modules are deliberately stateless and run inline in whatever process asked, so a lock server would be a new entry in the managed system's supervision tree, with a lifetime and a restart strategy, to serialise a command that runs a handful of times in a deployment's life.[node()]rather than the default[node() | nodes()]because every caller arrives on the running node —bin/castleisrpc, and the launcher's preboot only callsmake_releases/0— so this node is the whole set of callers. A cluster-wide lock would make an install wait on nodes that share nothing with the deployment, and make a network partition its business, and it still would not cover a caller in some other VM. That is the boundary and it should be said plainly: a second VM writing into this releases directory is outside the lock, and what defends the marker there is the filesystem half alone —publish/2refusing rather than replacing — which is no worse than before and no better. It waits rather than refusing, and the waiter then meets step 1 and is told a restart install is pending: the same message as before, said about a pair that is complete instead of said while taking half of it away.installandcommittake it;unpackandremovedo not. Those two arm nothing and hold no two-file invariant of their own, andrelease_handlerserialising its own record writes is the whole of what they need.commitwas left out at first, on the argument that putting it behind an install "waiting on a reboot" would be a deadlock dressed as caution. That was wrong, and the error was about when the lock is held rather than about commit. An install never holds it across a reboot:install_release/1replies beforeinit:reboot(), the reboot runs inrelease_handler's own process, soCommands.install/5returns and itstransreleases while the system is still up — and after a restart transition the VM that held the lock is gone entirely.bin/castle installthen pollsCastle.running/1over separate rpcs that take no lock at all. The only thing a commit can wait for is a hot install still insideinstall_release/1, and waiting there is correct: committing part-way through an upgrade is the thing not to do.What the omission left open was reachable and is the failure this protocol exists to prevent. A duplicate install of the version being committed materialises between
commit's two steps; the commit succeeds; that install then fails as already installed; and its configuration is what the newly permanent release boots on the next restart. A failed caller deciding what a successful one boots, through the one operation left outside the region.Materialising the target's configuration is inside the region, and the argument for keeping it outside was wrong. That argument was: it writes only into the target's own version directory, its own primitives refuse rather than replace, and holding this lock across a peer VM's boot would put every install behind another's configuration step. The middle claim is false about the step that matters. The staging refuses rather than replaces and
sys.config.pristinerefuses rather than replaces, but the last thing materialising does is rename the resolved configuration ontosys.config— a replace by design, and necessarily so, because that is the filerelease_handlerreads. So two callers materialising before either reached the lock meant the loser's providers could replace the configuration the winner's provisional release was about to boot, and the loser was then refused for the winner's marker: a refused install decided what a successful one booted. That providers may answer differently across evaluations is not a hypothetical — it is the entire reasonsys.config.pristineexists.The third claim is true and is not a reason. It is a throughput argument about concurrent installs, and this protocol refuses concurrent installs anyway; an install that waits is slow, and an install whose configuration is somebody else's is wrong.
Inside the region is not enough on its own — it has to be after the refusals.
Commands.install_upgradable/5runs the record check, thenunclaimed/3, then materialises, then arms. A caller refused for a pending restart install must be refused before it configures anything, because the version it would be configuring is the one the pending install's reboot is about to boot. Moving the materialisation inside the lock while leaving it in front ofunclaimed/3fixes nothing, and there is a test whose only job is to fail against exactly that arrangement.commitmaterialises inside the same region, and the argument for leaving it outside was wrong twice over. It went: commit is not the same case, because it makes permanent a version this node already installed and is running, so there is no marker, no reboot and no window between a configuration and a boot of it — and putting it behind the install lock would be the deadlock above anyway.The first half is true and does not license the second. The window is not between a configuration and a boot; it is between commit's own two steps. A duplicate install of the version being committed materialises there, the commit succeeds, that install fails as already installed, and its configuration is what the newly permanent release boots on the next restart. And the deadlock does not exist — see above: an install never holds this lock across a reboot, so the only thing commit can wait for is a hot install mid-
install_release/1, which is exactly when it should wait.So
Commands.commit/5takesrel_dirand materialises insideserialised/2, the wayinstalldoes, andCastle.commit/1composes nothing. The two renames onto onesys.configare now ordered wherever they meet.It is published the way
sys.config.pristineis — staged in an owner-only working directory and hard-linked into place — and for the same two reasons a link was chosen there. A link publishes a file that is already complete, so no start can read a marker that is empty or half written; and it refuses rather than replaces, so the loser of a race is told instead of silently taking the marker over. An exclusive create in place has neither property: it makes creation atomic and leaves the file empty between the open and the write, and a death in that window leaves an empty marker that blocks every later attempt.Castle.Commandstherefore callsCastle.Peer.work_dir/1,write_private/2andpublish/2directly, not through the injected modulematerialise/3uses: those start no VM, there is nothing about them a stub could stand for, and the guarantee is the point.Consuming them is Forecastle's, and what is atomic there is the claim: the pending marker is taken by rename, so exactly one start can act on the pair, and OTP's file is then read and removed separately. The two removals are not one operation and cannot be made one — no POSIX call renames two files together. What that costs is bounded by the order: the marker goes first, so an interruption anywhere after it leaves no marker and the next start boots the permanent version. The selection is lost, never duplicated, and never applied to a version nothing installed. Do not write that both are consumed atomically: this note said it, Forecastle's
AGENTS.mdsaid it, and Forecastle's release note said it, and it was false in all three.It is armed from the relup, and it has to be, because the reply cannot answer the question.
restart_emulatoris replied to with{ok, Vsn, Descr}— exactly what a completed hot upgrade replies — andinit:reboot()has already been called by the time the reply arrives. So a marker armed unconditionally and cleared on{ok, ...}would be racing a shutdown, and losing that race loses the upgrade silently.restart_planned?/3therefore reads the same filerelease_handlerwill read:do_get_rh_script/4looks for the from-version in the target's own relup and then for the to-version in the from-release's downgrade section, and the from-version is the release the system is running, which is whatget_latest_release/1selects and whatrunning_release/1already computes.which_releases/0is asked once and the answer used for both the record check and this, for the reason the record check lives inside the operation: two calls are two moments.This is a prediction and not a second state machine. Nothing here writes a release record, and it decides exactly one thing — whether to arm. Being wrong either way is bounded: unarmed, the reboot returns on the permanent version and
installreports that the version never became the running one; armed without a reboot, the hook consumes the marker on the next start and finds nothing to correlate it with.The two-stage
restart_new_emulatoris deliberately not armed for, and the reason is not that it is out of scope. The marker OTP writes for it names the temporary hybrid release,__new_emulator__<current>, andnew_emulator_make_hybrid_boot/6gives that version directory astart.bootand asys.configand none of the launcher's own furniture — noenv.sh, noelixir, novm.args. There is nothing there for a launcher to boot, so arming would point it at a version it cannot start. It is told apart the waydo_install_release/3tells them apart: the instruction at the head of the script. Anywhere else it is an error rather than a transition —syntax_check_script/1accepts onlyrestart_emulatorafter the point of no return.A marker that cannot be armed refuses the install, before
install_release/1is asked for anything, and so does a stalenew_start_erl.datathat cannot be cleared — because the alternative in both cases is a reboot that comes back on the wrong version with nothing saying so.Settling the marker afterwards happens on every way out, including the ones that do not return, and failing to settle it is reported. Both halves of that replaced something weaker.
The region is
Commands.installed/5, and it is an implicittry— the function body, withcatchandelseclauses, which is the formcredo --strictasks for. It used to be a barecaseover the reply withdisarmin the two failing branches, so an exit, a throw or a raise out ofinstall_release/1went past both: the marker stayed armed, and whereprepare_restart_new_emulator/7had already writtennew_start_erl.datathe pair was complete and the next start booted a version whose install had blown up. That is the hazard the whole protocol exists to prevent, reintroduced through the one path that is not a return. It iscatch/elseand notafter: anaftercannot see which way the block went, so it would disarm the successful restart install too, taking away the marker whose entire purpose is to outlive the call. There is a test for that.An exception is re-raised unchanged once the marker is settled —
Castleis the boundary that raises,Kernel.CLIcatches on the node and the calling VM re-raises, and Castle has nothing to add to an exception out ofrelease_handlerthat is worth losing the stacktrace for. The one exception to that is a marker it could not settle, where the exception is folded into the message instead, because that is the fact an operator most needs and a stacktrace is where it would be buried.Clearing the marker used to be best-effort on the argument that a releases directory the marker cannot be removed from is one it could not have been linked into, so the install would already have refused. That holds only if nothing changed in between, and
install_release/1runs in between — for as long as an upgrade takes, with the system's own code being replaced. Worse, an unreadable marker was classified as another attempt's and left alone, which reads as caution and is not: a marker that cannot be read is no evidence about whose it is, and the file it might be is the one the next start acts on.So
disarm/3has four answers and two of them are failures. Ours is removed, and a removal that fails is reported. Theirs is left, which is a success — a later attempt's marker is a reboot still owed. Gone is a start of the deployment having consumed it, which is the outcome that was wanted, so:enoentfrom either the read or the removal is success. Unverifiable is reported: Castle will not remove a marker it cannot show is its own, and will not pretend the question was answered. What the operator is told names the file, saysnew_start_erl.datamay already be beside it, says that an ordinary restart will therefore boot the version the install did not finish, and asks for the marker to be removed before the system is restarted.The read and the removal go through
Castle.Deployment.read/1andrm/1, for the reasonstat/1is there: the answers that decide what Castle says are the failing ones, and every fixture that makes areadorrmfail on a regular file in a writable directory does it with a mode, which root and some filesystems ignore — so the fixture would only sometimes describe the state it names and would pass either way. That seam is for outcomes Castle has to speak about and cannot cause; it is not a general filesystem seam, and the primitives that publish the marker stay called directly for the reason given above.The report changes with it.
reported/5says the version was installed, that the emulator is restarting, and that the version stays provisional until it is committed — instead of "Now running", which is false for as long as the reboot takes and which automation reads. - One pending restart install at a time. A marker already at the path
refuses the install rather than being adopted or replaced, which is what
keeps step 2 from clearing the
Every one of them is a command entry point, so Castle is the command
boundary: an operation that fails raises there, which is what leaves a non-zero
exit status behind for the shell that asked for it. Raising, not halting — the
expression runs on the running node, so halting would take down the system
under management; Kernel.CLI catches on the node and re-raises in the calling
VM, and only that VM exits. Castle.Commands holds the operations themselves,
returning their outcome instead of acting on the process, which is what makes
them testable.
Castle.Error is what report!/1 raises, and it is not everything a command
raises. report!/1 turns a returned {:error, message} into one; an
exception, a throw or an exit that the operation did not handle goes straight
past it. That is deliberate for the one place it can happen on purpose —
installed/5 settles the marker and re-raises install_release/1's failure
unchanged, folding it into a message only where the marker could not be settled
— so anything claiming that a failed command raises Castle.Error, in a @doc
or here, has to say which failures. Automation told to rescue Castle.Error
and nothing else would treat a release_handler that blew up as a success.
Castle.customize/1 is the one function in that module which is not one of
them — it runs at build time, in a consumer's mix.exs, and returns a value
rather than reporting an outcome. See Release integration below. Anything
that says "every function in Castle" has to say "but customize/1", and the
comment at the head of lib/castle.ex does.
What is published and what is hidden follows from that, and is
#11's decision. The whole of
Castle now carries @doc and @spec, and the @moduledoc says the two
things a reader has to know before calling any of it: that this is the runtime
half of a pair, and that these are commands rather than an API — a command
prints its report and returns :ok, so the return value carries nothing, and a
failure raises Castle.Error rather than returning {:error, _}. Every command
@doc names the bin/castle command that reaches it, because that is the
interface and the function is the thing behind it.
Two decisions inside that. make_releases/0 is @doc false: its only caller is
the launcher's env.sh fragment, in the preboot VM of a start or daemon
whose deployment has no RELEASES yet, and by hand it either does nothing (the
file is there) or does what the next start would do anyway. Its contract is with
a shell fragment in another project, so publishing it would document a function
nobody should call. It keeps its @spec regardless — the spec is the contract
whether or not the function is published. And install/2..5 is documented as
what it is, a seam the concurrency test drives: one @doc covers every arity of
a clause with defaults, so saying nothing about the extra four would leave them
reading as an API. Every other function is a command an operator invokes, and
hiding one of those would document nothing useful anywhere.
The specs say :: :ok and nothing more, because that is what report!/1
returns; a spec naming the lines, or an error tuple, would be describing
Castle.Commands. Nothing checks them — there is no Dialyzer here — so they are
kept by hand, and a claim in a @doc about what a command refuses is worth
checking against Castle.Commands before it is trusted.
A definition with defaults needs one @spec per arity, and "every public
function carries a spec" is a claim about arities. install/1..5 is five
functions and therefore five specs; the first version of this carried two, for
install/1 and install/5, and left three unspecced while this file and
RELEASE.md both said the surface was complete. Nothing catches that, and
mix docs is no help either: ExDoc renders one spec against a defaulted
definition, the widest arity's, so the page looks the same with two specs as
with five. credo --strict passes, and there is no Dialyzer. So check it with
Code.Typespec.fetch_specs(Castle) and count — against
Castle.__info__(:functions), which is the list that has to be covered —
rather than by reading the source, which is what missed three of them.
The @docs are claims, nothing checks them, and the first draft of them
walked back into two rules this file had already settled. Both are worth
naming, because both were regressions rather than staleness:
upgradable/0's said the record is synthesised when the system started "without areleases/RELEASESfile it could read" — which is the readable phrasing corrected above, admitting the malformed-terms counterexample, and it named the file unqualified besides. A@docthat describes a refusal has to say what the refusal says: name the authority (the file:release_handleraccepts, no single property of it being the test), and qualify the path withRELDIRand{sasl, releases_dir}.commit/1's said that committing an already-permanent version "succeeds and changes nothing", which was true of the shape castle#14 replaced.Commands.commit/5materialises inside its own serialised region, so the target'ssys.configis rewritten from current provider inputs beforemake_permanent/1— which is itself a no-op for that version — is called at all. OTP's step being idempotent is not the command being idempotent.
Three more were ordinary staleness of the same kind, and the pattern is that a
@doc describing a sequence goes stale where a @doc describing a value
does not: install/1's claimed two steps before :release_handler was asked
for anything, when install_upgradable/5 asks which_releases/0 first and
refuses for a pending marker in between, and the line that matters is
install_release/1 rather than any handler call; the rollback an install
leaves was described as "anything that takes the system down brings that one
back", which is false for the one reboot a restart install has already asked
for and the launcher is holding a marker to carry out; and unpacked was
glossed as "staged and never installed", which running/1's own @doc
already contradicts. bin/castle commit's argumentless form was described as
defaulting to "the version running now", where castle.sh.eex selects a
:current release and exits non-zero when there is none.
So: read a @doc about a sequence against the function that implements the
sequence, not against the module's summary of it, and read one about
bin/castle against Forecastle's priv/castle.sh.eex.
Forecastle is what arranges for these to be reachable: it leaves the
configuration Mix wrote alone, adds a :preboot script that starts :castle,
and writes the env.sh fragment and bin/castle wrapper that call into this
module.
Castle.customize/1 is the public integration point, and the whole of it: a
consumer's mix.exs names Castle and nothing else
(#12). It takes mix release
options and returns mix release options with the build-time steps spliced
around :assemble. It lives in Castle rather than in a module of its own for
that same reason.
The splice is Forecastle.steps/1, and there must not be a second
implementation of it here. That function finds :assemble, puts
pre_assemble/1 before it and post_assemble/1 after it, keeps whatever
surrounded them in the order it was given, and returns a list with no
:assemble untouched. customize/1 is Keyword.update/4 over :steps with
that as the function and nothing else. Forecastle's own release fixture stays on
the explicit pre_assemble/post_assemble steps deliberately: Forecastle has
to be testable without Castle's API, so do not "tidy" it onto customize/1.
The lazy fn -> … end release form is required rather than preferred, and the
@doc says so. Mix evaluates mix.exs on every load of the project, the
mix deps.get and mix deps.compile runs included, so a Castle.customize/1
written outside a function is a call to a module that has not been built yet.
Mix.Release.find_release/2 calls a 0-arity release option function only when a
release is being built (opts = if is_function(opts_fun_or_list, 0), do: opts_fun_or_list.()), by which point every dependency is compiled — which is
also what makes Forecastle loadable from here despite being runtime: false.
:steps defaults to [:assemble, :tar], and Mix's own default is
[:assemble]. Mix.Release.from_config!/4 is
Keyword.pop(opts, :steps, [:assemble]), so a release that says nothing gets no
tarball — and Castle.unpack/1 reaches :release_handler.unpack_release/1,
which reads releases/<name>-<vsn>.tar.gz. A version assembled without :tar
can never be handed to a running deployment, and handing versions to running
deployments is the whole of what Castle is for, so the default carries :tar.
It is written out in @default_steps rather than taken from
Forecastle.steps/1's own default, so that what the @doc claims is a claim
about this module's code.
A :steps list that is given without :tar is honoured, with a warning at
the build. Refusing it, or adding :tar back, would be wrong — and this is the
part a future reader will otherwise re-derive incorrectly. The reason is that
the deployment an upgrade is installed onto needs no tarball of its own.
unpack reads the tarball of the version being installed, never of the version
running, so a base deployment shipped as a directory — a container image, most
obviously — is a perfectly good Castle deployment built with
steps: [:assemble], and refusing it would refuse a working configuration.
:tar is also only Mix's own way of packing one — make_tar/1, private to
Mix.Tasks.Release and so not callable — and a function step in the project's
list can pack a tarball itself, so the absence of the atom is not the absence of
a tarball. What customize/1 knows is that the atom is not in the
list; that is what the warning says, and it says nothing further — the same rule
as the ERTS guard's message, which states the divergence and asserts no cause.
Honouring it silently was the third option and is the one to keep rejecting.
The cost is invisible until an operator meets it as a bin/castle unpack that
cannot find a file, and that failure names a missing tarball rather than the
release option that did not ask for one. The warning goes through
Mix.shell().error/1, which is what Forecastle's own Windows warning uses. That
is a reference to Mix from a module that ships inside the release, which is
sound because the branch only ever runs at build time, where Mix is by
definition loaded; nothing at runtime reaches it.
Nothing is said about a list with no :assemble, and nothing should be.
Mix.Release.validate_steps!/1 requires exactly one, refuses the release and
names the option, and Forecastle.steps/1 returns such a list untouched so that
refusal can happen. A :steps value that is not a list is handed back for the
same reason: Forecastle.steps/1 guards on is_list/1, and a
FunctionClauseError out of it would name a module the project never mentioned,
which is precisely what customize/1 exists to prevent. Mix validates :steps
after the lazy release function has been called — find_release/2, then
from_config!/4 — so its refusal is always downstream of this.
It changes exactly one option. What a consumer still has to declare by hand
is listed in the @doc, because the alternative is finding out from a failed
upgrade: the :appup project key with compilers: Mix.compilers() ++ [:appup],
a relup from mix forecastle.relup left in the project root, and
include_executables_for: [:unix] — Windows is unsupported and assembly only
warns — plus the optional rel/env.sh.eex. :steps is the one option whose
contents are Castle's business; the others are the project's own choices, and
one Castle set silently would be one a consumer could not see in their own
mix.exs.
| Path | Purpose |
|---|---|
lib/castle.ex |
The command boundary: print the outcome, or raise — plus customize/1, the build-time release integration, which is not a command |
lib/castle/commands.ex |
The commands themselves, returning their outcome |
lib/castle/deployment.ex |
The facts about the deployment Castle cannot arrange and a test cannot produce: the two roots, and the stat/read/rm whose failures decide what a refusal says |
lib/castle/peer.ex |
The temporary VM that runs the target's own config providers, both sides of it |
lib/castle/error.ex |
The exception a failed command raises |
test/support/ |
Stubs for :release_handler, :init, the peer, the deployment and config providers, plus the release-shaped tree a real peer is booted on |
- Run
mix precommitbefore committing. It is the single validation gate —compile --warnings-as-errors,deps.unlock --unused,format,credo --strict,test. Do not run the individual checks piecemeal. @versioninmix.exsis the single source of truth for the version.- Add user-visible changes to
RELEASE.mdon the feature branch, using Keep a Changelog sections. Do not defer release notes to release time, and exclude internal CI/lint churn. - Release with
mix publisho <patch|minor|major>, which bumps@version, foldsRELEASE.mdintoCHANGELOG.mdat the<!-- %% CHANGELOG_ENTRIES %% -->placeholder, commits and tags. Tags are bare semver — novprefix. Pushing a tag triggers.github/workflows/publish.yml, which publishes to Hex. - Never commit directly to
main; work on a feature branch and open a PR.
mix test covers Castle.Commands as units. :release_handler, :init,
Castle.Peer and Castle.Deployment are reached through module arguments that
default to them, so the tests hand them Castle.ReleaseHandlerStub,
Castle.InitStub, Castle.PeerStub and Castle.DeploymentStub instead;
materialise/3 takes the version directory it works on and make_releases/3 the
releases directory, so the tests give them a tmp_dir — and neither the commands
nor their tests touch the working directory, which is what lets them all run
async.
Only the cases about a synthetic root pass Castle.DeploymentStub. Every case
that predates the guard omits the argument and so runs against the real
Castle.Deployment — which is worth knowing rather than tidying, because under
mix test there is no RELEASE_ROOT and that is exactly the inert state: those
cases are the standing evidence that the guard lets an ordinary caller through,
and they would fail if it stopped being inert.
test/castle_test.exs drives the boundary itself against the real
:release_handler — which is running under mix test, because castle depends
on sasl — and the real :init, naming releases that do not exist. One test
there is not about the boundary: the record check rests on a claim about OTP's
own data, that a record read from a RELEASES file names applications, so it is
asserted against the record the real :release_handler read from the OTP
installation's own file rather than against a stub. It fails, loudly and with
the reason visible, on an installation that has no releases/RELEASES — and so
does the boundary's unpack/1 test, now that unpack makes the same check.
The record check's discriminators are about ordering, so they are written the
way materialise/3's are: the stub is given a reply that would have the
operation succeed, and the assertion is that it was never asked for it —
Stub.calls(:unpack_release) == [], Stub.calls(:install_release) == []. An
end-state test cannot tell a refusal that came first from one that came after,
because the refusal is the same either way. Two more assert
Stub.calls(:which_releases) == [[]] on the successful path: the check happened
in the call that acted, which is the whole of what this fixed, and a version
that asked it somewhere else would pass every other assertion here.
commit/3's regression guard is the mirror image — a synthesised record, and
Stub.calls(:which_releases) == [], because commit must not acquire the check.
The ERTS guard's tests are written the same way, and they have the same
difficulty in a sharper form: the guard is inert without a RELEASE_ROOT, and
mix test starts with none — so the state it exists to refuse has to be arranged
deliberately, either by substituting the roots or, in the boundary suite, by
putting the variable in the environment. Castle.DeploymentStub answers the two
roots and the stat and nothing else — the comparison, the normalisation and the
message stay in Castle.Commands and run for real — which is the same division
as stubbing which_releases/0 and letting the record rule run.
The stat is stubbed for one reason: two of the three answers cannot be produced
by a fixture. An :eacces needs a mode that root and some filesystems ignore,
and a zero inode needs a filesystem reporting no inode numbers. A fixture that
only sometimes produces its state is a test that only sometimes tests anything,
and the first attempt here proved it — it built a 0000 parent, branched on
whether the refusal mentioned :eacces, and so passed by printing "skipped" on
any runner where the mode did not bite, including against the regression it
named. Unstubbed, stat/1 is the real one, so a test that only cares about the
roots does not have to describe the filesystem.
Each gated operation is given a handler primed to succeed and asserted never to
have been asked (Stub.calls(:remove_release) == []), and unpack and install
also assert Stub.calls(:which_releases) == [], because on such a deployment the
record check is asking about the Erlang installation and would have nothing to
say: the refusal has to name the reason that is true. make_releases/3 has a
test of its own for the ordering against File.exists?, since an Erlang
installation has a releases/RELEASES and looking first would report success.
test/castle/erts_guard_test.exs is the other half, and it is async: false
because it puts RELEASE_ROOT in the environment and the environment is the
node's. It drives the boundary through the real Castle.Deployment, so what it
establishes is that the shipping module reads that variable — not that a launcher
exports it, which is a fact about the script mix release generates and is not
something a System.put_env/2 can witness. Every gated command raises,
upgradable/0 and releases/0 still answer, and with RELEASE_ROOT set to
code:root_dir() — or absent — remove/1 reaches the real :release_handler
and is refused for the release not existing, which is how the inert case is told
from the gated one.
One test there asserts the refusal's entire text rather than fragments of it. That is deliberate and it should stay that way: the defect being guarded is a categorical claim about the cause, and no set of refutations forbids one — "This is caused by include_erts: false" refutes clean against every phrasing this message has previously been wrong in, while keeping every word a fragment-based test would require. Only the whole string pins it, and rewording the message on purpose should mean editing that assertion on purpose.
test/castle/peer_test.exs is the exception: it starts real peers. Stubbing the
peer would prove nothing about the one thing it exists to do, which is to run a
release's own code. Castle.SyntheticRelease lays out a directory tree with
everything Castle.Peer needs — an emulator launcher under erts-<vsn>/bin,
applications under lib/<app>-<vsn>, a systools boot script over them, a
release file and a sys.config — so a peer boots on the same kind of script a
release ships, without a mix release. The test that matters most compiles two
versions of one provider module, loads one into the node running the tests and
puts the other on the peer's code path, and asserts that the answer came from
the peer's. Peer cleanup is asserted from outside: a provider records the
operating system pid of the VM it ran in, and the test waits for it to go.
It builds the unpacked shape by default, and the assembled one only when a
test asks for it — because the peer path is reached from install and commit,
so every version it is asked to configure was unpacked from a tarball. The two
shapes differ in the version directory's release files: Mix assembles one,
<name>.rel, while unpacking leaves two, that one plus the <name>-<vsn>.rel
release_handler copies in beside it. A fixture that only built the assembled
shape is how Castle.Peer came to refuse every unpacked release as ambiguous,
having been reviewed seven times against a directory the peer path never meets.
Castle.Peer.materialise/2 takes :boot_timeout and :resolve_timeout for the
same reason Castle.Commands takes the module to talk to — a deadline nothing
can shorten is a deadline no test can show is enforced. Two tests give it a
second and assert that the refusal names it, which is what keeps the deadlines
from being a claim in a comment.
Idempotence is asserted against a control rather than against a hard-coded
expectation: two versions of the same release are built in one root, sharing a
runtime.exs so that the state the providers carry is identical, one is
materialised twice with the environment changing in between — install, then
commit — and the other once with the environment as it ended up. The two
sys.config terms have to be equal. That is why Castle.SyntheticRelease makes
its symlinks idempotently: a root has to be able to hold two versions.
Castle.Peer.work_dir/1, secure_dir/1, write_like/3, write_private/2,
create_exclusive/1, fill/3 and publish/2 are public for the same kind of
reason: what they guarantee is about intermediate states, and a window nothing
can stand in is a window nothing can test. One test takes work_dir/1,
write_like/3 and publish/2 one at a time and looks at the destination in
between — where it finds no file, rather than a partial one — then checks that
publishing again is refused rather than allowed to replace. Another calls
work_dir/1 and finds the directory at 0700 and still empty. Call sites use
write_private/2; create_exclusive/1 and fill/3 are public only so that the
window between them can be stood in, and never to be called in sequence by
anything else.
fill/3 being separable is what lets a test swap the name between the exclusive
open and the write, and assert that the content reached the inode that was
created while the file the name now points at never saw it — with that same test
asserting the acknowledged cost, that the by-path chmod does land on the
swapped name. With the name left alone the two behaviours are identical, so
there is no other way to tell them apart.
secure_dir/1 is public so that the mkdir-to-chmod window can be stood in:
a test creates a directory at 0777, plants a sys.config symlink inside it, and
asserts that securing it is refused, the directory removed and what the symlink
pointed at untouched. That is the only way to observe it — nothing about the end
state distinguishes a directory that was empty when it was narrowed from one that
was not, which is what made the same mistake possible a sixth time at the
directory after five at the file. A companion test plants a name inside an
already-private directory and asserts write_private/2 refuses it rather than
writing through it, which is the half a private directory does not cover.
Those have to be written that way. The mode a file ends up with is the same whether it was set before or after the content, so a test of the end state passes either way — which is how the exposure survived two rounds of review that had already identified the class. The in-peer observation of the scratch file's mode is a regression guard on the site that was wrong, not a discriminator: it passes against a version that had the window too.
What is a discriminator, and what makes the exposure unreachable rather than
merely narrow, is the in-peer walk of the version directory taken while both
files exist and both hold configuration: the only thing Castle has put there is
one directory at 0700, every configuration-bearing file it made is inside that,
and the version directory itself holds nothing of Castle's but the two names it
publishes. Against the version that created those files next to sys.config
there is no such directory at all, so the assertion cannot pass by accident.
The other discriminator, and the reason the mode ordering cannot be reversed by
accident either, is the release whose sys.config is 0440: it materialises twice
and both files end at 0440, where setting the mode first fails to write the file
at all.
Two of these tests would pass for the wrong reason if written carelessly, so
they are written to fail when what they rest on moves. The compile-environment
test asserts the refusal, over provider state built by
Config.Provider.init/3: if Elixir stops representing that check as a list of
triples, init/3 stops producing one, no refusal happens, and the test fails.
It is paired with a release whose check is satisfied, so that "refuses
everything" cannot pass for "checks correctly", and with one carrying a shape
Elixir does not produce, which has to be refused rather than skipped.
The restart marker's tests are written the same way, and the discriminators are
the same kind: restart_planned?/3 is exercised by writing a real relup where
release_handler reads one and asserting on the file — armed for a script
carrying restart_emulator, from the from-release's downgrade section for a
downgrade, and not armed for a hot script, for restart_new_emulator at the
head, or when there is no relup at all. A relup with no entry for the running
version is what makes the downgrade case a genuine second lookup rather than the
first one succeeding by accident. Both arming refusals are arranged by putting a
directory where a file has to be — at the marker's name for the one, at
new_start_erl.data for the other — which is deterministic and needs no file
modes, since a fixture here may not turn on a mode that root or a filesystem can
ignore. And Stub.calls(:which_releases) == [[]] on the successful install is now
load bearing twice over: it says the record check happened in the call that acted,
and that the classification did not ask a second time.
The attempt-ownership tests are about states that only exist while
install_release/1 is in flight, so Castle.ReleaseHandlerStub accepts a
function as a reply and calls it with the arguments. That is the only seam
between the arming and the disarming, and it is what makes three otherwise
unobservable things assertable: a preparation that writes new_start_erl.data
and then fails, so that a same-version retry can be shown to clear it rather
than pair with it; the filesystem as a hard restart before the reboot would find
it, which is the marker alone and no pair; and a marker replaced between the
arming and the disarming, so that disarm/3 can be shown to leave a marker it
did not write. None of those has an end state that distinguishes it — a
successful install leaves the marker armed either way, and a failed one leaves it
gone either way — which is the same reason Castle.Peer's primitives are public.
A marker already at the path needs no seam of its own: it is a pending attempt,
and what is asserted is that the install is refused, that install_release/1 was
never called, and that neither the marker nor OTP's file was touched — the
second half being the ordering that keeps a refusal from destroying a pending
attempt's evidence.
Two real callers do need one, and the seam is which_releases/0 rather than
install_release/1. The state that used to be reachable is two callers past the
running-release lookup and neither of them armed, so what has to be held open is
the front of the serialised region — and the lookup is both the first thing in
it and the last thing before anything is written. So installer/3 runs
install/5 in a task of its own with a which_releases/0 that reports where it
got to and, optionally, waits to be released; a second caller is started while the
first is held there, and the discriminator is that its lookup never happens
(refute_receive {:looked_up, :second}). Task.await and assert_receive carry
generous timeouts because global's lock retry backs off by up to a second or
two, and every stub reply and call record lives in the task's process
dictionary, so a caller answers with its own Stub.calls(:install_release) and
its own PeerStub.calls() rather than the test reading them.
Four of them. The second is the other direction — a failed first install hands the region on, and the waiter arms its own marker. The third is not about the marker at all: one relup for 1.2.3 whose transition from 1.2.2 is hot and from 1.2.1 restarts the emulator, installed concurrently by two callers running different versions. It says the classification belongs to the caller that made it — the second half of why the region reaches past the arming — and that the hot caller neither adopts nor disarms the marker the restarting one is waiting on.
The fourth is about the configuration, and it is the one that needs providers
whose results can be told apart. Castle.PeerStub therefore accepts a
function reply, like Castle.ReleaseHandlerStub does, so a caller's
materialisation can write a distinguishable sys.config into the version
directory — which is what the real one ends by renaming into place. Two callers
are given different ones, the first is held at the lookup and goes on to arm and
"reboot", the second is refused for the pending marker, and the assertions are
that the second's peer was never called (PeerStub.calls() == []) and that
the target is left holding the first's configuration. With both callers answering
{:ok, []} there is nothing to see: the end state is identical whichever of them
ran, which is exactly why the composition survived three rounds of review. Note
what this test fails against, because it is the point of it — not just
materialising outside the lock, but materialising inside the lock and in front
of unclaimed/3, which is the fix that looks sufficient and is not.
It is also the one case that runs through Castle.install/5 rather than
Commands.install/5, and that is not a detail. The defect was a composition in
Castle.install/1, so a case that only ever called Commands.install/5 was
asserting about a function the defect was not in: putting materialise/3 back in
front of the install left it green. installer/3 takes through: :boundary for
this one, which runs the command boundary — so the two callers are two rpcs,
which is what they are in a deployment. The boundary prints what succeeded and
raises what failed, so invoke/2 puts both back into the shape
Commands.install/5 returns: with_io/1 inside the task, because that is whose
group leader has to be swapped, and an implicit-try attempt/1 to turn
Castle.Error back into an {:error, message}. Every other case here stays on
Commands.install/5, which is the right level for a claim about the serialised
region itself.
The exception path has tests of its own, and the seam is
Castle.ReleaseHandlerStub's function reply again. A raise, an exit and a
throw out of install_release/1, each asserted to leave no marker behind, with
new_start_erl.data written first in the raise case so that what survives would
be the complete pair. A fourth asserts the opposite for a successful restart
install — the marker stays — which is what forbids try/after in place of
catch/else, since an after cannot tell the two apart and no other assertion
here would notice.
Being unable to settle the marker is reached through Castle.DeploymentStub,
not through a mode. stub_read/1 and stub_rm/1 take a reply or a function of
the path, so a refusal can be scoped to the marker alone while everything else the
install touches goes through the real File. Unstubbed, both are the real thing,
for the reason stub_stat/1 is: a fixture that only sometimes turns on the state
it names is a test that only sometimes tests anything. The four cases are a
removal refused with :eacces, a read refused with :eio, both of those and a
raise from install_release/1 — where the exception has to be folded into the
message rather than let out — and the quiet one, a marker a start of the
deployment consumed, where :enoent must not be reported as a failure. That last
one is what keeps the reporting from being noise on an ordinary interrupted
install. The DeploymentStub in these is given nil for both roots, so the ERTS
guard is inert exactly as it is under mix test with no RELEASE_ROOT.
Every install case now names a configured(dir) — the version directory unpacked
and a peer that says it configured it — because materialising is a step of the
install rather than something composed in front of it. That is deliberate rather
than an inconvenience: a case that did not say what the peer did would not have
said what the version it installed is configured with. The ERTS-guard cases pass
an unstubbed Castle.PeerStub instead, which raises if it is reached, so
"refuses without starting a peer" is asserted by the guard holding rather than by
a separate look.
test/castle/customize_test.exs needs none of that machinery, and should not
acquire any: customize/1 is a pure function on a keyword list, so there is no
release to build and nothing to stub. Two things about how it is written are
load bearing. Every assertion is on the whole :steps list, in order — a
case that asked whether :steps was present, or whether the two Castle steps
appeared somewhere in it, would pass against a splice that put them the wrong
side of :assemble, which is the only way to get the splice wrong. And the
missing-:tar decision is pinned in both halves: that the list is built as
the project wrote it (no :tar appended) and that the warning is emitted, since
a case that only looked for the warning would pass against a customize/1 that
quietly added one. The warning is observed through Mix.Shell.Process, so the
file is async: false — Mix's shell is one setting for the whole node — and the
setup restores whatever shell was there. The cases that assert nothing was
said depend on that shell just as much as the one that asserts something was,
which is why the whole file is sync rather than those two cases.
What is not covered here is a booted release: the upgrade of a running
system, and the exit statuses bin/castle returns, belong to Forecastle's
:e2e suite (#8), which
exercises this code against a real release and asserts on the success messages
each command prints. Those strings — Unpacked <vsn> ok,
Now running <vsn> (previously <other>)., Committed <vsn>. … and the
releases/0 table — are a contract with that suite. Failure messages are not,
and neither is what a restart install reports: that message may not outlive the
reboot it announces, so the :e2e suite asserts the exit status and the state
the system ends up in and leaves the wording to the unit test here.
The restart transition is covered end to end all the same, in both halves:
Forecastle's restart_upgrade_test.exs drives a --restart relup through
unpack/install/commit against a real supervised release, asserts the OS pid
changes, kills the provisional release before committing to see it roll back, and
installs again from the release that came back.
It is also what finally covers the interaction between a materialised
sys.config and a later cold boot of the same version, which used to be listed
below as unverified. The provisional boot is that cold boot: it re-runs the
target's own providers over the materialised file, with SAMPLE_GREETING changed
underneath it, and answers with the new value. What lets it is that materialising
leaves no config_provider_booted marker behind and preserves the header Mix
wrote, so Elixir's pipeline is still armed in the file the launcher reads.
-
Nothing in the release restarts it after an emulator restart, and that is the design.
init:reboot()takes the OS process down,bin/startis inert, andHEART_COMMANDis unset, so the external supervisor is the only thing that brings the system back. A deployment started by hand from a shell therefore stays down until somebody starts it — and comes back on the installed version when they do, because the markers are still waiting. See forecastle#10 for why a second restart authority was refused rather than added. -
A hard kill during a restart install can only lose the reboot, not misapply it — and where the pair survives such a kill, booting the target is right rather than tolerated. Which window it lands in decides which:
Between the arming and
prepare_restart_new_emulator/7, only the marker exists — OTP's file was cleared on the way in — so there is no pair, the next start boots the permanent version, and the marker is consumed and discarded. Afterprepare_restart_new_emulator/7, both exist, and so does atmp_currentrecord for the target: the relup was evaluated in the VM that died, the target is unpacked with its configuration materialised, andtransform_release/3will make itcurrenton the way up. That is the state the reboot was going to produce, so the next start producing it is the protocol working.releases/start_erl.datastill names the previous permanent version either way, so nothing is committed by a crash.There is still no test that plants the pair by hand, and that is a decision rather than an omission. What a planted pair adds is a state OTP's records contradict — forging the markers for a version the handler has no
tmp_currentfor leaves the node running one release whilewhich_releases/0reports another — so pinning it would pin an incoherence. What is pinned is the state each window leaves: a unit test observes the filesystem from insideinstall_release/1and finds the marker alone, and the restart:e2esuite covers the far window by killing the provisional release before the commit. -
Serialising the install is node-local, so a second VM writing into the same releases directory is outside it.
Castle.Commands.serialised/2locks over[node()], which is exact for every caller Castle has —bin/castleisrpc, and the launcher's preboot step calls onlymake_releases/0— but it is a statement about callers rather than about the directory. Something else runningCastle.install/1in a VM of its own against the same deployment gets the filesystem half of the protocol and nothing more:publish/2refuses rather than replaces, so the marker cannot be silently taken over, and the window the lock closes — two callers both pastunclaimed/3before either publishes — is open again between them. Widening the lock does not fix it; a lock the filesystem holds would, at the price of a stale one after a hard kill blocking every later install. Nothing is known to do this, and Castle does not detect it. It is the one limitation here that a lock cannot narrow, which is why the filesystem half of the protocol —publish/2refusing rather than replacing — has to stand on its own. -
Nothing checks the
@specs. The public surface is documented as of #11 — see the end of What it does for what is published and why — but there is no Dialyzer in this project, so a spec that stops describing its function fails nothing. They are all:: :oktoday, which is the whole of whatreport!/1returns, so the way one goes wrong is a command that starts returning something else and a spec that keeps saying:ok. The same holds for what the@docs claim a command refuses:mix docscatches a broken reference, and nothing at all catches a true sentence that has stopped being true. Both are read againstCastle.Commandsby hand. -
The README is out of date. It documents an
:appupcompiler and amix castle.reluptask that moved to Forecastle in 0.3.0, the release management commands it describes onbin/<release>now live onbin/castle, and its integration section still tells a consumer to placeForecastle.pre_assemble/1andForecastle.post_assemble/1around:assembleby hand, which is whatCastle.customize/1replaced (#9).