├── .travis.yml ├── test ├── test_helper.exs ├── bottler │ └── helpers │ │ └── hook_test.exs └── release_test.exs ├── .gitignore ├── mix.lock ├── notes ├── aws.md └── scalable_shipment.md ├── config ├── dev.exs ├── test.exs └── config.exs ├── lib ├── mix │ ├── tasks │ │ ├── release.ex │ │ ├── install.ex │ │ ├── green_flag.ex │ │ ├── stable.ex │ │ ├── publish.ex │ │ ├── ship.ex │ │ ├── restart.ex │ │ ├── deploy.ex │ │ ├── exec.ex │ │ ├── rollback.ex │ │ ├── goto.ex │ │ └── observer.ex │ └── scripts │ │ └── observer.exs ├── bottler.ex ├── bottler │ ├── helpers │ │ ├── hook.ex │ │ └── gce.ex │ ├── restart.ex │ ├── rollback.ex │ ├── exec.ex │ ├── publish.ex │ ├── stable.ex │ ├── green_flag.ex │ ├── install.ex │ ├── ship.ex │ ├── release.ex │ └── helpers.ex └── scripts │ ├── connect.sh.eex │ ├── erl_connect.sh.eex │ └── watchdog.sh.eex ├── mix.exs ├── LICENSE └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.4.0 4 | otp_release: 5 | - 19.2 6 | sudo: false 7 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | # force test environment for tasks 4 | System.put_env "MIX_ENV", "test" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | *.swp 6 | *.kate-swp 7 | .kateproject.d 8 | .zedstate 9 | .directory 10 | 11 | /config/prod.exs 12 | 13 | /rel 14 | .bottler 15 | .idea/ 16 | *.iml 17 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], []}, 2 | "sshex": {:hex, :sshex, "2.1.2", "f37167cbef6b33c31754750c30ac281fc7b7f6480f437c7c29708db7ef6ae85f", [:mix], []}} 3 | -------------------------------------------------------------------------------- /notes/aws.md: -------------------------------------------------------------------------------- 1 | # AWS 2 | 3 | Notes on choices to deploy to AWS servers 4 | 5 | ## deploy to autoscaling live instances 6 | 7 | 1. stop autoscaling 8 | 1. get instances list 9 | 1. ship & install & restart to instances 10 | 1. restart autoscaling 11 | 12 | 13 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :bottler, :params, [servers: [server1: [ip: "1.1.1.1"], 4 | server2: [ip: "1.1.1.2"]], 5 | hooks: [pre_release: %{command: "pwd", continue_on_fail: false}], 6 | remote_user: "devuser", 7 | # rsa_pass_phrase: "bogus", 8 | cookie: "abc" ] 9 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :bottler, :params, [servers: [server1: [ip: "1.1.1.1"], 4 | server2: [ip: "1.1.1.2"]], 5 | hooks: [pre_release: %{command: "pwd", continue_on_fail: true}], 6 | remote_user: "testuser", 7 | cookie: "abc", 8 | additional_folders: ["extras"]] 9 | -------------------------------------------------------------------------------- /lib/mix/tasks/release.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.Release do 5 | 6 | @moduledoc """ 7 | Build a release file. Use like `mix bottler.release`. 8 | 9 | `prod` environment is used by default. Use like 10 | `MIX_ENV=other_env mix bottler.release` to force it to `other_env`. 11 | """ 12 | 13 | use Mix.Task 14 | 15 | def run(_args) do 16 | H.set_prod_environment 17 | c = H.read_and_validate_config 18 | |> H.validate_branch 19 | |> H.inline_resolve_servers 20 | 21 | :ok = B.release c 22 | :ok 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/bottler.ex: -------------------------------------------------------------------------------- 1 | 2 | defmodule Bottler do 3 | 4 | @moduledoc """ 5 | Main Bottler module. Exposes entry points for each individual task. 6 | """ 7 | 8 | defdelegate release(config), to: Bottler.Release 9 | defdelegate publish(config), to: Bottler.Publish 10 | defdelegate stable(config), to: Bottler.Stable 11 | defdelegate ship(config), to: Bottler.Ship 12 | defdelegate install(config), to: Bottler.Install 13 | defdelegate restart(config), to: Bottler.Restart 14 | defdelegate green_flag(config), to: Bottler.GreenFlag 15 | defdelegate rollback(config), to: Bottler.Rollback 16 | defdelegate exec(config, cmd, switches), to: Bottler.Exec 17 | 18 | end 19 | -------------------------------------------------------------------------------- /lib/mix/tasks/install.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.Install do 5 | 6 | @moduledoc """ 7 | Install a shipped file on configured remote servers. 8 | Use like `mix bottler.install`. 9 | 10 | `prod` environment is used by default. Use like 11 | `MIX_ENV=other_env mix bottler.install` to force it to `other_env`. 12 | """ 13 | 14 | use Mix.Task 15 | 16 | def run(args) do 17 | {switches, _} = H.parse_args!(args) 18 | 19 | H.set_prod_environment 20 | c = H.read_and_validate_config |> H.inline_resolve_servers(switches) 21 | {:ok, _} = B.install c 22 | :ok 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/mix/tasks/green_flag.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.GreenFlag do 5 | 6 | @moduledoc """ 7 | Wait for `tmp/alive` to contain the current version on configured remote servers. 8 | Use like `mix bottler.green_flag`. 9 | 10 | `prod` environment is used by default. Use like 11 | `MIX_ENV=other_env mix bottler.green_flag` to force it to `other_env`. 12 | """ 13 | 14 | use Mix.Task 15 | 16 | def run(args) do 17 | {switches, _} = H.parse_args!(args) 18 | 19 | H.set_prod_environment 20 | c = H.read_and_validate_config |> H.inline_resolve_servers(switches) 21 | B.green_flag c 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /lib/mix/tasks/stable.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.Stable do 5 | 6 | @moduledoc """ 7 | Ship a release file to configured stable servers. 8 | Use like `mix bottler.stable`. 9 | 10 | `prod` environment is used by default. Use like 11 | `MIX_ENV=other_env mix bottler.ship` to force it to `other_env`. 12 | """ 13 | 14 | use Mix.Task 15 | 16 | def run(args) do 17 | {switches, _} = H.parse_args!(args) 18 | 19 | H.set_prod_environment 20 | c = H.read_and_validate_config 21 | |> H.validate_branch 22 | |> H.inline_resolve_servers(switches) 23 | 24 | B.stable c 25 | end 26 | 27 | end 28 | 29 | -------------------------------------------------------------------------------- /lib/mix/tasks/publish.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.Publish do 5 | 6 | @moduledoc """ 7 | Ship a release file to configured publish servers. 8 | Use like `mix bottler.publish`. 9 | 10 | `prod` environment is used by default. Use like 11 | `MIX_ENV=other_env mix bottler.ship` to force it to `other_env`. 12 | """ 13 | 14 | use Mix.Task 15 | 16 | def run(args) do 17 | {switches, _} = H.parse_args!(args) 18 | 19 | H.set_prod_environment 20 | c = H.read_and_validate_config 21 | |> H.validate_branch 22 | |> H.inline_resolve_servers(switches) 23 | 24 | B.publish c 25 | end 26 | 27 | end 28 | 29 | -------------------------------------------------------------------------------- /lib/mix/tasks/ship.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.Ship do 5 | 6 | @moduledoc """ 7 | Ship a release file to configured remote servers. 8 | Use like `mix bottler.ship`. 9 | 10 | `prod` environment is used by default. Use like 11 | `MIX_ENV=other_env mix bottler.ship` to force it to `other_env`. 12 | """ 13 | 14 | use Mix.Task 15 | 16 | def run(args) do 17 | {switches, _} = H.parse_args!(args) 18 | 19 | H.set_prod_environment 20 | c = H.read_and_validate_config 21 | |> H.validate_branch 22 | |> H.inline_resolve_servers(switches) 23 | 24 | {:ok, _} = B.ship c 25 | :ok 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /lib/mix/scripts/observer.exs: -------------------------------------------------------------------------------- 1 | 2 | System.argv() 3 | |> inspect |> IO.puts 4 | 5 | defmodule ObserverWrapper do 6 | 7 | def wait_for_observer_to_end do 8 | try do 9 | :observer_wx.get_attrib({:font, :fixed}) 10 | :timer.sleep 2_000 11 | wait_for_observer_to_end 12 | rescue 13 | x in [ErlangError] -> x 14 | end 15 | end 16 | 17 | end 18 | 19 | :net_adm.ping(System.argv |> hd |> String.to_atom) 20 | 21 | :ok = :observer.start 22 | 23 | :timer.sleep 5_000 24 | 25 | observer_pid = :os.cmd('pgrep -f observerunique') 26 | ObserverWrapper.wait_for_observer_to_end 27 | IO.puts "Killing observer with pid #{observer_pid}" 28 | :os.cmd("kill #{observer_pid}" |> to_charlist) 29 | 30 | :ok 31 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Bottler.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :bottler, 6 | version: "0.5.0", 7 | elixir: ">= 1.0.0", 8 | package: package(), 9 | description: "Help you bottle, ship and serve your Elixir apps.", 10 | deps: deps()] 11 | end 12 | 13 | def application do 14 | [ applications: [:logger, :crypto], 15 | included_applications: [:public_key, :asn1, :iex] ] 16 | end 17 | 18 | defp package do 19 | [maintainers: ["Rubén Caro"], 20 | licenses: ["MIT"], 21 | links: %{github: "https://github.com/rubencaro/bottler"}] 22 | end 23 | 24 | defp deps do 25 | [{:sshex, ">= 2.1.2"}, 26 | {:poison, ">= 2.0.0"}] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/mix/tasks/restart.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.Restart do 5 | 6 | @moduledoc """ 7 | Touch `tmp/restart` on configured remote servers. 8 | That expects to have `Harakiri` or similar software reacting to that. 9 | Use like `mix bottler.restart`. 10 | 11 | `prod` environment is used by default. Use like 12 | `MIX_ENV=other_env mix bottler.restart` to force it to `other_env`. 13 | """ 14 | 15 | use Mix.Task 16 | 17 | def run(args) do 18 | {switches, _} = H.parse_args!(args) 19 | 20 | H.set_prod_environment 21 | c = H.read_and_validate_config |> H.inline_resolve_servers(switches) 22 | {:ok, _} = B.restart c 23 | :ok 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/bottler/helpers/hook.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | require Bottler.Helpers, as: H 3 | 4 | defmodule Bottler.Helpers.Hook do 5 | 6 | @moduledoc """ 7 | Hooks Helper - based on config 8 | """ 9 | 10 | def exec(name, config) do 11 | 12 | case config[:hooks][name] do 13 | nil -> :ok 14 | %{command: command, continue_on_fail: continue_on_fail} -> launch(command, continue_on_fail) 15 | end 16 | 17 | end 18 | 19 | defp launch(command, continue_on_fail) do 20 | L.info "Launching hook: " <> command <> " ..." 21 | command 22 | |> H.cmd 23 | |> prepare_return(continue_on_fail) 24 | end 25 | 26 | defp prepare_return(_command_result, true), do: :ok 27 | defp prepare_return(command_result, false), do: command_result 28 | 29 | end 30 | -------------------------------------------------------------------------------- /lib/mix/tasks/deploy.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Deploy do 5 | 6 | @moduledoc """ 7 | Build a release file, ship it to remote servers, install it, and restart 8 | the app. No hot code swap for now. 9 | 10 | Use like `mix deploy`. 11 | 12 | `prod` environment is used by default. Use like 13 | `MIX_ENV=other_env mix deploy` to force it to `other_env`. 14 | """ 15 | 16 | use Mix.Task 17 | 18 | def run(args) do 19 | {switches, _} = H.parse_args!(args) 20 | 21 | H.set_prod_environment 22 | c = H.read_and_validate_config 23 | |> H.validate_branch 24 | |> H.inline_resolve_servers(switches) 25 | 26 | :ok = B.release c 27 | :ok = B.publish c 28 | {:ok, _} = B.ship c 29 | {:ok, _} = B.install c 30 | {:ok, _} = B.restart c 31 | :ok = B.green_flag c 32 | :ok 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /lib/mix/tasks/exec.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.Exec do 5 | 6 | @moduledoc """ 7 | Execute given commmand on configured remote servers. 8 | Use like `mix bottler.exec 'ls -alt some/path'`. 9 | 10 | `prod` environment is used by default. Use like 11 | `MIX_ENV=other_env mix bottler.install` to force it to `other_env`. 12 | """ 13 | 14 | use Mix.Task 15 | 16 | def run(args) do 17 | {switches, remaining_args} = H.parse_args!(args, switches: [timeout: :integer]) 18 | 19 | # clean args 20 | cmd = remaining_args |> List.first # the first non-switch argument 21 | switches = switches |> H.defaults(timeout: 30_000) 22 | 23 | H.set_prod_environment 24 | c = H.read_and_validate_config |> H.inline_resolve_servers(switches) 25 | {:ok, _} = B.exec c, cmd, switches 26 | :ok 27 | end 28 | 29 | end 30 | -------------------------------------------------------------------------------- /lib/mix/tasks/rollback.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | alias Bottler, as: B 3 | 4 | defmodule Mix.Tasks.Bottler.Rollback do 5 | 6 | @moduledoc """ 7 | Simply move the _current_ link to the previous release and restart to 8 | apply. It's quite faster than to deploy a previous release, that is 9 | also possible. 10 | 11 | Be careful because the _previous release_ may be different on each server. 12 | It's up to you to keep all your servers rollback-able (yeah). 13 | 14 | Use like `mix bottler.rollback`. 15 | 16 | `prod` environment is used by default. Use like 17 | `MIX_ENV=other_env mix bottler.rollback` to force it to `other_env`. 18 | """ 19 | 20 | use Mix.Task 21 | 22 | def run(args) do 23 | {switches, _} = H.parse_args!(args) 24 | 25 | H.set_prod_environment 26 | c = H.read_and_validate_config |> H.inline_resolve_servers(switches) 27 | {:ok, _} = B.rollback c 28 | :ok 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /lib/bottler/helpers/gce.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | 3 | defmodule Bottler.Helpers.GCE do 4 | 5 | @moduledoc """ 6 | Interface helpers with GCE `gcloud` executable 7 | """ 8 | 9 | def instances(config) do 10 | "gcloud compute instances list --format=json" 11 | |> exec(config) 12 | |> Poison.decode! 13 | |> match(config[:servers][:match]) 14 | end 15 | 16 | def instance_ips(config) do 17 | config |> instances |> Enum.map( &H.get_nested(&1, ["networkInterfaces", 0, "accessConfigs", 0, "natIP"]) ) 18 | end 19 | 20 | def instance(config, name) do 21 | config |> instances |> Enum.find( &(&1["name"] == name) ) 22 | end 23 | 24 | def match(list, nil), do: list 25 | def match(list, regexstr) do 26 | r = Regex.compile!(regexstr) 27 | list |> Enum.filter(&Regex.match?(r, &1["name"])) 28 | end 29 | 30 | defp exec(command, config) do 31 | "#{command} --project=#{config[:servers][:gce_project]}" 32 | |> to_charlist 33 | |> :os.cmd 34 | |> to_string 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /lib/bottler/restart.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | require Bottler.Helpers, as: H 3 | 4 | defmodule Bottler.Restart do 5 | 6 | @moduledoc """ 7 | Restart the VM to apply the installed release. 8 | """ 9 | 10 | @doc """ 11 | Restart app on remote servers. 12 | It merely touches `app/tmp/restart`, so something like 13 | [Harakiri](http://github.com/rubencaro/harakiri) should be running 14 | on server. 15 | 16 | Returns `{:ok, details}` when done, `{:error, details}` if anything fails. 17 | """ 18 | def restart(config) do 19 | servers = config[:servers] |> H.prepare_servers 20 | 21 | L.info "Restarting #{servers |> Enum.map(&(&1[:id])) |> Enum.join(",")}..." 22 | 23 | app = Mix.Project.get!.project[:app] 24 | servers |> H.in_tasks( fn(args) -> 25 | args = args ++ [remote_user: config[:remote_user]] 26 | "ssh <%= remote_user %>@<%= ip %> 'touch #{app}/tmp/restart'" 27 | |> EEx.eval_string(args) |> to_charlist |> :os.cmd 28 | end, expected: [], to_s: true) 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | config :logger, :console, 14 | level: :debug, 15 | format: "$time $metadata[$level] $message\n" 16 | 17 | # It is also possible to import configuration files, relative to this 18 | # directory. For example, you can emulate configuration per environment 19 | # by uncommenting the line below and defining dev.exs, test.exs and such. 20 | # Configuration from the imported file will override the ones defined 21 | # here (which is why it is important to import them last). 22 | # 23 | # import_config "#{Mix.env}.exs" 24 | 25 | import_config "#{Mix.env}.exs" 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Rubén Caro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /notes/scalable_shipment.md: -------------------------------------------------------------------------------- 1 | # Scalable shipment 2 | 3 | The goal is to avoid the bottleneck that development machine's connection supposes when shipping the release over to every single target server. 4 | 5 | ## Straight inter-server network 6 | 7 | Ship one copy of the release to one server, and ship it in parallel to every other server from there. That leaves all the transfer load to the network between servers. 8 | 9 | ## Distributed shipment 10 | 11 | Use inter-server network to ship the release in an intelligent way, keeping transfer rate low enough not to affect other processes and not to suffocate any single machine. 12 | 13 | Maybe just ship one copy of the release to one server with the list of other targets. Then recursively do this: 14 | 15 | * Split it in two, and for each half: 16 | * Pop one server 17 | * Send a copy of the release and the list of remaining servers to the popped server 18 | * Use scalable middleplace to ship releases [*](notes/scalable_shipment.md) 19 | 20 | ## Use S3 21 | 22 | From the development machine upload the release to S3, then download it from every target server. 23 | 24 | ## Use github 25 | 26 | From the development machine, push the release to a github repo, then clone it from every target server. 27 | -------------------------------------------------------------------------------- /lib/mix/tasks/goto.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | 3 | defmodule Mix.Tasks.Goto do 4 | 5 | @moduledoc """ 6 | Use like `mix goto servername` 7 | 8 | It opens an SSH session on a new terminal window on the server with given name. 9 | If `all` is given as a server name, then one terminal is open for each configured server. 10 | The actual `terminal` command can be configured as a template. 11 | 12 | `prod` environment is used by default. Use like 13 | `MIX_ENV=other_env mix bottler.install` to force it to `other_env`. 14 | """ 15 | 16 | use Mix.Task 17 | 18 | def run(args) do 19 | H.set_prod_environment 20 | c = H.read_and_validate_config 21 | |> H.inline_resolve_servers 22 | 23 | args 24 | |> get_names(c) 25 | |> Enum.map(&open_terminal(&1, c)) 26 | 27 | Process.sleep(2_000) 28 | 29 | :ok 30 | end 31 | 32 | defp get_names(args, config) do 33 | name = args |> List.first |> String.to_atom 34 | case name do 35 | :all -> config[:servers] |> Keyword.keys 36 | x -> [x] 37 | end 38 | end 39 | 40 | defp open_terminal(name, config) do 41 | if not name in Keyword.keys(config[:servers]), 42 | do: raise "Server not found by that name" 43 | 44 | ip = config[:servers][name][:ip] 45 | 46 | spawn_link(fn-> 47 | config[:goto][:terminal] 48 | |> EEx.eval_string(title: "#{name}", command: "ssh #{config[:remote_user]}@#{ip}") 49 | |> to_charlist |> :os.cmd 50 | end) 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /test/bottler/helpers/hook_test.exs: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | require Bottler.Helpers.Hook, as: Hook 3 | 4 | defmodule HelpersHookTest do 5 | use ExUnit.Case, async: false 6 | 7 | setup do 8 | config = H.read_and_validate_config 9 | |> H.validate_branch 10 | |> H.inline_resolve_servers([]) 11 | 12 | {:ok, config} 13 | end 14 | 15 | @tag :no_config_entry 16 | test "returns :ok if no hook configured", config do 17 | assert :ok == Hook.exec(:release, config) 18 | end 19 | 20 | @tag :hook_continue_on_fail_true 21 | test "allways returns :ok if continue_on_fail is true", config do 22 | assert :ok = Hook.exec(:pre_release, config) 23 | config = %{config| hooks: [pre_release: %{continue_on_fail: true, command: "unexistent_command"}]} 24 | assert :ok == Hook.exec(:pre_release, config) 25 | end 26 | 27 | @tag :hook_happy_path 28 | test "happy path #2 - returns :ok if continue_on_fail is false and command executed successfully", config do 29 | config = %{config| hooks: [pre_release: %{continue_on_fail: false, command: "pwd"}]} 30 | assert :ok == Hook.exec(:pre_release, config) 31 | end 32 | 33 | @tag :hook_unhappy_path 34 | test "unhappy path - returns {:error, message if continue_on_fail is false and command fails", config do 35 | config = %{config| hooks: [pre_release: %{continue_on_fail: false, command: "my_inexistent_command"}]} 36 | assert {:error, "Release step failed. Please fix any errors and try again."} == Hook.exec(:pre_release, config) 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /lib/mix/tasks/observer.ex: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | require Logger, as: L 3 | 4 | defmodule Mix.Tasks.Observer do 5 | use Mix.Task 6 | 7 | def run(args) do 8 | name = args |> List.first |> String.to_atom 9 | 10 | H.set_prod_environment 11 | c = H.read_and_validate_config |> H.inline_resolve_servers 12 | 13 | if not name in Keyword.keys(c[:servers]), 14 | do: raise "Server not found by that name" 15 | 16 | ip = c[:servers][name][:ip] 17 | L.info "Target IP: #{ip}" 18 | L.info "Server name: #{name}" 19 | 20 | port = get_port(c, name, ip) 21 | 22 | # auto closing tunnel 23 | :os.cmd('killall epmd') # free distributed erlang port 24 | cmd = "ssh -L 4369:localhost:4369 -L #{port}:localhost:#{port} #{c[:remote_user]}@#{ip}" |> to_charlist 25 | IO.puts "Opening tunnel... \n#{cmd}" 26 | spawn fn -> :os.cmd(cmd) |> to_string |> IO.puts end 27 | :timer.sleep 1000 28 | node_name = erlang_node_name(name) 29 | 30 | # observer 31 | IO.puts "Starting observer..." 32 | cmd = "elixir --name observerunique@127.0.0.1 --cookie #{c[:cookie]} --no-halt #{__DIR__}/../../../lib/mix/scripts/observer.exs #{node_name}" |> to_charlist 33 | IO.puts cmd 34 | :os.cmd(cmd) |> to_string |> IO.puts 35 | 36 | IO.puts "Done" 37 | end 38 | 39 | defp get_port(c, server_name, ip) do 40 | cmd = "ssh #{c[:remote_user]}@#{ip} \"source /home/#{c[:remote_user]}/.bash_profile && epmd -names\" | grep #{server_name} | cut -d \" \" -f 5" 41 | :os.cmd(cmd |> to_charlist) |> to_string |> String.strip(?\n) 42 | end 43 | 44 | defp erlang_node_name(server_name) do 45 | app = server_name |> to_string |> String.split("-") |> hd 46 | "#{app}_at_#{server_name}@127.0.0.1" |> String.to_atom 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/bottler/rollback.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | require Bottler.Helpers, as: H 3 | 4 | defmodule Bottler.Rollback do 5 | @moduledoc """ 6 | Simply move the _current_ link to the previous release and restart to 7 | apply. It's also possible to deploy a previous release, but this is 8 | quite faster. 9 | 10 | Be careful because the _previous release_ may be different on each server. 11 | It's up to you to keep all your servers rollback-able (yeah). 12 | """ 13 | 14 | @doc """ 15 | Move the _current_ link to the previous r8elease and restart to apply. 16 | Returns `{:ok, details}` when done, `{:error, details}` if anything fails. 17 | """ 18 | def rollback(config) do 19 | :ssh.start 20 | {:ok, _} = config[:servers] |> Keyword.values # each ip 21 | |> Enum.map(fn(s) -> s ++ [ user: config[:remote_user] ] end) # add user 22 | |> H.in_tasks( fn(args) -> on_server(args) end ) 23 | 24 | Bottler.Restart.restart config 25 | end 26 | 27 | defp on_server(args) do 28 | ip = args[:ip] |> to_charlist 29 | user = args[:user] |> to_charlist 30 | 31 | {:ok, conn} = SSHEx.connect ip: ip, user: user 32 | 33 | previous = get_previous_release conn, user 34 | 35 | L.info "Rollback to #{previous} on #{ip}..." 36 | 37 | shift_current conn, user, previous 38 | :ok 39 | end 40 | 41 | defp get_previous_release(conn, user) do 42 | app = Mix.Project.get!.project[:app] 43 | {:ok, res, 0} = SSHEx.run conn, 'ls -t /home/#{user}/#{app}/releases' 44 | res |> String.split |> Enum.at(1) 45 | end 46 | 47 | defp shift_current(conn, user, vsn) do 48 | app = Mix.Project.get!.project[:app] 49 | {:ok, _, 0} = SSHEx.run conn, 50 | 'ln -sfn /home/#{user}/#{app}/releases/#{vsn} ' ++ 51 | ' /home/#{user}/#{app}/current' 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /lib/bottler/exec.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | require Bottler.Helpers, as: H 3 | alias SSHEx, as: S 4 | 5 | defmodule Bottler.Exec do 6 | 7 | @moduledoc """ 8 | Functions to execute shell commands on remote servers, in parallel. 9 | """ 10 | 11 | @doc """ 12 | Executes given shell `command`. 13 | 14 | Returns `{:ok, details}` when done, `{:error, details}` if anything fails. 15 | """ 16 | def exec(config, cmd, switches) do 17 | :ssh.start # sometimes it's not already started at this point... 18 | 19 | config[:servers] 20 | |> H.prepare_servers 21 | |> Enum.map(fn(s) -> s ++ [ user: config[:remote_user] ] end) # add user 22 | |> Enum.map(fn(s) -> s ++ [ rsa_pass_phrase: config[:rsa_pass_phrase] ] end) # add rsa_pass_phrase 23 | |> Enum.map(fn(s) -> s ++ [ cmd: cmd, switches: switches ] end) # add cmd and switches 24 | |> H.in_tasks( fn(args) -> on_server(args) end ) 25 | end 26 | 27 | defp on_server(args) do 28 | cmd = args[:cmd] |> to_charlist 29 | id = args[:id] 30 | 31 | L.info "Executing '#{args[:cmd]}' on #{id}..." 32 | 33 | {:ok, conn} = [ 34 | ip: args[:ip], 35 | user: args[:user] 36 | ] 37 | |> H.run_if(args[:rsa_pass_phrase], &(&1 ++ [rsa_pass_phrase: args[:rsa_pass_phrase]])) 38 | |> Enum.map(fn {k,v} -> {k, v |> to_charlist} end) 39 | |> S.connect 40 | 41 | conn 42 | |> S.stream(cmd, exec_timeout: args[:switches][:timeout]) 43 | |> Enum.each(fn(x)-> 44 | case x do 45 | {:stdout,row} -> process_stdout(id, row) 46 | {:stderr,row} -> process_stderr(id, row) 47 | {:status,status} -> process_exit_status(id, status) 48 | {:error,reason} -> process_error(id, reason) 49 | end 50 | end) 51 | 52 | :ok 53 | end 54 | 55 | defp process_stdout(id, row), do: "#{id}: #{inspect row}" |> L.info 56 | defp process_stderr(id, row), do: "#{id}: #{inspect row}" |> L.warn 57 | defp process_error(id, reason), do: "#{id}: Failed, reason: #{inspect reason}" |> L.error 58 | defp process_exit_status(id, status), 59 | do: "#{id}: Ended with status #{inspect status}" |> L.info 60 | 61 | end 62 | -------------------------------------------------------------------------------- /lib/bottler/publish.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | #require Bottler.Helpers, as: H 3 | 4 | defmodule Bottler.Publish do 5 | @moduledoc """ 6 | Code to place a release file on a remote publish server. 7 | """ 8 | 9 | @doc """ 10 | Copy local release file to remote publish 11 | Returns `{:ok, details}` when done, `{:error, details}` if anything fails. 12 | """ 13 | def publish(config) do 14 | publish_config = config[:publish] 15 | if publish_config do 16 | L.info "Publishing latest to #{publish_config[:server]}" 17 | 18 | project = Mix.Project.get!.project 19 | 20 | result = {:ok, %{config: publish_config, 21 | src_release: ~s(#{project[:app]}.tar.gz), 22 | dst_release: ~s(#{project[:app]}-#{project[:version]}.tar.gz)}} 23 | |> upload 24 | |> mark_as_latest 25 | 26 | case result do 27 | {:ok, _} -> 28 | :ok 29 | 30 | {:error, reason, _} -> 31 | Logger.error "Publish failed: #{reason}" 32 | :error 33 | end 34 | else 35 | :ok 36 | end 37 | end 38 | 39 | defp upload({:ok, state}) do 40 | result = System.cmd "scp", upload_args(state) 41 | 42 | case result do 43 | {_, 0} -> {:ok, state} 44 | {error, _} -> {:error, error, state} 45 | end 46 | end 47 | defp upload(x), do: x 48 | 49 | defp upload_args(%{config: config, src_release: src_release, dst_release: dst_release}) do 50 | scp_opts = ~w(-oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null -oLogLevel=ERROR) 51 | src_release_file = "rel/#{src_release}" 52 | scp_opts ++ ~w(#{src_release_file} #{config[:remote_user]}@#{config[:server]}:#{config[:folder]}/#{dst_release}) 53 | end 54 | 55 | defp mark_as_latest({:ok, state}) do 56 | result = System.cmd "ssh", mark_as_latest_args(state) 57 | 58 | case result do 59 | {_, 0} -> {:ok, state} 60 | {error, _} -> {:error, error, state} 61 | end 62 | end 63 | defp mark_as_latest(x), do: x 64 | 65 | defp mark_as_latest_args(%{config: config, dst_release: dst_release}) do 66 | remote_cmd = ~s(ln -sf #{config[:folder]}/#{dst_release} #{config[:folder]}/latest) 67 | ~w(#{config[:remote_user]}@#{config[:server]} #{remote_cmd}) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/bottler/stable.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | # require Bottler.Helpers, as: H 3 | 4 | defmodule Bottler.Stable do 5 | @moduledoc """ 6 | Code to place a release file on a remote stable server. 7 | """ 8 | 9 | @doc """ 10 | Copy local release file to remote stable 11 | Returns `{:ok, details}` when done, `{:error, details}` if anything fails. 12 | """ 13 | def stable(config) do 14 | 15 | publish_config = config[:publish] 16 | 17 | if publish_config do 18 | L.info "Publishing stable to #{publish_config[:server]}" 19 | 20 | project = Mix.Project.get!.project 21 | 22 | result = {:ok, %{config: publish_config, 23 | src_release: ~s(#{project[:app]}.tar.gz), 24 | dst_release: ~s(#{project[:app]}-#{project[:version]}.tar.gz)}} 25 | |> upload() 26 | |> mark_as_stable() 27 | 28 | case result do 29 | {:ok, _} -> 30 | :ok 31 | 32 | {:error, reason, _} -> 33 | Logger.error "Publish stable failed: #{reason}" 34 | :error 35 | end 36 | else 37 | :ok 38 | end 39 | end 40 | 41 | defp upload({:ok, state}) do 42 | result = System.cmd "scp", upload_args(state) 43 | 44 | case result do 45 | {_, 0} -> {:ok, state} 46 | {error, _} -> {:error, error, state} 47 | end 48 | end 49 | defp upload(x), do: x 50 | 51 | defp upload_args(%{config: config, src_release: src_release, dst_release: dst_release}) do 52 | scp_opts = ~w(-oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null -oLogLevel=ERROR) 53 | src_release_file = "rel/#{src_release}" 54 | scp_opts ++ ~w(#{src_release_file} #{config[:remote_user]}@#{config[:server]}:#{config[:folder]}/#{dst_release}) 55 | end 56 | 57 | defp mark_as_stable({:ok, state}) do 58 | result = System.cmd "ssh", mark_as_stable_args(state) 59 | 60 | case result do 61 | {_, 0} -> {:ok, state} 62 | {error, _} -> {:error, error, state} 63 | end 64 | end 65 | defp mark_as_stable(x), do: x 66 | 67 | defp mark_as_stable_args(%{config: config, dst_release: dst_release}) do 68 | remote_cmd = ~s(ln -sf #{config[:folder]}/#{dst_release} #{config[:folder]}/stable) 69 | ~w(#{config[:remote_user]}@#{config[:server]} #{remote_cmd}) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/bottler/green_flag.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | require Bottler.Helpers, as: H 3 | 4 | defmodule Bottler.GreenFlag do 5 | 6 | @moduledoc """ 7 | Wait for `tmp/alive` to contain the current version number. 8 | """ 9 | 10 | @doc """ 11 | Restart app on remote servers. 12 | It merely touches `app/tmp/restart`, so something like 13 | [Harakiri](http://github.com/rubencaro/harakiri) should be running 14 | on server. 15 | 16 | Returns `{:ok, details}` when done, `{:error, details}` if anything fails. 17 | """ 18 | def green_flag(config) do 19 | green_flag_config = config[:green_flag] |> H.defaults(timeout: 30_000) 20 | servers = config[:servers] |> H.prepare_servers 21 | 22 | :ssh.start # just in case 23 | 24 | L.info "Waiting for Green Flag on #{servers |> Enum.map(&(&1[:id])) |> Enum.join(",")}..." 25 | 26 | user = config[:remote_user] |> to_charlist 27 | timeout = green_flag_config[:timeout] 28 | 29 | {sign, _} = servers |> H.in_tasks( &(check_green_flag(&1, user, timeout)) ) 30 | 31 | sign 32 | end 33 | 34 | defp check_green_flag(args, user, timeout) do 35 | conn = connect(args, user) 36 | current = get_current_version(conn) 37 | expiration = now() + timeout 38 | 39 | L.info "Waiting for alive version to be #{current} on #{args[:id]}..." 40 | 41 | case wait_for_alive_to_be(conn, current, expiration) do 42 | :ok -> :ok 43 | {:timeout, alive} -> 44 | L.error "Timeout waiting for alive version to be #{current} on #{args[:id]}\n" 45 | <> " Alive version was #{alive}" 46 | :error 47 | end 48 | end 49 | 50 | defp connect(args, user) do 51 | ip = args[:ip] |> to_charlist 52 | {:ok, conn} = SSHEx.connect(ip: ip, user: user) 53 | conn 54 | end 55 | 56 | defp wait_for_alive_to_be(conn, current, expiration) do 57 | :timer.sleep 1_000 58 | case {get_alive_version(conn), now()} do 59 | {^current, _} -> :ok 60 | {v, ts} when ts > expiration -> {:timeout, v} 61 | _ -> wait_for_alive_to_be(conn, current, expiration) 62 | end 63 | end 64 | 65 | defp get_current_version(conn) do 66 | cmd = "readlink #{H.app}/current | cut -d'/' -f 6" |> to_charlist 67 | SSHEx.cmd!(conn, cmd) |> H.chop 68 | end 69 | 70 | defp get_alive_version(conn) do 71 | cmd = "cat #{H.app}/tmp/alive" |> to_charlist 72 | SSHEx.cmd!(conn, cmd) |> H.chop 73 | end 74 | 75 | defp now, do: System.system_time(:milliseconds) 76 | 77 | end 78 | -------------------------------------------------------------------------------- /test/release_test.exs: -------------------------------------------------------------------------------- 1 | require Bottler.Helpers, as: H 2 | 3 | defmodule ReleaseTest do 4 | use ExUnit.Case, async: false 5 | 6 | setup do 7 | extra_dir = "#{Mix.Project.build_path}/../../lib/extras" 8 | File.mkdir(extra_dir) 9 | :ok = File.write "#{extra_dir}/dummy", "" 10 | 11 | on_exit fn -> 12 | File.rm_rf(extra_dir) 13 | end 14 | 15 | :ok 16 | end 17 | 18 | test "release gets generated" do 19 | vsn = Bottler.Mixfile.project[:version] 20 | apps = [:bottler,:kernel,:stdlib,:elixir,:logger,:crypto,:sasl,:compiler, 21 | :ssh,:syntax_tools,:sshex,:poison] 22 | iapps = [:public_key,:asn1,:iex] 23 | 24 | # clean any previous work 25 | :os.cmd 'rm -fr rel' 26 | 27 | # generate release 28 | assert :ok = Mix.Tasks.Bottler.Release.run [] 29 | 30 | # check rel term 31 | assert {:ok,[{:release, app, erts, deps}]} = H.read_terms "rel/bottler.rel" 32 | assert {'bottler', to_charlist(vsn)} == app 33 | assert {:erts, :erlang.system_info(:version)} == erts 34 | for dep <- deps do 35 | case dep do 36 | {d,_,:load} -> assert d in iapps 37 | {d,_} -> assert d in apps 38 | end 39 | end 40 | 41 | # check script term 42 | assert {:ok,_} = H.read_terms "rel/bottler.script" 43 | 44 | # check config term 45 | assert {:ok,[config_term]} = H.read_terms "rel/sys.config" 46 | assert [logger: _, bottler: [params: [servers: _, hooks: _, remote_user: _, cookie: _, additional_folders: ["extras"]]]] = config_term 47 | 48 | # check tar.gz exists and extracts 49 | assert File.regular?("rel/bottler.tar.gz") 50 | :os.cmd 'mkdir -p rel/extracted' 51 | assert :ok = :erl_tar.extract('rel/bottler.tar.gz', 52 | [:compressed,{:cwd,'rel/extracted'}]) 53 | # check its contents 54 | assert ["lib","releases"] == File.ls!("rel/extracted") |> Enum.sort 55 | # releases 56 | assert ([vsn,"bottler.rel"] |> Enum.sort) == File.ls!("rel/extracted/releases") |> Enum.sort 57 | # release folder 58 | assert ["bottler.rel","start.boot","sys.config"] == File.ls!("rel/extracted/releases/#{vsn}") |> Enum.sort 59 | # libs included 60 | libs = for lib <- File.ls!("rel/extracted/lib"), into: [] do 61 | lib |> String.split("-") |> List.first 62 | end |> Enum.sort 63 | assert libs == (apps ++ iapps) |> Enum.map(&(to_string(&1))) |> Enum.sort 64 | # scripts too 65 | assert ["connect.sh","erl_connect.sh","watchdog.sh"] = File.ls!("rel/extracted/lib/bottler-#{vsn}/scripts") |> Enum.sort 66 | assert ["dummy"] = File.ls!("rel/extracted/lib/bottler-#{vsn}/extras") |> Enum.sort 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/scripts/connect.sh.eex: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This uses `$app@localhost` address to connect to the erlang node. 4 | # That is meant to be executed from the same machine where the erlang node lives 5 | # 6 | # To connect to your app from another machine, you should set the same cookie 7 | # for both, set node's name to sommething like `$app@$IPaddr` 8 | # and ensure networks are visible. 9 | 10 | app="<%= app %>" 11 | cookie="<%= cookie %>" 12 | name="$(hostname)" 13 | 14 | # connect with interactive erlang via iex 15 | cmd="iex --name connector@127.0.0.1 --cookie $cookie --remsh ${app}_at_${name}@127.0.0.1" 16 | $cmd 17 | 18 | ######################################## 19 | # 20 | # Some ways to run your app: 21 | # 22 | # To run (embedded erlang): 23 | # ``` 24 | # run_erl -daemon /home/$user/$app/pipes/ /home/$user/$app/log 25 | # "erl -boot $dir/boot/start 26 | # -config $dir/boot/sys 27 | # -env ERL_LIBS $dir/lib 28 | # -sname $app" 29 | # ``` 30 | # 31 | # To connect (embedded erlang): 32 | # ``` 33 | # to_erl /home/$user/$app/pipes/erlang.pipe.1 34 | # ``` 35 | # 36 | # To run (interactive erlang): 37 | # ``` 38 | # erl -boot $dir/boot/start 39 | # -config $dir/boot/sys 40 | # -env ERL_LIBS $dir/lib 41 | # -name $app@localhost 42 | # ``` 43 | # 44 | # To run (interactive elixir, using app's own `iex`): 45 | # ``` 46 | # erl -boot $dir/boot/start 47 | # -config $dir/boot/sys 48 | # -env ERL_LIBS $dir/lib 49 | # -name $app@localhost 50 | # -noshell -user Elixir.IEx.CLI -extra --no-halt 51 | # ``` 52 | # 53 | # To run (detached elixir): 54 | # ``` 55 | # iex --erl "-boot $dir/boot/start 56 | # -config $dir/boot/sys 57 | # -env ERL_LIBS $dir/lib" 58 | # --name $app@localhost 59 | # --detached 60 | # ``` 61 | # 62 | # To run (detached erlang): 63 | # ``` 64 | # erl -boot $dir/boot/start 65 | # -config $dir/boot/sys 66 | # -env ERL_LIBS $dir/lib 67 | # -name $app@localhost 68 | # -detached 69 | # ``` 70 | # 71 | # To connect (interactive elixir): 72 | # ``` 73 | # iex --remsh $app@localhost --sname connector 74 | # ``` 75 | # 76 | # To connect (interactive erlang): 77 | # ``` 78 | # erl -remsh $app@localhost -sname connector 79 | # ``` 80 | # 81 | # To connect (interactive elixir, using app's own `iex`): 82 | # ``` 83 | # erl -remsh $app@localhost -sname connector 84 | # # and then ... 85 | # ($app@localhost)1> 'Elixir.IEx':start(). 86 | # ``` 87 | # 88 | -------------------------------------------------------------------------------- /lib/scripts/erl_connect.sh.eex: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This uses `$app@localhost` address to connect to the erlang node. 4 | # That is meant to be executed from the same machine where the erlang node lives 5 | # 6 | # To connect to your app from another machine, you should set the same cookie 7 | # for both, set node's name to sommething like `$app@$IPaddr` 8 | # and ensure networks are visible. 9 | 10 | # To connect (interactive elixir, using app's own `iex`): 11 | # ``` 12 | # erl -remsh $app@localhost -sname connector 13 | # # and then ... 14 | # ($app@localhost)1> 'Elixir.IEx':start(). 15 | # ``` 16 | 17 | app="<%= app %>" 18 | cookie="<%= cookie %>" 19 | name="$(hostname)" 20 | 21 | 22 | # connect with interactive erlang via iex 23 | cmd="erl -name connector@127.0.0.1 -remsh ${app}_at_${name}@127.0.0.1 -setcookie $cookie" 24 | $cmd 25 | 26 | ######################################## 27 | # 28 | # Some ways to run your app: 29 | # 30 | # To run (embedded erlang): 31 | # ``` 32 | # run_erl -daemon /home/$user/$app/pipes/ /home/$user/$app/log 33 | # "erl -boot $dir/boot/start 34 | # -config $dir/boot/sys 35 | # -env ERL_LIBS $dir/lib 36 | # -sname $app" 37 | # ``` 38 | # 39 | # To connect (embedded erlang): 40 | # ``` 41 | # to_erl /home/$user/$app/pipes/erlang.pipe.1 42 | # ``` 43 | # 44 | # To run (interactive erlang): 45 | # ``` 46 | # erl -boot $dir/boot/start 47 | # -config $dir/boot/sys 48 | # -env ERL_LIBS $dir/lib 49 | # -name $app@localhost 50 | # ``` 51 | # 52 | # To run (interactive elixir, using app's own `iex`): 53 | # ``` 54 | # erl -boot $dir/boot/start 55 | # -config $dir/boot/sys 56 | # -env ERL_LIBS $dir/lib 57 | # -name $app@localhost 58 | # -noshell -user Elixir.IEx.CLI -extra --no-halt 59 | # ``` 60 | # 61 | # To run (detached elixir): 62 | # ``` 63 | # iex --erl "-boot $dir/boot/start 64 | # -config $dir/boot/sys 65 | # -env ERL_LIBS $dir/lib" 66 | # --name $app@localhost 67 | # --detached 68 | # ``` 69 | # 70 | # To run (detached erlang): 71 | # ``` 72 | # erl -boot $dir/boot/start 73 | # -config $dir/boot/sys 74 | # -env ERL_LIBS $dir/lib 75 | # -name $app@localhost 76 | # -detached 77 | # ``` 78 | # 79 | # To connect (interactive elixir): 80 | # ``` 81 | # iex --remsh $app@localhost --sname connector 82 | # ``` 83 | # 84 | # To connect (interactive erlang): 85 | # ``` 86 | # erl -remsh $app@localhost -sname connector 87 | # ``` 88 | # 89 | # To connect (interactive elixir, using app's own `iex`): 90 | # ``` 91 | # erl -remsh $app@localhost -sname connector 92 | # # and then ... 93 | # ($app@localhost)1> 'Elixir.IEx':start(). 94 | # ``` 95 | # 96 | -------------------------------------------------------------------------------- /lib/scripts/watchdog.sh.eex: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This is meant to be executed periodically. It will check if the application is 4 | # running. If it's not, it will start it. 5 | 6 | # on crontab: 7 | # * * * * * /bin/bash -l -c '/path/to/watchdog/watchdog.sh' 8 | 9 | app="<%= app %>" 10 | user="<%= user %>" 11 | cookie="<%= cookie %>" 12 | max_processes="<%= max_processes %>" 13 | name="$(hostname)" 14 | dir="/home/$user/$app/current" 15 | log="$dir/log/$app.log" 16 | touch $log # ensure existence 17 | 18 | # test proof of life 19 | alive="$dir/tmp/alive" 20 | if [ -f $alive ]; then 21 | mtime=$(ls -l --time-style=+%s $alive | awk '{print $6}') 22 | let "last = $(date +%s) - 60" 23 | [ $mtime -gt $last ] && exit 24 | fi 25 | touch $alive # ensure existence 26 | 27 | # start in an erlang VM 28 | cmd="erl -boot $dir/boot/start -config $dir/boot/sys -env ERL_LIBS $dir/lib +P $max_processes" 29 | cmd="$cmd -name ${app}_at_${name}@127.0.0.1 -setcookie $cookie -noshell" 30 | 31 | echo "$(date) Running: $cmd" >> $log 32 | 33 | $cmd 2>&1 >> $log & 34 | 35 | ######################################## 36 | # 37 | # Some ways to run your app: 38 | # 39 | # To run (embedded erlang): 40 | # ``` 41 | # run_erl -daemon /home/$user/$app/pipes/ /home/$user/$app/log 42 | # "erl -boot $dir/boot/start 43 | # -config $dir/boot/sys 44 | # -env ERL_LIBS $dir/lib 45 | # -sname $app" 46 | # ``` 47 | # 48 | # To connect (embedded erlang): 49 | # ``` 50 | # to_erl /home/$user/$app/pipes/erlang.pipe.1 51 | # ``` 52 | # 53 | # To run (interactive erlang): 54 | # ``` 55 | # erl -boot $dir/boot/start 56 | # -config $dir/boot/sys 57 | # -env ERL_LIBS $dir/lib 58 | # -name $app@localhost 59 | # ``` 60 | # 61 | # To run (interactive elixir, using app's own `iex`): 62 | # ``` 63 | # erl -boot $dir/boot/start 64 | # -config $dir/boot/sys 65 | # -env ERL_LIBS $dir/lib 66 | # -name $app@localhost 67 | # -noshell -user Elixir.IEx.CLI -extra --no-halt 68 | # ``` 69 | # 70 | # To run (detached elixir): 71 | # ``` 72 | # iex --erl "-boot $dir/boot/start 73 | # -config $dir/boot/sys 74 | # -env ERL_LIBS $dir/lib" 75 | # --name $app@localhost 76 | # --detached 77 | # ``` 78 | # 79 | # To run (detached erlang): 80 | # ``` 81 | # erl -boot $dir/boot/start 82 | # -config $dir/boot/sys 83 | # -env ERL_LIBS $dir/lib 84 | # -name $app@localhost 85 | # -detached 86 | # ``` 87 | # 88 | # To connect (interactive elixir): 89 | # ``` 90 | # iex --remsh $app@localhost --sname connector 91 | # ``` 92 | # 93 | # To connect (interactive erlang): 94 | # ``` 95 | # erl -remsh $app@localhost -sname connector 96 | # ``` 97 | # 98 | # To connect (interactive elixir, using app's own `iex`): 99 | # ``` 100 | # erl -remsh $app@localhost -sname connector 101 | # # and then ... 102 | # ($app@localhost)1> 'Elixir.IEx':start(). 103 | # ``` 104 | # 105 | -------------------------------------------------------------------------------- /lib/bottler/install.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | require Bottler.Helpers, as: H 3 | alias SSHEx, as: S 4 | 5 | defmodule Bottler.Install do 6 | 7 | @moduledoc """ 8 | Functions to install an already shipped release on remote servers. 9 | 10 | Actually running release is not touched. Next restart will run 11 | the new release. 12 | """ 13 | 14 | @doc """ 15 | Install previously shipped release on remote servers, making it _current_ 16 | release. 17 | Returns `{:ok, details}` when done, `{:error, details}` if anything fails. 18 | """ 19 | def install(config) do 20 | :ssh.start # sometimes it's not already started at this point... 21 | 22 | config[:servers] 23 | |> H.prepare_servers 24 | |> Enum.map(fn(s) -> 25 | s 26 | |> Keyword.put(:user, config[:remote_user]) 27 | |> Keyword.put(:additional_folders, config[:additional_folders]) 28 | |> Keyword.merge(config[:install] || []) 29 | end) 30 | |> H.in_tasks(fn(args) -> 31 | on_server(args) 32 | end) 33 | end 34 | 35 | defp on_server(args) do 36 | case args[:server_script] do 37 | script when is_binary(script) -> 38 | script_install(args) 39 | 40 | nil -> 41 | manual_install(args) 42 | end 43 | end 44 | 45 | defp script_install(args) do 46 | L.info "Invoking install_script (#{args[:server_script]}) on #{args[:ip]}..." 47 | 48 | install_script_args = get_install_script_args(args) 49 | 50 | result = System.cmd "ssh", install_script_args 51 | 52 | case result do 53 | {_, 0} -> :ok 54 | {_error, _} -> :error 55 | end 56 | end 57 | 58 | defp get_install_script_args(args) do 59 | ssh_opts = ["-oStrictHostKeyChecking=no", "-oUserKnownHostsFile=/dev/null", "-oLogLevel=ERROR"] 60 | ["-A"] ++ ssh_opts ++ ["#{args[:user]}@#{args[:ip]}", args[:server_script]] 61 | end 62 | 63 | defp manual_install(args) do 64 | ip = args[:ip] |> to_charlist 65 | user = args[:user] |> to_charlist 66 | 67 | L.info "Installing #{Mix.Project.get!.project[:version]} on #{args[:id]}..." 68 | 69 | {:ok, conn} = S.connect ip: ip, user: user 70 | 71 | {conn, user, ip, args} 72 | |> place_files 73 | |> make_current 74 | |> cleanup_old_releases 75 | 76 | :ok 77 | end 78 | 79 | # Decompress release file, put it in place, and make needed movements 80 | # 81 | defp place_files({conn, user, ip, opts}) do 82 | L.info "Settling files on #{opts[:id]}..." 83 | vsn = Mix.Project.get!.project[:version] 84 | app = Mix.Project.get!.project[:app] 85 | path = '/home/#{user}/#{app}/' 86 | S.cmd! conn, 'mkdir -p #{path}releases/#{vsn}' 87 | S.cmd! conn, 'mkdir -p #{path}log' 88 | S.cmd! conn, 'mkdir -p #{path}tmp' 89 | {:ok, _, 0} = S.run conn, 90 | 'tar --directory #{path}releases/#{vsn}/ ' ++ 91 | '-xf /tmp/#{app}.tar.gz' 92 | S.cmd! conn, 'ln -sfn #{path}tmp ' ++ 93 | '#{path}releases/#{vsn}/tmp' 94 | S.cmd! conn, 'ln -sfn #{path}log ' ++ 95 | '#{path}releases/#{vsn}/log' 96 | S.cmd! conn, 97 | 'ln -sfn #{path}releases/#{vsn}/releases/#{vsn} ' ++ 98 | '#{path}releases/#{vsn}/boot' 99 | S.cmd! conn, 100 | 'ln -sfn #{path}releases/#{vsn}/lib/#{app}-#{vsn}/scripts ' ++ 101 | '#{path}releases/#{vsn}/scripts' 102 | opts[:additional_folders] 103 | |> Enum.each(fn(folder) -> 104 | S.cmd! conn, 105 | 'ln -sfn #{path}releases/#{vsn}/lib/#{app}-#{vsn}/#{folder} ' ++ 106 | '#{path}releases/#{vsn}/#{folder}' 107 | end) 108 | {conn, user, ip, opts} 109 | end 110 | 111 | defp make_current({conn, user, ip, opts}) do 112 | app = Mix.Project.get!.project[:app] 113 | vsn = Mix.Project.get!.project[:version] 114 | L.info "Marking '#{vsn}' as current on #{opts[:id]}..." 115 | {:ok, _, 0} = S.run conn,'ln -sfn /home/#{user}/#{app}/releases/#{vsn} ' ++ 116 | '/home/#{user}/#{app}/current' 117 | {conn, user, ip, opts} 118 | end 119 | 120 | defp cleanup_old_releases({conn, user, ip, opts}) do 121 | app = Mix.Project.get!.project[:app] 122 | {:ok, res, 0} = S.run conn, 'ls -t /home/#{user}/#{app}/releases' 123 | excess_releases = res |> String.split("\n") |> Enum.slice(5..-2) 124 | 125 | for r <- excess_releases do 126 | L.info "Cleaning up old #{r} on #{opts[:id]}..." 127 | {:ok, _, 0} = S.run conn, 'rm -fr /home/#{user}/#{app}/releases/#{r}' 128 | end 129 | {conn, user, ip, opts} 130 | end 131 | 132 | end 133 | -------------------------------------------------------------------------------- /lib/bottler/ship.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | require Bottler.Helpers, as: H 3 | alias Keyword, as: K 4 | 5 | defmodule Bottler.Ship do 6 | 7 | @moduledoc """ 8 | Code to place a release file on remote servers. No more, no less. 9 | """ 10 | 11 | @doc """ 12 | Copy local release file to remote servers 13 | Returns `{:ok, details}` when done, `{:error, details}` if anything fails. 14 | """ 15 | def ship(config) do 16 | ship_config = config[:ship] |> H.defaults(timeout: 60_000, method: :scp) 17 | publish_config = config[:publish] || [] |> H.defaults(timeout: 60_000, method: :scp) 18 | servers = config[:servers] |> H.prepare_servers 19 | 20 | case ship_config[:method] do 21 | :scp -> scp_shipment(config, servers, ship_config) 22 | :remote_scp -> remote_scp_shipment(config, servers, ship_config) 23 | :release_script -> release_script_shipment(config, servers, ship_config, publish_config) 24 | end 25 | end 26 | 27 | defp scp_shipment(config, servers, ship_config) do 28 | L.info "Shipping to #{servers |> Enum.map(&(&1[:id])) |> Enum.join(",")} using straight SCP..." 29 | 30 | task_opts = [expected: [], to_s: true, timeout: ship_config[:timeout]] 31 | 32 | common = [remote_user: config[:remote_user], 33 | app: Mix.Project.get!.project[:app]] 34 | 35 | 36 | servers |> H.in_tasks( &(&1 |> K.merge(common) |> run_scp), task_opts) 37 | end 38 | 39 | defp remote_scp_shipment(config, servers, ship_config) do 40 | L.info "Shipping to #{servers |> Enum.map(&(&1[:id])) |> Enum.join(",")} using remote SCP..." 41 | 42 | task_opts = [expected: [], to_s: true, timeout: ship_config[:timeout]] 43 | 44 | common = [remote_user: config[:remote_user], 45 | app: Mix.Project.get!.project[:app]] 46 | 47 | [first | rest] = servers 48 | 49 | # straight scp to first remote 50 | L.info "Uploading release to #{first[:id]}..." 51 | [first] |> H.in_tasks( &(&1 |> K.merge(common) |> run_scp), task_opts) 52 | 53 | # scp from there to the rest 54 | L.info "Distributing release from #{first[:id]} to #{Enum.map_join(rest, ",", &(&1[:id]))}..." 55 | common_rest = common |> K.merge(src_ip: first[:ip], 56 | srcpath: "/tmp/#{common[:app]}.tar.gz", 57 | method: :remote_scp) 58 | rest |> H.in_tasks( &(&1 |> K.merge(common_rest) |> run_scp), task_opts) 59 | end 60 | 61 | defp release_script_shipment(config, servers, ship_config, publish_config) do 62 | L.info "Shipping to #{servers |> Enum.map(&(&1[:id])) |> Enum.join(",")} using release_script.." 63 | 64 | task_opts = [timeout: ship_config[:timeout]] 65 | 66 | common = [remote_user: config[:remote_user], 67 | app: Mix.Project.get!.project[:app], 68 | publish_user: publish_config[:remote_user], 69 | publish_host: publish_config[:server], 70 | publish_folder: publish_config[:folder], 71 | release_ship_script: ship_config[:release_ship_script]] 72 | 73 | servers |> H.in_tasks( &(&1 |> K.merge(common) |> invoke_download_latest_release), task_opts) 74 | end 75 | 76 | defp get_release_script_args(args) do 77 | ssh_opts = ["-oStrictHostKeyChecking=no", "-oUserKnownHostsFile=/dev/null", "-oLogLevel=ERROR"] 78 | ["-A"] ++ ssh_opts ++ ["#{args[:remote_user]}@#{args[:ip]}", args[:release_ship_script]] 79 | end 80 | 81 | defp invoke_download_latest_release(args) do 82 | release_script_args = get_release_script_args(args) 83 | 84 | L.info "Invoking release_script on #{args[:ip]}..." 85 | 86 | result = System.cmd "ssh", release_script_args 87 | 88 | case result do 89 | {_, 0} -> :ok 90 | {_error, _} -> :error 91 | end 92 | end 93 | 94 | defp get_scp_template(method) do 95 | scp_opts = "-oStrictHostKeyChecking=no -oUserKnownHostsFile=/dev/null -oLogLevel=ERROR" 96 | case method do 97 | :scp -> "scp #{scp_opts} <%= srcpath %> <%= remote_user %>@<%= ip %>:<%= dstpath %>" 98 | :remote_scp -> "ssh -A #{scp_opts} <%= remote_user %>@<%= src_ip %> scp #{scp_opts} <%= srcpath %> <%= remote_user %>@<%= ip %>:<%= dstpath %>" 99 | end 100 | end 101 | 102 | defp run_scp(args) do 103 | args = args |> H.defaults(srcpath: "rel/#{args[:app]}.tar.gz", 104 | dstpath: "/tmp/", 105 | method: :scp) 106 | 107 | args[:method] 108 | |> get_scp_template 109 | |> EEx.eval_string(args) 110 | |> to_charlist 111 | |> :os.cmd 112 | end 113 | 114 | end 115 | -------------------------------------------------------------------------------- /lib/bottler/release.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | require Bottler.Helpers, as: H 3 | require Bottler.Helpers.Hook, as: Hook 4 | 5 | defmodule Bottler.Release do 6 | 7 | @moduledoc """ 8 | Code to build a release file. Many small tools working in harmony. 9 | """ 10 | @doc """ 11 | Build a release tar.gz. Returns `:ok` when done. Crash otherwise. 12 | """ 13 | def release(config) do 14 | 15 | :ok = Hook.exec :pre_release, config 16 | 17 | L.info "Compiling deps for release..." 18 | env = System.get_env "MIX_ENV" 19 | :ok = H.cmd "MIX_ENV=#{env} mix deps.get" 20 | :ok = H.cmd "MIX_ENV=#{env} mix compile" 21 | 22 | if env == "prod" && !config[:skip_version_check], do: H.check_erts_versions(config) 23 | 24 | L.info "Generating release tar.gz ..." 25 | File.rm_rf! "rel" 26 | File.mkdir_p! "rel" 27 | generate_rel_file() 28 | generate_config_file() 29 | generate_tar_file config 30 | :ok 31 | end 32 | 33 | defp generate_rel_file, 34 | do: H.write_term("rel/#{Mix.Project.get!.project[:app]}.rel", get_rel_term()) 35 | 36 | defp generate_tar_file(config) do 37 | app = Mix.Project.get!.project[:app] |> to_charlist 38 | 39 | # add scripts folder 40 | process_scripts_folder config 41 | process_additional_folders config 42 | 43 | # list of atoms representing the dirs to include in the tar 44 | additional_folders = config[:additional_folders] |> Enum.map(&(String.to_atom(&1))) 45 | dirs = [:scripts] ++ additional_folders 46 | 47 | ebin_path = '#{Mix.Project.build_path}/lib/*/ebin' 48 | File.cd! "rel", fn() -> 49 | :systools.make_script(app,[path: [ebin_path]]) 50 | :systools.make_tar(app,[dirs: dirs, path: [ebin_path]]) 51 | end 52 | end 53 | 54 | # process templates found on scripts folder 55 | # 56 | defp process_scripts_folder(config) do 57 | vars = [app: Mix.Project.get!.project[:app], 58 | user: config[:remote_user], 59 | cookie: config[:cookie], 60 | max_processes: config[:max_processes] || 262144] 61 | dest_path = "#{Mix.Project.app_path}/scripts" 62 | File.mkdir_p! dest_path 63 | 64 | # render script templates 65 | scripts = get_all_scripts() 66 | renders = scripts 67 | |> Enum.filter(fn({_,v})-> String.match?(v,~r/\.eex$/) end) 68 | |> Enum.map(fn({k,v})-> { k, EEx.eval_file(v,vars) } end) 69 | 70 | # copy scripts 71 | for {f,v} <- scripts, do: :ok = File.cp v, "#{dest_path}/#{f}" 72 | # save renders over them 73 | for {f,body} <- renders, 74 | do: :ok = File.write "#{dest_path}/#{f}", body, [:write] 75 | end 76 | 77 | # copy additional folders to the destination folder to be included in the tar file 78 | # 79 | defp process_additional_folders(config) do 80 | config[:additional_folders] |> Enum.each(&(process_additional_folder(&1))) 81 | end 82 | 83 | defp process_additional_folder(additional_folder) do 84 | dest_path = "#{Mix.Project.app_path}/#{additional_folder}" 85 | File.mkdir_p! dest_path 86 | 87 | files = H.full_ls "lib/#{additional_folder}" 88 | 89 | for f <- files do 90 | :ok = File.cp f, "#{dest_path}/#{Path.basename(f)}" 91 | end 92 | end 93 | 94 | # Return all script files' names and full paths. Merging bottler's scripts 95 | # folder with project's scripts folder if it exists. 96 | # 97 | # Project's scripts overwrite bottler's with the same name, 98 | # ignoring the extra `.eex` part (i.e. `shell.sh` from bottler would be 99 | # replaced with `shell.sh.eex` from the project ). 100 | # 101 | defp get_all_scripts do 102 | pfiles = H.full_ls "lib/scripts" 103 | bfiles = H.full_ls "#{__DIR__}/../scripts" 104 | for f <- (bfiles ++ pfiles), into: %{}, do: {Path.basename(f,".eex"), f} 105 | end 106 | 107 | # TODO: ensure paths 108 | # 109 | defp generate_config_file do 110 | H.write_term "rel/sys.config", Mix.Config.read!("config/config.exs") 111 | end 112 | 113 | defp get_deps_term do 114 | {apps, iapps} = get_all_apps() 115 | 116 | iapps 117 | |> Enum.map(fn({n,v}) -> {n,v,:load} end) 118 | |> Enum.concat(apps) 119 | end 120 | 121 | defp get_rel_term do 122 | mixf = Mix.Project.get! 123 | app = mixf.project[:app] |> to_charlist 124 | vsn = mixf.project[:version] |> to_charlist 125 | 126 | {:release, 127 | {app, vsn}, 128 | {:erts, :erlang.system_info(:version)}, 129 | get_deps_term() } 130 | end 131 | 132 | # Get info for every compiled app's from its app file 133 | # 134 | defp read_all_app_files do 135 | infos = :os.cmd('find -L _build/#{Mix.env} -name *.app') 136 | |> to_string |> String.split 137 | 138 | for path <- infos do 139 | {:ok,[{_,name,data}]} = path |> H.read_terms 140 | {name, data[:vsn], data[:applications], data[:included_applications]} 141 | end 142 | end 143 | 144 | # Get compiled, and included apps with versions. 145 | # 146 | # If an included application is not loaded or compiled itself, version 147 | # number cannot be determined, and it will be ignored. If this is 148 | # your case, you should explicitly put it into your deps, so it gets 149 | # compiled, and then detected here. 150 | # 151 | defp get_all_apps do 152 | app_files_info = read_all_app_files() 153 | 154 | # a list of all apps ever needed or included 155 | needed = [:kernel, :stdlib, :elixir, :sasl, :compiler, :syntax_tools] 156 | all = app_files_info |> Enum.reduce([apps: needed, iapps: []], 157 | fn({n,_,a,ia},[apps: apps, iapps: iapps]) -> 158 | ia = if ia == nil, do: [], else: ia 159 | apps = Enum.concat([apps,[n],a,ia]) 160 | iapps = Enum.concat(iapps,ia) 161 | [apps: apps, iapps: iapps] 162 | end ) 163 | 164 | # load all of them, see what version they are on 165 | for a <- ( all[:apps] ++ all[:iapps] ), do: :ok = load(a) 166 | 167 | # get own included applications 168 | own_iapps = Mix.Project.get!.application 169 | |> Keyword.get(:included_applications, []) 170 | 171 | # get loaded app's versions 172 | versions = for {n,_,v} <- :application.info[:loaded], do: {n,v} 173 | 174 | only_included = all[:iapps] 175 | |> Enum.reject(&( &1 in all[:apps] )) 176 | |> :erlang.++(own_iapps) # own included are only included 177 | |> add_version_info(versions) 178 | 179 | apps = all[:apps] |> Enum.uniq 180 | |> :erlang.--(own_iapps) # do not start own included 181 | |> add_version_info(versions) 182 | 183 | {apps, only_included} 184 | end 185 | 186 | defp add_version_info(apps,versions) do 187 | apps 188 | |> Enum.map(fn(a) -> {a,versions[a]} end) 189 | |> Enum.reject(fn({_,v}) -> v == nil end) # ignore those with no vsn info 190 | end 191 | 192 | defp load(app) do 193 | # if it's a custom compiled app, ensure that's the one that gets loaded 194 | :code.add_patha('#{Mix.Project.build_path}/lib/#{app}/ebin') 195 | case :application.load app do 196 | {:error, {:already_loaded, ^app}} -> :ok 197 | x -> x 198 | end 199 | end 200 | 201 | end 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bottler (BETA) 2 | 3 | [![Build Status](https://travis-ci.org/rubencaro/bottler.svg?branch=master)](https://travis-ci.org/rubencaro/bottler) 4 | [![Hex Version](http://img.shields.io/hexpm/v/bottler.svg?style=flat)](https://hex.pm/packages/bottler) 5 | [![Hex Version](http://img.shields.io/hexpm/dt/bottler.svg?style=flat)](https://hex.pm/packages/bottler) 6 | 7 | ---- 8 | 9 | # Abandoned ! 10 | This project has not been used by me in production for months and I don't expect to be able to dedicate time to it for a while. It was never meant to be more than my own tools, just in the open. So no big deal. 11 | 12 | As always, feel free to fork it and go on with it if you find it useful for you! 13 | 14 | ---- 15 | 16 | Bottler is a collection of tools that aims to help you generate releases, ship 17 | them to your servers, install them there, and get them live on production. 18 | 19 | ## What 20 | 21 | Several tools that can be used separately: 22 | 23 | * __release__: generate `tar.gz` files with your app and its dependencies (not 24 | including the whole `erts` by now). 25 | * __ship__: ship your generated `tar.gz` via `scp` to every server you configure. 26 | * __install__: properly install your shipped release on each of those servers. 27 | * __restart__: fire a quick restart to apply the newly installed release if you 28 | are using [Harakiri](http://github.com/rubencaro/harakiri). 29 | * __green_flag__: wait for the deployed application to signal it's working. 30 | * __deploy__: _release_, _ship_, _install_, _restart_, and then wait for _green_flag_. 31 | * __rollback__: quick _restart_ on a previous release. 32 | * __observer__: opens an observer window connected to given server. 33 | * __exec__: runs given command on every server, showing their outputs. 34 | * __goto__: opens an SSH session with a server on a new terminal window. 35 | 36 | You should have public key ssh access to all servers you intend to work with. 37 | Erlang runtime should be installed there too. Everything else, including Elixir 38 | itself, is included in the release. 39 | 40 | By now it's not able to deal with all the hot code swap bolts, screws and nuts. 41 | Someday will be. 42 | 43 | ## Alternative to... 44 | 45 | Initially it was an alternative to [exrm](https://github.com/bitwalker/exrm), due to its lack of some features I love. 46 | 47 | Recently, after creating and using bottler on several projects for some months, I discovered [edeliver](https://github.com/boldpoker/edeliver) and it looks great! When I have time I will read carefully its code and play differences with bottler, maybe borrow some ideas. 48 | 49 | Looking forward to [distillery](https://github.com/bitwalker/distillery) too. The plan is to use it to generate the releases. 50 | 51 | ## Use 52 | 53 | Add to your `deps` like this: 54 | 55 | ```elixir 56 | {:bottler, " >= 0.5.0"} 57 | ``` 58 | 59 | Or if you want to take a walk on the wild side: 60 | 61 | ```elixir 62 | {:bottler, github: "rubencaro/bottler"} 63 | ``` 64 | 65 | On your config: 66 | 67 | ```elixir 68 | config :bottler, :params, [servers: [server1: [ip: "1.1.1.1"], 69 | server2: [ip: "1.1.1.2"]], 70 | remote_user: "produser", 71 | rsa_pass_phrase: "passphrase", 72 | cookie: "secretcookie", 73 | max_processes: 262144, 74 | additional_folders: ["docs"], 75 | ship: [timeout: 60_000, 76 | method: :scp], 77 | green_flag: [timeout: 30_000], 78 | goto: [terminal: "terminator -T '<%= title %>' -e '<%= command %>'"] 79 | forced_branch: "master", 80 | hooks: [pre_release: %{command: "whatever", continue_on_fail: false}]] 81 | ``` 82 | 83 | * `servers` - list of servers to deploy on. 84 | * `remote_user` - user name to log in. 85 | * `rsa_pass_phrase` - pass phrase for your SSH keys (We recommend not to put it on plain text here. `System.get_env("RSA_PASS_PHRASE")` would do.). 86 | * `cookie` - distributed Erlang cookie. 87 | * `max_processes` - maximum number of processes allowed on ErlangVM ([see here](http://erlang.org/doc/man/erl.html#max_processes)). Defaults to `262144`. 88 | * `additional_folders` - additional folders to include in the release under 89 | the `lib` folder. 90 | * `ship` - options for the `ship` task 91 | * `timeout` - timeout millis for shipment through scp, defaults to 60_000 92 | * `method` - method of shipment, one of (`:scp`, `:remote_scp`, etc..) 93 | * `green_flag` - options for the `green_flag` task 94 | * `timeout` - timeout millis waiting for green flags, defaults to 30_000 95 | * `goto` - options for the `goto` task 96 | * `terminal` - template for the actual terminal command 97 | * `forced_branch` - only allow executing _dangerous_ tasks when local git is on given branch 98 | * `hooks` - hooks to run external commands on interesting moments 99 | 100 | Then you can use the tasks like `mix bottler.release`. Take a look at the docs for each task with `mix help `. 101 | 102 | `prod` environment is used by default. Use like `MIX_ENV=other_env mix bottler.taskname` to force it to `other_env`. 103 | 104 | You may also want to add `/rel` and `/.bottler` to your `.gitignore` if you don't want every generated file, including release `.tar.gz`, get into your repo. 105 | 106 | ## Release 107 | 108 | Build a release file. Use like `mix bottler.release`. 109 | 110 | Any script (or `EEx` template) on a `lib/scripts folder` will be included into the release package. The `install` task also links that folder directly from the current release, so you can see your scripts on production inside `$HOME//current/scripts`. The contents of the folder will be merged with the own `bottler` `lib/scripts` folder. Take a look at it for examples ( https://github.com/rubencaro/bottler/tree/master/lib/scripts ). 111 | 112 | ## Ship 113 | 114 | Ship a release file to configured remote servers. 115 | Use like `mix bottler.ship`. 116 | 117 | You can configure some things about it, under the _ship_ section: 118 | * __timeout__: The timeout that applies to the upload process. 119 | * __method__: One of: 120 | * __scp__: Straight _scp_ from the local machine to every target server. 121 | * __remote_scp__: Upload the release only once from your local machine to the first configured server, and then _scp_ remotely to every other target. 122 | 123 | ## Install 124 | 125 | Install a shipped file on configured remote servers. 126 | Use like `mix bottler.install`. 127 | 128 | ## Restart 129 | 130 | Touch `tmp/restart` on configured remote servers. 131 | That expects to have `Harakiri` or similar software reacting to that. 132 | Use like `mix bottler.restart`. 133 | 134 | ## Alive Loop 135 | 136 | Tipically implemented on production like this: 137 | 138 | ```elixir 139 | @doc """ 140 | Tell the world outside we are alive 141 | """ 142 | def alive_loop(opts \\ []) do 143 | # register the name if asked 144 | if opts[:name], do: Process.register(self,opts[:name]) 145 | 146 | :timer.sleep 5_000 147 | tmp_path = Application.get_env(:myapp, :tmp_path) |> Path.expand 148 | {_, _, version} = Application.started_applications |> Enum.find(&(match?({:myapp, _, _}, &1))) 149 | :os.cmd 'echo \'#{version}\' > #{tmp_path}/alive' 150 | alive_loop 151 | end 152 | ``` 153 | 154 | And run by a `Task` on the supervision tree like this: 155 | 156 | ```elixir 157 | worker(Task, [MyApp, :alive_loop, [[name: MyApp.AliveLoop]]]) 158 | ``` 159 | 160 | It touches the `tmp/alive` file every ~5 seconds, so anyone outside of the ErlangVM can tell if the app is actually running. 161 | 162 | ### Watchdog script for crontab 163 | 164 | Among the generated scripts, put by the _deploy_ task inside `$HOME//current/scripts`, there's a `watchdog.sh` meant to be run by `cron`. 165 | 166 | That script checks the _mtime_ of the `tmp/alive` file to ensure that it's younger than 60 seconds. If it's not, then it starts the application. If the application is running, the watchdog script will not even try to start it again. 167 | 168 | ### Green Flag Test 169 | 170 | A task to wait until the contents of `tmp/alive` file matches the version of the `current` release, or the given timeout is reached. 171 | 172 | Use like `mix bottler.green_flag`. 173 | 174 | If you have special needs with the start of your application, such as to wait for some cache to fill or some connections to be made, then you just have to control the actual value that is written on the `alive` file. __It has to match the new version only when everything is ready to work.__ You can use an `Agent` like: 175 | 176 | ```elixir 177 | @doc """ 178 | Tell the world outside we are alive 179 | """ 180 | def alive_loop(opts \\ []) do 181 | #... 182 | version = Agent.get(:version_holder, &(&1)) 183 | #... 184 | end 185 | ``` 186 | 187 | ## Deploy 188 | 189 | Build a release file, ship it to remote servers, install it, and restart 190 | the app. Then it waits for the green flag test. No hot code swap for now. 191 | 192 | Use like `mix deploy`. 193 | 194 | ## Rollback 195 | 196 | Simply move the _current_ link to the previous release and restart to 197 | apply. It's also possible to deploy a previous release, but this is 198 | quite faster. 199 | 200 | Be careful because the _previous release_ may be different on each server. 201 | It's up to you to keep all your servers rollback-able (yeah). 202 | 203 | Use like `mix bottler.rollback`. 204 | 205 | ## Observer 206 | 207 | Use like `mix observer server1` 208 | 209 | It takes the ip of the given server from configuration, then opens a double SSH tunnel with its epmd service and its application node. Then executes an elixir script which spawns an observer window locally, connected with the tunnelled node. You just need to select the remote node from the _Nodes_ menu. 210 | 211 | ## Exec 212 | 213 | Use like `mix bottler.exec 'ls -alt some/path'` 214 | 215 | It runs the given command through parallel SSH connections with all the configured servers. It accepts an optional _--timeout_ parameter. 216 | 217 | ## Goto 218 | 219 | Use like `mix goto server1` 220 | 221 | It opens an SSH session on a new terminal window on the server with given name. The actual `terminal` command can be configured as a template. 222 | 223 | ## GCE support 224 | 225 | Whenever you can use Google's `gcloud` from your computer (i.e. authenticate and see if it works), you can configure `bottler` to use it too to get your instances IP addresses. Instead of: 226 | 227 | ```elixir 228 | servers: [server1: [ip: "1.1.1.1"], 229 | server2: [ip: "1.1.1.2"]] 230 | ``` 231 | 232 | You just do: 233 | ```elixir 234 | servers: [gce_project: "project-id", match: "regexstr"] 235 | ``` 236 | When you perform an operation on a server, its ip will be obtained using `gcloud` command. You don't need to reserve more static IP addresses for your instances. 237 | 238 | Optionally you can give a `match` regex string to default filter server names given by gcloud. Just the same you would give to the `--servers` switch of the tasks. This filter will be added to the one given at the commandline switch. I.e. if you configure `match` and then pass `--servers`, then only servers with a name that matches both regexes will pass. 239 | 240 | ## Hooks 241 | 242 | You can configure hooks to be run at several points of the process. To define a hook you must add it to your configuration like this: 243 | 244 | ```elixir 245 | hooks: [hook_point_name: %{command: "whatever", continue_on_fail: false}], 246 | ``` 247 | 248 | `continue_on_fail` marks the behaviour of bottler when the return code of given command is not zero. When `continue_on_fail` is `true`, bottler will continue with the normal execution. Otherwise it will halt. 249 | 250 | Supported hook points are: 251 | * __pre_release__: executed right before the _release_ task 252 | 253 | ## TODOs 254 | 255 | * Use [distillery](https://github.com/bitwalker/distillery) 256 | * Add more testing 257 | * Separate section for documenting every configuration option 258 | * Get it stable on production 259 | * Complete README 260 | * Rollback to _any_ previous version 261 | * Add support for deploy to AWS instances [*](https://github.com/gleber/erlcloud)[*](notes/aws.md) 262 | 263 | ## Changelog 264 | 265 | ### master 266 | 267 | * Add pre-release hook 268 | * Support for hooks 269 | * Remove 1.4 warnings 270 | * Configurable `max_processes` 271 | * Log using server names 272 | * Fix some `scp` glitches when shipping between servers 273 | * Support for `Regex` on server names 274 | * Green flag support. 275 | * Support for forced release branch 276 | * Log guessed server ips 277 | * Options to filter target servers from command line 278 | * Resolve server ips only once 279 | * Add support for deploy to GCE instances 280 | * remove `helper_scripts` task 281 | * `goto` task 282 | * Use SSHEx 2.1.0 283 | * Cookie support 284 | * configurable shipment timeout 285 | * `erl_connect` (no Elixir needed on target) 286 | * `observer` task 287 | * `bottler.exec` task 288 | * `remote_scp` shipment support 289 | * log erts versions on both sides 290 | 291 | ### 0.5.0 292 | 293 | * Use new SSHEx 1.1.0 294 | 295 | ### 0.4.1 296 | 297 | * Fix `:ssh` sometimes not started on install. 298 | 299 | ### 0.4.0 300 | 301 | * Use [SSHEx](https://github.com/rubencaro/sshex) 302 | * Add __helper_scripts__ 303 | 304 | ### 0.3.0 305 | 306 | * Individual tasks for each step 307 | * Add connect script 308 | * Add fast rollback 309 | * Few README improvements 310 | 311 | ### 0.2.0 312 | 313 | * First package released 314 | -------------------------------------------------------------------------------- /lib/bottler/helpers.ex: -------------------------------------------------------------------------------- 1 | require Logger, as: L 2 | alias Keyword, as: K 3 | 4 | defmodule Bottler.Helpers do 5 | 6 | @doc """ 7 | Parses given args using OptionParser with given opts. 8 | Raises ArgumentError if any unknown argument found. 9 | """ 10 | def parse_args!(args, opts \\ []) do 11 | {switches, remaining_args, unknown} = OptionParser.parse(args, opts) 12 | 13 | case unknown do 14 | [] -> {switches, remaining_args} 15 | x -> raise ArgumentError, message: "Unknown arguments: #{inspect x}" 16 | end 17 | end 18 | 19 | @doc """ 20 | Convenience to get environment bits. Avoid all that repetitive 21 | `Application.get_env( :myapp, :blah, :blah)` noise. 22 | """ 23 | def env(key, default \\ nil), do: env(:bottler, key, default) 24 | def env(app, key, default), do: Application.get_env(app, key, default) 25 | 26 | @doc """ 27 | Get current app's name 28 | """ 29 | def app, do: Mix.Project.get!.project[:app] 30 | 31 | @doc """ 32 | Chop final end of line chars to given string 33 | """ 34 | def chop(s), do: String.replace(s, ~r/[\n\r\\"]/, "") 35 | 36 | @doc """ 37 | Spit to output any passed variable, with location information. 38 | 39 | If `sample` option is given, it should be a float between 0.0 and 1.0. 40 | Output will be produced randomly with that probability. 41 | 42 | Given `opts` will be fed straight into `inspect`. Any option accepted by it should work. 43 | """ 44 | defmacro spit(obj \\ "", opts \\ []) do 45 | quote do 46 | opts = unquote(opts) 47 | obj = unquote(obj) 48 | opts = Keyword.put(opts, :env, __ENV__) 49 | 50 | Bottler.Helpers.maybe_spit(obj, opts, opts[:sample]) 51 | obj # chainable 52 | end 53 | end 54 | 55 | @doc false 56 | def maybe_spit(obj, opts, nil), do: do_spit(obj, opts) 57 | def maybe_spit(obj, opts, prob) when is_float(prob) do 58 | if :rand.uniform <= prob, do: do_spit(obj, opts) 59 | end 60 | 61 | defp do_spit(obj, opts) do 62 | %{file: file, line: line} = opts[:env] 63 | name = Process.info(self())[:registered_name] 64 | chain = [ :bright, :red, "\n\n#{file}:#{line}", :normal, "\n #{inspect self()}", :green," #{name}"] 65 | 66 | msg = inspect(obj, opts) 67 | chain = chain ++ [:red, "\n\n#{msg}"] 68 | 69 | (chain ++ ["\n\n", :reset]) |> IO.ANSI.format(true) |> IO.puts 70 | end 71 | 72 | @doc """ 73 | Print to stdout a _TODO_ message, with location information. 74 | """ 75 | defmacro todo(msg \\ "") do 76 | quote do 77 | %{file: file, line: line} = __ENV__ 78 | [ :yellow, "\nTODO: #{file}:#{line} #{unquote(msg)}\n", :reset] 79 | |> IO.ANSI.format(true) 80 | |> IO.puts 81 | :todo 82 | end 83 | end 84 | 85 | @doc """ 86 | Pipable log. Calls Logger and then returns first argument. 87 | Second argument is a template, or a function returning a template. 88 | To render the template `EEx` will be used, and the first argument will be passed. 89 | """ 90 | def pipe_log(obj, template, opts \\ []) 91 | def pipe_log(obj, fun, opts) when is_function(fun) do 92 | pipe_log(obj, fun.(obj), opts) 93 | end 94 | def pipe_log(obj, template, opts) do 95 | opts = opts |> defaults(level: :info) 96 | 97 | msg = template |> EEx.eval_string(data: obj) 98 | :ok = L.log opts[:level], msg 99 | obj 100 | end 101 | 102 | @doc """ 103 | Run given function in different Tasks. One `Task` for each entry on given 104 | list. Each entry on list will be given as args for the function. 105 | 106 | Explodes if `timeout` is reached waiting for any particular task to end. 107 | 108 | Once run, each return value from each task is compared with `expected`. 109 | It returns `{:ok, results}` if _every_ task returned as expected. 110 | 111 | If any task did not return as expected, then it returns `{:error, results}`. 112 | 113 | If `to_s` is `true` then results are fed to `to_string` before return. 114 | This is useful when returned value is a char list and is to be printed to 115 | stdout. 116 | """ 117 | def in_tasks(list, fun, opts \\ []) do 118 | expected = opts |> K.get(:expected, :ok) 119 | timeout = opts |> K.get(:timeout, 60_000) 120 | to_s = opts |> K.get(:to_s, false) 121 | 122 | # run and get results 123 | tasks = for args <- list, into: [], do: Task.async(fn -> fun.(args) end) 124 | results = for t <- tasks, into: [], do: Task.await(t, timeout) 125 | 126 | # figure out return value 127 | sign = if Enum.all?(results, &(&1 == expected)), do: :ok, else: :error 128 | if to_s, do: {sign, to_string(results)}, 129 | else: {sign, results} 130 | end 131 | 132 | @doc """ 133 | Set up `prod` environment variables. Be careful, it only applies to newly 134 | loaded modules. 135 | 136 | If `MIX_ENV` was already set, then it's not overwritten. 137 | """ 138 | def set_prod_environment do 139 | use Mix.Config 140 | 141 | if System.get_env("MIX_ENV") do 142 | L.info "MIX_ENV was already set, not forcing..." 143 | else 144 | L.info "Setting up 'prod' environment..." 145 | System.put_env "MIX_ENV","prod" 146 | Mix.env :prod 147 | end 148 | 149 | res = "config/config.exs" |> Path.absname 150 | |> Mix.Config.read! |> Mix.Config.persist 151 | 152 | # different responses for Elixir 1.0 and 1.1, we want both 153 | if not is_ok_response_for_10_and_11(res), 154 | do: raise "Could not persist the requested config: #{inspect(res)}" 155 | 156 | # destroy other environments' traces, helpful for environment debugging 157 | # {:ok, _} = File.rm_rf("_build") 158 | 159 | # support dynamic config, force project's compilation 160 | [] = :os.cmd 'touch config/config.exs' 161 | 162 | :ok 163 | end 164 | 165 | # hack to allow different responses for Elixir 1.0 and 1.1 166 | # 1.0 -> :ok 167 | # 1.1 -> is a list and includes at least bottler config keys 168 | # 169 | defp is_ok_response_for_10_and_11(res) do 170 | case res do 171 | :ok -> true 172 | x when is_list(x) -> Enum.all?([:logger,:bottler], fn(i)-> i in res end) 173 | _ -> false 174 | end 175 | end 176 | 177 | @doc """ 178 | Returns `:bottler` config keywords. It also validates they are all set. 179 | Raises an error if anything looks wrong. 180 | """ 181 | def read_and_validate_config do 182 | c = [ scripts_folder: ".bottler/scripts", 183 | into_path_folder: "~/.local/bin", 184 | remote_port: 22, 185 | additional_folders: [], 186 | ship: [], 187 | green_flag: [], 188 | goto: [terminal: "terminator -T '<%= title %>' -e '<%= command %>' &"] ] 189 | |> K.merge(Application.get_env(:bottler, :params)) 190 | 191 | if not is_valid_servers_list?(c[:servers]), 192 | do: raise ":bottler :servers should look like \n" <> 193 | " [srvname: [ip: '' | rest ] | rest ]\n" <> 194 | "or [gce_project: \"project-id\"]\n" <> 195 | "but it was\n #{inspect c[:servers]}" 196 | 197 | c 198 | end 199 | 200 | def validate_branch(config) do 201 | case check_active_branch(config[:forced_branch]) do 202 | true -> config 203 | false -> L.error "You are not in branch '#{config[:forced_branch]}'." 204 | raise "WrongBranchError" 205 | end 206 | end 207 | 208 | defp check_active_branch(nil), do: true 209 | defp check_active_branch(branch) do 210 | "git branch 2> /dev/null | sed -e '/^[^*]/d' -e \"s/* \\(.*\\)/\\1/\"" 211 | |> to_charlist 212 | |> :os.cmd 213 | |> to_string 214 | |> String.replace("\n","") 215 | |> Kernel.==(branch) 216 | end 217 | 218 | defp is_valid_servers_list?(s) do 219 | K.keyword?(s) and ( is_gce_servers?(s) or is_default_servers?(s) ) 220 | end 221 | 222 | defp is_default_servers?(s), 223 | do: Enum.all?(s, fn({_,v})-> :ip in K.keys(v) end) 224 | 225 | defp is_gce_servers?(s), 226 | do: match?(%{gce_project: _} , Enum.into(s,%{})) 227 | 228 | defp get_servers_type(s) do 229 | case is_gce_servers?(s) do 230 | true -> :gce 231 | false -> case is_default_servers?(s) do 232 | true -> :default 233 | false -> :none 234 | end 235 | end 236 | end 237 | 238 | @doc """ 239 | Return the server list, whatever its type is. 240 | Raises an error if it's not recognised. 241 | """ 242 | def guess_server_list(config) do 243 | case get_servers_type(config[:servers]) do 244 | :default -> config[:servers] # explicit, non GCE 245 | :gce -> get_gce_server_list(config) 246 | :none -> raise "Server list specification not recognised: '#{inspect config[:servers]}'" 247 | end 248 | end 249 | 250 | @doc """ 251 | Returns a copy of given config with the servers list well formed, 252 | and filtered using given parsed switches. 253 | """ 254 | def inline_resolve_servers(config), do: inline_resolve_servers(config, []) 255 | def inline_resolve_servers(config, switches) do 256 | servers_list = guess_server_list(config) 257 | |> inline_filter_servers(switches[:servers]) 258 | 259 | config |> Keyword.put(:servers, servers_list) 260 | end 261 | 262 | defp inline_filter_servers(servers, nil), do: servers 263 | defp inline_filter_servers(servers, switch) when is_binary(switch) do 264 | names = switch |> String.split(",") |> Enum.map(&Regex.compile!(&1)) 265 | servers 266 | |> Enum.filter(fn({k,_})-> 267 | k = to_string(k) 268 | names |> Enum.any?(&Regex.match?(&1, k)) 269 | end) 270 | end 271 | 272 | defp get_gce_server_list(config) do 273 | L.info "Getting server list from GCE..." 274 | 275 | config 276 | |> Bottler.Helpers.GCE.instances 277 | |> Enum.map(fn(i)-> 278 | name = i["name"] |> String.to_atom 279 | ip = i |> get_nested(["networkInterfaces", 0, "accessConfigs", 0, "natIP"]) 280 | {name, [ip: ip]} 281 | end) 282 | |> pipe_log("<%= inspect data %>") 283 | end 284 | 285 | @doc """ 286 | Adds the `name` and a new `id` element to the given servers `Keyword`. 287 | It returns a plain list, with a `Keyword` for each server. 288 | """ 289 | def prepare_servers(servers) do 290 | servers |> Enum.map(fn({name, values}) -> 291 | values ++ [ name: name, id: "#{name}(#{values[:ip]})" ] 292 | end) 293 | end 294 | 295 | @doc """ 296 | Writes an Elixir/Erlang term to the provided path 297 | """ 298 | def write_term(path, term), 299 | do: :file.write_file('#{path}', :io_lib.fwrite('~p.\n', [term])) 300 | 301 | @doc """ 302 | Reads a file as Erlang terms 303 | """ 304 | def read_terms(path), do: :file.consult('#{path}') 305 | 306 | @doc """ 307 | Apply given defaults to given Keyword. Returns merged Keyword. 308 | 309 | The inverse of `Keyword.merge`, best suited to apply some defaults in a 310 | chainable way. 311 | 312 | Ex: 313 | kw = gather_data 314 | |> transform_data 315 | |> H.defaults(k1: 1234, k2: 5768) 316 | |> here_i_need_defaults 317 | 318 | Instead of: 319 | kw1 = gather_data 320 | |> transform_data 321 | kw = [k1: 1234, k2: 5768] 322 | |> Keyword.merge(kw1) 323 | |> here_i_need_defaults 324 | 325 | """ 326 | def defaults(args, defs) do 327 | defs |> Keyword.merge(args) 328 | end 329 | 330 | @doc """ 331 | Run given command through `Mix.Shell` 332 | """ 333 | def cmd(command) do 334 | case Mix.Shell.cmd(command, &(IO.write(&1)) ) do 335 | 0 -> :ok 336 | _ -> {:error, "Release step failed. Please fix any errors and try again."} 337 | end 338 | end 339 | 340 | @doc """ 341 | `ls` with full paths 342 | Returns the list of full paths. An empty list if anything fails 343 | """ 344 | def full_ls(path) do 345 | expanded = Path.expand(path) 346 | case path |> File.ls do 347 | {:ok, list} -> Enum.map(list,&( "#{expanded}/#{&1}" )) 348 | _ -> [] 349 | end 350 | end 351 | 352 | @doc """ 353 | Delete, then recreate given folders 354 | """ 355 | def empty_dirs(paths) when is_list(paths) do 356 | for p <- paths, do: empty_dir(p) 357 | end 358 | 359 | @doc """ 360 | Delete, then recreate given folder 361 | """ 362 | def empty_dir(path) do 363 | File.rm_rf! path 364 | File.mkdir_p! path 365 | end 366 | 367 | @doc """ 368 | Log local and remote versions of erts 369 | """ 370 | def check_erts_versions(config) do 371 | :ssh.start # just in case 372 | 373 | local_release = :erlang.system_info(:version) |> to_string 374 | 375 | {_, remote_releases} = config[:servers] |> K.values 376 | |> in_tasks( fn(args)-> 377 | user = config[:remote_user] |> to_charlist 378 | ip = args[:ip] |> to_charlist 379 | {:ok, conn} = SSHEx.connect(ip: ip, user: user) 380 | cmd = "source ~/.bash_profile && erl -eval 'erlang:display(erlang:system_info(version)), halt().' -noshell" |> to_charlist 381 | SSHEx.cmd!(conn, cmd) 382 | |> String.replace(~r/[\n\r\\"]/, "") 383 | |> Kernel.<>(" on #{ip}") 384 | end, to_s: false) 385 | 386 | level = if Enum.all?(remote_releases, &( local_release == &1 |> String.split(" ") |> List.first )), do: :info, else: :error 387 | 388 | L.log level, "Compiling against Erlang/OTP release #{local_release}. Remote releases are #{Enum.map_join(remote_releases, ", ", &(&1))}." 389 | 390 | if level == :error, do: raise "Aborted release" 391 | end 392 | 393 | @doc """ 394 | Get the value at given coordinates inside the given nested structure. 395 | The structure must be composed of `Map` and `List`. 396 | 397 | If coordinates do not exist `nil` is returned. 398 | """ 399 | def get_nested(data, []), do: data 400 | def get_nested(data, [key | rest]) when is_map(data) do 401 | data |> Map.get(key) |> get_nested(rest) 402 | end 403 | def get_nested(data, [key | rest]) when is_list(data) do 404 | data |> Enum.at(key) |> get_nested(rest) 405 | end 406 | def get_nested(_, _), do: nil 407 | def get_nested(data, keys, default), do: get_nested(data, keys) || default 408 | 409 | @doc """ 410 | Put given value on given coordinates inside the given structure. 411 | Returns updated structure. 412 | 413 | If coordinates do not exist, needed structures are created. 414 | """ 415 | def put_nested(nil, [key], value) when is_integer(key), 416 | do: put_nested([], [key], value) 417 | def put_nested(nil, [key | _] = keys, value) when is_integer(key), 418 | do: put_nested([], keys, value) 419 | def put_nested(nil, [key], value), 420 | do: put_nested(%{}, [key], value) 421 | def put_nested(nil, keys, value), 422 | do: put_nested(%{}, keys, value) 423 | def put_nested(data, [key], value) when is_map(data) do 424 | {_, v} = Map.get_and_update(data, key, &({&1, value})) 425 | v 426 | end 427 | def put_nested(data, [key | rest], value) when is_map(data) do 428 | {_, v} = Map.get_and_update(data, key, &({&1, put_nested(&1, rest, value)})) 429 | v 430 | end 431 | def put_nested(data, [key], value) when is_list(data) and is_integer(key) do 432 | case List.update_at(data, key, fn(_)-> value end) do 433 | ^data -> data |> grow_list(key + 1) |> put_nested([key], value) 434 | x -> x 435 | end 436 | end 437 | def put_nested(data, [key | rest] = keys, value) when is_list(data) and is_integer(key) do 438 | case List.update_at(data, key, &put_nested(&1, rest, value)) do 439 | ^data -> data |> grow_list(key + 1) |> put_nested(keys, value) 440 | x -> x 441 | end 442 | end 443 | 444 | @doc """ 445 | Fills given list with nils until it is of the given length 446 | """ 447 | def grow_list(list, length) do 448 | count = length - Enum.count(list) 449 | list ++ List.duplicate(nil, count) 450 | end 451 | 452 | @doc """ 453 | Method applies a function against element if condition is true. 454 | """ 455 | def run_if(elem, condition, fun) do 456 | cond do 457 | condition -> fun.(elem) 458 | true -> elem 459 | end 460 | end 461 | end 462 | --------------------------------------------------------------------------------