core/mix/generate_release.ex

# 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