diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..32a47d5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at [INSERT EMAIL ADDRESS]. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ \ No newline at end of file diff --git a/README.md b/README.md index 6ea830b..7725074 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # Bootleg +[![CircleCI](https://img.shields.io/circleci/project/github/labzero/bootleg/master.svg)](https://circleci.com/gh/labzero/bootleg) [![Hex.pm](https://img.shields.io/hexpm/v/bootleg.svg)](https://hex.pm/packages/bootleg) [![Packagist](https://img.shields.io/packagist/l/doctrine/orm.svg)](https://github.com/labzero/bootleg/blob/master/LICENSE) + +Simple deployment and server automation for Elixir. + **Bootleg** is a simple set of commands that attempt to simplify building and deploying elixir applications. The goal of the project is to provide an extensible framework that can support many different deploy scenarios with one common set of commands. -Out of the box, Bootleg provides remote build and remote server automation for your existing [Distillery](https://github.com/bitwalker/distillery) releases. +Out of the box, Bootleg provides remote build and remote server automation for your existing [Distillery](https://github.com/bitwalker/distillery) releases. Bootleg assumes your project is committed into a `git` repository and some of the build steps use this assumption +to handle code in some steps of the build process. If you are using an scm other than git, please consider contributing to Bootleg to +add additional support. ## Installation @@ -13,8 +19,23 @@ def deps do end ``` +## Build server setup + +In order to build your project, Bootleg requires that your build server be set up to compile +Elixir code. Make sure you have already installed Elixir on any build server you define. The remote + + ## Quick Start +### Initialize your project + +This step is optional but if run will create an example `config/deploy.exs` file that you +can use as a starting point. + +```sh +$ mix bootleg.init +``` + ### Configure your release parameters ```elixir @@ -43,8 +64,8 @@ Create and configure Bootleg's `config/deploy.exs` file: # config/deploy.exs use Bootleg.Config -role :build, "build.myapp.com", user, "build", port: "2222", workspace: "/tmp/build/myapp" -role :app, ["web1.myapp.com", "web2.myapp.com"], user: "admin", workspace: "/var/www/myapp" +role :build, "build.example.com", user, "build", port: "2222", workspace: "/tmp/build/myapp" +role :app, ["web1.example.com", "web2.myapp.com"], user: "admin", workspace: "/var/www/myapp" ``` ## Roles @@ -54,8 +75,8 @@ Actions in Bootleg are paired with roles, which are simply a collection of hosts Role names are unique so there can only be one of each defined, but hosts can be grouped into one or more roles. Roles can be declared repeatedly to provide a different set of options to different sets of hosts. By defining roles, you are defining responsibility groups to cross cut your host infrastructure. The `build` and -`app` roles have inherent meaning to the default behavior of Bootleg, but you may also define more that you can later filter on when running commands inside a bootleg hook. There is another built in role `:all` which will always include -all hosts assigned to any role. +`app` roles have inherent meaning to the default behavior of Bootleg, but you may also define more that you can later filter on when running commands inside a Bootleg hook. There is another built in role `:all` which will always include +all hosts assigned to any role. `:all` is only available via `remote/2`. Some features or extensions may require additional roles, for example if your release needs to run Ecto migrations, you will need to assign the `:db` @@ -64,7 +85,7 @@ role to one host. ### Role and host options Options are set on roles and on hosts based on the order in which the roles are defined. Some are used internally -by bootleg: +by Bootleg: * `workspace` - remote path specifying where to perform a build or push a deploy (default `.`) * `user` - ssh username (default to local user) @@ -104,7 +125,7 @@ Supported SSH options include: * timeout * recv_timeout -> Refer to [Bootleg.SSH.supported_options/0](lib/bootleg/ssh.ex) for the complete list of supported options, and [:ssh.connect](http://erlang.org/doc/man/ssh.html#connect-2) for more information. +> Refer to `Bootleg.SSH.supported_options/0` for the complete list of supported options, and [:ssh.connect](http://erlang.org/doc/man/ssh.html#connect-2) for more information. ### Role restrictions @@ -132,7 +153,7 @@ mix bootleg.update production ## Admin Commands -bootleg has a set of commands to check up on your running nodes: +Bootleg has a set of commands to check up on your running nodes: ```console mix bootleg.restart production # Restarts a deployed release. @@ -144,10 +165,10 @@ mix bootleg.ping production # Check status of running nodes ## Hooks Hooks may be defined by the user in order to perform additional (or exceptional) -operations before or after certain actions performed by bootleg. +operations before or after certain actions performed by Bootleg. Hooks are defined within `config/deploy.exs`. Hooks may be defined to trigger -before or after a task. The following tasks are provided by bootleg: +before or after a task. The following tasks are provided by Bootleg: 1. `build` - build process for creating a release package 1. `compile` - compilation of your project @@ -211,11 +232,11 @@ $ ## `invoke` and `task` -There are a few ways for custom code to be executed during the bootleg life +There are a few ways for custom code to be executed during the Bootleg life cycle. Before showing some examples, here's a quick glossary of the related pieces. - * `task <:identifier> do ... end` - Assign a block of code to the symbol provided as `:identifier`. + * `task <:identifier> do ... end` - Assign a block of code to the atom provided as `:identifier`. This can then be executed by using the `invoke` macro. * `invoke <:identifier>` - Execute the `task` code blocked identified by `:identifier`, as well as any before/after hooks. @@ -264,7 +285,7 @@ end ## `remote` -The workhorse of the `bootleg` DSL is `remote`: it executes shell commands on remote servers and returns +The workhorse of the Bootleg DSL is `remote`: it executes shell commands on remote servers and returns the results. It takes a role and a block of commands to execute. The commands are executed on all servers belonging to the role, and raises an `SSHError` if an error is encountered. @@ -297,41 +318,56 @@ end Bootleg builds elixir apps, if your application has extra steps required make use of the hooks system to add additional functionality. A common case is for building assets for Phoenix -applications. To build phoenix assets during your build, define an after hook handler for the -`:compile` task and place it inside your `config/deploy.exs`. +applications. To build phoenix assets during your build, include the additional package +`bootleg_phoenix` to your `deps` list. This will automatically perform the additional steps required +for building phoenix releases. ```elixir -after :compile do - remote :build do - "[ -f package.json ] && npm install || true" - "[ -f brunch-config.js ] && [ -d node_modules ] && ./node_modules/brunch/bin/brunch b -p || true" - "[ -d deps/phoenix ] && mix phoenix.digest || true" - end +# mix.exs +def deps do + [{:distillery, "~> 1.3"}, + {:bootleg, "~> 0.2.0"}, + {:bootleg_phoenix, "~> 0.1.0"}] end ``` -## Help +For more about `bootleg_phoenix` see: https://github.com/labzero/bootleg_phoenix -If something goes wrong, retry with the `--verbose` option. -For detailed information about the bootleg commands and their options, try `mix bootleg help `. +## Sharing Tasks -## Examples +Sharing is a good thing. We love to share, espically awesome code we write. Bootleg supports loading +tasks from packages in a manner very similar to `Mix.Task`. Just define your module under `Bootleg.Tasks`, +`use Bootleg.Task` and pass it a block of Bootleg DSL. The contents will be discovered and executed +automatically at launch. -Build a release and deploy it to your production hosts: +```elixir +defmodule Bootleg.Tasks.Foo do + use Bootleg.Task do + task :foo do + IO.puts "Foo!!" + end -```sh -mix bootleg.build -mix bootleg.deploy -mix bootleg.start + before_task :build, :foo + end +end ``` -Or execute the above steps with a single command: +See `Bootleg.Task` for more details. + +## Help + +If something goes wrong, retry with the `--verbose` option. +For detailed information about the Bootleg commands and their options, try `mix bootleg help `. -```sh -mix bootleg.update production -``` ----- +## Acknowledgments + +Bootleg makes heavy use of the [bitcrowd/SSHKit.ex](https://github.com/bitcrowd/sshkit.ex) +library under the hood. We would like to acknowledge the effort from the bitcrowd team that went into +creating SSHKit.ex as well as for them prioritizing our requests and providing a chance to collaborate +on ideas for both the SSHKit.ex and Bootleg projects. + ## Contributing We welcome everyone to contribute to Bootleg and help us tackle existing issues! diff --git a/lib/bootleg/config.ex b/lib/bootleg/config.ex index 3f8c128..80abf2e 100644 --- a/lib/bootleg/config.ex +++ b/lib/bootleg/config.ex @@ -1,20 +1,44 @@ defmodule Bootleg.Config do @moduledoc """ - Configuration access for Bootleg. + Configuration DSL for Bootleg. """ - @doc false - alias Mix.Project alias Bootleg.{UI, SSH, Host, Role} defmacro __using__(_) do quote do import Bootleg.Config, only: [role: 2, role: 3, config: 2, config: 0, before_task: 2, - after_task: 2, invoke: 1, task: 2, remote: 1, remote: 2] + after_task: 2, invoke: 1, task: 2, remote: 1, remote: 2, load: 1] end end + @doc """ + Defines a role. + + Roles are a collection of hosts and their options that are responsible for the same function, + for example building a release, archiving a release, or executing commands against a running + application. + + `name` is the name of the role, and is globally unique. Calling `role/3` multiple times with + the same name will result in the host lists being merged. If the same host shows up mutliple + times, it will have its `options` merged. + + `hosts` can be a single hostname, or a `List` of hostnames. + + `options` is an optional `Keyword` used to provide configuration details about a specific host + (or collection of hosts). Certain options are passed to SSH directly (see + `Bootleg.SSH.supported_options/0`), others are used internally (`user` for example, is used + by both SSH and Git), and unknown options are simply stored. In the future `remote/1,2` will + allow for host filtering based on role options. Some Bootleg extensions may also add support + for additional options. + + ``` + use Bootleg.Config + + role :build, ["build1.example.com", "build2.example.com"], user: "foo", identity: "~/.ssh/id_rsa" + ``` + """ defmacro role(name, hosts, options \\ []) do # user is in the role options for scm user = Keyword.get(options, :user, System.get_env("USER")) @@ -26,48 +50,66 @@ defmodule Bootleg.Config do |> Keyword.put(:identity, ssh_options[:identity]) |> Enum.filter(fn {_, v} -> v end) - quote do + quote bind_quoted: binding() do hosts = - unquote(hosts) + hosts |> List.wrap() - |> Enum.map(&Host.init(&1, unquote(ssh_options), unquote(role_options))) + |> Enum.map(&Host.init(&1, ssh_options, role_options)) new_role = %Role{ - name: unquote(name), - user: unquote(user), + name: name, + user: user, hosts: [], - options: unquote(role_options) + options: role_options } role = :roles |> Bootleg.Config.Agent.get() - |> Keyword.get(unquote(name), new_role) + |> Keyword.get(name, new_role) |> Role.combine_hosts(hosts) Bootleg.Config.Agent.merge( :roles, - unquote(name), + name, role ) end end + @doc false + @spec get_role(atom) :: %Bootleg.Role{} | nil def get_role(name) do Keyword.get(Bootleg.Config.Agent.get(:roles), name) end + @doc """ + Fetches all key/value pairs currently defined in the Bootleg configuration. + """ defmacro config do quote do Bootleg.Config.Agent.get(:config) end end + @doc """ + Sets `key` in the Bootleg configuration to `value`. + + One of the cornerstones of the Bootleg DSL, `config/2` is used to pass configuration options + to Bootleg. See the documentation for the specific task you are trying to configure for what + keys it supports. + + ``` + use Bootleg.Config + + config :app, :my_cool_app + config :version, "1.0.0" + """ defmacro config(key, value) do - quote do + quote bind_quoted: binding() do Bootleg.Config.Agent.merge( :config, - unquote(key), - unquote(value) + key, + value ) end end @@ -92,6 +134,49 @@ defmodule Bootleg.Config do end end + @doc """ + Defines a before hook for a task. + + A hook is a piece of code that is executed before/after a task has been run. The hook can + either be a standalone code block, or the name of another task. Hooks are executed in an + unconditional fashion. Only an uncaught exeception will prevent futher execution. If a task + name is provided, it will be invoked via `invoke/1`. + + Just like with `invoke/1`, a task does not need to be defined to have a hook registered for + it, nor does the task need to be defined in order to be triggered via a hook. Tasks may also + be defined at a later point, provided execution has not begun. + + If multiple hooks are defined for the same task, they are executed in the order they were + originally defined. + + ``` + use Bootleg.Config + + before_task :build, :checksum_code + before_task :deploy do + Notify.team "Here we go!" + end + ``` + + Relying on the ordering of hook execution is heavily discouraged. It's better to explicitly + define the order using extra tasks and hooks. For example + + ``` + use Bootleg.Config + + before_task :build, :do_first + before_task :build, :do_second + ``` + + would be much better written as + + ``` + use Bootleg.Config + + before_task :build, :do_first + before_task :do_first, :do_second + ``` + """ defmacro before_task(task, do: block) when is_atom(task) do add_callback(task, :before, __CALLER__, do: block) end @@ -100,6 +185,21 @@ defmodule Bootleg.Config do quote do: before_task(unquote(task), do: invoke(unquote(other_task))) end + @doc """ + Defines an after hook for a task. + + Behaves exactly like a before hook, but executes after the task has run. See `before_task/2` + for more details. + + ``` + use Bootleg.Config + + after_task :build, :store_artifact + after_task :deploy do + Notify.team "Deployed!" + end + ``` + """ defmacro after_task(task, do: block) when is_atom(task) do add_callback(task, :after, __CALLER__, do: block) end @@ -108,14 +208,33 @@ defmodule Bootleg.Config do quote do: after_task(unquote(task), do: invoke(unquote(other_task))) end + @doc """ + Defines a task idefintied by `task`. + + This is one of the cornerstones of the Bootleg DSL. It takes a task name (`task`) a block of code + and registers the code to be executed when `task` is invoked. Inside the block, the full Bootleg + DSL is available. + + A warning will be emitted if a task is redefined. + + ``` + use Bootleg.Config + + task :hello do + IO.puts "Hello World!" + end + ``` + """ defmacro task(task, do: block) when is_atom(task) do file = __CALLER__.file() line = __CALLER__.line() module_name = module_for_task(task) quote do - if Code.ensure_compiled?(unquote(module_name)) do - {orig_file, orig_line} = unquote(module_name).location + module_name = unquote(module_name) + + if Code.ensure_compiled?(module_name) do + {orig_file, orig_line} = module_name.location UI.warn "warning: task '#{unquote(task)}' is being redefined. " <> "The most recent definition will win, but this is probably not what you meant to do. " <> "The previous definition was at: #{orig_file}:#{orig_line}" @@ -125,7 +244,7 @@ defmodule Bootleg.Config do Code.compiler_options(Map.put(original_opts, :ignore_module_conflict, true)) try do - defmodule unquote(module_name) do + defmodule module_name do @file unquote(file) def execute, do: unquote(block) def location, do: {unquote(file), unquote(line)} @@ -137,6 +256,7 @@ defmodule Bootleg.Config do end end + @spec invoke_task_callbacks(atom, atom) :: :ok defp invoke_task_callbacks(task, agent_key) do agent_key |> Bootleg.Config.Agent.get() @@ -144,10 +264,42 @@ defmodule Bootleg.Config do |> Enum.each(fn([module, fnref]) -> apply(module, fnref, []) end) end + @spec module_for_task(atom) :: atom defp module_for_task(task) do - :"Elixir.Bootleg.Tasks.DynamicTasks.#{Macro.camelize("#{task}")}" + :"Elixir.Bootleg.DynamicTasks.#{Macro.camelize("#{task}")}" + end + + @doc """ + Invokes the task identified by `task`. + + This is one of the cornerstones of the Bootleg DSL. Executing a task first calls any registered + `before_task/2` hooks, then executes the task itself (which was defined via `task/2`), then any + registered `after_task/2` hooks. + + The execution of the hooks and the task are unconditional. Return values are ignored, though an + uncuaght exception will stop further execution. The `task` does not need to exist. Any + hooks for a task with the name of `task` will still be executed, and no error or warning will be + emitted. This can be used to create events which a developer wants to be able to install hooks + around without needing to define no-op tasks. + + `invoke/1` executes immediately, so it should always be called from inside a task. If it's placed + directly inside `config/deploy.exs`, the task will be invoked when the configuration is first + read. This is probably not what is desired. + + ``` + use Bootleg.Config + + task :hello do + IO.puts "Hello?" + invoke :world end + task :world do + IO.puts "World!" + end + ``` + """ + @spec invoke(atom) :: :ok def invoke(task) when is_atom(task) do invoke_task_callbacks(task, :before_hooks) @@ -159,12 +311,17 @@ defmodule Bootleg.Config do invoke_task_callbacks(task, :after_hooks) end + @doc """ + Executes commands on all remote hosts. + + This is equivalent to calling `remote/2` with a role of `:all`. + """ defmacro remote(do: block) do - quote do: remote(nil, do: unquote(block)) + quote do: remote(:all, do: unquote(block)) end defmacro remote(lines) do - quote do: remote(nil, unquote(lines)) + quote do: remote(:all, unquote(lines)) end defmacro remote(role, do: {:__block__, _, lines}) do @@ -175,11 +332,56 @@ defmodule Bootleg.Config do quote do: remote(unquote(role), unquote(lines)) end + @doc """ + Executes commands on a remote host. + + This is the workhorse of the DSL. It executes shell commands on all hosts associated with + the `role`. If any of the shell commands exits with a non-zero status, execution will be stopped + and an `SSHError` will be raised. + + `lines` can be a `List` of commands to execute, or a code block where each line's return value is + used as a command. Each command will be simulataneously executed on all hosts in the role. Once + all hosts have finished executing the command, the next command in the list will be sent. + + `role` can be a single role, a list of roles, or the special role `:all` (all roles). If the same host + exists in multiple roles, the commands will be run once for each role where the host shows up. In the + case of multiple roles, each role is processed sequentially. + + Returns the results to the caller, per command and per host. See `Bootleg.SSH.run!` for more details. + + ``` + use Bootleg.Config + + remote :build, ["uname -a", "date"] + remote :build do + "ls -la" + "echo " <> Time.to_string(Time.utc_now) <> " > local_now" + end + + # will raise an error since `false` exits with a non-zero status + remote :build, ["false", "touch never_gonna_happen"] + + # runs for hosts found in all roles + remote do: "hostname" + remote :all, do: "hostname" + + # runs for hosts found in :build first, then for hosts in :app + remote [:build, :app], do: "hostname" + ``` + """ defmacro remote(role, lines) do - quote do - unquote(role) - |> SSH.init - |> SSH.run!(unquote(lines)) + roles = if role == :all do + quote do: Keyword.keys(Bootleg.Config.Agent.get(:roles)) + else + quote do: List.wrap(unquote(role)) + end + quote bind_quoted: binding() do + Enum.reduce(roles, [], fn role, outputs -> + role + |> SSH.init + |> SSH.run!(lines) + |> SSH.merge_run_results(outputs) + end) end end @@ -187,25 +389,32 @@ defmodule Bootleg.Config do Loads a configuration file. `file` is the path to the configuration file to be read and loaded. If that file doesn't - exist or if there's an error loading it, a `Mix.Config.LoadError` exception - will be raised. - + exist `{:error, :enoent}` is returned. If there's an error loading it, a `Code.LoadError` + exception will be raised. """ + @spec load(binary | charlist) :: :ok | {:error, :enoent} def load(file) do case File.regular?(file) do true -> Code.eval_file(file) - false -> {:error, "File not found"} + :ok + false -> {:error, :enoent} end end + @doc false + @spec get_config(atom, any) :: any def get_config(key, default \\ nil) do Keyword.get(Bootleg.Config.Agent.get(:config), key, default) end + @doc false + @spec app() :: any def app do get_config(:app, Project.config[:app]) end + @doc false + @spec version() :: any def version do get_config(:version, Project.config[:version]) end diff --git a/lib/bootleg/config/agent.ex b/lib/bootleg/config/agent.ex index 18704f5..cadb81b 100644 --- a/lib/bootleg/config/agent.ex +++ b/lib/bootleg/config/agent.ex @@ -5,12 +5,6 @@ defmodule Bootleg.Config.Agent do @typep data :: keyword - @spec agent_pid() :: pid | atom - def agent_pid do - {:ok, pid} = Bootleg.Config.Agent.start_link - pid - end - @spec start_link() :: {:ok, pid} def start_link do state_fn = fn -> @@ -58,8 +52,7 @@ defmodule Bootleg.Config.Agent do :ok end - @doc false - @spec agent_monitor(pid) :: :ok + @spec agent_monitor(pid) :: true def agent_monitor(parent_pid) do ref = Process.monitor(Bootleg.Config.Agent) Process.register(self(), :"Bootleg.Config.Agent.monitor") @@ -67,7 +60,7 @@ defmodule Bootleg.Config.Agent do receive do {:DOWN, ^ref, :process, _pid, _reason} -> Enum.each(:code.all_loaded(), fn {module, _file} -> - if String.starts_with?(Atom.to_string(module), "Elixir.Bootleg.Tasks.DynamicTasks.") do + if String.starts_with?(Atom.to_string(module), "Elixir.Bootleg.DynamicTasks.") do unload_code(module) end end) @@ -75,11 +68,13 @@ defmodule Bootleg.Config.Agent do end end + @spec unload_code(module) :: boolean defp unload_code(module) do :code.purge(module) :code.delete(module) end + @spec launch_monitor() :: :ok | nil defp launch_monitor do if Process.whereis(Bootleg.Config.Agent) do pid = Process.spawn(__MODULE__, :agent_monitor, [self()], []) @@ -89,4 +84,10 @@ defmodule Bootleg.Config.Agent do end end + @spec agent_pid() :: pid | atom + defp agent_pid do + {:ok, pid} = Bootleg.Config.Agent.start_link + pid + end + end diff --git a/lib/bootleg/mix_task.ex b/lib/bootleg/mix_task.ex new file mode 100644 index 0000000..0112a6a --- /dev/null +++ b/lib/bootleg/mix_task.ex @@ -0,0 +1,25 @@ +defmodule Bootleg.MixTask do + @moduledoc "Extends `Mix.Task` to provide Bootleg specific bootstrapping." + alias Bootleg.Config + + defmacro __using__(task) do + quote do + use Mix.Task + + @spec run(OptionParser.argv) :: :ok + if is_atom(unquote(task)) && unquote(task) do + def run(_args) do + use Config + + invoke unquote(task) + end + else + def run(_args) do + :ok + end + end + + defoverridable [run: 1] + end + end +end diff --git a/lib/bootleg/ssh.ex b/lib/bootleg/ssh.ex index dd5c433..acf5964 100644 --- a/lib/bootleg/ssh.ex +++ b/lib/bootleg/ssh.ex @@ -136,4 +136,17 @@ defmodule Bootleg.SSH do def supported_options do @ssh_options end + + @doc false + @spec merge_run_results(list, list) :: list + def merge_run_results(new, []) do + new + end + def merge_run_results(new, orig) when is_list(orig) do + new + |> Enum.zip(orig) + |> Enum.map(fn {n, o} -> + List.wrap(o) ++ List.wrap(n) + end) + end end diff --git a/lib/bootleg/task.ex b/lib/bootleg/task.ex index d9ba664..5c5bb00 100644 --- a/lib/bootleg/task.ex +++ b/lib/bootleg/task.ex @@ -1,24 +1,68 @@ defmodule Bootleg.Task do - @moduledoc "Extends `Mix.Task` to provide Bootleg specific bootstrapping." - alias Bootleg.Config + @moduledoc """ + Bootleg supports automatic discovery of tasks found in your dependencies, or your project itself. - defmacro __using__(task) do + To define a task that will automatically be loaded, define a new module in `Bootleg.Tasks`, and + `use Bootleg.Task`, passing along a block containing Bootleg DSL commands. Tasks defined in this + manner will be automatically loaded immediately after the core Bootleg tasks are loaded, and before + `config/deploy.exs`. This is the recommended way to write tasks that you intend to share with + others. + + ``` + defmodule Bootleg.Tasks.Example do + use Bootleg.Task do + task :example do + IO.puts "Hello!" + end + + before_task :build, :example + end + end + ``` + + Technically speaking, any module in the namespace `Bootleg.Tasks` that exports a `load/0` function will + be discovered and executed by Bootleg automatically. This usage is not recommended unless you need to + do work before `use Bootleg.Config`. + + ``` + defmodule Bootleg.Tasks.Other do + use Bootleg.Task + def load do + use Bootleg.Config + + task :other do + IO.puts "World?" + end + end + end + ``` + + These tasks can be packaged and distributed via hex packages, or you can make your own specific to your + application. + + """ + + @doc """ + A task needs to implement `load` which receives no arguments. Return values are ignored. + + If you use the block version of `use Bootleg.Task`, this callback will be generated for you. + """ + @callback load() :: any + + alias Bootleg.{UI, Config} + defmacro __using__(task_def) do quote do - use Mix.Task - - @spec run(OptionParser.argv) :: :ok - if is_atom(unquote(task)) && unquote(task) do - def run(_args) do - use Config - - invoke unquote(task) - end - else - def run(_args) do - end + unless String.starts_with?(Atom.to_string(__MODULE__), "Elixir.Bootleg.Tasks.")do + UI.warn "You seem to be trying to define a Bootleg task, but your module is not in the " <> + "`Bootleg.Tasks` namespace. Your task will not be loaded automatically." + end + def load do + use Config + + unquote(task_def) end - defoverridable [run: 1] + defoverridable [load: 0] end end end diff --git a/lib/bootleg/tasks.ex b/lib/bootleg/tasks.ex index 4ede3d2..2371caf 100644 --- a/lib/bootleg/tasks.ex +++ b/lib/bootleg/tasks.ex @@ -1,6 +1,5 @@ defmodule Bootleg.Tasks do @moduledoc false - alias Bootleg.Config def load_tasks do @@ -15,7 +14,43 @@ defmodule Bootleg.Tasks do |> Enum.map(fn (file) -> {File.read!(file), %{parent_env | file: file}} end) |> Enum.each(fn ({code, env}) -> Code.eval_string(code, [], env) end) + load_third_party() + Config.load("config/deploy.exs") :ok end + + defp load_third_party do + Enum.each(list_third_party(), fn mod -> + mod.load() + end) + end + + @prefix "Elixir.Bootleg.Tasks." + @suffix ".beam" + @prefix_size byte_size(@prefix) + @suffix_size byte_size(@suffix) + + defp list_third_party do + :code.get_path() + |> Enum.map(fn dir -> + case File.ls(dir) do + {:ok, files} -> files + {:error, _} -> [] + end + end) + |> List.flatten + |> Enum.uniq + |> Enum.map(fn file -> + segment_size = byte_size(file) - (@prefix_size + @suffix_size) + case file do + <<@prefix, task::binary-size(segment_size), @suffix>> -> + task_module = :"#{@prefix}#{task}" + Code.ensure_loaded?(task_module) && :erlang.function_exported(task_module, :load, 0) && + task_module + _ -> false + end + end) + |> Enum.filter(fn v -> v end) + end end diff --git a/lib/bootleg/tasks/ping.exs b/lib/bootleg/tasks/ping.exs index 62f56df..864f64d 100644 --- a/lib/bootleg/tasks/ping.exs +++ b/lib/bootleg/tasks/ping.exs @@ -1,8 +1,9 @@ +alias Bootleg.{UI, Config} use Bootleg.Config task :ping do - alias Bootleg.Strategies.Manage.Distillery - Distillery.init() - |> Distillery.ping() + remote :app do + "bin/#{Config.app} ping" + end :ok end diff --git a/lib/bootleg/tasks/restart.exs b/lib/bootleg/tasks/restart.exs index 9644f6d..5d963cc 100644 --- a/lib/bootleg/tasks/restart.exs +++ b/lib/bootleg/tasks/restart.exs @@ -1,8 +1,10 @@ +alias Bootleg.{UI, Config} use Bootleg.Config task :restart do - alias Bootleg.Strategies.Manage.Distillery - Distillery.init() - |> Distillery.restart() + remote :app do + "bin/#{Config.app} restart" + end + UI.info "#{Config.app} restarted" :ok end diff --git a/lib/bootleg/tasks/start.exs b/lib/bootleg/tasks/start.exs index 162a566..f97fb73 100644 --- a/lib/bootleg/tasks/start.exs +++ b/lib/bootleg/tasks/start.exs @@ -1,8 +1,10 @@ +alias Bootleg.{UI, Config} use Bootleg.Config task :start do - alias Bootleg.Strategies.Manage.Distillery - Distillery.init() - |> Distillery.start() + remote :app do + "bin/#{Config.app} start" + end + UI.info "#{Config.app} started" :ok end diff --git a/lib/bootleg/tasks/stop.exs b/lib/bootleg/tasks/stop.exs index ee50e18..4ffe5c2 100644 --- a/lib/bootleg/tasks/stop.exs +++ b/lib/bootleg/tasks/stop.exs @@ -1,8 +1,11 @@ +alias Bootleg.{UI, Config} use Bootleg.Config task :stop do - alias Bootleg.Strategies.Manage.Distillery - Distillery.init() - |> Distillery.stop() + app_name = Config.app + remote :app do + "bin/#{app_name} stop" + end + UI.info "#{app_name} stopped" :ok end diff --git a/lib/bootleg/tasks/update.exs b/lib/bootleg/tasks/update.exs new file mode 100644 index 0000000..39dd6d7 --- /dev/null +++ b/lib/bootleg/tasks/update.exs @@ -0,0 +1,7 @@ +use Bootleg.Config + +task :update do + invoke :build + invoke :deploy + invoke :start +end diff --git a/lib/mix/tasks/bootleg.ex b/lib/mix/tasks/bootleg.ex index 3729d59..939e1ad 100644 --- a/lib/mix/tasks/bootleg.ex +++ b/lib/mix/tasks/bootleg.ex @@ -1,21 +1,10 @@ defmodule Mix.Tasks.Bootleg do - use Bootleg.Task + use Bootleg.MixTask @shortdoc "Build and deploy releases" @moduledoc """ - Build and deploy Elixir applications - - # Usage: - - * mix bootleg command-info [Options] - * mix bootleg --help|--version - * mix bootleg help - - ## Build Commands: - - * mix bootleg build release [--refspec=|--tag=] [--branch=] [Options] - + Build and deploy Elixir applications """ end diff --git a/lib/mix/tasks/build.ex b/lib/mix/tasks/build.ex index b1a4251..1d8bbd1 100644 --- a/lib/mix/tasks/build.ex +++ b/lib/mix/tasks/build.ex @@ -1,5 +1,5 @@ defmodule Mix.Tasks.Bootleg.Build do - use Bootleg.Task, :build + use Bootleg.MixTask, :build @shortdoc "Build a release" @@ -8,11 +8,7 @@ defmodule Mix.Tasks.Bootleg.Build do # Usage: - * mix bootleg.build [Options] - - ## Build Commands: - - * mix bootleg.build release [--revision=|--tag=] [--branch=] [Options] + * mix bootleg.build """ diff --git a/lib/mix/tasks/deploy.ex b/lib/mix/tasks/deploy.ex index b879fdd..e5743ae 100644 --- a/lib/mix/tasks/deploy.ex +++ b/lib/mix/tasks/deploy.ex @@ -1,5 +1,5 @@ defmodule Mix.Tasks.Bootleg.Deploy do - use Bootleg.Task, :deploy + use Bootleg.MixTask, :deploy @shortdoc "Deploy a release from the local cache" @@ -8,7 +8,7 @@ defmodule Mix.Tasks.Bootleg.Deploy do # Usage: - * mix bootleg.deploy [cluster] [release] [Options] + * mix bootleg.deploy """ end diff --git a/lib/mix/tasks/init.ex b/lib/mix/tasks/init.ex new file mode 100644 index 0000000..b592200 --- /dev/null +++ b/lib/mix/tasks/init.ex @@ -0,0 +1,49 @@ +defmodule Mix.Tasks.Bootleg.Init do + use Bootleg.MixTask + require Mix.Generator + alias Mix.Generator + + @shortdoc "Initializes a project for use with Bootleg" + + @moduledoc """ + Initializes a project for use with Bootleg. + """ + + def run(_args) do + deploy_file_path = Path.join(["config", "deploy.exs"]) + Generator.create_directory("config") + Generator.create_file(deploy_file_path, deploy_file_text()) + end + + Generator.embed_text(:deploy_file, """ + use Bootleg.Config + + # Configure the following roles to match your environment. + # `build` defines what remote server your distillery release should be built on. + # `app` defines what remote servers your distillery release should be deployed and managed on. + # + # Some available options are: + # - `user`: ssh username to use for SSH authentication to the role's hosts + # - `password`: password to be used for SSH authentication + # - `identity`: local path to an identity file that will be used for SSH authentication instead of a password + # - `workspace`: remote file system path to be used for building and deploying this Elixir project + + role :build, "build.example.com", workspace: "/tmp/bootleg/build" + role :app, ["app1.example.com", "app2.example.com"], workspace: "/var/app/example" + + # Phoenix has some extra build steps which can be defined as task after the compile step runs. + # + # Uncomment the following task definition if this is a Phoenix application. To learn more about + # hooks and adding additional behavior to your deploy workflow, please refer to the bootleg + # README which can be found at https://github.com/labzero/bootleg/blob/master/README.md + + # after_task :compile do + # remote :build do + # "[ -f package.json ] && npm install || true" + # "[ -f brunch-config.js ] && [ -d node_modules ] && ./node_modules/brunch/bin/brunch b -p || true" + # "[ -d deps/phoenix ] && mix phoenix.digest || true" + # end + # end + """) + +end diff --git a/lib/mix/tasks/ping.ex b/lib/mix/tasks/ping.ex index 5c1279d..9073f3c 100644 --- a/lib/mix/tasks/ping.ex +++ b/lib/mix/tasks/ping.ex @@ -1,5 +1,5 @@ defmodule Mix.Tasks.Bootleg.Ping do - use Bootleg.Task, :ping + use Bootleg.MixTask, :ping @shortdoc "Pings an app." diff --git a/lib/mix/tasks/restart.ex b/lib/mix/tasks/restart.ex index 6e19b72..87ca79c 100644 --- a/lib/mix/tasks/restart.ex +++ b/lib/mix/tasks/restart.ex @@ -1,5 +1,5 @@ defmodule Mix.Tasks.Bootleg.Restart do - use Bootleg.Task, :restart + use Bootleg.MixTask, :restart @shortdoc "Restarts a deployed release." diff --git a/lib/mix/tasks/start.ex b/lib/mix/tasks/start.ex index 2f09d8d..2fd2f9c 100644 --- a/lib/mix/tasks/start.ex +++ b/lib/mix/tasks/start.ex @@ -1,5 +1,5 @@ defmodule Mix.Tasks.Bootleg.Start do - use Bootleg.Task, :start + use Bootleg.MixTask, :start @shortdoc "Starts a deployed release." diff --git a/lib/mix/tasks/stop.ex b/lib/mix/tasks/stop.ex index 8f752f6..8f86508 100644 --- a/lib/mix/tasks/stop.ex +++ b/lib/mix/tasks/stop.ex @@ -1,5 +1,5 @@ defmodule Mix.Tasks.Bootleg.Stop do - use Bootleg.Task, :stop + use Bootleg.MixTask, :stop @shortdoc "Stops a deployed release." diff --git a/lib/mix/tasks/update.ex b/lib/mix/tasks/update.ex new file mode 100644 index 0000000..a228528 --- /dev/null +++ b/lib/mix/tasks/update.ex @@ -0,0 +1,14 @@ +defmodule Mix.Tasks.Bootleg.Update do + use Bootleg.MixTask, :update + + @shortdoc "Build, deploy, and start a release all in one command." + + @moduledoc """ + Update a release + + # Usage: + + * mix bootleg.update + """ + +end diff --git a/lib/strategies/build/distillery.ex b/lib/strategies/build/distillery.ex index 27eb5b6..e27430a 100644 --- a/lib/strategies/build/distillery.ex +++ b/lib/strategies/build/distillery.ex @@ -1,6 +1,6 @@ defmodule Bootleg.Strategies.Build.Distillery do + @moduledoc false - @moduledoc "" use Bootleg.Config alias Bootleg.{Git, UI, SSH, Config} diff --git a/lib/strategies/deploy/distillery.ex b/lib/strategies/deploy/distillery.ex index fc4b182..c9aa78c 100644 --- a/lib/strategies/deploy/distillery.ex +++ b/lib/strategies/deploy/distillery.ex @@ -1,5 +1,5 @@ defmodule Bootleg.Strategies.Deploy.Distillery do - @moduledoc "" + @moduledoc false alias Bootleg.{Config, UI, SSH} diff --git a/lib/strategies/manage/distillery.ex b/lib/strategies/manage/distillery.ex index 1d18a46..4f89703 100644 --- a/lib/strategies/manage/distillery.ex +++ b/lib/strategies/manage/distillery.ex @@ -1,5 +1,5 @@ defmodule Bootleg.Strategies.Manage.Distillery do - @moduledoc "" + @moduledoc false alias Bootleg.{UI, SSH, Config} diff --git a/mix.exs b/mix.exs index 41efd81..0a35147 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Bootleg.Mixfile do use Mix.Project - @version "0.1.0" + @version "0.2.0" @source "https://github.com/labzero/bootleg" def project do diff --git a/test/bootleg/config/agent_test.exs b/test/bootleg/config/agent_test.exs index 5018840..26ffb08 100644 --- a/test/bootleg/config/agent_test.exs +++ b/test/bootleg/config/agent_test.exs @@ -1,5 +1,5 @@ defmodule Bootleg.Config.AgentTest do - use ExUnit.Case, async: false + use Bootleg.TestCase, async: false alias Bootleg.Config.Agent test "stores values for retrieval" do diff --git a/test/bootleg/config_functional_test.exs b/test/bootleg/config_functional_test.exs index a42c867..673e057 100644 --- a/test/bootleg/config_functional_test.exs +++ b/test/bootleg/config_functional_test.exs @@ -2,12 +2,22 @@ defmodule Bootleg.ConfigFunctionalTest do use Bootleg.FunctionalCase, async: false import ExUnit.CaptureIO - setup %{hosts: [host]} do + setup %{hosts: hosts} do use Bootleg.Config - role :app, host.ip, port: host.port, user: host.user, password: host.password, - silently_accept_hosts: true, workspace: "workspace" + + app_host = hd(hosts) + build_hosts = tl(hosts) + + role :app, app_host.ip, port: app_host.port, user: app_host.user, + password: app_host.password, silently_accept_hosts: true, workspace: "workspace" + + Enum.each(build_hosts, fn build_host -> + role :build, build_host.ip, port: build_host.port, user: build_host.user, + password: build_host.password, silently_accept_hosts: true, workspace: "workspace" + end) end + @tag boot: 3 test "remote/2" do use Bootleg.Config @@ -34,10 +44,78 @@ defmodule Bootleg.ConfigFunctionalTest do assert [{:ok, [stdout: "a single line!\n", stderr: "foo\n"], 0, _}] = out end + task :remote_multiple_hosts do + out = remote :build do + "echo a single line!" + end + assert [{:ok, [stdout: "a single line!\n"], 0, _}, {:ok, [stdout: "a single line!\n"], 0, _}] = out + end + + task :remote_multiple_hosts_multiple_lines do + out = remote :build do + "echo a single line!" + "echo another line" + end + assert [[{:ok, [stdout: "a single line!\n"], 0, _}, {:ok, [stdout: "a single line!\n"], 0, _}], + [{:ok, [stdout: "another line\n"], 0, _}, {:ok, [stdout: "another line\n"], 0, _}]] = out + end + capture_io(fn -> assert :ok = invoke :remote_functional_test assert :ok = invoke :remote_functional_single_line_test assert :ok = invoke :remote_functional_stderr_test + assert :ok = invoke :remote_multiple_hosts + assert :ok = invoke :remote_multiple_hosts_multiple_lines + end) + end + + @tag boot: 2 + test "remote/2 multiple roles" do + use Bootleg.Config + + task :remote_multiple_roles do + out = remote [:app, :build] do + "echo `hostname`" + end + assert [[{:ok, [stdout: hostname_1], 0, _}, {:ok, [stdout: hostname_2], 0, _}]] = out + assert hostname_1 != hostname_2 + end + + task :remote_multiple_roles_multiple_commands do + out = remote [:app, :build] do + "echo `hostname`" + "echo foo" + end + assert [[{:ok, [stdout: hostname_1], 0, _}, {:ok, [stdout: hostname_2], 0, _}], + [{:ok, [stdout: foo_1], 0, _}, {:ok, [stdout: foo_2], 0, _}]] = out + assert hostname_1 != hostname_2 + assert foo_1 == foo_2 + end + + task :remote_all_roles do + out = remote :all do + "echo `hostname`" + end + assert [[{:ok, [stdout: hostname_1], 0, _}, {:ok, [stdout: hostname_2], 0, _}]] = out + assert hostname_1 != hostname_2 + end + + task :remote_all_roles_multiple_commands do + out = remote :all do + "echo `hostname`" + "echo foo" + end + assert [[{:ok, [stdout: hostname_1], 0, _}, {:ok, [stdout: hostname_2], 0, _}], + [{:ok, [stdout: foo_1], 0, _}, {:ok, [stdout: foo_2], 0, _}]] = out + assert hostname_1 != hostname_2 + assert foo_1 == foo_2 + end + + capture_io(fn -> + assert :ok = invoke :remote_multiple_roles + assert :ok = invoke :remote_multiple_roles_multiple_commands + assert :ok = invoke :remote_all_roles + assert :ok = invoke :remote_all_roles_multiple_commands end) end diff --git a/test/bootleg/config_test.exs b/test/bootleg/config_test.exs index d8f9c3c..9b9d713 100644 --- a/test/bootleg/config_test.exs +++ b/test/bootleg/config_test.exs @@ -1,5 +1,5 @@ defmodule Bootleg.ConfigTest do - use ExUnit.Case, async: false + use Bootleg.TestCase, async: false alias Bootleg.{Config, UI, SSH} alias Mix.Project import Mock @@ -14,12 +14,13 @@ defmodule Bootleg.ConfigTest do defmacro assert_next_received(pattern, failure_message \\ nil) do quote do + failure_message = unquote(failure_message) || + "The next message does not match #{unquote(Macro.to_string(pattern))}, or the process mailbox is empty." receive do unquote(pattern) -> true + _ -> flunk(failure_message) after 0 -> - flunk(unquote(failure_message) || - "The next message does not match #{unquote(Macro.to_string(pattern))}, or the process mailbox is empty." - ) + flunk(failure_message) end end end @@ -75,6 +76,27 @@ defmodule Bootleg.ConfigTest do assert Enum.count(hosts) == 2 end + test "role/2,3 only unquote the name once" do + use Bootleg.Config + + role_name = fn -> + send(self(), :role_name_excuted) + :foo + end + + role role_name.(), "foo.example.com" + send(self(), :next) + + assert_next_received :role_name_excuted + assert_next_received :next + + role role_name.(), "foo.example.com", an_option: :foo + send(self(), :next) + + assert_next_received :role_name_excuted + assert_next_received :next + end + test "get_role/1", %{local_user: local_user} do use Bootleg.Config role :build, "build.labzero.com" @@ -180,16 +202,16 @@ defmodule Bootleg.ConfigTest do end end - Module.create(Bootleg.Tasks.DynamicTasks.Taskinvoketest, quoted, Macro.Env.location(__ENV__)) + Module.create(Bootleg.DynamicTasks.Taskinvoketest, quoted, Macro.Env.location(__ENV__)) Config.Agent.merge(:before_hooks, :taskinvoketest, [ - [Bootleg.Tasks.DynamicTasks.Taskinvoketest, :before_hook_1], - [Bootleg.Tasks.DynamicTasks.Taskinvoketest, :before_hook_2] + [Bootleg.DynamicTasks.Taskinvoketest, :before_hook_1], + [Bootleg.DynamicTasks.Taskinvoketest, :before_hook_2] ]) Config.Agent.merge(:after_hooks, :taskinvoketest, [ - [Bootleg.Tasks.DynamicTasks.Taskinvoketest, :after_hook_1], - [Bootleg.Tasks.DynamicTasks.Taskinvoketest, :after_hook_2] + [Bootleg.DynamicTasks.Taskinvoketest, :after_hook_1], + [Bootleg.DynamicTasks.Taskinvoketest, :after_hook_2] ]) invoke :taskinvoketest @@ -228,7 +250,7 @@ defmodule Bootleg.ConfigTest do task :task_test, do: true - module = Bootleg.Tasks.DynamicTasks.TaskTest + module = Bootleg.DynamicTasks.TaskTest assert apply(module, :execute, []) assert {file, line} = module.location assert file == __ENV__.file @@ -267,7 +289,10 @@ defmodule Bootleg.ConfigTest do refute_received {:before, :foo} end - test_with_mock "remote/2", SSH, [], [init: fn(role) -> {role} end, run!: fn(_, _cmd) -> :ok end] do + test_with_mock "remote/2", SSH, [:passthrough], [ + init: fn(role) -> {role} end, + run!: fn(_, _cmd) -> [:ok] end + ] do # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse use Bootleg.Config @@ -290,6 +315,31 @@ defmodule Bootleg.ConfigTest do remote :test_4, ["echo Hello", "echo World"] end + task :remote_test_all do + remote :all, do: "echo Hello World All!" + end + + task :remote_test_all_multi do + remote :all do + "echo All Hello" + "echo All World" <> "!" + end + end + + task :remote_test_roles do + remote [:foo, :bar], do: "echo Hello World Multi!" + end + + task :remote_test_roles_multi do + remote [:foo, :bar] do + "echo Multi Hello" + "echo Multi World" <> "!" + end + end + + role :foo, "never-used-foo.example.com" + role :bar, "never-used-bar.example.com" + invoke :remote_test_1 assert called SSH.init(:test_1) @@ -297,12 +347,15 @@ defmodule Bootleg.ConfigTest do invoke :remote_test_2 - assert called SSH.init(nil) - assert called SSH.run!({nil}, "echo Hello World2!") + assert called SSH.init(:foo) + assert called SSH.run!({:foo}, "echo Hello World2!") + assert called SSH.init(:bar) + assert called SSH.run!({:bar}, "echo Hello World2!") invoke :remote_test_3 - assert called SSH.run!({nil}, ["echo Hello", "echo World!"]) + assert called SSH.run!({:foo}, ["echo Hello", "echo World!"]) + assert called SSH.run!({:bar}, ["echo Hello", "echo World!"]) invoke :remote_test_4 @@ -319,7 +372,35 @@ defmodule Bootleg.ConfigTest do invoke :remote_test_5 assert called Time.utc_now - assert called SSH.run!({nil}, :now) + assert called SSH.run!(:_, :now) end + + invoke :remote_test_all + + assert called SSH.init(:foo) + assert called SSH.run!({:foo}, "echo Hello World All!") + assert called SSH.init(:bar) + assert called SSH.run!({:bar}, "echo Hello World All!") + + invoke :remote_test_all_multi + + assert called SSH.run!({:foo}, ["echo All Hello", "echo All World!"]) + assert called SSH.run!({:bar}, ["echo All Hello", "echo All World!"]) + + role :car, "never-used-car.example.com" + + invoke :remote_test_roles + + refute called SSH.init(:car) + assert called SSH.init(:foo) + assert called SSH.run!({:foo}, "echo Hello World Multi!") + assert called SSH.init(:bar) + assert called SSH.run!({:bar}, "echo Hello World Multi!") + + invoke :remote_test_roles_multi + + refute called SSH.init(:car) + assert called SSH.run!({:foo}, ["echo Multi Hello", "echo Multi World!"]) + assert called SSH.run!({:bar}, ["echo Multi Hello", "echo Multi World!"]) end end diff --git a/test/bootleg/host_test.exs b/test/bootleg/host_test.exs index 12b156c..b26e310 100644 --- a/test/bootleg/host_test.exs +++ b/test/bootleg/host_test.exs @@ -1,5 +1,5 @@ defmodule Bootleg.HostTest do - use ExUnit.Case, async: true + use Bootleg.TestCase, async: true alias Bootleg.Host doctest Bootleg.Host diff --git a/test/bootleg/role_test.exs b/test/bootleg/role_test.exs index 8623a70..869b12a 100644 --- a/test/bootleg/role_test.exs +++ b/test/bootleg/role_test.exs @@ -1,5 +1,5 @@ defmodule Bootleg.RoleTest do - use ExUnit.Case, async: true + use Bootleg.TestCase, async: true alias Bootleg.{Host, Role} alias SSHKit.Host, as: SSHKitHost diff --git a/test/bootleg/ssh_test.exs b/test/bootleg/ssh_test.exs index e324cf5..4bc1bdd 100644 --- a/test/bootleg/ssh_test.exs +++ b/test/bootleg/ssh_test.exs @@ -1,5 +1,5 @@ defmodule Bootleg.SSHTest do - use ExUnit.Case, async: false + use Bootleg.TestCase, async: false alias Bootleg.{SSH, Host} alias SSHKit.Context alias SSHKit.Host, as: SSHKitHost @@ -46,4 +46,11 @@ defmodule Bootleg.SSHTest do end end) end + + test "merge_run_results/2" do + assert [[2, 4, 1, 2]] = SSH.merge_run_results([[1, 2]], [[2, 4]]) + assert [[2, 4, 1, 2], [5, 6, 3, 4]] = SSH.merge_run_results([[1, 2], [3, 4]], [[2, 4], [5, 6]]) + assert [1, 2] = SSH.merge_run_results([1, 2], []) + assert [[1, 2]] = SSH.merge_run_results([[1, 2]], []) + end end diff --git a/test/bootleg/tasks/manage_tasks_test.exs b/test/bootleg/tasks/manage_tasks_test.exs new file mode 100644 index 0000000..f71b32e --- /dev/null +++ b/test/bootleg/tasks/manage_tasks_test.exs @@ -0,0 +1,75 @@ +defmodule Bootleg.Tasks.ManageTasksTest do + use Bootleg.FunctionalCase, async: false + alias Bootleg.{SSH, Config} + import ExUnit.CaptureIO + + setup %{hosts: [host]} do + use Bootleg.Config + role :app, [host.ip], port: host.port, user: host.user, password: host.password, + silently_accept_hosts: true, workspace: "workspace" + + config :app, "build_me" + config :version, "0.1.0" + + capture_io(fn -> + conn = SSH.init(:app) + SSH.run!(conn, "install-app build_me") + send(self(), {:connection, conn}) + end) + assert_received {:connection, conn} + + %{conn: conn} + end + + test "start" do + capture_io(fn -> + assert :ok = Config.invoke :start + end) + end + + test "invoke start with app running", %{conn: conn} do + capture_io(fn -> + SSH.run!(conn, "bin/build_me start") + assert :ok = Config.invoke :start + end) + end + + test "invoke stop", %{conn: conn} do + capture_io(fn -> + SSH.run!(conn, "launch-app build_me") + assert :ok = Config.invoke :stop + end) + end + + test "invoke stop with app not running" do + capture_io(fn -> + assert_raise SSHError, fn -> Config.invoke :stop end + end) + end + + test "invoke restart", %{conn: conn} do + capture_io(fn -> + SSH.run!(conn, "launch-app build_me") + assert :ok = Config.invoke :restart + end) + end + + test "invoke restart with app not running" do + capture_io(fn -> + assert_raise SSHError, fn -> Config.invoke :restart end + end) + end + + test "invoke ping", %{conn: conn} do + capture_io(fn -> + SSH.run!(conn, "launch-app build_me") + assert :ok = Config.invoke :ping + end) + end + + test "invoke ping with app not running" do + capture_io(fn -> + assert_raise SSHError, fn -> Config.invoke :ping end + end) + end +end diff --git a/test/bootleg/tasks_functional_test.exs b/test/bootleg/tasks_functional_test.exs new file mode 100644 index 0000000..d84af5c --- /dev/null +++ b/test/bootleg/tasks_functional_test.exs @@ -0,0 +1,23 @@ +defmodule Bootleg.TasksFunctionalTest do + use Bootleg.TestCase, async: false + alias Bootleg.Fixtures + + setup do + %{ + provider_location: Fixtures.inflate_project(:task_provider), + consumer_location: Fixtures.inflate_project(:task_consumer) + } + end + + test "packages can supply tasks", %{provider_location: provider, consumer_location: consumer} do + shell_env = [ + {"BOOTLEG_PATH", File.cwd!}, + {"TASK_PROVIDER_PATH", provider} + ] + + assert {_, 0} = System.cmd("mix", ["deps.get"], [env: shell_env, cd: consumer]) + assert {out, _} = System.cmd("mix", ["bootleg.build"], [env: shell_env, cd: consumer, stderr_to_stdout: true]) + assert String.match?(out, ~r/~~OTHER TASK~~/) + assert String.match?(out, ~r/~~EXAMPLE TASK~~/) + end +end diff --git a/test/bootleg_functional_test.exs b/test/bootleg_functional_test.exs index dc43927..670b596 100644 --- a/test/bootleg_functional_test.exs +++ b/test/bootleg_functional_test.exs @@ -34,6 +34,35 @@ defmodule Bootleg.FunctionalTest do end) end + @tag boot: 3 + test "update: build, deploy, manage roll-up", %{hosts: hosts} do + use Bootleg.Config + + build_host = List.first(hosts) + app_hosts = hosts -- [build_host] + + role :build, build_host.ip, port: build_host.port, user: build_host.user, + silently_accept_hosts: true, workspace: "workspace", identity: build_host.private_key_path + + Enum.each(app_hosts, fn host -> + role :app, host.ip, port: host.port, user: host.user, + silently_accept_hosts: true, workspace: "workspace", identity: host.private_key_path + end) + + config :app, :build_me + config :version, "0.1.0" + + location = Fixtures.inflate_project() + File.cd!(location, fn -> + assert String.match?(capture_io(fn -> + # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse + use Bootleg.Config + + invoke :update + end), ~r/build_me started/) + end) + end + @tag boot: 3 test "bootleg as a dependency", %{hosts: hosts} do shell_env = [{"BOOTLEG_PATH", File.cwd!}] @@ -61,4 +90,14 @@ defmodule Bootleg.FunctionalTest do end) end + @tag boot: 0 + test "init" do + shell_env = [{"BOOTLEG_PATH", File.cwd!}] + location = Fixtures.inflate_project(:n00b) + Enum.each(["deps.get", "bootleg.init"], fn cmd -> + assert {_, 0} = System.cmd("mix", [cmd], [env: shell_env, cd: location]) + end) + assert File.regular?(Path.join([location, "config", "deploy.exs"])) + end + end diff --git a/test/fixtures/n00b/.gitignore b/test/fixtures/n00b/.gitignore new file mode 100644 index 0000000..06c30c4 --- /dev/null +++ b/test/fixtures/n00b/.gitignore @@ -0,0 +1,3 @@ +/_build +/deps +/releases diff --git a/test/fixtures/n00b/config/config.exs b/test/fixtures/n00b/config/config.exs new file mode 100644 index 0000000..4e96b4a --- /dev/null +++ b/test/fixtures/n00b/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure for your application as: +# +# config :n00b, key: :value +# +# And access this configuration in your application as: +# +# Application.get_env(:n00b, :key) +# +# Or configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +# import_config "#{Mix.env}.exs" diff --git a/test/fixtures/n00b/lib/n00b.ex b/test/fixtures/n00b/lib/n00b.ex new file mode 100644 index 0000000..072afaf --- /dev/null +++ b/test/fixtures/n00b/lib/n00b.ex @@ -0,0 +1,18 @@ +defmodule N00b do + @moduledoc """ + Documentation for N00b. + """ + + @doc """ + Hello world. + + ## Examples + + iex> N00b.hello + :world + + """ + def hello do + :world + end +end diff --git a/test/fixtures/n00b/mix.exs b/test/fixtures/n00b/mix.exs new file mode 100644 index 0000000..30cda25 --- /dev/null +++ b/test/fixtures/n00b/mix.exs @@ -0,0 +1,36 @@ +defmodule N00b.Mixfile do + use Mix.Project + + def project do + [app: :n00b, + version: "0.1.0", + elixir: "~> 1.4", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + deps: deps()] + end + + # Configuration for the OTP application + # + # Type "mix help compile.app" for more information + def application do + # Specify extra applications you'll use from Erlang/Elixir + [extra_applications: [:logger]] + end + + # Dependencies can be Hex packages: + # + # {:my_dep, "~> 0.3.0"} + # + # Or git/path repositories: + # + # {:my_dep, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + # + # Type "mix help deps" for more examples and options + defp deps do + [ + {:distillery, "~> 1.4", runtime: false}, + {:bootleg, ">= 0.0.0", path: System.get_env("BOOTLEG_PATH"), runtime: false} + ] + end +end diff --git a/test/fixtures/task_consumer/.gitignore b/test/fixtures/task_consumer/.gitignore new file mode 100644 index 0000000..06c30c4 --- /dev/null +++ b/test/fixtures/task_consumer/.gitignore @@ -0,0 +1,3 @@ +/_build +/deps +/releases diff --git a/test/fixtures/task_consumer/config/config.exs b/test/fixtures/task_consumer/config/config.exs new file mode 100644 index 0000000..748218f --- /dev/null +++ b/test/fixtures/task_consumer/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure for your application as: +# +# config :task_consumer, key: :value +# +# And access this configuration in your application as: +# +# Application.get_env(:task_consumer, :key) +# +# Or configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +# import_config "#{Mix.env}.exs" diff --git a/test/fixtures/task_consumer/lib/task_consumer.ex b/test/fixtures/task_consumer/lib/task_consumer.ex new file mode 100644 index 0000000..88e2714 --- /dev/null +++ b/test/fixtures/task_consumer/lib/task_consumer.ex @@ -0,0 +1,18 @@ +defmodule TaskConsumer do + @moduledoc """ + Documentation for TaskConsumer. + """ + + @doc """ + Hello world. + + ## Examples + + iex> TaskConsumer.hello + :world + + """ + def hello do + :world + end +end diff --git a/test/fixtures/task_consumer/mix.exs b/test/fixtures/task_consumer/mix.exs new file mode 100644 index 0000000..4bb8a16 --- /dev/null +++ b/test/fixtures/task_consumer/mix.exs @@ -0,0 +1,37 @@ +defmodule TaskConsumer.Mixfile do + use Mix.Project + + def project do + [app: :task_consumer, + version: "0.1.0", + elixir: "~> 1.4", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + deps: deps()] + end + + # Configuration for the OTP application + # + # Type "mix help compile.app" for more information + def application do + # Specify extra applications you'll use from Erlang/Elixir + [extra_applications: [:logger]] + end + + # Dependencies can be Hex packages: + # + # {:my_dep, "~> 0.3.0"} + # + # Or git/path repositories: + # + # {:my_dep, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + # + # Type "mix help deps" for more examples and options + defp deps do + [ + {:distillery, "~> 1.4", runtime: false}, + {:task_provider, ">= 0.0.0", path: System.get_env("TASK_PROVIDER_PATH"), runtime: false}, + {:bootleg, ">= 0.0.0", path: System.get_env("BOOTLEG_PATH"), runtime: false} + ] + end +end diff --git a/test/fixtures/task_provider/.gitignore b/test/fixtures/task_provider/.gitignore new file mode 100644 index 0000000..06c30c4 --- /dev/null +++ b/test/fixtures/task_provider/.gitignore @@ -0,0 +1,3 @@ +/_build +/deps +/releases diff --git a/test/fixtures/task_provider/config/config.exs b/test/fixtures/task_provider/config/config.exs new file mode 100644 index 0000000..4960574 --- /dev/null +++ b/test/fixtures/task_provider/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure for your application as: +# +# config :task_provider, key: :value +# +# And access this configuration in your application as: +# +# Application.get_env(:task_provider, :key) +# +# Or configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +# import_config "#{Mix.env}.exs" diff --git a/test/fixtures/task_provider/lib/other_task.ex b/test/fixtures/task_provider/lib/other_task.ex new file mode 100644 index 0000000..92d8d91 --- /dev/null +++ b/test/fixtures/task_provider/lib/other_task.ex @@ -0,0 +1,15 @@ +defmodule Bootleg.Tasks.Other do + @moduledoc false + alias Bootleg.{Task, Config} + use Task + + def load do + use Config + + task :other do + IO.puts "~~OTHER TASK~~" + end + + before_task :build, :other + end +end diff --git a/test/fixtures/task_provider/lib/task_provider.ex b/test/fixtures/task_provider/lib/task_provider.ex new file mode 100644 index 0000000..6b111c3 --- /dev/null +++ b/test/fixtures/task_provider/lib/task_provider.ex @@ -0,0 +1,10 @@ +defmodule Bootleg.Tasks.Example do + @moduledoc false + use Bootleg.Task do + task :example do + IO.puts "~~EXAMPLE TASK~~" + end + + before_task :build, :example + end +end diff --git a/test/fixtures/task_provider/mix.exs b/test/fixtures/task_provider/mix.exs new file mode 100644 index 0000000..db53110 --- /dev/null +++ b/test/fixtures/task_provider/mix.exs @@ -0,0 +1,35 @@ +defmodule TaskProvider.Mixfile do + use Mix.Project + + def project do + [app: :task_provider, + version: "0.1.0", + elixir: "~> 1.4", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + deps: deps()] + end + + # Configuration for the OTP application + # + # Type "mix help compile.app" for more information + def application do + # Specify extra applications you'll use from Erlang/Elixir + [extra_applications: [:logger]] + end + + # Dependencies can be Hex packages: + # + # {:my_dep, "~> 0.3.0"} + # + # Or git/path repositories: + # + # {:my_dep, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + # + # Type "mix help deps" for more examples and options + defp deps do + [ + {:bootleg, ">= 0.0.0", path: System.get_env("BOOTLEG_PATH"), runtime: false} + ] + end +end diff --git a/test/ssherror_test.exs b/test/ssherror_test.exs index 8181510..f223979 100644 --- a/test/ssherror_test.exs +++ b/test/ssherror_test.exs @@ -1,5 +1,5 @@ defmodule SSHErrorTest do - use ExUnit.Case, async: true + use Bootleg.TestCase, async: true doctest SSHError test "exception([cmd, output, status, host])" do diff --git a/test/strategies/build/distillery_test.exs b/test/strategies/build/distillery_test.exs index 7dc4284..88c835c 100644 --- a/test/strategies/build/distillery_test.exs +++ b/test/strategies/build/distillery_test.exs @@ -1,5 +1,5 @@ defmodule Bootleg.Strategies.Build.DistilleryTest do - use ExUnit.Case, async: false + use Bootleg.TestCase, async: false import ExUnit.CaptureIO import Mock alias Bootleg.{SSH, Git, Config, Strategies.Build.Distillery} @@ -14,11 +14,12 @@ defmodule Bootleg.Strategies.Build.DistilleryTest do |> Map.get(:host) with_mocks([ - {SSH, [], [ - init: fn _ -> %SSHKit.Context{} end, - run!: fn _, _ -> [{:ok, [stdout: ""], 0, ssh_host}] end, - ssh_host_options: fn _ -> ssh_host end, - download: fn _, _, _ -> :ok end + { + SSH, [:passthrough], [ + init: fn _ -> %SSHKit.Context{} end, + run!: fn _, _ -> [{:ok, [stdout: ""], 0, ssh_host}] end, + ssh_host_options: fn _ -> ssh_host end, + download: fn _, _, _ -> :ok end ]}, { Git, [], [ diff --git a/test/strategies/manage/distillery_functional_test.exs b/test/strategies/manage/distillery_functional_test.exs deleted file mode 100644 index d6ff2cc..0000000 --- a/test/strategies/manage/distillery_functional_test.exs +++ /dev/null @@ -1,98 +0,0 @@ -defmodule Bootleg.Strategies.Manage.DistilleryFunctionalTest do - use Bootleg.FunctionalCase, async: false - alias Bootleg.{Strategies.Manage.Distillery, SSH} - import ExUnit.CaptureIO - - setup %{hosts: [host]} do - use Bootleg.Config - role :app, [host.ip], port: host.port, user: host.user, password: host.password, - silently_accept_hosts: true, workspace: "workspace" - - config :app, "build_me" - config :version, "0.1.0" - - capture_io(fn -> - conn = SSH.init(:app) - SSH.run!(conn, "install-app build_me") - send(self(), {:connection, conn}) - end) - assert_received {:connection, conn} - - %{conn: conn} - end - - test "init/0", %{hosts: [host]} do - capture_io(fn -> - assert %SSHKit.Context{ - hosts: [%SSHKit.Host{name: hostname, options: options}], path: "workspace", user: nil - } = Distillery.init() - assert hostname == host.ip - assert options[:user] == host.user - assert options[:silently_accept_hosts] == true - assert options[:port] == host.port - end) - end - - test "init/0 failure", %{hosts: [host]} do - # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse - use Bootleg.Config - role :app, ["bad-host.local"], port: host.port, user: host.user, password: host.password, - silently_accept_hosts: true, workspace: "workspace" - - capture_io(fn -> - assert_raise SSHError, fn -> Distillery.init() end - end) - end - - test "start/1", %{conn: conn} do - capture_io(fn -> - assert {:ok, %SSHKit.Context{}} = Distillery.start(conn) - end) - end - - test "start/1 with app running", %{conn: conn} do - capture_io(fn -> - SSH.run!(conn, "bin/build_me start") - assert {:ok, %SSHKit.Context{}} = Distillery.start(conn) - end) - end - - test "stop/1", %{conn: conn} do - capture_io(fn -> - SSH.run!(conn, "launch-app build_me") - assert {:ok, %SSHKit.Context{}} = Distillery.stop(conn) - end) - end - - test "stop/1 with app not running", %{conn: conn} do - capture_io(fn -> - assert_raise SSHError, fn -> Distillery.stop(conn) end - end) - end - - test "restart/1", %{conn: conn} do - capture_io(fn -> - SSH.run!(conn, "launch-app build_me") - assert {:ok, %SSHKit.Context{}} = Distillery.restart(conn) - end) - end - - test "restart/1 with app not running", %{conn: conn} do - capture_io(fn -> - assert_raise SSHError, fn -> Distillery.restart(conn) end - end) - end - - test "ping/1", %{conn: conn} do - capture_io(fn -> - SSH.run!(conn, "launch-app build_me") - assert {:ok, %SSHKit.Context{}} = Distillery.ping(conn) - end) - end - - test "ping/1 with app not running", %{conn: conn} do - capture_io(fn -> - assert_raise SSHError, fn -> Distillery.ping(conn) end - end) - end -end diff --git a/test/support/functional_case.ex b/test/support/functional_case.ex index 7477a2f..909f926 100644 --- a/test/support/functional_case.ex +++ b/test/support/functional_case.ex @@ -1,6 +1,5 @@ defmodule Bootleg.FunctionalCase do @moduledoc false - use ExUnit.CaseTemplate import Bootleg.FunctionalCaseHelpers @@ -13,9 +12,14 @@ defmodule Bootleg.FunctionalCase do @user "me" @pass "pass" - using do + using args do quote do @moduletag :functional + unless unquote(args)[:async] do + setup do + Bootleg.Config.Agent.wait_cleanup() + end + end end end diff --git a/test/support/test_case.ex b/test/support/test_case.ex new file mode 100644 index 0000000..a9f4cd1 --- /dev/null +++ b/test/support/test_case.ex @@ -0,0 +1,14 @@ +defmodule Bootleg.TestCase do + @moduledoc false + use ExUnit.CaseTemplate + + using args do + quote do + unless unquote(args)[:async] do + setup do + Bootleg.Config.Agent.wait_cleanup() + end + end + end + end +end diff --git a/test/ui_test.exs b/test/ui_test.exs index 617b984..ae3e7d6 100644 --- a/test/ui_test.exs +++ b/test/ui_test.exs @@ -1,5 +1,5 @@ defmodule Bootleg.UITest do - use ExUnit.Case, async: false + use Bootleg.TestCase, async: false import ExUnit.CaptureIO