defmodule Edeliver.Relup.RunnableInstruction do
@moduledoc """
This module can be used to provide custom instructions executed during the upgrade.
They can be used in implementations of the `Edeliver.Relup.Modification` behaviours.
A runnable instruction must implement a `c:Edeliver.Relup.RunnableInstruction.run/1` function which will be executed
during the upgrade on the nodes.
Example:
defmodule Acme.Relup.PingNodeInstruction do
use Edeliver.Relup.RunnableInstruction
def modify_relup(instructions = %Instructions{up_instructions: up_instructions}, _config = %{}) do
node_name = :"node@host"
%{instructions|
up_instructions: [call_this([node_name]) | instructions.up_instructions],
down_instructions: [call_this([node_name]) | instructions.down_instructions]
}
end
# executed during hot code upgrade from relup file
def run(_options = [node_name]) do
:net_adm.ping(node_name)
end
# actually implemented already in this module
def call_this(arguments) do
# creates a relup instruction to call `run/1` of this module
{:apply, {__MODULE__, :run, arguments}}
end
end
# using the instruction
defmodule Acme.Relup.Modification do
use Edeliver.Relup.Modification
def modify_relup(instructions = %Instructions{}, config = %{}) do
instructions |> Edeliver.Relup.DefaultModification.modify_relup(config) # use default modifications
|> Acme.Relup.PingNodeInstruction.modify_relup(config) # apply also custom instructions
end
end
"""
require Logger
import Edeliver.Relup.ShiftInstruction, only: [
ensure_module_loaded_before_first_runnable_instructions: 3,
ensure_module_unloaded_after_last_runnable_instruction: 3,
]
alias Edeliver.Relup.Instructions
@doc """
The function to run during hot code upgrade on nodes.
If it throws an error before the `point_of_no_return` the
upgrade is aborted. If it throws an error and was executed
after that point, the release is restarted
"""
@callback run(options::[term]) :: :ok
@doc """
Returns a function which inserts the relup instruction
that calls the `c:Edeliver.Relup.RunnableInstruction.run/1` function of this module.
Default is inserting it at the end of the instructions
"""
@callback insert_where() :: ((%Edeliver.Relup.Instructions{}, Edeliver.Relup.Instruction.instruction) -> %Edeliver.Relup.Instructions{})
@doc """
Returns the arguments which will be passed the `c:Edeliver.Relup.RunnableInstruction.run/1` function during the upgrade.
Default is an empty list.
"""
@callback arguments(instructions::%Edeliver.Relup.Instructions{}, config::Edeliver.Relup.Config.t) :: [term]
@doc """
Returns a list of module names which implement the behaviour `Edeliver.Relup.RunnableInstruction`
and are used / referenced by this runnable instruction. These modules must be loaded before this instruction
is executed for upgrades and unloaded after this instruction for downgrades. Default is an empty list.
"""
@callback dependencies() :: [instruction_module::atom]
@doc """
Logs the message of the given type on the node
which executes the upgrade and displays it as output of
the `$APP/bin/$APP upgrade $RELEASE` command. The message is
prefixed with a string derived from the message type.
"""
@spec log_in_upgrade_script(type:: :error|:warning|:info|:debug, message::String.t) :: no_return
def log_in_upgrade_script(type, message) do
message = String.to_charlist(message)
prefix = case type do
:error -> '---> X '
:warning -> '---> ! '
:info -> '---> '
_ -> '----> ' # debug
end
format_in_upgrade_script('~s~s~n', [prefix, message])
end
@doc """
Formats and prints the message on the node
running the upgrade script which was started by the
`$APP/bin/$APP upgrade $RELEASE` command.
"""
@spec format_in_upgrade_script(format::charlist, arguments::[term]) :: no_return
def format_in_upgrade_script(format, arguments) do
:erlang.nodes |> Enum.filter(fn node ->
Regex.match?(~r/upgrader_\d+/, Atom.to_string(node))
end) |> Enum.each(fn node ->
:rpc.cast(node, :io, :format, [:user, format, arguments])
end)
end
@doc """
Logs an error using the `Logger` on the running node which is upgraded.
In addition the same error message is logged on the node which executes
the upgrade and is displayed as output of the
`$APP/bin/$APP upgrade $RELEASE` command.
"""
@spec error(message::String.t) :: no_return
def error(message) do
Logger.error message
log_in_upgrade_script(:error, message)
end
@doc """
Logs a warning using the `Logger` on the running node which is upgraded.
In addition the same warning message is logged on the node which executes
the upgrade and is displayed as output of the
`$APP/bin/$APP upgrade $RELEASE` command.
"""
@spec warn(message::String.t) :: no_return
def warn(message) do
Logger.warn message
log_in_upgrade_script(:warning, message)
end
@doc """
Logs an info message using the `Logger` on the running node which is upgraded.
In addition the same info message is logged on the node which executes
the upgrade and is displayed as output of the
`$APP/bin/$APP upgrade $RELEASE` command.
"""
@spec info(message::String.t) :: no_return
def info(message) do
Logger.info message
log_in_upgrade_script(:info, message)
end
@doc """
Logs a debug message using the `Logger` on the running node which is upgraded.
In addition the same debug message is logged on the node which executes
the upgrade and is displayed as output of the
`$APP/bin/$APP upgrade $RELEASE` command.
"""
@spec debug(message::String.t) :: no_return
def debug(message) do
Logger.debug message
log_in_upgrade_script(:debug, message)
end
@doc """
Ensures that all `Edeliver.Relup.RunnableInstruction` modules used / referenced by this instruction
and returned by the `c:Edeliver.Relup.RunnableInstruction.dependencies/0` callback are loaded before this instruction is executed
during the upgrade.
"""
@spec ensure_dependencies_loaded_before_instruction_for_upgrade(instructions::Instructions.t, runnable_instruction::{:apply, {module::atom, :run, arguments::[term]}}, dependencies::[instruction_module::atom]) :: Instructions.t
def ensure_dependencies_loaded_before_instruction_for_upgrade(instructions = %Instructions{}, call_this_instruction, dependencies) do
dependencies |> Enum.reduce(instructions, fn(dependency, instructions_acc = %Instructions{up_instructions: up_instructions}) ->
%{instructions_acc| up_instructions: ensure_module_loaded_before_first_runnable_instructions(up_instructions, call_this_instruction, dependency)}
end)
end
@doc """
Ensures that all `Edeliver.Relup.RunnableInstruction` modules used / referenced by this instruction
and returned by the `c:Edeliver.Relup.RunnableInstruction.dependencies/0` callback are unloaded after this instruction is executed
during the downgrade.
"""
@spec ensure_dependencies_unloaded_after_instruction_for_downgrade(instructions::Instructions.t, runnable_instruction::{:apply, {module::atom, :run, arguments::[term]}}, dependencies::[instruction_module::atom]) :: Instructions.t
def ensure_dependencies_unloaded_after_instruction_for_downgrade(instructions = %Instructions{}, call_this_instruction, dependencies) do
dependencies |> Enum.reduce(instructions, fn(dependency, instructions_acc = %Instructions{down_instructions: down_instructions}) ->
%{instructions_acc| down_instructions: ensure_module_unloaded_after_last_runnable_instruction(down_instructions, call_this_instruction, dependency)}
end)
end
@doc """
Assumes that the pattern matches or throws an error with the given error message.
The error message is logged as error to the logfile
using the `Logger` and displayed as error output by the
`$APP/bin/$APP upgrade $RELEASE` task using the
`$APP/ebin/install_upgrade.escript` script. If the pattern matches
the variables from the matching are assigned.
"""
defmacro assume({:=, _, [left, right]} = assertion, error_message) do
code = Macro.escape(assertion)
left = Macro.expand(left, __CALLER__)
vars = collect_vars_from_pattern(left)
quote do
right = unquote(right)
expr = unquote(code)
unquote(vars) =
case right do
unquote(left) ->
unquote(vars)
_ ->
error unquote(error_message)
# error is shown as erlang term in the upgrade script
# `$APP/ebin/install_upgrade.escript`. so use an erlang
# string as error message
throw {:error, String.to_charlist(unquote(error_message))}
end
right
end
end
# Used by the assume macro for pattern assignment
defp collect_vars_from_pattern(expr) do
{_, vars} =
Macro.prewalk(expr, [], fn
{:::, _, [left, _]}, acc ->
{[left], acc}
{skip, _, [_]}, acc when skip in [:^, :@] ->
{:ok, acc}
{:_, _, context}, acc when is_atom(context) ->
{:ok, acc}
{name, _, context}, acc when is_atom(name) and is_atom(context) ->
{:ok, [{name, [generated: true], context}|acc]}
node, acc ->
{node, acc}
end)
Enum.uniq(vars)
end
@doc false
defmacro __using__(_opts) do
quote do
use Edeliver.Relup.Instruction
import Edeliver.Relup.RunnableInstruction
@behaviour Edeliver.Relup.RunnableInstruction
alias Edeliver.Relup.Instructions
require Logger
def modify_relup(instructions = %Instructions{}, config = %{}) do
call_this_instruction = call_this(arguments(instructions, config))
insert_where_fun = insert_where()
instructions |> insert_where_fun.(call_this_instruction)
|> ensure_module_loaded_before_instruction(call_this_instruction, __MODULE__)
|> ensure_dependencies_loaded_before_instruction_for_upgrade(call_this_instruction, dependencies())
|> ensure_dependencies_unloaded_after_instruction_for_downgrade(call_this_instruction, dependencies())
end
@spec arguments(%Edeliver.Relup.Instructions{}, Edeliver.Relup.Config.t) :: term
def arguments(%Edeliver.Relup.Instructions{}, %{}), do: []
@spec insert_where()::Instruction.insert_fun
def insert_where, do: &append/2
@spec dependencies() :: [instruction_module::atom]
def dependencies, do: []
defoverridable [modify_relup: 2, insert_where: 0, arguments: 2, dependencies: 0]
@doc """
Calls the `run/1` function of this module
from the relup file during hot code upgrade
"""
@spec call_this(arguments::[term]) :: Instruction.instruction|Instruction.instructions
def call_this(arguments \\ []) do
{:apply, {__MODULE__, :run, [arguments]}}
end
end # quote
end # defmacro __using__
end