Current section

Files

Jump to
exrm lib mix tasks release.ex
Raw

lib/mix/tasks/release.ex

defmodule Mix.Tasks.Release do
@moduledoc """
Build a release for the current mix application.
## Examples
# Build a release using defaults
mix release
# Pass args to erlexec when running the release
mix release --erl="-env TZ UTC"
# Enable dev mode. Make changes, compile using MIX_ENV=prod
# and execute your release again to pick up the changes
mix release --dev
# Set the verbosity level
mix release --verbosity=[silent|quiet|normal|verbose]
# Do not ask for confirmation to skip missing applications warning
mix release --no-confirm-missing
You may pass any number of arguments as needed. Make sure you pass arguments
using `--key=value`, not `--key value`, as the args may be interpreted incorrectly
otherwise.
"""
@shortdoc "Build a release for the current mix application"
use Mix.Task
import ExUnit.CaptureIO
import ReleaseManager.Utils
alias ReleaseManager.Utils
alias ReleaseManager.Utils.Logger
alias ReleaseManager.Config
alias ReleaseManager.Deps
@_RELXCONF "relx.config"
@_BOOT_FILE "boot"
@_NODETOOL "nodetool"
@_SYSCONFIG "sys.config"
@_VMARGS "vm.args"
@_RELEASE_DEF "release_definition.txt"
@_RELEASES "{{{RELEASES}}}"
@_NAME "{{{PROJECT_NAME}}}"
@_VERSION "{{{PROJECT_VERSION}}}"
@_ERTS_VSN "{{{ERTS_VERSION}}}"
@_ERL_OPTS "{{{ERL_OPTS}}}"
@_LIB_DIRS "{{{LIB_DIRS}}}"
def run(args) do
Mix.Project.compile(args)
if Mix.Project.umbrella? do
config = [umbrella?: true]
for %Mix.Dep{app: app, opts: opts} <- Mix.Dep.Umbrella.loaded do
Mix.Project.in_project(app, opts[:path], config, fn _ -> do_run(args) end)
end
else
do_run(args)
end
end
defp do_run(args) do
# Start with a clean slate
Mix.Tasks.Release.Clean.do_cleanup(:build)
# Collect release configuration
config = parse_args(args)
Logger.notice "Building release with MIX_ENV=#{config.env}."
# Begin release pipeline
config
|> check_applications(args)
|> generate_relx_config
|> generate_sys_config
|> generate_vm_args
|> generate_boot_script
|> execute_before_hooks
|> do_release
|> generate_nodetool
|> generate_install_escript
|> execute_after_hooks
|> update_release_package
|> execute_package_hooks
Logger.info "The release for #{config.name}-#{config.version} is ready!"
Logger.info "You can boot a console running your release with `$ rel/#{config.name}/bin/#{config.name} console`"
end
defp check_applications(%Config{} = config, args) do
case Deps.print_missing_applications(ignore: [:exrm]) do
"" -> config
output ->
IO.puts IO.ANSI.yellow
IO.puts "You have dependencies (direct/transitive) which are not in :applications!"
IO.puts "The following apps should be added to :applications in mix.exs:\n#{output}#{IO.ANSI.reset}\n"
case "--no-confirm-missing" in args do
true ->
config
false ->
msg = IO.ANSI.yellow <> "Continue anyway? Your release may not work as expected if these dependencies are required!"
answer = IO.gets(msg <> " [Yn]: ") |> String.rstrip(?\n)
IO.puts IO.ANSI.reset
case answer =~ ~r/^(Y(es)?)?$/i do
true -> config
false -> abort!
end
end
end
end
defp generate_relx_config(%Config{name: name, version: version} = config) do
Logger.debug "Generating relx configuration..."
# Get paths
rel_def = rel_file_source_path @_RELEASE_DEF
source = rel_source_path @_RELXCONF
# Get relx.config template contents
relx_config = source |> File.read!
# Get release definition template contents
tmpl = rel_def |> File.read!
# Generate release configuration for historical releases
releases = get_releases(name)
|> Enum.map(fn {rname, rver} -> tmpl |> replace_release_info(rname, rver) end)
|> Enum.join
# Set upgrade flag if this is an upgrade release
config = case releases do
"" -> config
_ -> %{config | :upgrade? => true}
end
elixir_paths = get_elixir_lib_paths |> Enum.map(&String.to_char_list/1)
lib_dirs = [ '#{Path.join(Mix.Project.build_path, "lib")}', '#{Mix.Project.deps_path}' | elixir_paths ]
# Build release configuration
relx_config = relx_config
|> String.replace(@_RELEASES, releases)
|> String.replace(@_LIB_DIRS, :io_lib.fwrite('~p.\n\n', [{:lib_dirs, lib_dirs}]) |> List.to_string)
# Replace placeholders for current release
relx_config = relx_config |> replace_release_info(name, version)
# Read config as Erlang terms
relx_config = Utils.string_to_terms(relx_config)
# Merge user provided relx.config
user_config_path = rel_dest_path @_RELXCONF
merged = case user_config_path |> File.exists? do
true ->
Logger.debug "Merging custom relx configuration from #{user_config_path |> Path.relative_to_cwd}..."
case Utils.read_terms(user_config_path) do
[] -> relx_config
[{_, _}|_] = user_config -> Utils.merge(relx_config, user_config)
[user_config] when is_list(user_config) -> Utils.merge(relx_config, user_config)
[user_config] -> Utils.merge(relx_config, [user_config])
end
_ ->
relx_config
end
# Save relx config for use later
%{config | :relx_config => merged}
end
defp generate_sys_config(%Config{env: env} = config) do
default_sysconfig = rel_file_source_path @_SYSCONFIG
user_sysconfig = rel_dest_path @_SYSCONFIG
dest = rel_file_dest_path @_SYSCONFIG
Logger.debug "Generating sys.config..."
# Read in current project config
project_conf = load_config(env)
# Merge project config with either the user-provided config, or the default sys.config we provide.
# If a sys.config is provided by the user, it will take precedence over project config. If the
# default sys.config is used, the project config will take precedence instead.
merged = case user_sysconfig |> File.exists? do
true ->
Logger.debug "Merging custom sys.config from #{user_sysconfig |> Path.relative_to_cwd}..."
# User-provided
case user_sysconfig |> Utils.read_terms do
[] -> project_conf
[user_conf] when is_list(user_conf) -> Mix.Config.merge(project_conf, user_conf)
[user_conf] -> Mix.Config.merge(project_conf, [user_conf])
end
_ ->
# Default
[default_conf] = default_sysconfig |> Utils.read_terms
Mix.Config.merge(default_conf, project_conf)
end
# Ensure parent directory exists prior to writing
File.mkdir_p!(dest |> Path.dirname)
# Write the config to disk
dest |> Utils.write_term(merged)
# Continue..
config
end
defp generate_vm_args(%Config{version: version, relx_config: relx_config} = config) do
Logger.debug "Generating vm.args..."
vmargs_path = rel_dest_path(@_VMARGS)
vmargs_path = case File.exists?(vmargs_path) do
false ->
src_path = rel_file_source_path(@_VMARGS)
dest_path = rel_file_dest_path(@_VMARGS)
contents = File.read!(src_path) |> String.replace(@_NAME, config.name)
File.write!(dest_path, contents)
dest_path
true ->
vmargs_path
end
overlays = [overlay: [
{:copy, String.to_char_list(vmargs_path), 'releases/#{version}/vm.args'}
]]
# Update configuration to add new overlay for vm.args
updated = Utils.merge(relx_config, overlays)
%{config | :relx_config => updated}
end
defp generate_boot_script(%Config{name: name, version: version, erl: erl_opts} = config) do
erts = extract_erts_version(config)
boot = rel_file_source_path @_BOOT_FILE
winboot = rel_file_source_path "#{@_BOOT_FILE}.bat"
dest = rel_file_dest_path @_BOOT_FILE
windest = rel_file_dest_path "#{@_BOOT_FILE}.bat"
shim = rel_file_source_path "boot_shim"
winshim = rel_file_source_path "boot_shim.bat"
shim_dest = rel_file_dest_path "boot_shim"
winshim_dest = rel_file_dest_path "boot_shim.bat"
Logger.debug "Generating boot script..."
[{boot, dest}, {winboot, windest}, {shim, shim_dest}, {winshim, winshim_dest}]
|> Enum.each(fn {infile, outfile} ->
contents = File.read!(infile)
|> String.replace(@_NAME, name)
|> String.replace(@_VERSION, version)
|> String.replace(@_ERTS_VSN, erts)
|> String.replace(@_ERL_OPTS, erl_opts)
File.write!(outfile, contents)
# Make executable
outfile |> chmod(0o700)
end)
# Continue..
config
end
defp execute_before_hooks(%Config{} = config) do
# Just in case there are plugins which expect relx.config to already
# be persisted, we'll persist it before any are run, and again after each plugin runs
Utils.write_terms(relx_config_path, config.relx_config)
plugins = ReleaseManager.Plugin.load_all
Enum.reduce plugins, config, fn plugin, conf ->
try do
# Handle the case where a child plugin does not return the configuration
config = case plugin.before_release(conf) do
%Config{} = result -> result
_ -> conf
end
Utils.write_terms(relx_config_path, config.relx_config)
config
rescue
exception ->
stacktrace = System.stacktrace
Logger.error "Failed to execute before_release hook for #{plugin}!"
reraise exception, stacktrace
end
end
end
defp execute_after_hooks(%Config{} = config) do
plugins = ReleaseManager.Plugin.load_all
Enum.reduce plugins, config, fn plugin, conf ->
try do
# Handle the case where a child plugin does not return the configuration
case plugin.after_release(conf) do
%Config{} = result -> result
_ -> conf
end
rescue
exception ->
stacktrace = System.stacktrace
Logger.error "Failed to execute after_release hook for #{plugin}!"
reraise exception, stacktrace
end
end
end
defp execute_package_hooks(%Config{} = config) do
plugins = ReleaseManager.Plugin.load_all
Enum.reduce plugins, config, fn plugin, conf ->
try do
# Handle the case where a child plugin does not return the configuration
case plugin.after_package(conf) do
%Config{} = result -> result
_ -> conf
end
rescue
exception ->
stacktrace = System.stacktrace
Logger.error "Failed to execute after_package hook for #{plugin}!"
reraise exception, stacktrace
end
end
end
defp do_release(%Config{name: name, version: version, verbosity: verbosity, upgrade?: upgrade?, dev: dev_mode?, env: env} = config) do
Logger.debug "Generating release..."
# Persist relx.config one last time in case it was updated by a plugin
Utils.write_terms(relx_config_path, config.relx_config)
# If this is an upgrade release, generate an appup
if upgrade? do
# Change mix env for appup generation
with_env env, fn ->
# Generate appup
app = name |> String.to_atom
v1 = get_last_release(name)
v1_path = rel_dest_path [name, "lib", "#{name}-#{v1}"]
v2_path = Mix.Project.compile_path |> String.replace("/ebin", "")
own_path = rel_dest_path "#{name}.appup"
# Look for user's own .appup file before generating one
case own_path |> File.exists? do
true ->
# Copy it to ebin
case File.cp(own_path, Path.join([v2_path, "/ebin", "#{name}.appup"])) do
:ok ->
Logger.info "Using custom .appup located in rel/#{name}.appup"
{:error, reason} ->
Logger.error "Unable to copy custom .appup file: #{reason}"
abort!
end
_ ->
# No custom .appup found, proceed with autogeneration
case ReleaseManager.Appups.make(app, v1, version, v1_path, v2_path) do
{:ok, _} ->
Logger.info "Generated .appup for #{name} #{v1} -> #{version}"
{:error, reason} ->
Logger.error "Appup generation failed with #{reason}"
abort!
end
end
end
end
# Do release
try do
logs = capture_io(:stdio, fn ->
result = relx(name, version, :verbose, upgrade?, dev_mode?)
send(self(), result)
end)
receive do
:ok ->
if verbosity == :verbose do
IO.puts(logs)
end
# Clean up template files
Mix.Tasks.Release.Clean.do_cleanup(:relfiles)
# Continue..
config
{:error, message} ->
IO.puts(logs)
Logger.error "ERROR: #{inspect message}"
abort!
end
catch
err ->
Logger.error "#{IO.inspect err}"
Logger.error "Failed to build release package! Try running with `--verbosity=verbose` to see debugging info!"
abort!
end
end
defp generate_nodetool(%Config{name: name} = config) do
Logger.debug "Generating nodetool..."
nodetool = rel_file_source_path @_NODETOOL
dest = rel_dest_path [name, "bin", @_NODETOOL]
# Copy
File.cp! nodetool, dest
# Make executable
dest |> chmod(0o700)
# Continue..
config
end
defp generate_install_escript(%Config{name: name} = config) do
escript = rel_file_source_path "install_upgrade.escript"
dest = rel_dest_path [name, "bin", "install_upgrade.escript"]
File.cp! escript, dest
config
end
defp update_release_package(%Config{dev: true} = config), do: config
defp update_release_package(%Config{name: name, version: version, relx_config: relx_config} = config) do
Logger.debug "Packaging release..."
# Delete original release package
tarball = rel_dest_path [name, "#{name}-#{version}.tar.gz"]
File.rm! tarball
# Make sure we have a start.boot file for upgrades/downgrades
source_boot = rel_dest_path([name, "releases", version, "#{name}.boot"])
dest_boot = rel_dest_path([name, "releases", version, "start.boot"])
File.cp! source_boot, dest_boot
# Get include_erts value from relx_config
include_erts = Keyword.get(relx_config, :include_erts, true)
erts = "erts-#{extract_erts_version(config)}"
extras = case include_erts do
false -> []
_ -> [{'#{erts}', '#{rel_dest_path([name, erts])}'}]
end
# Re-package release with modifications
file_list = File.ls!(rel_dest_path(name))
|> Enum.reject(fn n -> n in [erts, "tmp"] end)
|> Enum.map(fn
"releases" -> [Path.join("releases", "RELEASES"),
Path.join("releases", "start_erl.data") |
File.ls!(rel_dest_path([name, "releases", version]))
|> Enum.reject(&(String.ends_with?(&1, ".tar.gz")))
|> Enum.map(fn n -> Path.join(["releases", version, n]) end)]
"lib" -> File.ls!(rel_dest_path([name, "lib"]))
|> Enum.reject(fn n -> String.starts_with?(n, "#{name}-") && !String.ends_with?(n, "-#{version}") end)
|> Enum.map(fn n -> Path.join("lib", n) end)
n -> [n]
end)
|> List.flatten
|> Enum.map(&({'#{&1}', '#{rel_dest_path([name, &1])}'}))
|> Enum.concat(extras)
# Create archive
release_tarball = rel_dest_path([name, "releases", version, "#{name}.tar.gz"])
:ok = :erl_tar.create(
'#{tarball}',
file_list,
[:compressed]
)
# Clean up
File.cp! tarball, release_tarball
File.rm_rf! tarball
# Continue..
%{config | package: release_tarball}
end
defp parse_args(argv) do
{args, _, _} = OptionParser.parse(argv)
defaults = %Config{
name: Mix.Project.config |> Keyword.get(:app) |> Atom.to_string,
version: Mix.Project.config |> Keyword.get(:version),
env: Mix.env
}
Enum.reduce args, defaults, fn arg, config ->
case arg do
{:verbosity, verbosity} ->
verbosity = String.to_atom(verbosity)
Logger.configure(verbosity)
%{config | :verbosity => verbosity}
{key, value} ->
Map.put(config, key, value)
end
end
end
defp replace_release_info(template, name, version) do
template
|> String.replace(@_NAME, name)
|> String.replace(@_VERSION, version)
end
defp extract_erts_version(%Config{relx_config: relx_config}) do
include_erts = Keyword.get(relx_config, :include_erts, true)
case include_erts do
true -> :erlang.system_info(:version) |> IO.iodata_to_binary
false -> ""
path ->
case File.ls("#{path}") do
{:error, _} -> ""
{:ok, entries} ->
erts = entries |> Enum.find(fn
<<"erts-", _version::binary>> -> true
_ -> false
end)
case erts do
<<"erts-", version::binary>> -> version
_ -> ""
end
end
end
end
def relx_config_path do
path = rel_file_dest_path @_RELXCONF
# Ensure destination base path exists
path |> Path.dirname |> File.mkdir_p!
path
end
end