Skip to content

Commit 0c135e6

Browse files
authored
Merge pull request #31 from ausimian/issue/29-error-messages
Tighten operator-facing diagnostics
2 parents efe2d8a + c8a2ac5 commit 0c135e6

15 files changed

Lines changed: 938 additions & 473 deletions

AGENTS.md

Lines changed: 184 additions & 95 deletions
Large diffs are not rendered by default.

RELEASE.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Forecastle 1.x and Elixir 1.18 or later.
2323

2424
- Release-management commands now raise on refusal or a returned OTP error, so
2525
`bin/castle` exits non-zero. Successful command output is unchanged.
26+
- Operator-facing errors and warnings are shorter, distinguish preflight
27+
refusals from attempted operations, report whether the configuration step ran,
28+
and preserve paths, reasons and recovery steps.
2629
- `make_releases/0` now derives the release directory from the running emulator
2730
instead of the current working directory.
2831
- The minimum supported Elixir version is now 1.18.
@@ -41,9 +44,17 @@ Forecastle 1.x and Elixir 1.18 or later.
4144
the shared Erlang installation.
4245
- Give actionable recovery instructions when `:release_handler` booted without
4346
an accepted `RELEASES` file.
44-
- Fix the wording when a restart install is refused because something unusual,
45-
such as a named pipe, is already at the path Castle uses for its restart
46-
marker. The message used to read "a other".
47+
- Clarify restart-marker failures, including whether the configuration step ran,
48+
whether `new_start_erl.data` was removed or already absent, and when release
49+
records, `castle-restart-pending` and `new_start_erl.data` must be inspected
50+
before restarting, retrying or removing a marker. Also explain the `unpacked`
51+
record left by an unfinished install and fix the former "a other" wording for
52+
named pipes and similar marker-path conflicts.
53+
- Report a failed commit as possibly partial instead of claiming the version was
54+
not made permanent. `:release_handler` writes `releases/start_erl.data` before
55+
it updates the release record, so an error can leave the file that selects the
56+
boot version already naming the target; the message now says so and directs
57+
the operator to inspect release state.
4758
- Report restart installs without raising `CaseClauseError`.
4859
- Return an empty release list without raising `Enum.EmptyError`.
4960
- Report `RELEASES` read and write errors instead of raising `MatchError`.

lib/castle.ex

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,10 @@ defmodule Castle do
9999
# to unpack - and that failure names a missing file rather than the release
100100
# option that did not ask for it.
101101
#
102-
# It states the omission and stops. Nothing here knows the release was meant
103-
# to be distributed: the system an upgrade is installed *onto* needs no
104-
# tarball of its own, and `:tar` is Mix's own way of packing one - its
102+
# It states the omission and preserves the valid qualifications. Nothing here
103+
# knows the release was meant to be distributed: the system an upgrade is
104+
# installed *onto* needs no tarball of its own, and `:tar` is Mix's own way of
105+
# packing one - its
105106
# `make_tar/1` is private to `Mix.Tasks.Release` - rather than the only way,
106107
# so a function step in the list may be packing one itself.
107108
#
@@ -120,18 +121,15 @@ defmodule Castle do
120121
# acknowledged the counterexample in a trailing sentence without retracting
121122
# either claim, which is the worst of both: a definite diagnosis on the error
122123
# channel sending an operator to investigate a packaging failure that may not
123-
# exist. Say what was seen, say what follows *unless* something else packs it,
124-
# and stop.
124+
# exist. Say what was seen, say what follows unless something else packs it,
125+
# and keep the valid tarball-free base-deployment case explicit.
125126
defp warn_missing_tar(steps) do
126127
if :assemble in steps and :tar not in steps do
127128
Mix.shell().error(
128-
"warning: Castle.customize/1 was given a :steps list with no :tar step. " <>
129-
"Unless a step of your own packs one, this release will not produce the " <>
130-
"<name>-<vsn>.tar.gz that is copied into a deployment's releases " <>
131-
"directory for bin/castle unpack to read - and bin/castle unpack is how " <>
132-
"a version is installed onto a running system. Add :tar after :assemble " <>
133-
"if this version is meant to be installed anywhere. A deployment that is " <>
134-
"only ever upgraded *from* needs no tarball of its own."
129+
"warning: release :steps has no :tar step. Add :tar after :assemble to create " <>
130+
"the <name>-<vsn>.tar.gz used by bin/castle unpack. No change is needed if " <>
131+
"another step creates the archive. A deployment used only as an upgrade base needs " <>
132+
"no tarball of its own."
135133
)
136134
end
137135
end

lib/castle/commands.ex

Lines changed: 234 additions & 162 deletions
Large diffs are not rendered by default.

lib/castle/deployment.ex

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,20 @@ defmodule Castle.Deployment do
99
# the real rule over substituted inputs, the way the release-record check
1010
# exercises the real rule over a substituted `which_releases/0`.
1111
#
12-
# Two roots, for the ERTS guard, and three filesystem operations: the `stat/1`
13-
# that guard falls back to, and the `read/1` and `rm/1` that settle the restart
14-
# marker's ownership on the way out of a failed install. All three are here for
15-
# one reason - the answers that matter are the *failing* ones, and every way of
16-
# arranging a failing `read` or `rm` from a fixture is a mode that root and some
17-
# filesystems ignore. See `stat/1`.
12+
# Two roots, for the ERTS guard, and four filesystem operations: the `stat/1`
13+
# that guard falls back to, the `lstat/1` that classifies the restart-marker
14+
# path before configuration changes, and the `read/1` and `rm/1` that settle
15+
# marker ownership after a failed install. All four are here for one reason -
16+
# the answers that matter are the *failing* ones, and every fixture that relies
17+
# on a permission failure depends on a mode that root and some filesystems
18+
# ignore. See `stat/1`.
1819
#
1920
# **This is not a general filesystem seam and must not become one.** The
2021
# primitives that *publish* the marker - `Castle.Peer.work_dir/1`,
2122
# `write_private/2` and `publish/2` - are deliberately called directly and not
2223
# through here: what they guarantee is the point of them, and a stub would
23-
# prove nothing about it. What these two carry is the opposite kind of thing,
24-
# an outcome Castle has to have something to say about and no way to cause.
24+
# prove nothing about it. These operations carry the opposite kind of thing:
25+
# outcomes Castle has to describe and no reliable way to cause in a fixture.
2526

2627
@doc """
2728
The root `:release_handler` resolves its own relative paths against.
@@ -81,6 +82,18 @@ defmodule Castle.Deployment do
8182
@spec stat(Path.t()) :: {:ok, File.Stat.t()} | {:error, File.posix()}
8283
def stat(path), do: File.stat(path)
8384

85+
@doc """
86+
Inspects a path without following its final symbolic link.
87+
88+
The restart-marker preflight has to distinguish a missing path, a regular
89+
marker and some other occupant. A failure to inspect is a fourth state, but it
90+
cannot be produced reliably with permissions in a test. Keeping this read here
91+
lets the lifecycle test establish that Castle refuses before configuration or
92+
`install_release/1` is reached.
93+
"""
94+
@spec lstat(Path.t()) :: {:ok, File.Stat.t()} | {:error, File.posix()}
95+
def lstat(path), do: File.lstat(path)
96+
8497
@doc """
8598
Reads a file, for deciding whether the restart marker is still this attempt's.
8699

lib/castle/file_reason.ex

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule Castle.FileReason do
2+
@moduledoc false
3+
4+
@spec format(term()) :: String.t()
5+
def format(reason) when is_atom(reason) do
6+
formatted = reason |> :file.format_error() |> to_string()
7+
8+
if contains_identity?(formatted, reason) do
9+
formatted
10+
else
11+
"#{inspect(reason)} (#{formatted})"
12+
end
13+
end
14+
15+
def format(reason), do: inspect(reason)
16+
17+
defp contains_identity?(formatted, reason) do
18+
identity = reason |> Atom.to_string() |> Regex.escape()
19+
Regex.match?(Regex.compile!("(?<![[:alnum:]_])#{identity}(?![[:alnum:]_])"), formatted)
20+
end
21+
end

lib/castle/peer.ex

Lines changed: 45 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule Castle.Peer do
22
@moduledoc false
33

4+
alias Castle.FileReason
5+
46
# Materialises the configuration of the release being upgraded *to*, by
57
# running that release's own `Config.Provider` pipeline in a temporary VM
68
# booted from that release's own boot script and code.
@@ -468,7 +470,7 @@ defmodule Castle.Peer do
468470
{:error, "Cannot read #{path} as a release file. It holds #{inspect(terms)}."}
469471

470472
{:error, reason} ->
471-
{:error, "Cannot read #{path}. #{format_error(reason)}"}
473+
{:error, "Cannot read #{path}. #{FileReason.format(reason)}"}
472474
end
473475
end
474476

@@ -516,7 +518,7 @@ defmodule Castle.Peer do
516518
release_file(rel_vsn_dir, Enum.filter(entries, &String.ends_with?(&1, ".rel")))
517519

518520
{:error, reason} ->
519-
{:error, "Cannot list #{rel_vsn_dir}. #{format_error(reason)}"}
521+
{:error, "Cannot list #{rel_vsn_dir}. #{FileReason.format(reason)}"}
520522
end
521523
end
522524

@@ -529,16 +531,17 @@ defmodule Castle.Peer do
529531
end
530532

531533
defp release_file(rel_vsn_dir, names) do
532-
case unpacked_copy(names, Path.basename(rel_vsn_dir)) do
534+
vsn = Path.basename(rel_vsn_dir)
535+
536+
case unpacked_copy(names, vsn) do
533537
{:ok, name} ->
534538
{:ok, Path.join(rel_vsn_dir, name)}
535539

536540
:none ->
537541
{:error,
538-
"Found more than one release file in #{rel_vsn_dir} - #{Enum.join(names, ", ")}. An " <>
539-
"unpacked version directory holds two, a release file and the copy unpacking " <>
540-
"leaves beside it, and these are not that pair - so the emulator to evaluate its " <>
541-
"configuration with is ambiguous."}
542+
"Cannot determine the release file in #{rel_vsn_dir}: found " <>
543+
"#{Enum.join(names, ", ")}. Expected one file or an unpacked " <>
544+
"<name>.rel/<name>-#{vsn}.rel pair."}
542545
end
543546
end
544547

@@ -640,13 +643,14 @@ defmodule Castle.Peer do
640643
end
641644
end
642645

643-
## Writing a file that holds configuration
646+
## Private staging primitives
644647
#
645-
# Every file this module creates holds a release's configuration - the base,
646-
# and the scratch copy the providers are resolved into - so none of them may be
647-
# readable by anyone the `sys.config` it came from or is about to become would
648-
# not let read it. An operator who restricts that file has said something, and
649-
# it has to hold for the copies too.
648+
# The configuration path uses these for the base and scratch files, and the
649+
# restart protocol uses them for its marker. The primitives enforce filesystem
650+
# facts only; their callers say which lifecycle operation was interrupted and
651+
# what had already changed. Configuration files must not become readable by
652+
# anyone `sys.config` excludes, and the same owner-only staging also protects a
653+
# marker from being replaced before publication.
650654
#
651655
# What protects them is the *directory* they are made in, and it has to be,
652656
# because OTP cannot create a file with a mode. `:file.open/2`'s modes say how
@@ -822,18 +826,14 @@ defmodule Castle.Peer do
822826
case File.ls(path) do
823827
{:ok, []} -> :ok
824828
{:ok, entries} -> {:error, occupied(path, entries)}
825-
{:error, reason} -> {:error, "Cannot list #{path}. #{format_error(reason)}"}
829+
{:error, reason} -> {:error, "Cannot list #{path}. #{FileReason.format(reason)}"}
826830
end
827831
end
828832

829833
defp occupied(path, entries) do
830-
"Cannot assemble configuration in #{path}. Castle had just created that " <>
831-
"directory and written nothing to it, and it already holds " <>
832-
"#{Enum.join(entries, ", ")} - so something else can write where this " <>
833-
"release's configuration is about to be, and a name planted there is a name " <>
834-
"the configuration could be written through. Nothing has been written and " <>
835-
"the directory has been removed. Check the umask the release runs under: a " <>
836-
"new directory has to be private to the account doing the install."
834+
"Cannot use #{path}: newly created directory contains #{Enum.join(entries, ", ")}. " <>
835+
"Castle removed it without writing to it. " <>
836+
"Check that the release umask creates owner-only directories."
837837
end
838838

839839
@doc false
@@ -866,7 +866,7 @@ defmodule Castle.Peer do
866866
def create_exclusive(path) do
867867
case File.open(path, [:write, :exclusive, :raw]) do
868868
{:ok, handle} -> {:ok, handle}
869-
{:error, reason} -> {:error, "Cannot create #{path}. #{format_error(reason)}"}
869+
{:error, reason} -> {:error, "Cannot create #{path}. #{FileReason.format(reason)}"}
870870
end
871871
end
872872

@@ -898,14 +898,14 @@ defmodule Castle.Peer do
898898
defp written(handle, path, bytes) do
899899
case :file.write(handle, bytes) do
900900
:ok -> :ok
901-
{:error, reason} -> {:error, "Cannot write #{path}. #{format_error(reason)}"}
901+
{:error, reason} -> {:error, "Cannot write #{path}. #{FileReason.format(reason)}"}
902902
end
903903
end
904904

905905
defp closed(handle, path) do
906906
case File.close(handle) do
907907
:ok -> :ok
908-
{:error, reason} -> {:error, "Cannot write #{path}. #{format_error(reason)}"}
908+
{:error, reason} -> {:error, "Cannot write #{path}. #{FileReason.format(reason)}"}
909909
end
910910
end
911911

@@ -915,14 +915,14 @@ defmodule Castle.Peer do
915915
case File.ln(staging, path) do
916916
:ok -> :ok
917917
{:error, :eexist} -> :taken
918-
{:error, reason} -> {:error, "Cannot write #{path}. #{format_error(reason)}"}
918+
{:error, reason} -> {:error, "Cannot write #{path}. #{FileReason.format(reason)}"}
919919
end
920920
end
921921

922922
defp carry_mode(from, to) do
923923
case File.stat(from) do
924924
{:ok, %File.Stat{mode: mode}} -> chmod(to, Bitwise.band(mode, 0o7777))
925-
{:error, reason} -> {:error, "Cannot read #{from}. #{format_error(reason)}"}
925+
{:error, reason} -> {:error, "Cannot read #{from}. #{FileReason.format(reason)}"}
926926
end
927927
end
928928

@@ -1082,15 +1082,15 @@ defmodule Castle.Peer do
10821082

10831083
## Files
10841084

1085-
# Whether a file holding configuration may be created here at all: nothing
1086-
# granted to group or other, so there is no path through this directory for
1087-
# anyone else to open what is inside it by. Checked on every creation rather
1088-
# than assumed of the working directory, because assuming it at the call sites
1089-
# is the mistake this whole arrangement exists to make impossible.
1085+
# Whether a private staging file may be created here at all: nothing granted to
1086+
# group or other, so there is no path through this directory for anyone else to
1087+
# open what is inside it by. Checked on every creation rather than assumed of
1088+
# the working directory, because assuming it at the call sites is the mistake
1089+
# this whole arrangement exists to make impossible.
10901090
defp private_dir(path) do
10911091
case File.stat(path) do
10921092
{:ok, %File.Stat{mode: mode}} -> private_mode(path, Bitwise.band(mode, 0o7777))
1093-
{:error, reason} -> {:error, "Cannot read #{path}. #{format_error(reason)}"}
1093+
{:error, reason} -> {:error, "Cannot read #{path}. #{FileReason.format(reason)}"}
10941094
end
10951095
end
10961096

@@ -1099,18 +1099,16 @@ defmodule Castle.Peer do
10991099
:ok
11001100
else
11011101
{:error,
1102-
"Cannot write in #{path}, whose mode is 0#{Integer.to_string(mode, 8)}. A file holding " <>
1103-
"a release's configuration is only ever created in a directory Castle has made " <>
1104-
"owner-only, so that nothing can open it while it is being written. Castle chmods " <>
1105-
"that directory to 0700 as it creates it, and a wider mode than that means the " <>
1106-
"filesystem holding the release did not take it."}
1102+
"Cannot write in #{path}: directory mode " <>
1103+
"0#{Integer.to_string(mode, 8)} allows access beyond its owner. Castle requires " <>
1104+
"owner-only mode 0700; check the release filesystem and permissions."}
11071105
end
11081106
end
11091107

11101108
defp mkdir(path) do
11111109
case File.mkdir(path) do
11121110
:ok -> :ok
1113-
{:error, reason} -> {:error, "Cannot create #{path}. #{format_error(reason)}"}
1111+
{:error, reason} -> {:error, "Cannot create #{path}. #{FileReason.format(reason)}"}
11141112
end
11151113
end
11161114

@@ -1124,7 +1122,7 @@ defmodule Castle.Peer do
11241122
defp read(path) do
11251123
case File.read(path) do
11261124
{:ok, contents} -> {:ok, contents}
1127-
{:error, reason} -> {:error, "Cannot read #{path}. #{format_error(reason)}"}
1125+
{:error, reason} -> {:error, "Cannot read #{path}. #{FileReason.format(reason)}"}
11281126
end
11291127
end
11301128

@@ -1137,31 +1135,31 @@ defmodule Castle.Peer do
11371135
{:error, "Cannot read #{path}: expected one configuration term, found #{length(terms)}."}
11381136

11391137
{:error, reason} ->
1140-
{:error, "Cannot read #{path}. #{format_error(reason)}"}
1138+
{:error, "Cannot read #{path}. #{FileReason.format(reason)}"}
11411139
end
11421140
end
11431141

11441142
defp write(path, contents) do
11451143
case File.write(path, contents) do
11461144
:ok -> :ok
1147-
{:error, reason} -> {:error, "Cannot write #{path}. #{format_error(reason)}"}
1145+
{:error, reason} -> {:error, "Cannot write #{path}. #{FileReason.format(reason)}"}
11481146
end
11491147
end
11501148

11511149
defp chmod(path, mode) do
11521150
case File.chmod(path, mode) do
1153-
:ok -> :ok
1154-
{:error, reason} -> {:error, "Cannot set the mode of #{path}. #{format_error(reason)}"}
1151+
:ok ->
1152+
:ok
1153+
1154+
{:error, reason} ->
1155+
{:error, "Cannot set the mode of #{path}. #{FileReason.format(reason)}"}
11551156
end
11561157
end
11571158

11581159
defp rename(source, destination) do
11591160
case File.rename(source, destination) do
11601161
:ok -> {:ok, []}
1161-
{:error, reason} -> {:error, "Cannot write #{destination}. #{format_error(reason)}"}
1162+
{:error, reason} -> {:error, "Cannot write #{destination}. #{FileReason.format(reason)}"}
11621163
end
11631164
end
1164-
1165-
defp format_error(reason) when is_atom(reason), do: :file.format_error(reason)
1166-
defp format_error(reason), do: inspect(reason)
11671165
end

0 commit comments

Comments
 (0)