Skip to content
276 changes: 260 additions & 16 deletions AGENTS.md

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
### Added

- The configuration of the version being installed is now expanded by running
*that version's* config providers, in a temporary VM booted from that
version's own boot script on its own emulator, rather than by running provider
state stashed at build time in the version that happens to be running. A
provider module can differ between the two — which is precisely what an
upgrade may change — and only the target's own answer is the right one. It
also leaves Elixir's `Config.Provider` as the single implementation of the
provider pipeline: Castle drives it and no longer keeps a copy of it.

The temporary VM needs no epmd, no cookie, no node name and no distributed
Erlang: it talks to the running node over a socket on the loopback interface,
and whatever it prints — a provider explaining what it could not find, say —
arrives on the terminal that asked for the install. It is stopped on every way
out, including every failing one, and it cannot hold an install open: both its
boot and the work it is asked to do have deadlines. Everything that can refuse
to go on refuses before the upgrade is applied, so configuration that cannot
be expanded leaves an install that did not happen rather than one that
half did.

Each expansion starts from the configuration the release was built with, which
the first one copies aside as `sys.config.pristine` and none of them
overwrites. Config providers are not obliged to be idempotent, and the
familiar ones are not: a `runtime.exs` that sets a key only when an
environment variable is present says nothing about that key when it is absent,
so expanding over the previous result would leave a value behind after the
provider had stopped supplying it — and the version made permanent would be
configured differently from the way it goes on to boot. Expanding from the
original instead means installing and then committing produce the same answer
a boot would, which is the point of expanding at either. That copy is made
atomically and with the permissions `sys.config` has, so a partly written one
can never be found and read, and restricting `sys.config` — as an operator
might, since it holds credentials — restricts this too.

Among the things that refuse is the check Elixir makes on a configuration
before booting into it: that what `Application.compile_env/3` read when the
release was compiled is what the resolved configuration says now. A version
whose runtime configuration contradicts what it was compiled against is
refused here, where refusing costs nothing, rather than accepted and then
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.
- `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
22 changes: 16 additions & 6 deletions lib/castle.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ defmodule Castle do
end

def install(vsn) when is_binary(vsn) do
generate(vsn)
materialise(vsn)
report!(Commands.install(vsn))
end

Expand All @@ -43,7 +43,7 @@ defmodule Castle do
end

def commit(vsn) when is_binary(vsn) do
generate(vsn)
materialise(vsn)
report!(Commands.commit(vsn))
end

Expand All @@ -55,10 +55,20 @@ defmodule Castle do
report!(Commands.releases())
end

# The version directory of the running release. Where the configuration is
# written is derived from the release that is running, never chosen by the
# caller - see castle#13, which materialises target configuration in a peer
# rather than extending this path.
# Makes sure the target version's configuration exists before the version is
# handed to `:release_handler`, and fails here if it cannot be made to. It
# runs ahead of both operations that need it, and everything that can refuse
# to go on - a peer that will not start, a boot script that is not there, a
# provider that raises - refuses from inside this call, which is to say before
# `install_release/1` has been asked for anything. Nothing after that point
# 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])

defp report!({:ok, lines}), do: Enum.each(lines, &IO.puts/1)
Expand Down
40 changes: 40 additions & 0 deletions lib/castle/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,46 @@ defmodule Castle.Commands do
end
end

@doc """
Materialises the configuration of the release in `rel_vsn_dir`.

Two shapes of release reach this, and the presence of `build.config` is what
tells them apart. Forecastle used to intercept configuration at assembly time:
it stripped the providers out of the release, stashed their initialised state
under this application's key, and renamed the `sys.config` Mix had written to
`build.config`. The only thing that can expand a release assembled that way is
`generate/1`, folding the stashed state over the file it was taken from - and
the presence of `build.config` is the test rather than the absence of
`sys.config`, because from its first boot onwards such a release has both:
writing one beside the other is what `generate/1` does.

A release Mix configured normally has its providers where Mix put them and its
`sys.config` under the name Mix gave it, and nothing has been renamed - so the
absence of `build.config` says the pipeline is intact, and the target can be
evaluated the way Elixir intends: in a VM of its own, running its own
providers, which is what `Castle.Peer` does. That is the only sound way to do
it, since a provider module can differ between the version that is running and
the version being installed.

The module is an argument for the same reason `:release_handler` is: so that a
test can watch which way the decision went without starting a VM.
"""
@spec materialise(Path.t(), module()) :: result()
def materialise(rel_vsn_dir, peer \\ Castle.Peer) do
cond do
File.exists?(Path.join(rel_vsn_dir, "build.config")) ->
generate(rel_vsn_dir)

File.dir?(rel_vsn_dir) ->
peer.materialise(rel_vsn_dir)

true ->
{:error,
"Cannot configure #{Path.basename(rel_vsn_dir)}: #{rel_vsn_dir} does not exist. " <>
"Unpack the release first."}
end
end

@doc """
Expands the build-time configuration in `rel_vsn_dir` into its `sys.config`.

Expand Down
Loading