Current section
Files
Jump to
Current section
Files
lib/mix/tasks/systemd.ex
defmodule Mix.Tasks.Systemd do
@moduledoc false
# Directory where `mix systemd.generate` stores output files,
# e.g. _build/prod/systemd
@output_dir "systemd"
# Directory where `mix systemd.init` copies templates in user project
@template_dir "rel/templates/systemd"
@app :mix_systemd
@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 = Application.get_all_env(@app) |> Keyword.merge(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 =
cond do
not is_nil(mix_config[:app]) ->
mix_config[:app]
not is_nil(user_config[:app_name]) ->
user_config[:app_name]
is_nil(mix_config[:app]) and Mix.Project.umbrella?() ->
mix_config[:default_release] || Enum.at(mix_config[:releases], 0) |> elem(0)
end
# 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_join("", &String.capitalize/1)
base_dir = user_config[:base_dir] || "/srv"
build_path = Mix.Project.build_path()
output_dir =
user_config[:output_dir] || Path.join([build_path, @output_dir, "/lib/systemd/system"])
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 run app
app_user: ext_name,
app_group: ext_name,
# Name in logs
syslog_identifier: service_name,
# 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,
# Service start type
# https://www.freedesktop.org/software/systemd/man/systemd.service.html#Type=
# :simple | :exec | :notify | :forking
service_type: :simple,
# 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: output_dir,
# Directory with templates which override defaults
template_dir: @template_dir,
mix_env: Mix.env(),
# LANG variable
env_lang: "en_US.utf8",
# Number of open file descriptors, LimitNOFILE
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#LimitCPU=
limit_nofile: 65_535,
# File mode creation mask, UMask
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#UMask=
umask: "0027",
# 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",
],
# Script run by envronment config systemd unit
runtime_environment_service_script: nil,
# ExecStartPre commands to run before ExecStart
# https://www.freedesktop.org/software/systemd/man/systemd.service.html#ExecStartPre=
exec_start_pre: [],
# Whether the service shall be restarted when the service process exits, is killed, or times out
# https://www.freedesktop.org/software/systemd/man/systemd.service.html#Restart=
restart: "always",
# Time to sleep before restarting a service
# https://www.freedesktop.org/software/systemd/man/systemd.service.html#RestartSec=
restart_sec: nil,
# Time to wait for start-up
# https://www.freedesktop.org/software/systemd/man/systemd.service.html#TimeoutStartSec=
timeout_start_sec: nil,
# Wrapper script for ExecStart
exec_start_wrap: nil,
# Start unit after other systemd unit targets
unit_after_targets: [],
# Start runtime environment script after other systemd unit targets
runtime_environment_unit_after_targets: [],
# Enable chroot
chroot: false,
read_write_paths: [],
read_only_paths: [],
inaccessible_paths: [],
# Enable extra restrictions
paranoia: false,
# Config keys which have variable expansion
expand_keys: [
:env_files,
:env_vars,
:runtime_environment_service_script,
:exec_start_pre,
:exec_start_wrap,
:read_write_paths,
:read_only_paths,
:inaccessible_paths
],
# 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"),
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"),
start_command:
cfg[:start_command] || start_command(cfg[:service_type], cfg[:release_system]),
exec_start_wrap: ensure_trailing_space(cfg[:exec_start_wrap]),
unit_after_targets:
if cfg[:runtime_environment_service_script] do
cfg[:unit_after_targets] ++ ["#{cfg[:service_name]}-runtime-environment.service"]
else
cfg[:unit_after_targets]
end
],
cfg
)
# Set things based on values computed above
cfg =
Keyword.merge(
[
working_dir: cfg[:working_dir] || cfg[:current_dir]
],
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
# Set start comand based on systemd service type and release system
@doc false
@spec start_command(atom, atom) :: binary
def start_command(service_type, release_system)
# https://hexdocs.pm/mix/Mix.Tasks.Release.html#module-daemon-mode-unix-like
def start_command(:forking, :mix), do: "daemon"
def start_command(type, :mix) when type in [:simple, :exec, :notify], do: "start"
# https://hexdocs.pm/distillery/tooling/cli.html#release-tasks
def start_command(:forking, :distillery), do: "start"
def start_command(type, :distillery) when type in [:simple, :exec, :notify], do: "foreground"
# Make sure that script name has a trailing space if defined
@doc false
@spec ensure_trailing_space(nil | binary) :: binary
def ensure_trailing_space(nil), do: ""
def ensure_trailing_space(value) do
if String.ends_with?(value, " "), do: value, else: value <> " "
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
Enum.map_join(terms, "", &expand_vars(&1, cfg))
end
def expand_vars(value, _cfg), do: to_string(value)
end
defmodule Mix.Tasks.Systemd.Init do
@moduledoc """
Initialize template files.
## Command line options
* `--template_dir` - target directory
## Usage
# Copy default templates into your project
mix systemd.init
"""
@shortdoc "Initialize template files"
use Mix.Task
@app :mix_systemd
@impl Mix.Task
def run(args) do
cfg = Mix.Tasks.Systemd.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.Systemd.Generate do
@moduledoc """
Create systemd unit files for project.
## Usage
# Create unit files
MIX_ENV=prod mix systemd.generate
"""
@shortdoc "Create systemd unit file"
use Mix.Task
alias MixSystemd.Templates
@impl Mix.Task
def run(args) do
cfg = Mix.Tasks.Systemd.parse_args(args)
dest_dir = cfg[:output_dir]
service_name = cfg[:service_name]
write_template(cfg, dest_dir, "systemd.service", "#{service_name}.service")
if cfg[:restart_method] == :systemd_flag do
write_template(cfg, dest_dir, "restart.service", "#{service_name}-restart.service")
write_template(cfg, dest_dir, "restart.path", "#{service_name}-restart.path")
end
if cfg[:runtime_environment_service] do
write_template(
cfg,
dest_dir,
"runtime-environment.service",
"#{service_name}-runtime-environment.service"
)
end
end
defp write_template(cfg, dest_dir, template, file) do
# target_file = Path.join(dest_dir, file)
# Mix.shell.info "Generating #{target_file} from template #{template}"
Templates.write_template(cfg, dest_dir, template, file)
end
end