Minex

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.

Installation

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:

Get Started

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, "deploy@1.2.3.4")

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.

Basics

Tasks and commands

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 deploy@1.2.3.4 -- $'( 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"])

Minex defines some base tasks:

Public tasks:

Helper tasks:

And some internal commands:

Settings

These settings wills be used by the base tasks:

Other settings can be defined at will for your own use.

Advanced usage

Multiple environments

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)

Build on a seperate build server

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)

Use a single SSH connection

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)

Overriding tasks

It's possible to override the build-in (or your own) tasks:

task(:remote_exec, [override: true], fn [command, options] ->
  # your own implementation
end)

Generate script tasks

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)

Use a PTY

Some (interactive) commands need a PTY to be present. The enable this:

set(:remote_command_options, [pty: true])
set(:ssh_opts, "-t")

Entering passwords

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.

Semi interactive commands

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, "user_with_password@1.2.3.4")
  set(:remote_command_options, [on_receive: on_receive, pty: true])

  run(:remote, fn ->
    command("ls")
  end)
end)

Dry run

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

Local environment variables

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")

Acknowledgements

Inspired by the Ruby gem Mina and by Bootleg