Skip to content

Commit 0564785

Browse files
authored
Merge pull request #27 from ausimian/issue/12-customize
feat: add Castle.customize/1 as the release integration API
2 parents f2299a6 + 3c8ed54 commit 0564785

4 files changed

Lines changed: 459 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -919,16 +919,104 @@ re-raises in the calling VM, and only that VM exits. `Castle.Commands` holds
919919
the operations themselves, returning their outcome instead of acting on the
920920
process, which is what makes them testable.
921921

922+
`Castle.customize/1` is the one function in that module which is *not* one of
923+
them — it runs at build time, in a consumer's `mix.exs`, and returns a value
924+
rather than reporting an outcome. See **Release integration** below. Anything
925+
that says "every function in `Castle`" has to say "but `customize/1`", and the
926+
comment at the head of `lib/castle.ex` does.
927+
922928
Forecastle is what arranges for these to be reachable: it leaves the
923929
configuration Mix wrote alone, adds a `:preboot` script that starts `:castle`,
924930
and writes the `env.sh` fragment and `bin/castle` wrapper that call into this
925931
module.
926932

933+
## Release integration
934+
935+
`Castle.customize/1` is the public integration point, and the whole of it: a
936+
consumer's `mix.exs` names `Castle` and nothing else
937+
([#12](https://github.com/ausimian/castle/issues/12)). It takes `mix release`
938+
options and returns `mix release` options with the build-time steps spliced
939+
around `:assemble`. It lives in `Castle` rather than in a module of its own for
940+
that same reason.
941+
942+
**The splice is `Forecastle.steps/1`, and there must not be a second
943+
implementation of it here.** That function finds `:assemble`, puts
944+
`pre_assemble/1` before it and `post_assemble/1` after it, keeps whatever
945+
surrounded them in the order it was given, and returns a list with no
946+
`:assemble` untouched. `customize/1` is `Keyword.update/4` over `:steps` with
947+
that as the function and nothing else. Forecastle's own release fixture stays on
948+
the explicit `pre_assemble`/`post_assemble` steps deliberately: Forecastle has
949+
to be testable without Castle's API, so do not "tidy" it onto `customize/1`.
950+
951+
**The lazy `fn -> … end` release form is required rather than preferred, and the
952+
`@doc` says so.** Mix evaluates `mix.exs` on every load of the project, the
953+
`mix deps.get` and `mix deps.compile` runs included, so a `Castle.customize/1`
954+
written outside a function is a call to a module that has not been built yet.
955+
`Mix.Release.find_release/2` calls a 0-arity release option function only when a
956+
release is being built (`opts = if is_function(opts_fun_or_list, 0), do:
957+
opts_fun_or_list.()`), by which point every dependency is compiled — which is
958+
also what makes `Forecastle` loadable from here despite being `runtime: false`.
959+
960+
**`:steps` defaults to `[:assemble, :tar]`, and Mix's own default is
961+
`[:assemble]`.** `Mix.Release.from_config!/4` is
962+
`Keyword.pop(opts, :steps, [:assemble])`, so a release that says nothing gets no
963+
tarball — and `Castle.unpack/1` reaches `:release_handler.unpack_release/1`,
964+
which reads `releases/<name>-<vsn>.tar.gz`. A version assembled without `:tar`
965+
can never be handed to a running deployment, and handing versions to running
966+
deployments is the whole of what Castle is for, so the default carries `:tar`.
967+
It is written out in `@default_steps` rather than taken from
968+
`Forecastle.steps/1`'s own default, so that what the `@doc` claims is a claim
969+
about this module's code.
970+
971+
**A `:steps` list that *is* given without `:tar` is honoured, with a warning at
972+
the build. Refusing it, or adding `:tar` back, would be wrong — and this is the
973+
part a future reader will otherwise re-derive incorrectly.** The reason is that
974+
*the deployment an upgrade is installed onto needs no tarball of its own.*
975+
`unpack` reads the tarball of the version being installed, never of the version
976+
running, so a base deployment shipped as a directory — a container image, most
977+
obviously — is a perfectly good Castle deployment built with
978+
`steps: [:assemble]`, and refusing it would refuse a working configuration.
979+
`:tar` is also only Mix's own way of packing one — `make_tar/1`, private to
980+
`Mix.Tasks.Release` and so not callable — and a function step in the project's
981+
list can pack a tarball itself, so the absence of the atom is not the absence of
982+
a tarball. What `customize/1` knows is that the atom is not in the
983+
list; that is what the warning says, and it says nothing further — the same rule
984+
as the ERTS guard's message, which states the divergence and asserts no cause.
985+
986+
Honouring it *silently* was the third option and is the one to keep rejecting.
987+
The cost is invisible until an operator meets it as a `bin/castle unpack` that
988+
cannot find a file, and that failure names a missing tarball rather than the
989+
release option that did not ask for one. The warning goes through
990+
`Mix.shell().error/1`, which is what Forecastle's own Windows warning uses. That
991+
is a reference to `Mix` from a module that ships inside the release, which is
992+
sound because the branch only ever runs at build time, where Mix is by
993+
definition loaded; nothing at runtime reaches it.
994+
995+
**Nothing is said about a list with no `:assemble`, and nothing should be.**
996+
`Mix.Release.validate_steps!/1` requires exactly one, refuses the release and
997+
names the option, and `Forecastle.steps/1` returns such a list untouched so that
998+
refusal can happen. A `:steps` value that is not a list is handed back for the
999+
same reason: `Forecastle.steps/1` guards on `is_list/1`, and a
1000+
`FunctionClauseError` out of it would name a module the project never mentioned,
1001+
which is precisely what `customize/1` exists to prevent. Mix validates `:steps`
1002+
*after* the lazy release function has been called — `find_release/2`, then
1003+
`from_config!/4` — so its refusal is always downstream of this.
1004+
1005+
**It changes exactly one option.** What a consumer still has to declare by hand
1006+
is listed in the `@doc`, because the alternative is finding out from a failed
1007+
upgrade: the `:appup` project key with `compilers: Mix.compilers() ++ [:appup]`,
1008+
a relup from `mix forecastle.relup` left in the project root, and
1009+
`include_executables_for: [:unix]` — Windows is unsupported and assembly only
1010+
warns — plus the optional `rel/env.sh.eex`. `:steps` is the one option whose
1011+
contents are Castle's business; the others are the project's own choices, and
1012+
one Castle set silently would be one a consumer could not see in their own
1013+
`mix.exs`.
1014+
9271015
## Layout
9281016

9291017
| Path | Purpose |
9301018
| --- | --- |
931-
| `lib/castle.ex` | The command boundary: print the outcome, or raise |
1019+
| `lib/castle.ex` | The command boundary: print the outcome, or raise — plus `customize/1`, the build-time release integration, which is not a command |
9321020
| `lib/castle/commands.ex` | The commands themselves, returning their outcome |
9331021
| `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 |
9341022
| `lib/castle/peer.ex` | The temporary VM that runs the target's own config providers, both sides of it |
@@ -1242,6 +1330,22 @@ an **unstubbed** `Castle.PeerStub` instead, which raises if it is reached, so
12421330
"refuses without starting a peer" is asserted by the guard holding rather than by
12431331
a separate look.
12441332

1333+
`test/castle/customize_test.exs` needs none of that machinery, and should not
1334+
acquire any: `customize/1` is a pure function on a keyword list, so there is no
1335+
release to build and nothing to stub. Two things about how it is written are
1336+
load bearing. **Every assertion is on the whole `:steps` list, in order** — a
1337+
case that asked whether `:steps` was present, or whether the two Castle steps
1338+
appeared somewhere in it, would pass against a splice that put them the wrong
1339+
side of `:assemble`, which is the only way to get the splice wrong. And the
1340+
missing-`:tar` decision is pinned in **both** halves: that the list is built as
1341+
the project wrote it (no `:tar` appended) and that the warning is emitted, since
1342+
a case that only looked for the warning would pass against a `customize/1` that
1343+
quietly added one. The warning is observed through `Mix.Shell.Process`, so the
1344+
file is `async: false` — Mix's shell is one setting for the whole node — and the
1345+
setup restores whatever shell was there. The cases that assert *nothing* was
1346+
said depend on that shell just as much as the one that asserts something was,
1347+
which is why the whole file is sync rather than those two cases.
1348+
12451349
What is *not* covered here is a booted release: the upgrade of a running
12461350
system, and the exit statuses `bin/castle` returns, belong to Forecastle's
12471351
`:e2e` suite ([#8](https://github.com/ausimian/castle/issues/8)), which
@@ -1314,10 +1418,17 @@ wrote, so Elixir's pipeline is still armed in the file the launcher reads.
13141418
it. It is the one limitation here that a lock cannot narrow, which is why the
13151419
filesystem half of the protocol — `publish/2` refusing rather than replacing —
13161420
has to stand on its own.
1317-
- **The public API is undocumented.** `@moduledoc` is still the generated
1318-
placeholder and there are no `@doc` or `@spec` annotations
1319-
([#11](https://github.com/ausimian/castle/issues/11)).
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.
13201428
- **The README is out of date.** It documents an `:appup` compiler and a
1321-
`mix castle.relup` task that moved to Forecastle in 0.3.0, and the release
1322-
management commands it describes on `bin/<release>` now live on `bin/castle`
1429+
`mix castle.relup` task that moved to Forecastle in 0.3.0, the release
1430+
management commands it describes on `bin/<release>` now live on `bin/castle`,
1431+
and its integration section still tells a consumer to place
1432+
`Forecastle.pre_assemble/1` and `Forecastle.post_assemble/1` around
1433+
`:assemble` by hand, which is what `Castle.customize/1` replaced
13231434
([#9](https://github.com/ausimian/castle/issues/9)).

RELEASE.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
### Added
22

3+
- `Castle.customize/1`, which is now the whole of what a project needs to make a
4+
Mix release Castle-capable. It takes the options `mix release` accepts and
5+
returns options `mix release` accepts, with the build-time steps installed
6+
around `:assemble`, so a release reads
7+
`my_app: fn -> [steps: [:assemble, :tar]] |> Castle.customize() end` and names
8+
nothing else. A release definition no longer has to mention
9+
`Forecastle.pre_assemble/1` and `Forecastle.post_assemble/1`, or know where in
10+
the list they belong: Forecastle is the build-time half that Castle brings in
11+
for itself, and what it does at assembly time can now change without every
12+
consumer's `mix.exs` changing with it. Steps a project has of its own are kept,
13+
in the order they were written.
14+
15+
Define the release as a function, as above, rather than as a plain list. Mix
16+
evaluates the whole of `mix.exs` every time it loads the project - including on
17+
the `mix deps.get` and `mix deps.compile` runs that have yet to build `castle`
18+
itself - and it calls the function only once it has been asked for a release,
19+
by which point every dependency has been compiled.
20+
21+
A release that asks for no `:steps` at all gets `[:assemble, :tar]`, which is
22+
Mix's own default plus `:tar`. That is deliberate: `:tar` is what packs the
23+
`<name>-<vsn>.tar.gz` that gets copied into a deployment's `releases` directory
24+
for `bin/castle unpack` to read, so a version built without one can be
25+
assembled and run but can never be installed onto a running system - which is
26+
the only reason to be using Castle. A `:steps` list that *is* given and has no
27+
`:tar` in it is built exactly as it was written, since it is the project's list
28+
and not Castle's to rewrite, but the build says what is missing - the
29+
alternative being an operator finding out on a deployment where there is
30+
nothing to unpack. It says it as an omission rather than as a verdict, because
31+
the system an upgrade is installed *onto* needs no tarball of its own, and a
32+
step of the project's own may be packing one.
33+
34+
Four things are still the project's to declare, and the function's
35+
documentation lists them: the `:appup` project key and the `:appup` compiler,
36+
a relup generated by `mix forecastle.relup`, `include_executables_for: [:unix]`
37+
(there is no Windows support), and a `rel/env.sh.eex` if one is wanted.
338
- The configuration of the version being installed is now expanded by running
439
*that version's* config providers, in a temporary VM booted from that
540
version's own boot script on its own emulator, rather than by running provider

0 commit comments

Comments
 (0)