You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: AGENTS.md
+117-6Lines changed: 117 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -919,16 +919,104 @@ re-raises in the calling VM, and only that VM exits. `Castle.Commands` holds
919
919
the operations themselves, returning their outcome instead of acting on the
920
920
process, which is what makes them testable.
921
921
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
+
922
928
Forecastle is what arranges for these to be reachable: it leaves the
923
929
configuration Mix wrote alone, adds a `:preboot` script that starts `:castle`,
924
930
and writes the `env.sh` fragment and `bin/castle` wrapper that call into this
925
931
module.
926
932
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
+
927
1015
## Layout
928
1016
929
1017
| Path | Purpose |
930
1018
| --- | --- |
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 |
932
1020
|`lib/castle/commands.ex`| The commands themselves, returning their outcome |
933
1021
|`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 |
934
1022
|`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
1242
1330
"refuses without starting a peer" is asserted by the guard holding rather than by
1243
1331
a separate look.
1244
1332
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
+
1245
1349
What is *not* covered here is a booted release: the upgrade of a running
1246
1350
system, and the exit statuses `bin/castle` returns, belong to Forecastle's
1247
1351
`: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.
1314
1418
it. It is the one limitation here that a lock cannot narrow, which is why the
1315
1419
filesystem half of the protocol — `publish/2` refusing rather than replacing —
1316
1420
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
0 commit comments