Skip to content

Commit 5923d30

Browse files
authored
Merge pull request #20 from ausimian/issue/13-relfile
fix: accept the two release files an unpacked release has
2 parents 3ec5979 + b30dd1f commit 5923d30

4 files changed

Lines changed: 192 additions & 13 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,15 @@ puts the other on the peer's code path, and asserts that the answer came from
388388
the peer's. Peer cleanup is asserted from outside: a provider records the
389389
operating system pid of the VM it ran in, and the test waits for it to go.
390390

391+
It builds the **unpacked** shape by default, and the assembled one only when a
392+
test asks for it — because the peer path is reached from `install` and `commit`,
393+
so every version it is asked to configure was unpacked from a tarball. The two
394+
shapes differ in the version directory's release files: Mix assembles one,
395+
`<name>.rel`, while unpacking leaves two, that one plus the `<name>-<vsn>.rel`
396+
`release_handler` copies in beside it. A fixture that only built the assembled
397+
shape is how `Castle.Peer` came to refuse every unpacked release as ambiguous,
398+
having been reviewed seven times against a directory the peer path never meets.
399+
391400
`Castle.Peer.materialise/2` takes `:boot_timeout` and `:resolve_timeout` for the
392401
same reason `Castle.Commands` takes the module to talk to — a deadline nothing
393402
can shorten is a deadline no test can show is enforced. Two tests give it a

lib/castle/peer.ex

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -467,9 +467,43 @@ defmodule Castle.Peer do
467467
end
468468

469469
# Mix names the release file after the release, so it is found rather than
470-
# named. A version directory holds exactly one: Mix renames the one belonging
471-
# to the `start` script and removes the rest. Listed rather than globbed,
472-
# since a release root is a path and not a pattern.
470+
# named. A version directory holds one or two of them, and which it holds says
471+
# how the version got there.
472+
#
473+
# Mix's copy is `<name>.rel` - the release file belonging to the `start`
474+
# script, renamed after the release - and a version directory as Mix
475+
# assembled it holds that one alone. Unpacking a tarball into it adds a
476+
# second: `release_handler` extracts the tarball's `<name>-<vsn>.rel`, reads
477+
# *that* to decide what the version is - `check_rel` on the way in, and the
478+
# `RELEASES` entry is written from the record it returns - and then copies it
479+
# into the version directory, "keeping this for backwards compatibility
480+
# reasons with older systools:make_tar, where there is no copy of the .rel
481+
# file in the releases/<vsn> dir. See OTP-9746." For a tarball `systools`
482+
# built the two names are the same and that copy overwrites Mix's, which is
483+
# why the comment reads as harmless. They differ here, so both survive, and
484+
# every unpacked release has two.
485+
#
486+
# Of the two, `release_handler`'s copy is the authoritative one: it is the
487+
# file the version was admitted on, and the applications and emulator version
488+
# `RELEASES` records came from those very bytes, so it is the release as the
489+
# system understands it. The two are byte-identical in practice - Forecastle
490+
# writes the tarball's copy from Mix's with `File.cp!`, the tar carries it
491+
# unchanged, and `release_handler`'s copy is a read and a write of the whole
492+
# file - and nothing here compares them, deliberately. This is a choice of
493+
# which file is authoritative rather than a tie-break between equals, so it
494+
# holds however the two differ, and a difference in bytes that cannot change
495+
# the answer is no reason to refuse an install that works.
496+
#
497+
# Anything other than those two shapes is refused, naming what it found: two
498+
# names that are not that pair are two release files rather than two copies of
499+
# one, and so is any third. The version is all the pair is recognised by, and
500+
# it is the version directory's own name - `release_handler` copies into
501+
# `releases/<Vsn>` for the `Vsn` it has just read out of the file, and Mix
502+
# assembles into `releases/<version>` - so the directory is the version, and
503+
# the release name never has to be known here.
504+
#
505+
# Listed rather than globbed, since a release root is a path and not a
506+
# pattern.
473507
defp release_file(rel_vsn_dir) do
474508
case File.ls(rel_vsn_dir) do
475509
{:ok, entries} ->
@@ -489,11 +523,34 @@ defmodule Castle.Peer do
489523
end
490524

491525
defp release_file(rel_vsn_dir, names) do
492-
{:error,
493-
"Found more than one release file in #{rel_vsn_dir} - #{Enum.join(names, ", ")} - so " <>
494-
"the emulator to evaluate its configuration with is ambiguous."}
526+
case unpacked_copy(names, Path.basename(rel_vsn_dir)) do
527+
{:ok, name} ->
528+
{:ok, Path.join(rel_vsn_dir, name)}
529+
530+
:none ->
531+
{:error,
532+
"Found more than one release file in #{rel_vsn_dir} - #{Enum.join(names, ", ")}. An " <>
533+
"unpacked version directory holds two, a release file and the copy unpacking " <>
534+
"leaves beside it, and these are not that pair - so the emulator to evaluate its " <>
535+
"configuration with is ambiguous."}
536+
end
537+
end
538+
539+
# The copy `release_handler` made, recognised only while the file it was made
540+
# from is still beside it: the one name of the two that is the other with the
541+
# version in it.
542+
defp unpacked_copy([first, second], vsn) do
543+
cond do
544+
first == copy_of(second, vsn) -> {:ok, first}
545+
second == copy_of(first, vsn) -> {:ok, second}
546+
true -> :none
547+
end
495548
end
496549

550+
defp unpacked_copy(_names, _vsn), do: :none
551+
552+
defp copy_of(name, vsn), do: "#{Path.rootname(name)}-#{vsn}.rel"
553+
497554
## sys.config, and the base it is resolved from
498555

499556
# The base is `sys.config.pristine` once there is one, and `sys.config` itself

test/castle/peer_test.exs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,86 @@ defmodule Castle.PeerTest do
247247

248248
test "reports a version directory with no release file", %{tmp_dir: root} do
249249
vsn_dir = SyntheticRelease.build(root)
250-
File.rm!(Path.join(vsn_dir, "synthetic.rel"))
250+
for path <- Path.wildcard(Path.join(vsn_dir, "*.rel")), do: File.rm!(path)
251251

252252
assert {:error, message} = Castle.Peer.materialise(vsn_dir)
253253
assert message =~ "Cannot find a release file"
254254
end
255255

256+
# Every version this path is asked to configure was unpacked from a tarball,
257+
# and an unpacked version directory holds two release files: the one Mix put
258+
# inside the tarball's version directory, and the copy `release_handler`
259+
# makes of the tarball's own `<name>-<vsn>.rel` beside it. That pair is not
260+
# ambiguous, and refusing it refused every install - which is what the rest
261+
# of this file now builds by default.
262+
test "reads the release file in a version directory that was unpacked", %{tmp_dir: root} do
263+
vsn_dir = SyntheticRelease.build(root)
264+
265+
assert Enum.sort(rel_files(vsn_dir)) == ["synthetic-1.0.0.rel", "synthetic.rel"]
266+
267+
# The two hold the same bytes, which is why nothing but a difference
268+
# planted between them can show which one was read.
269+
assert File.read!(Path.join(vsn_dir, "synthetic-1.0.0.rel")) ==
270+
File.read!(Path.join(vsn_dir, "synthetic.rel"))
271+
272+
assert Castle.Peer.materialise(vsn_dir) == {:ok, []}
273+
end
274+
275+
test "reads the copy unpacking left, and not Mix's", %{tmp_dir: root} do
276+
vsn_dir = SyntheticRelease.build(root)
277+
278+
# Mix's copy, made to name an emulator that is not there. It is the copy
279+
# `release_handler` admitted the version on that says what the version is -
280+
# `RELEASES` was written from those bytes - so that is the one to read, and
281+
# preferring this one would refuse a release that boots.
282+
File.write!(
283+
Path.join(vsn_dir, "synthetic.rel"),
284+
:io_lib.format(~c"~tp.~n", [
285+
{:release, {~c"synthetic", ~c"1.0.0"}, {:erts, ~c"0.0.0"}, []}
286+
])
287+
)
288+
289+
assert Castle.Peer.materialise(vsn_dir) == {:ok, []}
290+
end
291+
292+
test "reads the one release file a version Mix assembled has", %{tmp_dir: root} do
293+
vsn_dir = SyntheticRelease.build(root, shape: :assembled)
294+
295+
assert rel_files(vsn_dir) == ["synthetic.rel"]
296+
assert Castle.Peer.materialise(vsn_dir) == {:ok, []}
297+
end
298+
299+
test "refuses two release files that are not one release's", %{tmp_dir: root} do
300+
vsn_dir = SyntheticRelease.build(root, shape: :assembled)
301+
File.cp!(Path.join(vsn_dir, "synthetic.rel"), Path.join(vsn_dir, "other.rel"))
302+
303+
assert {:error, message} = Castle.Peer.materialise(vsn_dir)
304+
assert message =~ "Found more than one release file"
305+
assert message =~ "synthetic.rel"
306+
assert message =~ "other.rel"
307+
end
308+
309+
test "refuses a copy that belongs to another version", %{tmp_dir: root} do
310+
# What is accepted is a release file and the copy made of it *for this
311+
# version*, since the version directory's own name is the version. A name
312+
# that merely looks like such a copy is two release files.
313+
vsn_dir = SyntheticRelease.build(root, shape: :assembled)
314+
File.cp!(Path.join(vsn_dir, "synthetic.rel"), Path.join(vsn_dir, "synthetic-2.0.0.rel"))
315+
316+
assert {:error, message} = Castle.Peer.materialise(vsn_dir)
317+
assert message =~ "Found more than one release file"
318+
assert message =~ "synthetic-2.0.0.rel"
319+
end
320+
321+
test "refuses a third release file beside the pair", %{tmp_dir: root} do
322+
vsn_dir = SyntheticRelease.build(root)
323+
File.cp!(Path.join(vsn_dir, "synthetic.rel"), Path.join(vsn_dir, "other.rel"))
324+
325+
assert {:error, message} = Castle.Peer.materialise(vsn_dir)
326+
assert message =~ "Found more than one release file"
327+
assert message =~ "other.rel"
328+
end
329+
256330
test "gives up on a peer that never boots, at the deadline", %{tmp_dir: root} do
257331
vsn_dir = SyntheticRelease.build(root, config: with_providers([{PeerProviderStub, []}], []))
258332
File.write!(Path.join(vsn_dir, "preboot.boot"), "not a boot script")
@@ -783,6 +857,7 @@ defmodule Castle.PeerTest do
783857
"preboot.boot",
784858
"preboot.script",
785859
"synthetic.rel",
860+
"synthetic-1.0.0.rel",
786861
"sys.config",
787862
"sys.config.pristine",
788863
Path.join(work, "sys.config"),
@@ -978,6 +1053,8 @@ defmodule Castle.PeerTest do
9781053

9791054
defp mode(path), do: Bitwise.band(File.stat!(path).mode, 0o777)
9801055

1056+
defp rel_files(vsn_dir), do: Enum.filter(File.ls!(vsn_dir), &String.ends_with?(&1, ".rel"))
1057+
9811058
# What the peer saw, as paths relative to the version directory.
9821059
defp snapshot(path, relative_to) do
9831060
assert {:ok, [entries]} = :file.consult(to_charlist(path))

test/support/synthetic_release.ex

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ defmodule Castle.SyntheticRelease do
1515
# applications - kernel, stdlib, sasl, compiler, elixir and castle
1616
# started, anything else on the code path only, which is the shape
1717
# Forecastle's preboot script has,
18-
# * `releases/<vsn>/<name>.rel`, which is where the emulator version comes
19-
# from, and
18+
# * the release file or files, which is where the emulator version comes
19+
# from - see `write_release_files/4`, since a version directory holds one
20+
# of them or two depending on how the version got there, and
2021
# * `releases/<vsn>/sys.config`.
2122
#
2223
# The boot script is generated by `systools` from the real application
@@ -25,6 +26,9 @@ defmodule Castle.SyntheticRelease do
2526

2627
@started [:kernel, :stdlib, :sasl, :compiler, :elixir, :castle]
2728

29+
# The release name, which is what the release file or files are named after.
30+
@name "synthetic"
31+
2832
@doc """
2933
Builds a release under `root` and returns its version directory.
3034
@@ -36,6 +40,9 @@ defmodule Castle.SyntheticRelease do
3640
* `:apps` - `{app, vsn, dir}` triples to add to the code path without
3741
starting them, which is how a provider module that lives outside the
3842
applications preboot starts is reached
43+
* `:shape` - `:unpacked`, the default, or `:assembled`; which release files
44+
the version directory holds, and so how the version is to have got there.
45+
See `write_release_files/4`
3946
"""
4047
def build(root, opts \\ []) do
4148
vsn = Keyword.get(opts, :vsn, "1.0.0")
@@ -49,7 +56,7 @@ defmodule Castle.SyntheticRelease do
4956
apps = apps ++ for {app, app_vsn, dir} <- extra, do: {app, app_vsn, :none, dir}
5057

5158
make_boot_script(vsn_dir, vsn, apps, link_apps(lib, apps, extra))
52-
write_rel(Path.join(vsn_dir, "synthetic.rel"), vsn, apps)
59+
write_release_files(vsn_dir, vsn, apps, Keyword.get(opts, :shape, :unpacked))
5360
write_sys_config(vsn_dir, opts)
5461

5562
vsn_dir
@@ -159,8 +166,10 @@ defmodule Castle.SyntheticRelease do
159166
end
160167

161168
# `systools` writes the `.rel` it was given back out beside the boot script,
162-
# and a version directory holds exactly one release file, so preboot's goes as
163-
# soon as it has been used - which is what Mix does with its own.
169+
# so preboot's goes as soon as it has been used - which is what Mix does with
170+
# the release file of every boot script but `start`. Left there it would be a
171+
# release file no release has, and one `Castle.Peer` would be right to refuse
172+
# to choose between.
164173
defp make_boot_script(vsn_dir, vsn, apps, paths) do
165174
path = Path.join(vsn_dir, "preboot")
166175
write_rel(path <> ".rel", vsn, apps)
@@ -178,14 +187,41 @@ defmodule Castle.SyntheticRelease do
178187
File.rm!(path <> ".rel")
179188
end
180189

190+
# The release file, under the name or names the shape being built has.
191+
#
192+
# `:assembled` is what `mix release` leaves: one file, `<name>.rel`, the one
193+
# belonging to the `start` script, which Mix renames after the release.
194+
#
195+
# `:unpacked` is that plus `<name>-<vsn>.rel`, and is what
196+
# `release_handler:unpack_release/1` leaves behind - so it is the shape the
197+
# peer path meets, every version it is asked to configure having been unpacked
198+
# from a tarball. The second name is the copy Forecastle puts at the top of
199+
# the tarball's `releases` directory, which `release_handler` reads to admit
200+
# the version and then copies in here, for compatibility with tarballs that
201+
# carried no release file inside the version directory. Made by copying,
202+
# because copying is how the two come to hold the same bytes in a real
203+
# release: `File.cp!` at assembly, and a read and a write of the whole file on
204+
# unpack.
205+
#
206+
# The unpacked shape is the default because it is the one reality presents. A
207+
# test that means the assembled shape asks for it.
208+
defp write_release_files(vsn_dir, vsn, apps, shape) do
209+
assembled = Path.join(vsn_dir, "#{@name}.rel")
210+
write_rel(assembled, vsn, apps)
211+
212+
if shape == :unpacked do
213+
File.cp!(assembled, Path.join(vsn_dir, "#{@name}-#{vsn}.rel"))
214+
end
215+
end
216+
181217
defp write_rel(path, vsn, apps) do
182218
entries =
183219
for app_spec <- apps do
184220
{elem(app_spec, 0), to_charlist(elem(app_spec, 1)), elem(app_spec, 2)}
185221
end
186222

187223
rel =
188-
{:release, {~c"synthetic", to_charlist(vsn)}, {:erts, :erlang.system_info(:version)},
224+
{:release, {to_charlist(@name), to_charlist(vsn)}, {:erts, :erlang.system_info(:version)},
189225
entries}
190226

191227
File.write!(path, :io_lib.format(~c"~tp.~n", [rel]))

0 commit comments

Comments
 (0)