Current section
Files
Jump to
Current section
Files
lib/mix/tasks/deploy.ex
defmodule Mix.Tasks.Deploy do
@moduledoc false
# Directory where `mix deploy.generate` stores output files,
# e.g. _build/prod/deploy
@output_dir "deploy"
# Directory where `mix deploy.init` copies templates in user project
@template_dir "rel/templates/deploy"
@app :mix_deploy
@doc "Generate cfg from mix.exs and app config"
@spec parse_args(OptionParser.argv()) :: Keyword.t()
def parse_args(argv) do
opts = [strict: [version: :string]]
{overrides, _} = OptionParser.parse!(argv, opts)
user_config = Keyword.merge(Application.get_all_env(@app), overrides)
mix_config = Mix.Project.config()
create_config(mix_config, user_config)
end
@doc "Generate cfg based on params"
@spec create_config(Keyword.t(), Keyword.t()) :: Keyword.t()
def create_config(mix_config, user_config) do
# Elixir app name, from mix.exs
app_name = mix_config[:app]
# External name, used for files and directories
ext_name =
app_name
|> to_string
|> String.replace("_", "-")
# Name of systemd unit
service_name = ext_name
# Elixir camel case module name version of snake case app name
module_name =
app_name
|> to_string
|> String.split("_")
|> Enum.map(&String.capitalize/1)
|> Enum.join("")
base_dir = user_config[:base_dir] || "/srv"
build_path = Mix.Project.build_path()
{{cur_user, _cur_uid}, {cur_group, _cur_gid}, _} = MixDeploy.User.get_id()
defaults = [
# Elixir application name
app_name: app_name,
# Elixir module name in camel case
module_name: module_name,
# Name of release
release_name: app_name,
# External name, used for files and directories
ext_name: ext_name,
# Name of service
service_name: service_name,
# OS user to own files
deploy_user: cur_user,
deploy_group: cur_group,
# App version
version: mix_config[:version],
# Base directory on target system, e.g. /srv
base_dir: base_dir,
# Directory for release files on target
deploy_dir: "#{base_dir}/#{ext_name}",
# Target systemd version
# systemd_version: 219, # CentOS 7
# systemd_version: 229, # Ubuntu 16.04
# systemd_version: 237, # Ubuntu 18.04
systemd_version: 235,
dirs: [
# :runtime, # App runtime files which may be deleted between runs, /run/#{ext_name}
# :configuration, # Config files, Erlang cookie
# :logs, # External log file, not journald
# :cache, # App cache files which can be deleted
# :state, # App state persisted between runs
# :tmp, # App temp files
],
# Standard directory locations for under systemd for various purposes.
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectory=
#
# Recent versions of systemd (since 235) will create directories if they
# don't exist if they are configured in the unit file.
#
# For security, modes are tighter than the systemd default of 755.
# Note that these are strings, not integers, as they are actually octal.
cache_directory: service_name,
cache_directory_base: "/var/cache",
cache_directory_mode: "750",
configuration_directory: service_name,
configuration_directory_base: "/etc",
configuration_directory_mode: "750",
logs_directory: service_name,
logs_directory_base: "/var/log",
logs_directory_mode: "750",
runtime_directory: service_name,
runtime_directory_base: "/run",
runtime_directory_mode: "750",
# Whether to preserve the runtime dir on app restart
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectoryPreserve=
# "no" | "yes" | "restart"
runtime_directory_preserve: "no",
state_directory: service_name,
state_directory_base: "/var/lib",
state_directory_mode: "750",
tmp_directory: service_name,
tmp_directory_base: "/var/tmp",
tmp_directory_mode: "750",
# Elixir 1.9+ mix releases or Distillery
# :mix | :distillery
release_system: :mix,
# How service is restarted on update
# :systemctl | :systemd_flag | :touch
restart_method: :systemctl,
# Mix build_path
build_path: build_path,
# Staging output directory for generated files
output_dir: Path.join(build_path, @output_dir),
# Directory where scripts will be generated in build
# Default is top level bin dir in project
# Set to `[:output_dir, "bin"]` to generate under `_build/prod/deploy/bin`
bin_dir: "bin",
# Directory with templates which override defaults
template_dir: @template_dir,
mix_env: Mix.env(),
# LANG environment var for running scripts
env_lang: "en_US.utf8",
# Environment files to read, e.g.
# env files to read, e.g.
# The "-" at the beginning means that the file is optional
env_files: [
# ["-", :configuration_dir, "/environment"],
# ["-", :deploy_dir, "/etc/environment"],
],
# Misc env vars to set
env_vars: [
# PORT=8080
# Use runtime dir for tmp files
# ["RELEASE_TMP=", :runtime_dir]
# Distillery
# Use runtime dir for tmp and startup log
# ["RELEASE_MUTABLE_DIR=", :runtime_dir]
# "REPLACE_OS_VARS=true",
],
# Whether to create /etc/suders.d file which allows deploy and/or app user to
# restart app by running sudo with specific commands
sudo_deploy: false,
sudo_app: false,
# Script run by envronment config systemd unit
runtime_environment_service_script: nil,
# Path to conform config file, used to set CONFOM_CONF_PATH
conform_conf_path: nil,
# User dirs to create
create_dirs: [],
# Files to copy when deploying locally
# User files to copy
copy_files: [
# %{src: "config/environment", dst: :configuration_dir, user: "$DEPLOY_USER", group: "$APP_GROUP", mode: "640"},
# %{src: "rel/etc/environment", dst: [:deploy_dir, "/etc"], user: "$DEPLOY_USER", group: "$APP_GROUP", mode: "640"},
],
# Prefix for generated script files
target_prefix: "deploy-",
# Files to generate
templates: [
# Systemd wrappers
# "start",
# "stop",
# "restart",
# "enable",
# System setup
# "create-users",
# "create-dirs",
# Local deploy
# "init-local",
# "copy-files",
# "release",
# "rollback",
# CodeDeploy
# "clean-target",
# "extract-release",
# "set-perms",
# CodeBuild
# "stage-files",
# "sync-assets-s3",
# Release commands
# "set-env",
# "remote-console",
# "migrate",
# Runtime environment
# "sync-config-s3",
# "runtime-environment-file",
# "runtime-environment-wrap",
# "set-cookie-ssm",
],
# Config keys which have variable expansion
expand_keys: [
:env_files,
:env_vars,
:runtime_environment_service_script,
:conform_conf_path,
:pid_file,
:root_directory,
:bin_dir
],
# Add your keys here
expand_keys_extra: []
]
# Override values from user config
cfg = Keyword.merge(defaults, user_config)
# Calcualate values from other things
cfg =
Keyword.merge(
[
releases_dir: cfg[:releases_dir] || Path.join(cfg[:deploy_dir], "releases"),
scripts_dir: cfg[:scripts_dir] || Path.join(cfg[:deploy_dir], "bin"),
flags_dir: cfg[:flags_dir] || Path.join(cfg[:deploy_dir], "flags"),
current_dir: cfg[:current_dir] || Path.join(cfg[:deploy_dir], "current"),
etc_dir: cfg[:current_dir] || Path.join(cfg[:deploy_dir], "etc"),
runtime_dir:
cfg[:runtime_dir] || Path.join(cfg[:runtime_directory_base], cfg[:runtime_directory]),
configuration_dir:
cfg[:configuration_dir] ||
Path.join(cfg[:configuration_directory_base], cfg[:configuration_directory]),
logs_dir: cfg[:logs_dir] || Path.join(cfg[:logs_directory_base], cfg[:logs_directory]),
tmp_dir: cfg[:logs_dir] || Path.join(cfg[:tmp_directory_base], cfg[:tmp_directory]),
state_dir:
cfg[:state_dir] || Path.join(cfg[:state_directory_base], cfg[:state_directory]),
cache_dir:
cfg[:cache_dir] || Path.join(cfg[:cache_directory_base], cfg[:cache_directory]),
# Loation of pid file when running as a daemon
pid_file:
cfg[:pid_file] ||
Path.join([cfg[:runtime_directory_base], cfg[:runtime_directory], "#{app_name}.pid"]),
# Chroot dir
root_directory: cfg[:root_directory] || Path.join(cfg[:deploy_dir], "current"),
# OS user that app runs as
app_user: cfg[:deploy_user],
app_group: cfg[:deploy_group]
],
cfg
)
# for {key, value} <- cfg do
# Mix.shell.info "cfg: #{key} #{inspect value}"
# end
expand_keys(cfg, cfg[:expand_keys] ++ cfg[:expand_keys_extra])
end
# Expand cfg vars in keys
@doc false
@spec expand_keys(Keyword.t(), list(atom)) :: Keyword.t()
def expand_keys(cfg, keys) do
Enum.reduce(Keyword.take(cfg, keys), cfg, fn {key, value}, acc ->
Keyword.put(acc, key, expand_value(value, acc))
end)
end
# Expand vars in value or list of values
@doc false
@spec expand_value(atom | binary | list, Keyword.t()) :: binary | list(binary)
def expand_value(values, cfg) when is_list(values) do
Enum.map(values, &expand_vars(&1, cfg))
end
def expand_value(value, cfg), do: expand_vars(value, cfg)
# Expand references in values
@doc false
@spec expand_vars(binary | nil | atom | list, Keyword.t()) :: binary
def expand_vars(value, _cfg) when is_binary(value), do: value
def expand_vars(nil, _cfg), do: ""
def expand_vars(key, cfg) when is_atom(key) do
case Keyword.fetch(cfg, key) do
{:ok, value} ->
expand_vars(value, cfg)
:error ->
to_string(key)
end
end
def expand_vars(terms, cfg) when is_list(terms) do
terms
|> Enum.map(&expand_vars(&1, cfg))
|> Enum.join("")
end
def expand_vars(value, _cfg), do: to_string(value)
end
defmodule Mix.Tasks.Deploy.Init do
@moduledoc """
Initialize template files.
## Command line options
* `--template_dir` - target directory
## Usage
# Copy default templates into your project
mix deploy.init
"""
@shortdoc "Initialize template files"
use Mix.Task
@app :mix_deploy
@impl Mix.Task
def run(args) do
cfg = Mix.Tasks.Deploy.parse_args(args)
template_dir = cfg[:template_dir]
app_dir = Application.app_dir(@app, ["priv", "templates"])
:ok = File.mkdir_p(template_dir)
{:ok, _files} = File.cp_r(app_dir, template_dir)
end
end
defmodule Mix.Tasks.Deploy.Generate do
@moduledoc """
Create deploy scripts and files for project.
## Usage
# Create scripts and files
MIX_ENV=prod mix deploy.generate
"""
@shortdoc "Create deploy scripts and files"
use Mix.Task
alias MixDeploy.Templates
@impl Mix.Task
def run(args) do
cfg = Mix.Tasks.Deploy.parse_args(args)
dirs =
cfg[:create_dirs] ++
[
%{
path: cfg[:deploy_dir],
user: "$DEPLOY_USER",
group: "$APP_GROUP",
mode: "750",
comment: "Base dir"
},
%{
path: cfg[:releases_dir],
user: "$DEPLOY_USER",
group: "$APP_GROUP",
mode: "750",
comment: "Releases"
},
%{
path: cfg[:scripts_dir],
user: "$DEPLOY_USER",
group: "$APP_GROUP",
mode: "750",
comment: "Target scripts"
},
%{
enabled: cfg[:restart_method] in [:systemd_flag, :touch],
path: cfg[:flags_dir],
user: "$DEPLOY_USER",
group: "$APP_GROUP",
mode: if(cfg[:restart_method] == :touch, do: "770", else: "750"),
comment: "Flag files"
},
%{
path: cfg[:etc_dir],
user: "$DEPLOY_USER",
group: "$APP_GROUP",
mode: "750",
comment: "Release config"
},
%{
path: "/lib/systemd/system",
comment: "systemd unit files"
},
%{
enabled: cfg[:sudo_app] or cfg[:sudo_deploy],
path: "/etc/sudoers.d",
comment: "sudoers config"
}
] ++
if cfg[:systemd_version] < 235 do
# systemd will automatically create directories in newer versions
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectory=
[
%{
enabled: :runtime in cfg[:dirs],
path: cfg[:runtime_dir],
user: "$APP_USER",
group: "$APP_GROUP",
mode: cfg[:runtime_directory_mode],
comment: "systemd RuntimeDirectory"
},
%{
enabled: :configuration in cfg[:dirs],
path: cfg[:configuration_dir],
user: "$DEPLOY_USER",
group: "$APP_GROUP",
mode: cfg[:confguration_directory_mode],
comment: "systemd ConfigurationDirectory"
},
%{
enabled: :logs in cfg[:dirs],
path: cfg[:logs_dir],
user: "$APP_USER",
group: "$APP_GROUP",
mode: cfg[:logs_directory_mode],
comment: "systemd LogsDirectory"
},
%{
enabled: :state in cfg[:dirs],
path: cfg[:state_dir],
user: "$APP_USER",
group: "$APP_GROUP",
mode: cfg[:state_directory_mode],
comment: "systemd StateDirectory"
},
%{
enabled: :cache in cfg[:dirs],
path: cfg[:cache_dir],
user: "$APP_USER",
group: "$APP_GROUP",
mode: cfg[:cache_directory_mode],
comment: "systemd CacheDirectory"
},
# Better handled by PrivateTmp in newer systemd
%{
enabled: :tmp in cfg[:dirs],
path: cfg[:tmp_dir],
user: "$APP_USER",
group: "$APP_GROUP",
mode: cfg[:tmp_directory_mode],
comment: "Temp directory"
}
]
else
[]
end
files =
cfg[:copy_files] ++
[
%{
src: ["_build/", :mix_env, "/systemd/lib/systemd/system/*"],
dst: "/lib/systemd/system/",
mode: "600"
},
%{
enabled: cfg[:sudo_deploy] or cfg[:sudo_app],
src: ["_build/", :mix_env, "/deploy/etc/sudoers.d/", :ext_name],
dst: ["/etc/sudoers.d/", :ext_name],
mode: "600"
}
]
files =
for file <- files, file[:enabled] != false do
%{
file
| src: Mix.Tasks.Deploy.expand_vars(file.src, cfg),
dst: Mix.Tasks.Deploy.expand_vars(file.dst, cfg)
}
end
dirs =
for dir <- dirs, dir[:enabled] != false do
%{dir | path: Mix.Tasks.Deploy.expand_vars(dir.path, cfg)}
end
vars = [create_dirs: dirs, copy_files: files] ++ cfg
for template <- cfg[:templates], do: write_template(vars, cfg[:bin_dir], template)
if cfg[:sudo_deploy] or cfg[:sudo_app] do
# Give deploy and/or app user ability to run start/stop commands via sudo
write_template(
cfg,
Path.join(cfg[:output_dir], "/etc/sudoers.d"),
"sudoers",
cfg[:ext_name]
)
end
end
defp write_template(cfg, dest_dir, template),
do: write_template(cfg, dest_dir, template, template)
defp write_template(cfg, dest_dir, template, file) do
output_file = cfg[:target_prefix] <> file
# target_file = Path.join(dest_dir, output_file)
# Mix.shell.info "Generating #{target_file} from template #{template}"
Templates.write_template(cfg, dest_dir, template, output_file)
end
end
defmodule Mix.Tasks.Deploy.Local do
@shortdoc "Deploy release to local machine"
@moduledoc """
This task deploys a Distillery release to the local machine.
It extracts the release tar to a timestamped directory like
`/srv/:app/releases/20170619175601`, then makes a symlink
from `/srv/:app/current` to it.
This module looks for configuration in the mix project, to get the app and version,
and under the application environment under `mix_deploy`.
```
"""
use Mix.Task
def run(args) do
cfg = Mix.Tasks.Deploy.parse_args(args)
release_dir = Path.join(cfg[:releases_dir], create_timestamp())
Mix.shell().info("Deploying release to #{release_dir}")
:ok = File.mkdir_p(release_dir)
release_name = to_string(cfg[:release_name])
version = cfg[:version]
tar_file =
case cfg[:release_system] do
:mix ->
Path.join([cfg[:build_path], "#{release_name}-#{version}.tar.gz"])
:distillery ->
Path.join([
cfg[:build_path],
"rel",
release_name,
"releases",
version,
"#{release_name}.tar.gz"
])
end
Mix.shell().info("Extracting tar #{tar_file}")
:ok = :erl_tar.extract(to_charlist(tar_file), [{:cwd, release_dir}, :compressed])
current_link = cfg[:current_dir]
if File.exists?(current_link) do
# Mix.shell.info "Removing current link: #{current_link}"
:ok = File.rm(current_link)
end
:ok = File.ln_s(release_dir, current_link)
end
@spec create_timestamp() :: binary
defp create_timestamp do
{{year, month, day}, {hour, minute, second}} =
:calendar.now_to_universal_time(:os.timestamp())
timestamp =
:io_lib.format("~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B", [
year,
month,
day,
hour,
minute,
second
])
timestamp |> List.flatten() |> to_string
end
end
defmodule Mix.Tasks.Deploy.Local.Rollback do
@moduledoc """
Update current symlink to point to the previous release directory.
"""
use Mix.Task
def run(args) do
cfg = Mix.Tasks.Deploy.parse_args(args)
dirs = cfg[:releases_dir] |> File.ls!() |> Enum.sort() |> Enum.reverse()
rollback(dirs, cfg)
end
@spec rollback([Path.t()], Keyword.t()) :: :ok
defp rollback([_current, prev | _rest], cfg) do
release_path = Path.join(cfg[:releases_dir], prev)
current_dir = cfg[:current_dir]
:ok = remove_link(current_dir)
Mix.shell().info("Making link from #{release_path} to #{current_dir}")
:ok = File.ln_s(release_path, current_dir)
end
defp rollback(dirs, _cfg) do
Mix.shell().info("Nothing to roll back to: releases = #{inspect(dirs)}")
:ok
end
@spec remove_link(Path.t()) :: :ok | {:error, :file.posix()}
defp remove_link(current_path) do
case File.read_link(current_path) do
{:ok, target} ->
Mix.shell().info("Removing link from #{target} to #{current_path}")
File.rm(current_path)
{:error, _reason} ->
Mix.shell().info("No current link #{current_path}")
:ok
end
end
end