├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── dicon.ex ├── dicon │ ├── executor.ex │ └── secure_shell.ex └── mix │ ├── dicon.ex │ └── tasks │ ├── dicon.control.ex │ ├── dicon.deploy.ex │ └── dicon.switch.ex ├── mix.exs ├── mix.lock └── test ├── dicon └── executor_test.exs ├── fixtures └── empty.tar.gz ├── mix └── tasks │ ├── dicon.control_test.exs │ ├── dicon.deploy_test.exs │ └── dicon.switch_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | lint: 11 | name: Code linting 12 | runs-on: ubuntu-18.04 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Set up Elixir environment 18 | uses: erlef/setup-elixir@v1 19 | with: 20 | otp-version: 23 21 | elixir-version: 1.11 22 | 23 | - name: Check unused dependencies 24 | run: mix deps.get && mix deps.unlock --check-unused 25 | 26 | - name: Check format 27 | run: mix format --check-formatted 28 | 29 | - name: Check compilation warnings 30 | run: mix compile --warnings-as-errors 31 | 32 | test: 33 | name: Test suite 34 | runs-on: ubuntu-16.04 35 | 36 | strategy: 37 | matrix: 38 | versions: 39 | - otp: 18.3 40 | elixir: 1.3 41 | - otp: 23 42 | elixir: 1.11 43 | 44 | env: 45 | MIX_ENV: test 46 | 47 | steps: 48 | - uses: actions/checkout@v2 49 | 50 | - name: Set up Elixir environment 51 | uses: erlef/setup-elixir@v1 52 | with: 53 | elixir-version: ${{ matrix.versions.elixir }} 54 | otp-version: ${{ matrix.versions.otp }} 55 | 56 | - name: Install dependencies 57 | run: mix deps.get --only test 58 | 59 | - name: Run tests 60 | run: mix test 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config/* 2 | /_build 3 | /cover 4 | /deps 5 | erl_crash.dump 6 | *.ez 7 | /doc 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.5.0 4 | 5 | ### Breaking changes 6 | 7 | * Move configuration of hosts under each host in the `:dicon` configuration, and make the `:hosts` configuration option be a list of host names (as atoms). 8 | * Require Elixir `~> 1.3`. 9 | 10 | ### Improvements and bug fixes 11 | 12 | * Add support for host-specific OS environment when executing commands on the remote host in `dicon.control`. 13 | * Fix Elixir 1.4 warnings. 14 | * Make `--only`/`--skip` fail when any of the listed hosts don't exist in the configuration. 15 | * Print feedback when connecting to hosts and print commands executed by the executor. 16 | * Add the `:connect_timeout`, `:exec_timeout`, and `:write_timeout` options to the `Dicon.SecureShell` executor. 17 | * Change progress bar to spinner in `mix dicon.deploy`. 18 | * Merge custom application environment with the contents of `sys.config`. 19 | * Improve transferring speed by not using SFTP. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Aleksei Magusev 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Digital Conveyor 2 | 3 | ![CI Status](https://github.com/lexmag/dicon/workflows/CI/badge.svg) 4 | [![Hex Version](https://img.shields.io/hexpm/v/dicon.svg)](https://hex.pm/packages/dicon) 5 | 6 | Simple release deliverer for Elixir. 7 | 8 | ## Installation 9 | 10 | Add dicon as a dependency in your `mix.exs` file: 11 | 12 | ```elixir 13 | def deps do 14 | [{:dicon, "~> 0.5", [only: :dev, runtime: false]}] 15 | end 16 | ``` 17 | 18 | After you are done, run `mix deps.get` in your shell to fetch the dependencies. 19 | 20 | ## License 21 | 22 | This software is licensed under [the ISC license](LICENSE). 23 | -------------------------------------------------------------------------------- /lib/dicon.ex: -------------------------------------------------------------------------------- 1 | defmodule Dicon do 2 | @moduledoc """ 3 | Simple release deliverer for Elixir. 4 | 5 | Dicon gets most of the information needed to deploy and manage releases from 6 | the configuration of the `:dicon` application. For example, in your 7 | application's configuration (`my_app/config/config.exs`): 8 | 9 | config :dicon, 10 | target_dir: "/home/deploy/my_app" 11 | 12 | ## Configuration options 13 | 14 | * `:otp_app` - an atom that specifies the name of the application being 15 | deployed. 16 | 17 | * `:target_dir` - a binary that specifies the directory where the release 18 | tarball will be extracted into. 19 | 20 | * `:hosts` - a list of `host_name` atoms that specifies which 21 | servers the release should be deployed to. Each host should be then 22 | configured under the `:dicon` application. See the "Configuration for hosts" 23 | section below. 24 | `authority`s should be "authorities" according to [this 25 | RFC](https://tools.ietf.org/html/rfc3986#section-3.2), i.e., binaries with 26 | an optional userinfo followed by `@`, an hostname, and an optional port 27 | preceded by `:`. For example, `me:mypassword@example.com:22`. 28 | 29 | * `:executor` - a module that will be used to execute commands on servers. 30 | By default, it's `Dicon.SecureShell`. 31 | 32 | ### Configuration for hosts 33 | 34 | Each host listed in the `:hosts` configuration option mentioned above can be 35 | configured under the `:dicon` application. For example, take this configuration: 36 | 37 | config :dicon, 38 | hosts: [:app01, :app02] 39 | 40 | Now the `:app01` and `:app02` hosts can be configured like this: 41 | 42 | config :dicon, :app01, 43 | authority: "myuser@app01.example.net" 44 | 45 | These are the supported host configuration options: 46 | 47 | * `:authority` - (binary) an "authority" according to [this 48 | RFC](https://tools.ietf.org/html/rfc3986#section-3.2), that is, a binary with 49 | an optional userinfo followed by `@`, an hostname, and an optional port 50 | preceded by `:`. For example, `"me:mypassword@example.net:22"`. 51 | 52 | * `:os_env` - (map) a map of environment variable name (as a binary) to 53 | value (as a binary). These environment variables will be used when running 54 | commands on the target host. 55 | 56 | * `:apps_env` - (keyword list) a keyword list of application to configuration 57 | that can be used to override the configuration for some applications on 58 | the target host. 59 | 60 | ### Configuration for executors 61 | 62 | Each executor can be configured differently; to configure an executor, specify 63 | the configuration for that executor under the configuration for the `:dicon` 64 | application. 65 | 66 | config :dicon, Dicon.SecureShell, 67 | dir: "..." 68 | 69 | """ 70 | 71 | @doc false 72 | def config(key, opts \\ []) 73 | 74 | def config(:hosts, opts) do 75 | only = Keyword.get_values(opts, :only) |> Enum.map(&String.to_atom/1) 76 | skip = Keyword.get_values(opts, :skip) |> Enum.map(&String.to_atom/1) 77 | 78 | hosts = Application.fetch_env!(:dicon, :hosts) 79 | 80 | assert_filtered_hosts_exist(hosts, only ++ skip) 81 | 82 | Enum.filter(hosts, host_selector(only, skip)) 83 | end 84 | 85 | def config(key, _opts) do 86 | Application.fetch_env!(:dicon, key) 87 | end 88 | 89 | def host_config(name) do 90 | Application.fetch_env!(:dicon, name) 91 | end 92 | 93 | defp assert_filtered_hosts_exist(hosts, filtered_hosts) do 94 | if unknown_host = Enum.find(filtered_hosts, &(not (&1 in hosts))) do 95 | Mix.raise("Unknown host: #{inspect(Atom.to_string(unknown_host))}") 96 | end 97 | end 98 | 99 | defp host_selector([], skip) do 100 | &(not (&1 in skip)) 101 | end 102 | 103 | defp host_selector(only, skip) do 104 | only = only -- skip 105 | &(&1 in only) 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/dicon/executor.ex: -------------------------------------------------------------------------------- 1 | defmodule Dicon.Executor do 2 | @moduledoc """ 3 | Behaviour for executors. 4 | 5 | This behaviour specifies the callbacks that executors must implement. Look at 6 | the documentation for the `Dicon` module for more information about executors. 7 | """ 8 | 9 | alias Dicon.SecureShell 10 | 11 | @type conn :: identifier | struct 12 | 13 | @type t :: %__MODULE__{executor: module, conn: conn} 14 | 15 | defstruct [:executor, :conn] 16 | 17 | @doc """ 18 | Connects to the given authority, returning a term that identifies the 19 | connection. 20 | """ 21 | @callback connect(authority :: binary) :: {:ok, conn} | {:error, binary} 22 | 23 | @doc """ 24 | Executes the given `command` on the given connection, writing the output of 25 | `command` to `device`. 26 | """ 27 | @callback exec(conn, command :: charlist, device :: atom | pid) :: :ok | {:error, binary} 28 | 29 | @callback write_file(conn, target :: charlist, content :: iodata, :write | :append) :: 30 | :ok | {:error, binary} 31 | 32 | @doc """ 33 | Copies the local file `source` over to the destination `target` on the given 34 | connection. 35 | """ 36 | @callback copy(conn, source :: charlist, target :: charlist) :: :ok | {:error, binary} 37 | 38 | @doc """ 39 | Connects to authority. 40 | 41 | The connection happens through the executor configured in the configuration 42 | for the `:dicon` application; see the documentation for the `Dicon` module for 43 | more information. 44 | 45 | ## Examples 46 | 47 | %Dicon.Executor{} = Dicon.Executor.connect("meg:secret@example.com") 48 | 49 | """ 50 | @spec connect(binary) :: {:ok, t} | {:error, term} 51 | def connect(authority) do 52 | executor = Application.get_env(:dicon, :executor, SecureShell) 53 | 54 | case executor.connect(authority) do 55 | {:ok, conn} -> 56 | Mix.shell().info("Connected to #{authority}") 57 | %__MODULE__{executor: executor, conn: conn} 58 | 59 | {:error, reason} -> 60 | raise_error(executor, reason) 61 | end 62 | end 63 | 64 | @doc """ 65 | Executes the given `command` on the connection in `state`. 66 | 67 | ## Examples 68 | 69 | state = Dicon.Executor.connect("meg:secret@example.com") 70 | Dicon.Executor.exec(state, "ls -la") 71 | #=> :ok 72 | 73 | """ 74 | def exec(%__MODULE__{} = state, command, device \\ Process.group_leader()) do 75 | Mix.shell().info("==> EXEC #{command}") 76 | run(state, :exec, [command, device]) 77 | end 78 | 79 | @doc """ 80 | Copies the `source` file on the local machine to the `target` on the remote 81 | machine on the connection in `state`. 82 | 83 | ## Examples 84 | 85 | state = Dicon.Executor.connect("meg:secret@example.com") 86 | Dicon.Executor.copy(state, "hello.txt", "uploaded-hello.txt") 87 | #=> :ok 88 | 89 | """ 90 | def copy(%__MODULE__{} = state, source, target) do 91 | Mix.shell().info("==> COPY #{source} #{target}") 92 | run(state, :copy, [source, target]) 93 | end 94 | 95 | def write_file(%__MODULE__{} = state, target, content, mode \\ :write) 96 | when mode in [:write, :append] and (is_binary(content) or is_list(content)) do 97 | Mix.shell().info("==> WRITE #{target}") 98 | run(state, :write_file, [target, content, mode]) 99 | end 100 | 101 | defp run(%{executor: executor, conn: conn}, fun, args) do 102 | case apply(executor, fun, [conn | args]) do 103 | {:error, reason} -> raise_error(executor, reason) 104 | :ok -> :ok 105 | end 106 | end 107 | 108 | defp raise_error(executor, reason) when is_binary(reason) do 109 | Mix.raise("(in #{inspect(executor)}) " <> reason) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/dicon/secure_shell.ex: -------------------------------------------------------------------------------- 1 | defmodule Dicon.SecureShell do 2 | @moduledoc """ 3 | A `Dicon.Executor` based on SSH. 4 | 5 | ## Configuration 6 | 7 | The configuration for this executor must be specified under the configuration 8 | for the `:dicon` application: 9 | 10 | config :dicon, Dicon.SecureShell, 11 | dir: "..." 12 | 13 | The available configuration options for this executor are: 14 | 15 | * `:dir` - a binary that specifies the directory where the SSH keys are (in 16 | the local machine). Defaults to `"~/.ssh"`. 17 | 18 | * `:connect_timeout` - an integer that specifies the timeout (in milliseconds) 19 | when connecting to the host. 20 | 21 | * `:write_timeout` - an integer that specifies the timeout (in milliseconds) 22 | when writing data to the host. 23 | 24 | * `:exec_timeout` - an integer that specifies the timeout (in milliseconds) 25 | when executing commands on the host. 26 | 27 | The username and password user to connect to the server will be picked up by 28 | the URL that identifies that server (in `:dicon`'s configuration); read more 29 | about this in the documentation for the `Dicon` module. 30 | """ 31 | 32 | @behaviour Dicon.Executor 33 | 34 | # Size in bytes. 35 | @file_chunk_size 100_000 36 | 37 | defstruct [ 38 | :conn, 39 | :connect_timeout, 40 | :write_timeout, 41 | :exec_timeout 42 | ] 43 | 44 | def connect(authority) do 45 | config = Application.get_env(:dicon, __MODULE__, []) 46 | connect_timeout = Keyword.get(config, :connect_timeout, 5_000) 47 | write_timeout = Keyword.get(config, :write_timeout, 5_000) 48 | exec_timeout = Keyword.get(config, :exec_timeout, 5_000) 49 | user_dir = Keyword.get(config, :dir, "~/.ssh") |> Path.expand() 50 | {user, passwd, host, port} = parse_elements(authority) 51 | 52 | opts = 53 | put_option([], :user, user) 54 | |> put_option(:password, passwd) 55 | |> put_option(:user_dir, user_dir) 56 | 57 | host = String.to_charlist(host) 58 | 59 | result = 60 | with :ok <- ensure_started(), 61 | {:ok, conn} <- :ssh.connect(host, port, opts, connect_timeout) do 62 | state = %__MODULE__{ 63 | conn: conn, 64 | connect_timeout: connect_timeout, 65 | write_timeout: write_timeout, 66 | exec_timeout: exec_timeout 67 | } 68 | 69 | {:ok, state} 70 | end 71 | 72 | format_if_error(result) 73 | end 74 | 75 | defp put_option(opts, _key, nil), do: opts 76 | 77 | defp put_option(opts, key, value) do 78 | [{key, String.to_charlist(value)} | opts] 79 | end 80 | 81 | defp ensure_started() do 82 | case :ssh.start() do 83 | :ok -> 84 | :ok 85 | 86 | {:error, {:already_started, :ssh}} -> 87 | :ok 88 | 89 | {:error, reason} -> 90 | {:error, "could not start ssh application: " <> Application.format_error(reason)} 91 | end 92 | end 93 | 94 | defp parse_elements(authority) do 95 | parts = String.split(authority, "@", parts: 2) 96 | 97 | [user_info, host_info] = 98 | case parts do 99 | [host_info] -> 100 | ["", host_info] 101 | 102 | result -> 103 | result 104 | end 105 | 106 | parts = String.split(user_info, ":", parts: 2, trim: true) 107 | destructure([user, passwd], parts) 108 | 109 | parts = String.split(host_info, ":", parts: 2, trim: true) 110 | 111 | {host, port} = 112 | case parts do 113 | [host, port] -> 114 | {host, String.to_integer(port)} 115 | 116 | [host] -> 117 | {host, 22} 118 | end 119 | 120 | {user, passwd, host, port} 121 | end 122 | 123 | def exec(%__MODULE__{} = state, command, device) do 124 | %{conn: conn, connect_timeout: connect_timeout, exec_timeout: exec_timeout} = state 125 | 126 | result = 127 | with {:ok, channel} <- :ssh_connection.session_channel(conn, connect_timeout), 128 | :success <- :ssh_connection.exec(conn, channel, command, exec_timeout) do 129 | handle_reply(conn, channel, device, exec_timeout, _acc = []) 130 | end 131 | 132 | format_if_error(result) 133 | end 134 | 135 | defp handle_reply(conn, channel, device, exec_timeout, acc) do 136 | receive do 137 | {:ssh_cm, ^conn, {:data, ^channel, _code, data}} -> 138 | handle_reply(conn, channel, device, exec_timeout, [acc | data]) 139 | 140 | {:ssh_cm, ^conn, {:eof, ^channel}} -> 141 | handle_reply(conn, channel, device, exec_timeout, acc) 142 | 143 | {:ssh_cm, ^conn, {:exit_status, ^channel, _status}} -> 144 | handle_reply(conn, channel, device, exec_timeout, acc) 145 | 146 | {:ssh_cm, ^conn, {:closed, ^channel}} -> 147 | IO.write(device, acc) 148 | after 149 | exec_timeout -> {:error, :timeout} 150 | end 151 | end 152 | 153 | def write_file(%__MODULE__{} = state, target, content, :append) do 154 | write_file(state, ["cat >> ", target], content) 155 | end 156 | 157 | def write_file(%__MODULE__{} = state, target, content, :write) do 158 | write_file(state, ["cat > ", target], content) 159 | end 160 | 161 | defp write_file(state, command, content) do 162 | %{conn: conn, connect_timeout: connect_timeout, exec_timeout: exec_timeout} = state 163 | 164 | result = 165 | with {:ok, channel} <- :ssh_connection.session_channel(conn, connect_timeout), 166 | :success <- :ssh_connection.exec(conn, channel, command, exec_timeout), 167 | :ok <- :ssh_connection.send(conn, channel, content, exec_timeout), 168 | :ok <- :ssh_connection.send_eof(conn, channel) do 169 | handle_reply(conn, channel, Process.group_leader(), exec_timeout, _acc = []) 170 | end 171 | 172 | format_if_error(result) 173 | end 174 | 175 | def copy(%__MODULE__{} = state, source, target) do 176 | %{conn: conn, connect_timeout: connect_timeout, exec_timeout: exec_timeout} = state 177 | 178 | result = 179 | with {:ok, %File.Stat{size: size}} <- File.stat(source), 180 | chunk_count = round(Float.ceil(size / @file_chunk_size)), 181 | stream = File.stream!(source, [], @file_chunk_size) |> Stream.with_index(1), 182 | {:ok, channel} <- :ssh_connection.session_channel(conn, connect_timeout), 183 | :success <- :ssh_connection.exec(conn, channel, ["cat > ", target], exec_timeout), 184 | Enum.each(stream, fn {chunk, chunk_index} -> 185 | # TODO: we need to remove this assertion here as well, once we have a 186 | # better "streaming" API. 187 | :ok = :ssh_connection.send(conn, channel, chunk, exec_timeout) 188 | write_spinner(chunk_index, chunk_count) 189 | end), 190 | IO.write(IO.ANSI.format([:clear_line, ?\r])), 191 | :ok <- :ssh_connection.send_eof(conn, channel) do 192 | handle_reply(conn, channel, Process.group_leader(), exec_timeout, _acc = []) 193 | end 194 | 195 | format_if_error(result) 196 | end 197 | 198 | @spinner_chars {?|, ?/, ?-, ?\\} 199 | 200 | defp write_spinner(index, count) do 201 | percent = round(100 * index / count) 202 | spinner = elem(@spinner_chars, rem(index, tuple_size(@spinner_chars))) 203 | 204 | [:clear_line, ?\r, spinner, ?\s, Integer.to_string(percent), ?%] 205 | |> IO.ANSI.format() 206 | |> IO.write() 207 | end 208 | 209 | defp format_if_error(:failure) do 210 | {:error, "failure on the SSH connection"} 211 | end 212 | 213 | defp format_if_error({:error, reason} = error) when is_binary(reason) do 214 | error 215 | end 216 | 217 | defp format_if_error({:error, reason}) do 218 | case :inet.format_error(reason) do 219 | 'unknown POSIX error' -> 220 | {:error, inspect(reason)} 221 | 222 | message -> 223 | {:error, List.to_string(message)} 224 | end 225 | end 226 | 227 | defp format_if_error(other), do: other 228 | end 229 | -------------------------------------------------------------------------------- /lib/mix/dicon.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Dicon do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Converts a command-line switch to its string representation. 6 | """ 7 | def switch_to_string({name, nil}), do: name 8 | def switch_to_string({name, val}), do: name <> "=" <> val 9 | end 10 | -------------------------------------------------------------------------------- /lib/mix/tasks/dicon.control.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dicon.Control do 2 | use Mix.Task 3 | 4 | @shortdoc "Execute a command on the remote release" 5 | 6 | @moduledoc """ 7 | This task is used to execute commands on the remote release. 8 | 9 | It accepts one argument and forwards that command to the remote release. 10 | 11 | ## Usage 12 | 13 | mix dicon.control COMMAND 14 | 15 | ## Examples 16 | 17 | mix dicon.control ping 18 | 19 | """ 20 | 21 | import Dicon, only: [config: 1, config: 2, host_config: 1] 22 | 23 | alias Dicon.Executor 24 | 25 | @options [strict: [only: :keep, skip: :keep]] 26 | 27 | def run(argv) do 28 | case OptionParser.parse(argv, @options) do 29 | {opts, [command], []} -> 30 | for host <- config(:hosts, opts) do 31 | host_config = host_config(host) 32 | authority = Keyword.fetch!(host_config, :authority) 33 | conn = Executor.connect(authority) 34 | exec(conn, host_config, config(:target_dir), command) 35 | end 36 | 37 | {_opts, _commands, [switch | _]} -> 38 | Mix.raise("Invalid option: " <> Mix.Dicon.switch_to_string(switch)) 39 | 40 | {_opts, _commands, _errors} -> 41 | Mix.raise("Expected a single argument (the command to execute)") 42 | end 43 | end 44 | 45 | defp exec(conn, host_config, target_dir, command) do 46 | otp_app = config(:otp_app) |> Atom.to_string() 47 | 48 | env = 49 | Enum.map(host_config[:os_env] || %{}, fn 50 | {key, value} when is_binary(key) and is_binary(value) -> 51 | [key, ?=, inspect(value, binaries: :as_strings), ?\s] 52 | end) 53 | 54 | command = env ++ [target_dir, "/current/bin/", otp_app, ?\s, command] 55 | Executor.exec(conn, command) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/mix/tasks/dicon.deploy.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dicon.Deploy do 2 | use Mix.Task 3 | 4 | @shortdoc "Uploads a tarball to the configured server" 5 | 6 | @moduledoc """ 7 | This task uploads the specified tarball on the configured hosts. 8 | 9 | The configured hosts are picked up from the application's configuration; read 10 | the `Dicon` documentation for more information. 11 | 12 | This task accepts two arguments: the compressed tarball to upload and its version. 13 | 14 | ## Usage 15 | 16 | mix dicon.deploy TARBALL VERSION 17 | 18 | ## Examples 19 | 20 | mix dicon.deploy my_app.tar.gz 1.0.0 21 | 22 | """ 23 | 24 | import Dicon, only: [config: 1, config: 2, host_config: 1] 25 | 26 | alias Dicon.Executor 27 | 28 | @options [strict: [only: :keep, skip: :keep]] 29 | 30 | def run(argv) do 31 | case OptionParser.parse(argv, @options) do 32 | {opts, [source, version], []} -> 33 | target_dir = config(:target_dir) 34 | 35 | for host <- config(:hosts, opts) do 36 | host_config = host_config(host) 37 | authority = Keyword.fetch!(host_config, :authority) 38 | conn = Executor.connect(authority) 39 | release_file = upload(conn, [source], target_dir) 40 | target_dir = [target_dir, ?/, version] 41 | unpack(conn, release_file, target_dir) 42 | write_custom_config(conn, host_config, target_dir, version) 43 | end 44 | 45 | {_opts, _commands, [switch | _]} -> 46 | Mix.raise("Invalid option: " <> Mix.Dicon.switch_to_string(switch)) 47 | 48 | {_opts, _commands, _errors} -> 49 | Mix.raise("Expected two arguments (the tarball path and the version)") 50 | end 51 | end 52 | 53 | defp ensure_dir(conn, path) do 54 | Executor.exec(conn, ["mkdir -p ", path]) 55 | end 56 | 57 | defp upload(conn, source, target_dir) do 58 | ensure_dir(conn, target_dir) 59 | release_file = [target_dir, "/release.tar.gz"] 60 | Executor.copy(conn, source, release_file) 61 | release_file 62 | end 63 | 64 | defp unpack(conn, release_file, target_dir) do 65 | ensure_dir(conn, target_dir) 66 | command = ["tar -C ", target_dir, " -zxf ", release_file] 67 | Executor.exec(conn, command) 68 | Executor.exec(conn, ["rm ", release_file]) 69 | end 70 | 71 | defp write_custom_config(conn, host_config, target_dir, version) do 72 | if config = host_config[:apps_env] do 73 | sys_config_path = [target_dir, "/releases/", version, "/sys.config"] 74 | 75 | # We use StringIO to receive "sys.config" content 76 | # that we can parse later. 77 | {:ok, device} = StringIO.open("") 78 | Executor.exec(conn, ["cat ", sys_config_path], device) 79 | {:ok, {"", sys_config_content}} = StringIO.close(device) 80 | {:ok, device} = StringIO.open(sys_config_content) 81 | 82 | sys_config = 83 | case :io.read(device, "") do 84 | {:ok, sys_config} -> sys_config 85 | {:error, _reason} -> Mix.raise("Could not parse \"sys.config\" file") 86 | :eof -> Mix.raise("\"sys.config\" file is incomplete") 87 | end 88 | 89 | {:ok, _} = StringIO.close(device) 90 | 91 | config = Mix.Config.merge(sys_config, config) 92 | content = :io_lib.format("~p.~n", [config]) 93 | Executor.write_file(conn, sys_config_path, content) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/mix/tasks/dicon.switch.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dicon.Switch do 2 | use Mix.Task 3 | 4 | @shortdoc "Switches the version of the deployed application on the specified servers" 5 | 6 | @moduledoc """ 7 | This task switches the version of the deployed application on the specified servers. 8 | 9 | The configured hosts are picked up from the application's configuration; read 10 | the `Dicon` documentation for more information. 11 | 12 | This task accepts one argument: the application version to switch to. 13 | Note that switching version is a lightweight operation that consists in symlinking 14 | the currently active release to the one specified by the given version. Switching 15 | versions won't affect the running application. 16 | 17 | ## Usage 18 | 19 | mix dicon.switch VERSION 20 | 21 | ## Examples 22 | 23 | mix dicon.switch 1.1.0 24 | 25 | """ 26 | 27 | import Dicon, only: [config: 1, config: 2, host_config: 1] 28 | 29 | alias Dicon.Executor 30 | 31 | @options [strict: [only: :keep, skip: :keep]] 32 | 33 | def run(argv) do 34 | case OptionParser.parse(argv, @options) do 35 | {opts, [version], []} -> 36 | target_dir = 37 | case config(:target_dir) do 38 | "/" <> _ = dir -> dir 39 | dir -> ["$PWD", ?/, dir] 40 | end 41 | 42 | for host <- config(:hosts, opts) do 43 | authority = Keyword.fetch!(host_config(host), :authority) 44 | conn = Executor.connect(authority) 45 | symlink(conn, [target_dir, ?/, version], [target_dir, "/current"]) 46 | end 47 | 48 | {_opts, _commands, [switch | _]} -> 49 | Mix.raise("Invalid option: " <> Mix.Dicon.switch_to_string(switch)) 50 | 51 | {_opts, _commands, _errors} -> 52 | Mix.raise("Expected a single argument (the version)") 53 | end 54 | end 55 | 56 | defp symlink(conn, source, target) do 57 | command = ["ln -snf ", source, ?\s, target] 58 | Executor.exec(conn, command) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Dicon.Mixfile do 2 | use Mix.Project 3 | 4 | def project() do 5 | [ 6 | app: :dicon, 7 | version: "0.5.0", 8 | elixir: "~> 1.3", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | package: package(), 12 | deps: deps(), 13 | description: description() 14 | ] 15 | end 16 | 17 | def application() do 18 | [applications: [:logger, :ssh]] 19 | end 20 | 21 | defp package() do 22 | [ 23 | maintainers: ["Aleksei Magusev", "Andrea Leopardi"], 24 | licenses: ["ISC"], 25 | links: %{"GitHub" => "https://github.com/lexmag/dicon"} 26 | ] 27 | end 28 | 29 | defp description() do 30 | "Simple release deliverer for Elixir" 31 | end 32 | 33 | defp deps() do 34 | [ 35 | {:earmark, ">= 0.0.0", only: :docs}, 36 | {:ex_doc, ">= 0.0.0", only: :docs} 37 | ] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.2.2", "f718159d6b65068e8daeef709ccddae5f7fdc770707d82e7d126f584cd925b74", [:mix], [], "hexpm", "59514c4a207f9f25c5252e09974367718554b6a0f41fe39f7dc232168f9cb309"}, 3 | "ex_doc": {:hex, :ex_doc, "0.16.2", "3b3e210ebcd85a7c76b4e73f85c5640c011d2a0b2f06dcdf5acdb2ae904e5084", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm", "b6fb4aef8125c62e6b6a7d7507eff70f376a7050c7745af11f08333ea9beebd3"}, 4 | } 5 | -------------------------------------------------------------------------------- /test/dicon/executor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Dicon.ExecutorTest do 2 | use ExUnit.Case 3 | 4 | alias Dicon.Executor 5 | 6 | defmodule FakeExecutor do 7 | @behaviour Executor 8 | 9 | def connect("fail"), do: {:error, "connect failed"} 10 | def connect(term), do: {:ok, term} 11 | 12 | def exec(_conn, 'fail', _device), do: {:error, "exec failed"} 13 | def exec(_conn, _command, _device), do: :ok 14 | 15 | def write_file(_conn, 'fail', "fail", _mode), do: {:error, "write failed"} 16 | def write_file(_conn, _target, _content, _mode), do: :ok 17 | 18 | def copy(_conn, 'fail', 'fail'), do: {:error, "copy failed"} 19 | def copy(_conn, _source, _target), do: :ok 20 | end 21 | 22 | setup_all do 23 | Application.put_env(:dicon, :executor, FakeExecutor) 24 | on_exit(fn -> Application.delete_env(:dicon, :executor) end) 25 | end 26 | 27 | test "connect/1" do 28 | assert %Executor{} = Executor.connect("whatever") 29 | assert_receive {:mix_shell, :info, ["Connected to whatever"]} 30 | 31 | message = "(in Dicon.ExecutorTest.FakeExecutor) connect failed" 32 | assert_raise Mix.Error, message, fn -> Executor.connect("fail") end 33 | end 34 | 35 | test "exec/2" do 36 | conn = Executor.connect("whatever") 37 | 38 | assert Executor.exec(conn, "whatever") == :ok 39 | assert_receive {:mix_shell, :info, ["==> EXEC whatever"]} 40 | 41 | message = "(in Dicon.ExecutorTest.FakeExecutor) exec failed" 42 | assert_raise Mix.Error, message, fn -> Executor.exec(conn, 'fail') end 43 | end 44 | 45 | test "copy/2" do 46 | conn = Executor.connect("whatever") 47 | 48 | assert Executor.copy(conn, 'source', 'target') == :ok 49 | assert_receive {:mix_shell, :info, ["==> COPY source target"]} 50 | 51 | message = "(in Dicon.ExecutorTest.FakeExecutor) copy failed" 52 | assert_raise Mix.Error, message, fn -> Executor.copy(conn, 'fail', 'fail') end 53 | end 54 | 55 | test "write_file/4" do 56 | conn = Executor.connect("whatever") 57 | 58 | assert Executor.write_file(conn, 'target', "content") == :ok 59 | assert_receive {:mix_shell, :info, ["==> WRITE target"]} 60 | 61 | message = "(in Dicon.ExecutorTest.FakeExecutor) write failed" 62 | 63 | assert_raise Mix.Error, message, fn -> 64 | Executor.write_file(conn, 'fail', "fail") 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/fixtures/empty.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lexmag/dicon/beed0d58d75d2ae64cc21a4d99831df7941c8db4/test/fixtures/empty.tar.gz -------------------------------------------------------------------------------- /test/mix/tasks/dicon.control_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dicon.ControlTest do 2 | use DiconTest.Case 3 | 4 | import Mix.Tasks.Dicon.Control, only: [run: 1] 5 | 6 | setup do 7 | put_dicon_env(%{ 8 | otp_app: :sample, 9 | target_dir: "test", 10 | hosts: [:one, :two], 11 | one: [authority: "one"], 12 | two: [authority: "two"] 13 | }) 14 | 15 | :ok 16 | end 17 | 18 | test "commands are run and feedback is received" do 19 | run(["run"]) 20 | 21 | assert_receive {:dicon, ref, :connect, ["one"]} 22 | assert_receive {:dicon, ^ref, :exec, ["test/current/bin/sample run"]} 23 | 24 | assert_receive {:dicon, ref, :connect, ["two"]} 25 | assert_receive {:dicon, ^ref, :exec, ["test/current/bin/sample run"]} 26 | 27 | refute_receive {:dicon, _, _, _} 28 | end 29 | 30 | test "hosts filtering" do 31 | put_dicon_env(%{ 32 | otp_app: :sample, 33 | target_dir: "test", 34 | hosts: [:one, :two], 35 | one: [authority: "one"], 36 | two: [authority: "two"] 37 | }) 38 | 39 | run(["run", "--only", "one"]) 40 | assert_receive {:dicon, ref, :connect, ["one"]} 41 | :ok = flush_reply(ref) 42 | refute_receive {:dicon, _, _, _} 43 | 44 | run(["run", "--skip", "one"]) 45 | assert_receive {:dicon, ref, :connect, ["two"]} 46 | :ok = flush_reply(ref) 47 | refute_receive {:dicon, _, _, _} 48 | 49 | run(["run", "--skip", "one", "--only", "one"]) 50 | refute_receive {:dicon, _, _, _} 51 | 52 | run(["run", "--only", "one", "--only", "two"]) 53 | assert_receive {:dicon, ref, :connect, ["one"]} 54 | :ok = flush_reply(ref) 55 | assert_receive {:dicon, ref, :connect, ["two"]} 56 | :ok = flush_reply(ref) 57 | refute_receive {:dicon, _, _, _} 58 | 59 | run(["run", "--skip", "one", "--skip", "two"]) 60 | refute_receive {:dicon, _, _, _} 61 | 62 | assert_raise Mix.Error, "Unknown host: \"foo\"", fn -> 63 | run(["run", "--skip", "foo", "--skip", "two"]) 64 | end 65 | end 66 | 67 | test "the task only accepts one argument" do 68 | message = "Expected a single argument (the command to execute)" 69 | assert_raise Mix.Error, message, fn -> run([]) end 70 | assert_raise Mix.Error, message, fn -> run(~w(one two)) end 71 | 72 | message = "Invalid option: --invalid" 73 | assert_raise Mix.Error, message, fn -> run(~w(--invalid option)) end 74 | 75 | message = "Invalid option: --no-value" 76 | assert_raise Mix.Error, message, fn -> run(~w(--no-value)) end 77 | end 78 | 79 | test "OS environment" do 80 | put_dicon_env(%{ 81 | otp_app: :sample, 82 | target_dir: "test", 83 | hosts: [:one], 84 | one: [authority: "one", os_env: %{"IS_FOO" => "yes it is", "BAR" => "baz\"bong"}] 85 | }) 86 | 87 | run(["run"]) 88 | assert_receive {:dicon, ref, :connect, ["one"]} 89 | 90 | assert_receive {:dicon, ^ref, :exec, 91 | [~S(BAR="baz\"bong" IS_FOO="yes it is" test/current/bin/sample run)]} 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/mix/tasks/dicon.deploy_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dicon.DeployTest do 2 | use DiconTest.Case 3 | 4 | import PathHelpers 5 | import Mix.Tasks.Dicon.Deploy, only: [run: 1] 6 | 7 | setup_all do 8 | put_dicon_env(%{ 9 | target_dir: "test", 10 | hosts: [:one, :two], 11 | one: [ 12 | authority: "one", 13 | apps_env: [foo: [bar: "baz"]] 14 | ], 15 | two: [ 16 | authority: "two" 17 | ] 18 | }) 19 | 20 | :ok 21 | end 22 | 23 | test "the release is uploaded correctly" do 24 | source = fixture_path("empty.tar.gz") 25 | release_file = "test/release.tar.gz" 26 | 27 | on_exec("cat test/0.1.0/releases/0.1.0/sys.config", fn device -> 28 | IO.write(device, "[{foo,[{qux,<<\"baz\">>}]}].\n") 29 | end) 30 | 31 | run([source, "0.1.0"]) 32 | 33 | assert_receive {:dicon, ref, :connect, ["one"]} 34 | assert_receive {:dicon, ^ref, :exec, ["mkdir -p test"]} 35 | assert_receive {:dicon, ^ref, :copy, [^source, ^release_file]} 36 | assert_receive {:dicon, ^ref, :exec, ["mkdir -p test/0.1.0"]} 37 | assert_receive {:dicon, ^ref, :exec, ["tar -C test/0.1.0 -zxf " <> ^release_file]} 38 | assert_receive {:dicon, ^ref, :exec, ["rm " <> ^release_file]} 39 | assert_receive {:dicon, ^ref, :exec, ["cat test/0.1.0/releases/0.1.0/sys.config"]} 40 | 41 | assert_receive {:dicon, ^ref, :write_file, 42 | [ 43 | "test/0.1.0/releases/0.1.0/sys.config", 44 | "[{foo,[{qux,<<\"baz\">>},{bar,<<\"baz\">>}]}].\n", 45 | :write 46 | ]} 47 | 48 | assert_receive {:dicon, ref, :connect, ["two"]} 49 | assert_receive {:dicon, ^ref, :exec, ["mkdir -p test"]} 50 | assert_receive {:dicon, ^ref, :copy, [^source, ^release_file]} 51 | assert_receive {:dicon, ^ref, :exec, ["mkdir -p test/0.1.0"]} 52 | assert_receive {:dicon, ^ref, :exec, ["tar -C test/0.1.0 -zxf " <> ^release_file]} 53 | assert_receive {:dicon, ^ref, :exec, ["rm " <> ^release_file]} 54 | 55 | refute_receive {:dicon, _, _, _} 56 | end 57 | 58 | test "hosts filtering" do 59 | source = fixture_path("empty.tar.gz") 60 | 61 | on_exec("cat test/0.1.0/releases/0.1.0/sys.config", fn device -> 62 | IO.write(device, "[].\n") 63 | end) 64 | 65 | run([source, "0.1.0", "--only", "one"]) 66 | assert_receive {:dicon, ref, :connect, ["one"]} 67 | :ok = flush_reply(ref) 68 | refute_receive {:dicon, _, _, _} 69 | 70 | run([source, "0.1.0", "--skip", "one"]) 71 | assert_receive {:dicon, ref, :connect, ["two"]} 72 | :ok = flush_reply(ref) 73 | refute_receive {:dicon, _, _, _} 74 | 75 | run([source, "0.1.0", "--skip", "one", "--only", "one"]) 76 | refute_receive {:dicon, _, _, _} 77 | 78 | on_exec("cat test/0.1.0/releases/0.1.0/sys.config", fn device -> 79 | IO.write(device, "[].\n") 80 | end) 81 | 82 | run([source, "0.1.0", "--only", "one", "--only", "two"]) 83 | assert_receive {:dicon, ref, :connect, ["one"]} 84 | :ok = flush_reply(ref) 85 | assert_receive {:dicon, ref, :connect, ["two"]} 86 | :ok = flush_reply(ref) 87 | refute_receive {:dicon, _, _, _} 88 | 89 | run([source, "0.1.0", "--skip", "one", "--skip", "two"]) 90 | refute_receive {:dicon, _, _, _} 91 | 92 | assert_raise Mix.Error, "Unknown host: \"foo\"", fn -> 93 | run([source, "0.1.0", "--skip", "foo", "--skip", "two"]) 94 | end 95 | end 96 | 97 | test "it accepts only two arguments" do 98 | message = "Expected two arguments (the tarball path and the version)" 99 | assert_raise Mix.Error, message, fn -> run([]) end 100 | assert_raise Mix.Error, message, fn -> run(~w(one)) end 101 | 102 | message = "Invalid option: --invalid" 103 | assert_raise Mix.Error, message, fn -> run(~w(--invalid option)) end 104 | 105 | message = "Invalid option: --no-value" 106 | assert_raise Mix.Error, message, fn -> run(~w(--no-value)) end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/mix/tasks/dicon.switch_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dicon.SwitchTest do 2 | use DiconTest.Case 3 | 4 | import Mix.Tasks.Dicon.Switch, only: [run: 1] 5 | 6 | test "relative path" do 7 | put_dicon_env(%{ 8 | target_dir: "test", 9 | hosts: [:one, :two], 10 | one: [authority: "one"], 11 | two: [authority: "two"] 12 | }) 13 | 14 | run(["0.1.0"]) 15 | 16 | assert_receive {:dicon, ref, :connect, ["one"]} 17 | assert_receive {:dicon, ^ref, :exec, ["ln -snf $PWD/test/0.1.0 $PWD/test/current"]} 18 | 19 | assert_receive {:dicon, ref, :connect, ["two"]} 20 | assert_receive {:dicon, ^ref, :exec, ["ln -snf $PWD/test/0.1.0 $PWD/test/current"]} 21 | 22 | refute_receive {:dicon, _, _, _} 23 | end 24 | 25 | test "absolute path" do 26 | put_dicon_env(%{ 27 | target_dir: "/home/test", 28 | hosts: [:one], 29 | one: [authority: "one"] 30 | }) 31 | 32 | run(["0.2.0"]) 33 | 34 | assert_receive {:dicon, ref, :connect, ["one"]} 35 | assert_receive {:dicon, ^ref, :exec, ["ln -snf /home/test/0.2.0 /home/test/current"]} 36 | 37 | refute_receive {:dicon, _, _, _} 38 | end 39 | 40 | test "hosts filtering" do 41 | put_dicon_env(%{ 42 | target_dir: "test", 43 | hosts: [:one, :two], 44 | one: [authority: "one"], 45 | two: [authority: "two"] 46 | }) 47 | 48 | run(["0.2.0", "--only", "one"]) 49 | assert_receive {:dicon, ref, :connect, ["one"]} 50 | :ok = flush_reply(ref) 51 | refute_receive {:dicon, _, _, _} 52 | 53 | run(["0.2.0", "--skip", "one"]) 54 | assert_receive {:dicon, ref, :connect, ["two"]} 55 | :ok = flush_reply(ref) 56 | refute_receive {:dicon, _, _, _} 57 | 58 | run(["0.2.0", "--skip", "one", "--only", "one"]) 59 | refute_receive {:dicon, _, _, _} 60 | 61 | run(["0.2.0", "--only", "one", "--only", "two"]) 62 | assert_receive {:dicon, ref, :connect, ["one"]} 63 | :ok = flush_reply(ref) 64 | assert_receive {:dicon, ref, :connect, ["two"]} 65 | :ok = flush_reply(ref) 66 | refute_receive {:dicon, _, _, _} 67 | 68 | run(["0.2.0", "--skip", "one", "--skip", "two"]) 69 | refute_receive {:dicon, _, _, _} 70 | 71 | assert_raise Mix.Error, "Unknown host: \"foo\"", fn -> 72 | run(["0.2.0", "--skip", "foo", "--skip", "two"]) 73 | end 74 | end 75 | 76 | test "the task only accepts one argument" do 77 | message = "Expected a single argument (the version)" 78 | assert_raise Mix.Error, message, fn -> run([]) end 79 | assert_raise Mix.Error, message, fn -> run(~w(one two)) end 80 | 81 | message = "Invalid option: --invalid" 82 | assert_raise Mix.Error, message, fn -> run(~w(--invalid option)) end 83 | 84 | message = "Invalid option: --no-value" 85 | assert_raise Mix.Error, message, fn -> run(~w(--no-value)) end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(refute_receive_timeout: 200) 2 | 3 | Mix.shell(Mix.Shell.Process) 4 | 5 | defmodule PathHelpers do 6 | def fixtures_path() do 7 | Path.expand("fixtures", __DIR__) 8 | end 9 | 10 | def fixture_path(extra) do 11 | Path.join(fixtures_path(), extra) 12 | end 13 | end 14 | 15 | defmodule DiconTest.Case do 16 | use ExUnit.CaseTemplate 17 | 18 | @behaviour Dicon.Executor 19 | 20 | using _ do 21 | quote do 22 | import unquote(__MODULE__), only: [flush_reply: 1, on_exec: 2, put_dicon_env: 1] 23 | end 24 | end 25 | 26 | setup_all do 27 | Application.put_env(:dicon, :executor, __MODULE__) 28 | 29 | on_exit(fn -> 30 | Application.delete_env(:dicon, :executor) 31 | end) 32 | end 33 | 34 | setup do 35 | Application.put_env(:dicon, __MODULE__, test_pid: self()) 36 | 37 | on_exit(fn -> 38 | Application.delete_env(:dicon, __MODULE__) 39 | end) 40 | end 41 | 42 | def connect(authority) do 43 | conn = make_ref() 44 | notify_test({:dicon, conn, :connect, [authority]}) 45 | {:ok, conn} 46 | end 47 | 48 | def exec(conn, command, device) do 49 | command = List.to_string(command) 50 | run_callback(command, device) 51 | notify_test({:dicon, conn, :exec, [command]}) 52 | :ok 53 | end 54 | 55 | def write_file(conn, target, content, mode) do 56 | content = IO.iodata_to_binary(content) 57 | target = List.to_string(target) 58 | notify_test({:dicon, conn, :write_file, [target, content, mode]}) 59 | :ok 60 | end 61 | 62 | def copy(conn, source, target) do 63 | source = List.to_string(source) 64 | target = List.to_string(target) 65 | notify_test({:dicon, conn, :copy, [source, target]}) 66 | :ok 67 | end 68 | 69 | defp notify_test(message) do 70 | :dicon 71 | |> Application.fetch_env!(__MODULE__) 72 | |> Keyword.fetch!(:test_pid) 73 | |> send(message) 74 | end 75 | 76 | def on_exec(command, callback) do 77 | env = 78 | :dicon 79 | |> Application.fetch_env!(__MODULE__) 80 | |> Keyword.update(:exec_callbacks, %{command => callback}, &Map.put(&1, command, callback)) 81 | 82 | Application.put_env(:dicon, __MODULE__, env) 83 | end 84 | 85 | def flush_reply(conn) do 86 | receive do 87 | {:dicon, ^conn, _, _} -> 88 | flush_reply(conn) 89 | after 90 | 50 -> :ok 91 | end 92 | end 93 | 94 | def put_dicon_env(config) do 95 | # TODO: Use Application.put_all_env/2 when we 96 | # dropped support for Elixir versions older than 1.9. 97 | for {key, value} <- config, do: Application.put_env(:dicon, key, value) 98 | end 99 | 100 | defp run_callback(command, device) do 101 | env = Application.fetch_env!(:dicon, __MODULE__) 102 | {callback, env} = pop_in(env, [:exec_callbacks, command]) 103 | 104 | if callback do 105 | callback.(device) 106 | Application.put_env(:dicon, __MODULE__, env) 107 | end 108 | 109 | :ok 110 | end 111 | end 112 | --------------------------------------------------------------------------------