lib/distillery/plugins/modify_relup.ex

defmodule Releases.Plugin.ModifyRelup do
  @moduledoc """
    Distillery plugin to auto-patch the relup file when building upgrades.

    To be able use this plugin, it must be added in the `rel/config.exs`
    distillery config as plugin like this:

    ```
    environment :prod do
      ..
      plugin Releases.Plugin.ModifyRelup
    end
    ```
  """
  use Distillery.Releases.Plugin
  alias Edeliver.Relup.Instructions

  def before_assembly(_, _), do: nil

  def after_assembly(release = %Release{is_upgrade: true, version: version, name: name, profile: %Distillery.Releases.Profile{output_dir: output_dir}}, _) do
    case System.get_env "SKIP_RELUP_MODIFICATIONS" do
      "true" -> nil
      _ ->
        info "Modifying relup file..."
        relup_file = Path.join([output_dir, "releases", version, "relup"])
        relup_modification_module = case get_relup_modification_module(release) do
          [module] -> module
          modules = [_|_] ->
            Mix.raise "Found multiple modules implementing behaviour Edeliver.Relup.DefaultModification:\n#{inspect modules}\nPlease use the --relup-mod=<module-name> option."
        end
        debug "Using #{inspect relup_modification_module} module for relup modification."
        if File.exists?(relup_file) do
          case :file.consult(to_charlist(relup_file)) do
            {:ok, [{up_version,
                    [{down_version, up_description, up_instructions}],
                    [{down_version, down_description, down_instructions}]
                  }]} when is_atom(relup_modification_module) ->
              instructions = %Instructions{
                up_instructions: up_instructions,
                down_instructions: down_instructions,
                up_version: List.to_string(up_version),
                down_version: List.to_string(down_version),
                changed_modules: changed_modules(up_instructions, name, String.to_charlist(version))
              }
              %Instructions{
                up_instructions: up_instructions,
                down_instructions: down_instructions,
              } = relup_modification_module.modify_relup(instructions, release)
              relup = {up_version,
                [{down_version, up_description, up_instructions}],
                [{down_version, down_description, down_instructions}]
              }
              write_relup(relup, relup_file)
            error ->
              debug "Error when loading relup file: #{:io_lib.format('~p~n', [error])}"
              Mix.raise "Failed to load relup file from #{relup_file}\nYou can skip this step using the --skip-relup-mod option."
          end
        end
    end
    nil
  end
  def after_assembly(_, _), do: nil

  def before_package(_, _), do: nil

  def after_package(_, _), do: nil

  def after_cleanup(_, _), do: nil

  defp changed_modules([{:load_object_code, {name, version, modules}}|_], name, version), do: modules
  defp changed_modules([_|rest], name, version), do: changed_modules(rest, name, version)
  defp changed_modules(_up_instructions, _name, _version), do: []

  defp write_relup(relup, relup_file) do
    case :file.open(relup_file, [:write]) do
      {:ok, fd} ->
        :io.format(fd, "~p.~n", [relup])
        :file.close(fd)
      {:error, reason} ->
         Mix.raise "Failed to save relup file to #{relup_file}. Reason: #{inspect reason}"
    end
  end

  defp get_relup_modification_module(release = %Release{}) do
    case System.get_env "RELUP_MODIFICATION_MODULE" do
      module = <<_,_::binary>> ->
        module = String.to_atom(module)
        if Code.ensure_loaded?(module) do
          [module]
        else
            Mix.raise "Module used by the --relup-mod=#{inspect module} option cannot be found."
        end
      _ -> # find module in path
        Path.wildcard("**/*/ebin/**/*.{beam}")
        |> Stream.map(fn path ->
          {:ok, {mod, chunks}} = :beam_lib.chunks('#{path}', [:attributes])
          {mod, get_in(chunks, [:attributes, :behaviour])}
        end)
        |> Stream.filter(fn {module, behaviours} ->
          is_list(behaviours) &&
          Edeliver.Relup.Modification in behaviours &&
          Code.ensure_loaded?(module) &&
          module.usable?(release)
        end)
        |> Stream.map(fn {module, _} ->
          {module, module.priority}
        end)
        |> Enum.uniq()
        |> Enum.sort(fn {module_a, priority_a}, {module_b, priority_b} ->
          cond do
            module_a == module_b -> true
            String.starts_with?(Atom.to_string(module_a), "Elixir.Edeliver.Relup.") -> false # prefer custom modules
            priority_a < priority_b -> false
            true -> true
          end
        end)
        |> Enum.reduce({[], nil}, fn {module, priority}, {modules, highest_priority} ->
          highest_priority = if highest_priority == nil, do: priority, else: highest_priority
          modules = if priority == highest_priority, do: [module|modules], else: modules
          {modules, highest_priority}
        end)
        |> Tuple.to_list
        |> List.first
        |> Enum.reverse
    end
  end

end