Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 78 additions & 63 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,24 @@ build-time dependency.

Castle's job is configuration and release management on a running node.

- **`Castle.generate/1`** — reads `build.config` from the release's version
directory, folds the stashed config providers over it, and writes the result
as that version's `sys.config`. This is the whole reason the pair exists:
Mix expands runtime configuration once, at boot, from the version it booted;
Castle re-expands it for the version being upgraded *to*, before the relup
runs. It is now the older of two ways to do that — see the next bullet — and
goes away with the third step of
[#13](https://github.com/ausimian/castle/issues/13).
- **Materialising the target's configuration**, which `install/1` and `commit/1`
do before they hand a version to `:release_handler`. Which way depends on
whether `releases/<vsn>/build.config` exists, and that is a sound
discriminator because it is Forecastle that creates it: assembling a release
today strips the providers out, stashes their initialised state under
`:castle`, and renames the `sys.config` Mix wrote to `build.config`. So the
file is present exactly when the configuration was intercepted at build time,
and `Castle.generate/1` is then the only thing that can expand it. Note that
it has to be the *presence of `build.config`* rather than the absence of
`sys.config`: once such a release has booted once, it has both.

When it is absent, Mix's pipeline is intact and `Castle.Peer` materialises the
configuration instead: a `:peer` reached over a loopback socket — so no epmd,
cookie, node name or distribution; the peer reports `nonode@nohost` and
do 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` — the `sys.config` Forecastle 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 is
`Castle.generate/1`. Both halves of that are gone: forecastle#6 stopped
intercepting configuration at build time, and the third step of
[#13](https://github.com/ausimian/castle/issues/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 `:peer` reached over a loopback socket — so no
epmd, cookie, node name or distribution; the peer reports `nonode@nohost` and
`is_alive() == false` — booted on the target's own `preboot` script and its
own emulator, which runs `Config.Provider.boot/1` over the target's own
provider modules and hands the resolved configuration back to be written.
Expand Down Expand Up @@ -214,24 +210,14 @@ Castle's job is configuration and release management on a running node.
scratch will be created with that group and the mode bits will be honoured
against it.

`Castle.Commands.write_sys_config/2`, on the `build.config` path, is the one
place this rule is not applied: it creates `sys.config` with the process umask
when the file does not exist yet. There is no transient exposure there — the
mode it is granted is the mode it keeps — and that path is deleted in step 3,
while "nothing observable changes for a release assembled by today's
Forecastle" pins its behaviour until then. It is a real gap, recorded rather
than fixed here.

A base that cannot be read as a configuration is refused, naming the remedy,
rather than resolved from: it is preferred to `sys.config` by definition, so
failing loudly is the only safe thing left.

This is permanent design: the `build.config` path has always had a pristine
base —
`build.config` *is* one — and this is what carries that property forward when
step 3 deletes it. It is deliberately not called `build.config`, since that
name is the discriminator and would send the release back down the path being
removed. `sys.config` gains a `CASTLE_MATERIALISED` comment line, which makes
This is permanent design: the path this replaced always had a pristine base —
`build.config` *was* one, and nothing ever wrote it — and this is what carries
that property forward now that it is gone.
`sys.config` gains a `CASTLE_MATERIALISED` comment 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
Expand Down Expand Up @@ -285,7 +271,39 @@ Castle's job is configuration and release management on a running node.

- **`Castle.make_releases/0`** — creates the `RELEASES` file from the running
permanent release if it does not already exist, so a release assembled by Mix
can manage its own upgrades.
can manage its own upgrades. The directory is derived from `code:root_dir()`,
which is the root `:release_handler` resolves *its* relative paths against
(`consult/2` is `file:consult(root_dir_relative_path(File))`, and
`do_write_release/3` the same), so no caller has to change directory and none
should: the working directory was only ever visible to the `File.exists?/1`
guard, which is what let the file this looked for and the file OTP wrote be
different ones. It calls **`create_RELEASES/3`**, never `/4` with the root
supplied: `/3` is `create_RELEASES("", RelDir, RelFile, LibDirs)`, and
`check_rel_data/4` stores library directories as `lib/<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.
- **`Castle.upgradable/0`** — succeeds when the running release can be upgraded
from, and refuses when `:release_handler` is working from the record it
synthesises for itself. It reads `RELEASES` once, in `init/1`, and when it
cannot it builds a record out of the boot script's name and version with the
`libs` field 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's `point_of_no_return` switches code paths for
`get_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/0` reports
`mk_lib_name(Libs)`, `mk_lib_name([]) -> []`, and a record read from a
`RELEASES` file names at least `kernel` and `stdlib`. It has to be asked of the
node rather than of the filesystem, which is why this is here and not in
`bin/castle`: a file that appeared *after* the boot that looked for it passes
a shell test for the file and still leaves the node on the synthesised record.
The remedy the message names is a restart, because that is the only thing that
changes the answer.
- **`unpack/1`, `install/1`, `commit/1`, `remove/1`, `releases/0`** — wrappers
over `:release_handler`, with the target version's configuration materialised
ahead of `install` and `commit` so that it exists before the version is
Expand All @@ -296,9 +314,8 @@ Castle's job is configuration and release management on a running node.
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 install` repeats it rather than trusting the reply,
from Forecastle 1.0.0 — the revision pinned in this project's `mix.lock`
installs with a single rpc and never calls this, so do not describe the
polling as something Castle's own integrated state does. Two conditions. The
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: the
`current` one, or the `permanent` one when none is current — `install` leaves
its target `current` and `commit` promotes it, so both count; `unpacked` (a
Expand Down Expand Up @@ -335,10 +352,10 @@ 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.

Forecastle is what arranges for these to be reachable: it renames `sys.config`
to `build.config` at assembly time, adds a `:preboot` script that starts
`:castle`, and writes the `env.sh` fragment and `bin/castle` wrapper that call
into this module.
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.

## Layout

Expand Down Expand Up @@ -370,11 +387,18 @@ into this module.
`mix test` covers `Castle.Commands` as units. `:release_handler`, `:init` and
`Castle.Peer` are reached through module arguments that default to them, so the
tests hand them `Castle.ReleaseHandlerStub`, `Castle.InitStub` and
`Castle.PeerStub` instead; `generate/1` and `materialise/2` take the version
directory they work on, so the tests give them a `tmp_dir`.
`Castle.PeerStub` instead; `materialise/2` takes the version directory it works
on and `make_releases/2` 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.
`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.
on sasl — and the real `:init`, naming releases that do not exist. One test
there is not about the boundary: `upgradable/0` 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`.

`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
Expand Down Expand Up @@ -478,24 +502,15 @@ each command prints. Those strings — `Unpacked <vsn> ok`,

## Known limitations

- **Concurrent boots race on `sys.config`.** `generate/1` writes into the
version directory, so simultaneous `start`/`daemon`/`eval` invocations with
differing environments overwrite each other's configuration. Do not fix this
by letting callers choose where the configuration is written: it goes with
`generate/1` itself, once
[forecastle#6](https://github.com/ausimian/forecastle/issues/6) has stopped
intercepting configuration at build time and the third step of
[#13](https://github.com/ausimian/castle/issues/13) has deleted the path that
reads `build.config`. The peer path does not have it — nothing boots to
configure a target — but a boot still goes through `generate/1` until then.
- **How the materialised `sys.config` and a later cold boot of the same version
interact is not verified yet.** Both write the same file. Materialisation
resolves from `sys.config.pristine` and leaves no `config_provider_booted`
marker behind, so a cold boot re-runs the providers over the materialised
result — which is what the issue expects, and what the header Mix wrote is
preserved for. It only becomes reachable with
[forecastle#6](https://github.com/ausimian/forecastle/issues/6), and belongs
there.
interact is not verified.** Both write the same file. Materialisation resolves
from `sys.config.pristine` and leaves no `config_provider_booted` marker
behind, so a cold boot re-runs the providers over the materialised result —
which is what the issue expects, and what the header Mix wrote is preserved
for. That is now reachable, since
[forecastle#6](https://github.com/ausimian/forecastle/issues/6) landed, but
nothing asserts it: Forecastle's `:e2e` suite installs and commits without
restarting afterwards. It belongs there, because it takes a booted release.
- **The public API is undocumented.** `@moduledoc` is still the generated
placeholder and there are no `@doc` or `@spec` annotations
([#11](https://github.com/ausimian/castle/issues/11)).
Expand Down
46 changes: 36 additions & 10 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,23 @@
found to be unbootable — which, for an upgrade that restarts, is found on the
way back up with a rollback as the only way out.

Which way a release is configured is settled by the release itself. One whose
configuration was intercepted at build time — every release assembled by the
Forecastle this is released alongside, recognisable by the `build.config` in
its version directory — is expanded exactly as it was before, so nothing about
installing or committing such a release changes. The new path is taken by a
release whose ordinary Mix provider pipeline is intact, which is the shape
Forecastle stops interfering with in its own next release.
This is how every release is configured now, and the only way: the path that
read a `build.config` is gone, along with the build-time interception that
produced one — see *Removed* below.
- `Castle.upgradable/0`, which succeeds when the release the system is running
can be upgraded from, and fails when it cannot. `:release_handler` reads
`releases/RELEASES` once, as it starts, and when the file is not there it makes
a release record up out of the boot script's name and version — a record that
names no applications at all. Upgrading a system in that state is worse than
being stopped: the install reports success, and every application whose version
changed but whose code the upgrade does not explicitly load goes on running its
old code out of the directory of the release that was just replaced, until a
later `remove` deletes it. Nothing can repair the running system afterwards,
because creating the file changes no record the node holds — so what the
failure says is to restart the system before upgrading it, which is the one
thing that does. The question is asked of the node's own records rather than of
the filesystem, which is the only way to see the case where the file exists but
the boot that went looking for it was earlier.
- `Castle.Error`, the exception raised by a release-management command that did
not succeed.
- `Castle.running/1`, which succeeds when the version it is given is the
Expand Down Expand Up @@ -91,7 +101,12 @@
### Changed

- Raised the minimum Elixir requirement to 1.18.
- `unpack/1`, `install/1`, `commit/1`, `remove/1`, `generate/1` and
- `make_releases/0` no longer depends on the working directory. It looks for
`releases/RELEASES` under the root of the release - `code:root_dir()`, which is
the root `:release_handler` resolves its own relative paths against - so the
file it looks for is necessarily the file OTP writes, and a caller that used to
change directory before calling it no longer has to.
- `unpack/1`, `install/1`, `commit/1`, `remove/1` and
`make_releases/0` now fail when the operation fails, instead of printing the
reason and returning normally. These are invoked over `bin/castle`, which
reaches them by `rpc`, and by the launcher's preboot `eval`, so the reason
Expand All @@ -102,12 +117,23 @@
node, re-raised in the short-lived VM that made the call, and it is that VM
which exits. What a successful command reports is unchanged.

### Removed

- `Castle.generate/1`, and with it the path through `install/1` and `commit/1`
that read a `build.config`. Expanding the target's configuration by folding
provider state stashed at build time over a renamed `sys.config`, in whichever
version happens to be running, is what the temporary VM above replaces - and
from Forecastle 1.0.0 nothing assembles a release that has a `build.config` to
read. Runtime configuration on a normal boot is Mix's own again, and the
configuration of a version being installed is expanded by that version's own
providers.

### Fixed

- `install/1` reports the emulator restart that an upgrade to a new emulator,
or to a new kernel, stdlib or sasl, needs - rather than failing with a
`CaseClauseError` while the upgrade proceeds.
- `releases/0` reports nothing at all, rather than raising `Enum.EmptyError`,
when no releases are installed.
- `generate/1` and `make_releases/0` say what went wrong - which file could not
be read or written, and why - rather than raising `MatchError`.
- `make_releases/0` says what went wrong - which file could not be read or
written, and why - rather than raising `MatchError`.
30 changes: 18 additions & 12 deletions lib/castle.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ defmodule Castle do

# Every function in this module is a command entry point: `bin/castle` sends
# each one to the running node over `bin/<release> rpc`, and the launcher's
# env.sh fragment evaluates generate/1 and make_releases/0 in the preboot VM.
# There is no separate CLI layer to carry the process status, so these
# functions are the command boundary, and it is here that a failure raises.
# env.sh fragment evaluates make_releases/0 in the preboot VM, on the first
# start of a deployment. There is no separate CLI layer to carry the process
# status, so these functions are the command boundary, and it is here that a
# failure raises.
#
# Raising, rather than halting or returning: the rpc expression runs on the
# running release node, so halting there would halt the system under
Expand All @@ -22,11 +23,11 @@ defmodule Castle do
# rather than acting on the process, so that they can be tested.

def make_releases do
report!(Commands.make_releases())
report!(Commands.make_releases(rel_dir()))
end

def generate(vsn) do
report!(Commands.generate(rel_vsn_dir(vsn)))
def upgradable do
report!(Commands.upgradable())
end

def unpack(name) when is_binary(name) do
Expand Down Expand Up @@ -64,12 +65,17 @@ defmodule Castle do
# may fail without saying that an install happened.
defp materialise(vsn), do: report!(Commands.materialise(rel_vsn_dir(vsn)))

# The version directory of the release being operated on, under the root of
# the release that is running. Derived, never chosen by the caller: which file
# the configuration lands in is a property of the installation, not an
# argument. It resolves for any version the running release knows about,
# because `:release_handler` unpacks every version into this same root.
defp rel_vsn_dir(vsn), do: Path.join([:code.root_dir(), "releases", vsn])
# The release directory, and the version directory of the release being
# operated on beneath it. Derived, never chosen by the caller: which file the
# configuration lands in, and which file the release records go in, are
# properties of the installation rather than arguments. `code:root_dir()` is
# the root because that is the root `:release_handler` itself resolves
# relative paths against, so these name the files it will read, and a caller's
# working directory cannot make them name different ones. The version
# directory resolves for any version the running release knows about, because
# `:release_handler` unpacks every version into this same root.
defp rel_dir, do: Path.join(to_string(:code.root_dir()), "releases")
defp rel_vsn_dir(vsn), do: Path.join(rel_dir(), vsn)

defp report!({:ok, lines}), do: Enum.each(lines, &IO.puts/1)
defp report!({:error, message}), do: raise(Castle.Error, message)
Expand Down
Loading