A deployment helper for Elixir.
Minex has no strong opinion on how you should do your deployments. It has no deployment strategies, but allows you to define them youself with a simple syntax. It does provide helpers to easily run commands. Both locally and over SSH (in a single session where possible).
Local commands are run with an interactive shell by default (using Erlexec). Remote commands are run as ssh
commands.
Add to your mix.exs
and run mix deps.get
:
def deps do
[
{:minex, "~> 0.1.0", only: :dev}
]
end
Get started by running:
mix minex.init
This will create the following files:
config/deploy.exs
config/deploy/tasks.exs
The initial deploy.exs
contains a basic sample of how a deployment can work. You define tasks that can execute commands locally or on a remote server.
Example deploy file:
use Minex
set(:name, "my_app")
set(:deploy_to, "/apps/#{get(:name)}")
set(:host, "[email protected]")
Code.require_file("deploy/tasks.exs", Path.dirname(__ENV__.file))
# expects a config/deploy/Dockerfile
public_task(:build, fn ->
command("docker container rm dummy", allow_fail: true)
command("docker build -f config/deploy/Dockerfile -t #{get(:name)} .")
command("docker create -ti --name dummy #{get(:name)}:latest bash")
command("docker cp dummy:/release.tar.gz release.tar.gz")
end)
public_task(:deploy, fn ->
run(:build)
run(:upload, ["release.tar.gz", "#{get(:deploy_to)}/release.tar.gz"])
run(:remote, fn ->
command("cd #{get(:deploy_to)}")
command("( [ -d \"bin\" ] && ./bin/#{get(:name)} stop || true )")
command("tar -zxf release.tar.gz -C .")
command("./bin/#{get(:name)} daemon")
end)
end)
Minex.run(System.argv())
A public task can be run with mix minex task_name
, for example:
mix minex deploy
Tip: in your mix.exs
you can add aliases for your most used commands. You can for example add deploy: ["minex deploy"]
and then deploy with mix deploy
.
A normal task
can only be run internally and not from the command line.
task(:my_task, fn ->
command("echo a")
command("echo b")
command("echo c")
end)
By default a command
is run from the local shell. Only if it's included in a :remote
block, the nested commands will be run over SSH remotely.
run(:remote, fn ->
run(:my_task)
end)
The code above will result in the following command being executed:
ssh [email protected] -- $'( echo a && echo b && echo c )'
If a command results in a non-zero exit code, the rest of the script is aborted (both locally and remotely).
Tasks can accept arguments if needed:
task(:my_task, fn [arg] ->
command("echo #{arg}")
end)
run(:my_task, ["test"])
Public tasks:
:help
- public task to display the help message (list available public tasks). This is als triggered if you run minex without arguments.:generate_script
- public task to generate a bash script for the tasks defined withgenerate_script_task
.
Helper tasks:
:command
- this task is triggered for eachcommand()
you call. Can execute locally or remotely, depending on the context.:remote
- collect all nested commands and execute them on the:host
over SSH by chaining them.:upload
- takes a[local_path, remote_path]
as argument and uses:scp
to upload the file to the:host
:download
- takes a[remote_path, local_path]
as argument and uses:scp
to download the file from the:host
:scp
- takes a[source, dest]
, so this should include the host. Used by upload and download internally.
And some internal commands:
:remote_command
- run a single command remotely. Used internally by the:remote
task.:local_command
- run a single command locally. Used internally if not in remote mode. This will raise on non-zero exit codes.:remote_exec
- core task to run a command over SSH. this actually triggers a localcommand
that executesssh
from the shell.:local_exec
- core task to run a command locally (uses Erlexec)
These settings wills be used by the base tasks:
:host
- target server for remote tasks:ssh_opts
- string with options for thessh
command line call:scp_opts
- string with options for thescp
command line call:remote_command_options
- keyword list of commands options the executing thessh
commands locally. Default is[]
:local_command_options
- keyword list of basic commands options for all local commands. Default is[interact: true, echo_cmd: true]
:generate_script_template
- EEx template that renders the bash script for thegenerate_script
command.
Other settings can be defined at will for your own use.
A common usecase is having multiple environments like staging and production.
A way to support that is by first specifying the environment before you execute a task.
use Minex
# ...
args =
case System.argv() do
["staging" | args] ->
set(:host, "your_staging_host")
args
["production" | args] ->
set(:host, "your_production_host")
args
[_other | _] ->
raise "please supply an environment as the first argument"
[] ->
# allow empty to display help
[]
end
# Run with the rest of the args
Minex.run(args)
If you want to run your commands on a seperate build server, you can either change your host to the build server and change it back to the target server later or you can create a specific task to temporarily change the settings:
task(:build_server, fn [fun] ->
# set always returns the old settings of the keys you set
previous_settings =
set(
host: get(:build_server_host),
ssh_opts: "-i ~/.ssh/build_key"
)
run(:remote, fn ->
fun.()
end)
# reset
set(previous_settings)
end)
run(:build_server, fn ->
# ...
end)
To re-use the same connection you can set up a control master. You can do this by specifiying it in your ~/.ssh/config
or by starting it in the task:
# Build in settings that are used in the scp/ssh task
set(:ssh_opts, ~s[-o ControlPath=".ssh-control.%h"])
set(:scp_opts, ~s[-o ControlPath=".ssh-control.%h"])
# Start SSH connection in master mode and detach the first time data is received
task(:start_ssh, fn ->
command("ssh #{get(:ssh_opts)} -TM #{get(:host)}", on_receive: fn _, _ -> :detach end, interact: false)
end)
task(:deploy, fn ->
run(:build)
run(:start_ssh)
# ...
end)
It's possible to override the build-in (or your own) tasks:
task(:remote_exec, [override: true], fn [command, options] ->
# your own implementation
end)
The generate_script_task
creates tasks that will be exported to a bash script. This is because for some tasks (like a remote console) you need a full shell/pty and this is not easy to start from the beam.
generate_script_task(:iex, fn ->
run(:remote, fn ->
command("cd #{get(:deploy_to)} && ./bin/#{get(:name)} remote")
end)
end)
Generate the bash script to a file of your choosing:
mix minex generate_script my_script
./my_script iex
If you have multiple environments configured:
mix minex production generate_script script/production
mix minex staging generate_script script/staging
./script/production iex
Note: to create both public and generate_script tasks, you can define them like this:
public_task(:name, [generate_script: true], fn ->
#...
end)
Some (interactive) commands need a PTY to be present. The enable this:
set(:remote_command_options, [pty: true])
set(:ssh_opts, "-t")
Entering passwords works, but they will be visible in the terminal. The easiest way around this would be generate_script
tasks and then actually executing the script from the shell.
The other option is grabbing the password with some helper that clears the terminal when typing and then using the on_receive
option to send the password when needed.
It's possible to automatically respond to promps by specifying a custom handler in your command. This can be abused to automatically login for example (use at your own risk):
public_task(:login_and_ls, fn ->
on_receive = fn pid, %{buffer: [last | _]} = state ->
cond do
last =~ ~r/Permission denied/ ->
raise("Password failed")
last =~ ~r/password: $/ ->
Minex.Command.send_input(pid, "your_password\n")
{:cont, state}
true ->
{:cont, state}
end
end
set(:host, "[email protected]")
set(:remote_command_options, [on_receive: on_receive, pty: true])
run(:remote, fn ->
command("ls")
end)
end)
Output the commands that would be executed otherwise:
task(:dry_run, fn [fun] ->
IO.puts "# Dry run:"
collect(fun)
|> Enum.join("\n")
|> IO.puts()
end)
case System.argv() do
["dry_run" | args] ->
run(:dry_run, fn ->
Minex.run(args)
end)
args ->
Minex.run(args)
end
Set environment variables for local commands:
Inline:
command("VAR=val; echo $VAR")
Per command:
command("echo $VAR", env: %{"VAR" => "val"})
For all commands:
set(:local_command_options, [interact: true, echo_cmd: true, env: %{"VAR" => "val"}])
command("echo $VAR")