Skip to content

Commit bc19bf5

Browse files
authored
Merge pull request #28 from ausimian/issue/11-docs
docs: document the command boundary and spec its public surface
2 parents 0564785 + edadd54 commit bc19bf5

6 files changed

Lines changed: 591 additions & 42 deletions

File tree

AGENTS.md

Lines changed: 136 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ Castle's job is configuration and release management on a running node.
315315
library directories *relatively*`filename:join("lib", LibName)`, so the file
316316
stays relocatable — so every `lib/<app>-<vsn>` the handler reads, writes or
317317
deletes resolves there, as does the `extract_tar(Root, Tar)` an unpack goes
318-
through and the `erts-<vsn>` a removal deletes. So a Castle that wrote to
318+
through and the `erts-<erts_vsn>` a removal deletes. So a Castle that wrote to
319319
`$RELEASE_ROOT` would put the configuration where the handler never looks, and
320320
an upgrade would go on using applications under the installation — a silent
321321
divergence in place of a loud failure. Do not "fix" the guard that way.
@@ -383,6 +383,15 @@ Castle's job is configuration and release management on a running node.
383383
bookkeeping and leaves the applications themselves being extracted into, read
384384
from and deleted out of the emulator's root.
385385

386+
`EVsn` there is the *ERTS* version out of the release record, not the release
387+
version, and `do_remove_release/4` deletes that directory only when no
388+
remaining release refers to the same emulator. The two are unrelated numbers
389+
and normally different ones - a release at `0.1.1` carrying `erts-16.2` - so
390+
prose must not reuse `<vsn>` for both. Writing `erts-<vsn>` beside
391+
`lib/<app>-<vsn>` reads as one version and names a directory that generally
392+
does not exist; it had got into `remove/1`'s `@doc` and into the ERTS guard's
393+
refusal message, which ships. Say `erts-<erts_vsn>`.
394+
386395
**The comparison has three answers, not two.** `compare_dirs/2` is
387396
`Path.expand/1` on both and then a `stat` on device and inode, and it returns
388397
`:same`, `:different` or `{:indeterminate, why}`. The third is the one to keep:
@@ -515,8 +524,11 @@ Castle's job is configuration and release management on a running node.
515524
more: a diagnostic, not a gate, and nothing has to call it. It stays because
516525
the state it reports is otherwise invisible — the file can be present while the
517526
record the node works from was synthesised — so an operator needs a way to ask
518-
that does not unpack or install anything. Whether it belongs in the documented
519-
API surface is [#11](https://github.com/ausimian/castle/issues/11)'s to settle.
527+
that does not unpack or install anything.
528+
[#11](https://github.com/ausimian/castle/issues/11) settled that it belongs in
529+
the documented surface, and for that same reason: `bin/castle upgradable` is
530+
how an operator asks, and a diagnostic nobody is told about is one nobody
531+
thinks to ask.
520532
- **`unpack/1`, `install/1`, `commit/1`, `remove/1`, `releases/0`** — wrappers
521533
over `:release_handler`, with the target version's configuration materialised
522534
before `install` and `commit` hand it over, the record check inside `unpack`
@@ -911,20 +923,124 @@ Castle's job is configuration and release management on a running node.
911923
reboot takes and which automation reads.
912924

913925
Every one of them is a command entry point, so `Castle` is the command
914-
boundary: an operation that fails raises `Castle.Error` there, which is what
915-
leaves a non-zero exit status behind for the shell that asked for it. Raising,
916-
not halting — the expression runs on the *running* node, so halting would take
917-
down the system under management; `Kernel.CLI` catches on the node and
918-
re-raises in the calling VM, and only that VM exits. `Castle.Commands` holds
919-
the operations themselves, returning their outcome instead of acting on the
920-
process, which is what makes them testable.
926+
boundary: an operation that fails raises there, which is what leaves a non-zero
927+
exit status behind for the shell that asked for it. Raising, not halting — the
928+
expression runs on the *running* node, so halting would take down the system
929+
under management; `Kernel.CLI` catches on the node and re-raises in the calling
930+
VM, and only that VM exits. `Castle.Commands` holds the operations themselves,
931+
returning their outcome instead of acting on the process, which is what makes
932+
them testable.
933+
934+
**`Castle.Error` is what `report!/1` raises, and it is not everything a command
935+
raises.** `report!/1` turns a returned `{:error, message}` into one; an
936+
exception, a throw or an exit that the operation did not handle goes straight
937+
past it. That is deliberate for the one place it can happen on purpose —
938+
`installed/5` settles the marker and re-raises `install_release/1`'s failure
939+
unchanged, folding it into a message only where the marker could not be settled
940+
— so anything claiming that a failed command raises `Castle.Error`, in a `@doc`
941+
or here, has to say which failures. Automation told to rescue `Castle.Error`
942+
and nothing else would treat a `release_handler` that blew up as a success.
921943

922944
`Castle.customize/1` is the one function in that module which is *not* one of
923945
them — it runs at build time, in a consumer's `mix.exs`, and returns a value
924946
rather than reporting an outcome. See **Release integration** below. Anything
925947
that says "every function in `Castle`" has to say "but `customize/1`", and the
926948
comment at the head of `lib/castle.ex` does.
927949

950+
**What is published and what is hidden follows from that, and is
951+
[#11](https://github.com/ausimian/castle/issues/11)'s decision.** The whole of
952+
`Castle` now carries `@doc` and `@spec`, and the `@moduledoc` says the two
953+
things a reader has to know before calling any of it: that this is the runtime
954+
half of a pair, and that these are commands rather than an API — a command
955+
prints its report and returns `:ok`, so the return value carries nothing, and a
956+
refusal raises `Castle.Error` rather than returning `{:error, _}`. Every command
957+
`@doc` names the `bin/castle` command that reaches it, because that is the
958+
interface and the function is the thing behind it.
959+
960+
**Say "a refusal", not "a failure", and the distinction is the one drawn just
961+
above.** `report!/1` turns a returned `{:error, message}` into `Castle.Error`,
962+
and that covers every refusal these commands make deliberately — but
963+
`installed/5` re-raises an exception, a throw or an exit out of
964+
`install_release/1` unchanged once the marker is settled, and anything a
965+
dependency raises comes through as itself. So automation that rescues
966+
`Castle.Error` alone misses a command that blew up rather than refused. This
967+
paragraph exists because the blanket version of the claim was written here in
968+
the same commit that corrected it in the `@moduledoc`, three paragraphs apart.
969+
970+
Two decisions inside that. `make_releases/0` is `@doc false`: its only caller is
971+
the launcher's `env.sh` fragment, in the preboot VM of a `start` or `daemon`
972+
whose deployment has no `RELEASES` yet, and by hand it either does nothing (the
973+
file is there) or does what the next start would do anyway. Its contract is with
974+
a shell fragment in another project, so publishing it would document a function
975+
nobody should call. It keeps its `@spec` regardless — the spec is the contract
976+
whether or not the function is published. And `install/2..5` is documented as
977+
what it is, a seam the concurrency test drives: one `@doc` covers every arity of
978+
a clause with defaults, so saying nothing about the extra four would leave them
979+
reading as an API. `running/1` is a third case, and documented rather than
980+
hidden for a different reason than the other two. It has no subcommand either —
981+
`bin/castle` dispatches exactly `releases`, `upgradable`, `unpack`, `install`,
982+
`remove` and `commit`, and `running/1`'s only shipped caller is the confirmation
983+
loop inside `bin/castle install`. But unlike `make_releases/0` it answers a
984+
read-only question and mutates nothing, and automation driving `rpc` rather than
985+
`bin/castle` needs precisely it to know when an install has finished booting.
986+
Hiding it would strand that caller; publishing it costs nothing. Every remaining
987+
function is a command an operator invokes, and hiding one of those would
988+
document nothing useful anywhere.
989+
990+
The specs say `:: :ok` and nothing more, because that is what `report!/1`
991+
returns; a spec naming the lines, or an error tuple, would be describing
992+
`Castle.Commands`. Nothing checks them — there is no Dialyzer here — so they are
993+
kept by hand, and a claim in a `@doc` about what a command refuses is worth
994+
checking against `Castle.Commands` before it is trusted.
995+
996+
**A definition with defaults needs one `@spec` per arity, and "every public
997+
function carries a spec" is a claim about arities.** `install/1..5` is five
998+
functions and therefore five specs; the first version of this carried two, for
999+
`install/1` and `install/5`, and left three unspecced while this file and
1000+
`RELEASE.md` both said the surface was complete. Nothing catches that, and
1001+
`mix docs` is no help either: ExDoc renders one spec against a defaulted
1002+
definition, the widest arity's, so the page looks the same with two specs as
1003+
with five. `credo --strict` passes, and there is no Dialyzer. So check it with
1004+
`Code.Typespec.fetch_specs(Castle)` and count — against
1005+
`Castle.__info__(:functions)`, which is the list that has to be covered —
1006+
rather than by reading the source, which is what missed three of them.
1007+
1008+
**The `@doc`s are claims, nothing checks them, and the first draft of them
1009+
walked back into two rules this file had already settled.** Both are worth
1010+
naming, because both were regressions rather than staleness:
1011+
1012+
* `upgradable/0`'s said the record is synthesised when the system started
1013+
"without a `releases/RELEASES` file it could read" — which is the
1014+
*readable* phrasing corrected above, admitting the malformed-terms
1015+
counterexample, and it named the file unqualified besides. A `@doc` that
1016+
describes a refusal has to say what the refusal says: name the authority
1017+
(the file `:release_handler` accepts, no single property of it being the
1018+
test), and qualify the path with `RELDIR` and `{sasl, releases_dir}`.
1019+
* `commit/1`'s said that committing an already-permanent version "succeeds
1020+
and changes nothing", which was true of the shape castle#14 replaced.
1021+
`Commands.commit/5` materialises inside its own serialised region, so the
1022+
target's `sys.config` is rewritten from current provider inputs before
1023+
`make_permanent/1` — which is itself a no-op for that version — is called
1024+
at all. OTP's step being idempotent is not the command being idempotent.
1025+
1026+
Three more were ordinary staleness of the same kind, and the pattern is that a
1027+
`@doc` describing a *sequence* goes stale where a `@doc` describing a value
1028+
does not: `install/1`'s claimed two steps before `:release_handler` was asked
1029+
for anything, when `install_upgradable/5` asks `which_releases/0` first and
1030+
refuses for a pending marker in between, and the line that matters is
1031+
`install_release/1` rather than any handler call; the rollback an install
1032+
leaves was described as "anything that takes the system down brings that one
1033+
back", which is false for the one reboot a restart install has already asked
1034+
for and the launcher is holding a marker to carry out; and `unpacked` was
1035+
glossed as "staged and never installed", which `running/1`'s own `@doc`
1036+
already contradicts. `bin/castle commit`'s argumentless form was described as
1037+
defaulting to "the version running now", where `castle.sh.eex` selects a
1038+
`:current` release and exits non-zero when there is none.
1039+
1040+
So: read a `@doc` about a sequence against the function that implements the
1041+
sequence, not against the module's summary of it, and read one about
1042+
`bin/castle` against Forecastle's `priv/castle.sh.eex`.
1043+
9281044
Forecastle is what arranges for these to be reachable: it leaves the
9291045
configuration Mix wrote alone, adds a `:preboot` script that starts `:castle`,
9301046
and writes the `env.sh` fragment and `bin/castle` wrapper that call into this
@@ -1418,13 +1534,16 @@ wrote, so Elixir's pipeline is still armed in the file the launcher reads.
14181534
it. It is the one limitation here that a lock cannot narrow, which is why the
14191535
filesystem half of the protocol — `publish/2` refusing rather than replacing —
14201536
has to stand on its own.
1421-
- **The public API is undocumented, apart from `customize/1`.** `@moduledoc` is
1422-
still the generated placeholder and the commands carry no `@doc` or `@spec`
1423-
annotations ([#11](https://github.com/ausimian/castle/issues/11)).
1424-
`Castle.customize/1` has both, and was documented with #12 rather than left
1425-
for #11 because it is the function a consumer's `mix.exs` calls and nothing
1426-
else says how. It is the standard the rest has to be brought up to, not an
1427-
exception to be levelled down.
1537+
- **Nothing checks the `@spec`s.** The public surface is documented as of
1538+
[#11](https://github.com/ausimian/castle/issues/11) — see the end of *What it
1539+
does* for what is published and why — but there is no Dialyzer in this
1540+
project, so a spec that stops describing its function fails nothing. They are
1541+
all `:: :ok` today, which is the whole of what `report!/1` returns, so the way
1542+
one goes wrong is a command that starts returning something else and a spec
1543+
that keeps saying `:ok`. The same holds for what the `@doc`s claim a command
1544+
refuses: `mix docs` catches a broken *reference*, and nothing at all catches a
1545+
true sentence that has stopped being true. Both are read against
1546+
`Castle.Commands` by hand.
14281547
- **The README is out of date.** It documents an `:appup` compiler and a
14291548
`mix castle.relup` task that moved to Forecastle in 0.3.0, the release
14301549
management commands it describes on `bin/<release>` now live on `bin/castle`,

RELEASE.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,54 @@
254254
The rollback that provisional state buys is real and needs nothing:
255255
`make_permanent/1` is the only thing that writes `releases/start_erl.data`, so
256256
a provisional release that dies before `Castle.commit/1` is followed by an
257-
ordinary start of the version that was permanent before.
257+
ordinary start of the version that was permanent before. The one restart that
258+
is not that is the one the install asked for: while the pair is still
259+
unconsumed the next start boots the target, which is the reboot being carried
260+
out rather than a rollback being missed.
258261

259262
The two-stage `restart_new_emulator` transition remains unsupported. It reboots
260263
into a temporary hybrid release whose version directory holds a boot script and
261264
a configuration and none of the launcher's own files, so there is nothing for a
262265
launcher to boot; Forecastle refuses to generate one.
266+
- Documentation for the `Castle` module, which used to be the placeholder
267+
`mix new` writes. It now says what Castle is - the runtime half of a pair whose
268+
build-time half is Forecastle - and, before anything else, what kind of thing
269+
the functions in it are: `customize/1` is the build-time integration point and
270+
the only one meant to be called from other Elixir code, and everything else is
271+
a command entry point that `bin/castle` reaches over `rpc`. That distinction is
272+
worth reading before calling any of them, because a command prints its report
273+
and returns a bare `:ok`, and a command that fails raises rather than returning
274+
an error - which is what leaves a non-zero exit status behind for the shell,
275+
and is not what a caller expecting an ordinary function would write code for.
276+
The moduledoc is careful about what it raises, because automation acts on it: a
277+
refusal the command made and an error `:release_handler` returned become
278+
`Castle.Error`, while an exception, throw or exit the operation did not handle
279+
is let out unchanged - so a rescue narrowed to `Castle.Error` will miss a
280+
command that blew up.
281+
282+
Every command is documented with the `bin/castle` command that reaches it -
283+
`releases`, `upgradable`, `unpack`, `install`, `commit`, `remove` - and with
284+
what it refuses and what it leaves behind: that an install is provisional until
285+
it is committed, and what that does and does not promise (an ordinary restart
286+
returns to the previous version, but the reboot a restart transition has
287+
already asked for boots the target); the five steps an install takes before
288+
`:release_handler.install_release/1` is asked for anything, in the order it
289+
takes them; that `bin/castle install` confirms the version is running rather
290+
than trusting the reply; that `bin/castle commit` with no version commits the
291+
release awaiting commit and fails when there is none, and that an explicit
292+
commit re-expands the target's configuration even for a version that is
293+
already permanent; that removing a version deletes what nothing else is using;
294+
and that the two questions - `upgradable` and `releases` - answer on a
295+
deployment where everything else is refused. `Castle.running/1` is documented
296+
too: it has no `bin/castle` command of its own, and it is what automation
297+
driving an upgrade over `rpc` needs in order to confirm one.
298+
299+
Every public function now carries a `@spec`, every arity of them:
300+
`Castle.install/1` through `install/5` are one definition with defaults, so
301+
they are five specs rather than two. `Castle.make_releases/0` is deliberately
302+
not published: its only caller is the launcher, on the first start of a
303+
deployment, and by hand it either does nothing or does what the next start
304+
would do anyway.
263305

264306
### Changed
265307

@@ -321,7 +363,7 @@
321363
the reason, rather than quietly managing the Erlang installation it happens to
322364
be running on. Such a release ships no emulator, so it runs the system one, and
323365
`code:root_dir()` — the directory `:release_handler` extracts applications
324-
into, resolves every `lib/<app>-<vsn>` against, and deletes `erts-<vsn>` from —
366+
into, resolves every `lib/<app>-<vsn>` against, and deletes `erts-<erts_vsn>` from —
325367
is then the shared Erlang installation rather than the deployment. Left to itself, `make_releases/0` created that installation's
326368
`releases/RELEASES`, which usually fails for want of permission and, where it
327369
succeeds, puts the release records of unrelated deployments in one file;

0 commit comments

Comments
 (0)