# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.
defmodule Mix.Tasks.AntikytheraCore.GenerateRelease do
@shortdoc "Generates a new release tarball for antikythera instance using `mix release`"
@moduledoc """
#{@shortdoc}.
Notes on the implementation details of this task:
- Release generation is basically done under `rel_erlang-*/`.
During tests (i.e. when `ANTIKYTHERA_COMPILE_ENV=local`), files are generated under `rel_local_erlang-*/`.
- Erlang/OTP major version number is included in the path in order to distinguish artifacts generated by different OTP releases.
No binary compatibility is maintained by major version release of Erlang/OTP due to precompilation of Elixir's regex sigils.
For more details see [documentation for Regex module](https://hexdocs.pm/elixir/Regex.html#module-precompilation).
- Generated releases are placed under `rel(_local)_erlang-*/<antikythera_instance>/releases/`.
- If no previous releases found, this task generates a new release from scratch (i.e. without relup).
If any previous releases exist, relup file to upgrade from the latest existing release to the current release is also generated.
- Making a new release tarball consists of the following steps:
- Preparation
- Template files (`rel/*.eex`) used by `mix release` to create `env.sh` and `vm.args`.
- Configuration for `mix release` provided by `#{__MODULE__}.config_for_mix_release/0`.
- Release generation
- Ensure that source files are compiled and `<antikythera_instance>.app` is up-to-date.
- If a previous release is found, generate `*.appup` and `relup` files.
- `*.appup` files are generated before assembling.
- `relup` file is generated after assembling.
- Generate a new release by `:assemble` step of `mix release`.
- Generate `RELEASES` file, which is required by `:release_handler`.
- Cleanup
- Make a tarball by `:tar` step of `mix release`, then move it to the version directory.
"""
use Mix.Task
prefix = if Antikythera.Env.compile_env() == :local, do: "rel_local", else: "rel"
@release_output_dir_basename prefix <> "_erlang-#{System.otp_release()}"
@antikythera_repo_rel_templates_path Path.expand(Path.join([__DIR__, "..", "..", "rel"]))
@impl Mix.Task
def run(_args) do
Mix.Project.get!()
config = Mix.Project.config()
instance_app = config[:app]
if instance_app == :antikythera do
Mix.raise("Application name of an antikythera instance must not be `:antikythera`")
end
if config[:releases][instance_app] == nil do
Mix.raise("""
Configuration for `mix release #{instance_app}` is required.
See `#{__MODULE__}.config_for_mix_release/0` for more details.
""")
end
Mix.Task.run("compile")
# <antikythera_instance>.app might be stale in some situations
# (e.g. when antikythera instance's version is updated by an empty commit).
if read_app_version_from_app_file(instance_app) != config[:version] do
Mix.Task.rerun("compile.app", ["--force"])
end
release_name_str = Atom.to_string(instance_app)
:ok = Mix.Task.run("release", [release_name_str])
end
@doc """
Generates a release configuration required by `mix #{Mix.Task.task_name(__MODULE__)}`.
A release name for the configuration must be identical to the antikythera instance name.
The following is an example configuration for `antikythera_instance_example`:
```
def project do
[
releases: [
antikythera_instance_example: &#{__MODULE__}.config_for_mix_release/0,
...
]
]
end
```
"""
def config_for_mix_release() do
# Release name to generate is identical to the instance app name.
release_name_str = Atom.to_string(instance_app())
[
path: Path.join(@release_output_dir_basename, release_name_str),
include_executables_for: [:unix],
rel_templates_path: @antikythera_repo_rel_templates_path,
steps: [&before_assemble/1, :assemble, &after_assemble/1, :tar, &move_tarball/1],
# `AntikytheraCore.Release.Appup` detects changes of modules including their metadata.
# To include module's metadata in a BEAM file, we set `strip_beams: false` here.
strip_beams: false,
# Elixir checks the release existence by looking for only a directory for its version,
# but we should check the release existence by looking for its release package (tarball).
# To skip Elixir's check and proceed to our check, we set `overwrite: true` here.
overwrite: true
]
end
defp instance_app(), do: Mix.Project.config()[:app]
defp before_assemble(%Mix.Release{version: version} = release) do
existing_versions = get_existing_release_versions(release)
cond do
existing_versions == [] ->
Mix.shell().info("Generating release #{version} without upgrade ...")
release
version in existing_versions ->
Mix.shell().info("Version '#{version}' already exists.")
%Mix.Release{release | steps: []}
true ->
# Only supporting upgrade from the latest existing version.
latest_existing = Enum.max(existing_versions)
if latest_existing >= version do
Mix.raise(
"Latest existing version (#{latest_existing}) " <>
"must precede the current version (#{version})!"
)
end
Mix.shell().info(
"Generating release #{version} with upgrade instruction from #{latest_existing} ..."
)
generate_appup_files(release, latest_existing)
put_latest_existing_version(release, latest_existing)
end
end
defp get_existing_release_versions(%Mix.Release{} = release) do
case File.ls(releases_dir(release)) do
{:error, _} ->
[]
{:ok, release_version_dirs_and_other_files} ->
Enum.filter(release_version_dirs_and_other_files, &version_with_tarball?(&1, release))
end
end
defp version_with_tarball?(version, %Mix.Release{} = release) do
with true <- Antikythera.VersionStr.valid?(version) do
release
|> tarball_path(version)
|> File.exists?()
end
end
defp generate_appup_files(%Mix.Release{} = release, prev_release_version) do
prev_app_versions = read_app_versions_from_rel_file(release, prev_release_version)
# We don't support upgrading Erlang/Elixir's core applications.
upgradable_apps = [instance_app() | Mix.Project.deps_apps()]
Enum.each(upgradable_apps, &generate_appup_if_needed(&1, prev_app_versions[&1], release))
end
defp generate_appup_if_needed(_app, nil, _release), do: :ok
defp generate_appup_if_needed(app, prev_version, release) do
case read_app_version_from_app_file(app) do
^prev_version ->
:ok
version ->
dir = Application.app_dir(app)
prev_dir = lib_dir_for_app(release, app, prev_version)
:ok = AntikytheraCore.Release.Appup.make(app, prev_version, version, prev_dir, dir)
Mix.shell().info("Generated #{app}.appup.")
end
end
defp after_assemble(%Mix.Release{} = release) do
create_RELEASES(release)
case get_latest_existing_version(release) do
nil ->
release
prev_version ->
make_relup(release, prev_version)
release
end
end
# credo:disable-for-next-line Credo.Check.Readability.FunctionNames
defp create_RELEASES(%Mix.Release{version: version} = release) do
releases_dir = String.to_charlist(releases_dir(release))
rel_file = rel_file_path(release, version)
:ok = :release_handler.create_RELEASES('.', releases_dir, rel_file, [])
Mix.shell().info("Generated RELEASES.")
end
defp make_relup(
%Mix.Release{version: version, version_path: version_path} = release,
prev_version
) do
current = make_name_for_relup(release, version)
up_from = make_name_for_relup(release, prev_version)
code_paths =
Enum.map(
read_app_versions_from_rel_file(release, version) ++
read_app_versions_from_rel_file(release, prev_version),
fn {app, app_version} -> code_path_for_app(release, app, app_version) end
)
outdir = String.to_charlist(version_path)
:ok = :systools.make_relup(current, [up_from], [up_from], path: code_paths, outdir: outdir)
Mix.shell().info("Generated relup from #{prev_version} to #{version}.")
end
defp move_tarball(%Mix.Release{name: name, path: path, version: version} = release) do
src = Path.join(path, "#{name}-#{version}.tar.gz")
dst = tarball_path(release, version)
File.copy!(src, dst)
File.rm!(src)
release
end
defp read_app_version_from_app_file(app) do
app
|> Application.app_dir()
|> AntikytheraCore.Version.read_from_app_file(app)
end
defp releases_dir(%Mix.Release{path: path}), do: Path.join(path, "releases")
defp tarball_path(%Mix.Release{name: name} = release, version) do
release
|> releases_dir()
|> Path.join(version)
|> Path.join("#{name}.tar.gz")
end
defp rel_file_path(%Mix.Release{name: name} = release, version) do
release
|> releases_dir()
|> Path.join(version)
|> Path.join("#{name}.rel")
|> String.to_charlist()
end
defp read_app_versions_from_rel_file(%Mix.Release{name: name} = release, version) do
release_name_chars = Atom.to_charlist(name)
{:ok, [{:release, {^release_name_chars, _}, {:erts, _}, apps}]} =
release
|> rel_file_path(version)
|> :file.consult()
Keyword.new(apps, fn
{app, app_version} -> {app, List.to_string(app_version)}
{app, app_version, _} -> {app, List.to_string(app_version)}
end)
end
defp make_name_for_relup(%Mix.Release{} = release, version) do
release
|> rel_file_path(version)
|> :filename.rootname('.rel')
end
defp lib_dir_for_app(%Mix.Release{path: path}, app, app_version) do
Path.join([path, "lib", "#{app}-#{app_version}"])
end
defp code_path_for_app(%Mix.Release{} = release, app, app_version) do
release
|> lib_dir_for_app(app, app_version)
|> Path.join("ebin")
|> String.to_charlist()
end
defp put_latest_existing_version(%Mix.Release{options: options} = release, latest_existing) do
%Mix.Release{
release
| options: Keyword.put(options, :latest_existing, latest_existing)
}
end
defp get_latest_existing_version(%Mix.Release{options: options}) do
Keyword.get(options, :latest_existing)
end
end