WeTools: An Elixir Command Line Tool
Within WePay’s tools team, we are always looking for ways to make our engineers more productive. One of the challenging things we are trying to do is distribute a series of automated tasks to our developers, such as a way for developers to tag a service for deployment, tail logs from ELK, cherry-pick a commit to multiple branches, or OAuth with external services. Rather than create a bunch of single use scripts, we wanted to put everything into one modular tool to perform tasks in a unified, well-tested way. The tool needed to be aware of any updates and provide a way to update itself. We also wanted this tool to be portable between our development environments and our infrastructure.
Choosing the platform
For the most part, WePay is a Java and Python shop. Python and Java both have numerous libraries and techniques to create CLIs fairly well. However, during our proof of concept experiments we ran into a lot of issues with managing versions of Python on developer machines, as well as needing to write a lot of Bash scripts to orchestrate a Java CLI. The CLI project is isolated enough that we could experiment with a new language. I was able to make prototypes with Golang and Elixir and present them to the team. After some debate, we decided to go with an Elixir Escript application. Escript allows a developer to compile the Elixir program into an executable that will run inside the Erlang Virtual Machine (BEAM). Erlang has been around since 1986, and the runtime environment doesn’t change much, so managing the version of Erlang on a developer’s machine is much easier than managing versions of other runtimes that developers are actively developing for.
Structure of the application
WeTools CLI is set up as an Elixir umbrella project. This is done for ease of managing internal dependencies and executing any unit or functional tests on the entire suite of applications that make up WeTools. We have a main app we
and additional apps for each team, while each team providing modules for the we
command.
The we
command
The we
command is the main command that parses arguments and passes the execution of the command submodules. We follow a similar pattern to other CLIs like git
, docker
, gcloud
, etc.
$> we [subcommand] [arguments for subcommand]
We use the optimus
library to parse arguments. Optimus provides a clean way of writing a command specification. Optimus also provides some functionality for outputting command usage and help.
$> we --help
WePay CLI Framework 4.0.3
WePay CLI tool kit for executing every day tasks
USAGE:
we ...
we --version
we --help
we help subcommand
SUBCOMMANDS:
logtail Tail elasticsearch logs on your console
$> we help logtail
WePay CLI Framework 4.0.3
Tail elasticsearch logs on your console
USAGE:
we logtail [--verbose] [--original_message] [--program program] [--hostname hostname] [--level level] [--regex regex] [--start_from start_from] [--field field] env
ARGS:
env Environment
FLAGS:
-v, --verbose Verbosity level
-o, --original_message Raw message sent by the application
OPTIONS:
-p, --program Name of the Service.
-h, --hostname Pod name or the Host name
-l, --level log level: DEBUG, INFO, ERROR, TRACE
-r, --regex Free text or regex expression
-s, --start_from Time in UTC to start the search from. By default
this is set to current time for live logs. Ex:
"2017-08-04T23:24:28.019539"
-f, --field name of fields: ex: @timestamp,program,level,message
Since we set up the project as an umbrella and we could have many modules added to WeTools over time, each module can be responsible for its own command specification. We then dynamically generate the command specification at build time for we
and all the subcommands.
First we provide a list of all the subcommands we want to compile into the we
command.
# config/config.exs
# Map pattern `cli sub command` => `Module` eg:
# This assumes that the Module given has a CmdSpec.spec/0 Module and function
config :we, submodule_specs: %{ :auth => We.Auth,
:logtail => We.Logtail
}
Then, in our command spec definition, we load the specs of any submodules listed. This is done with the Kernel.apply
functionality, similar to ”String”.constantize
in Rails, or converting a string to Object in Java.
defmodule We.CmdSpec do
@moduledoc """
Command Spec for `we` root module
"""
@submodule_specs Application.get_env(:we, :submodule_specs)
def new do
Optimus.new!(
name: "we",
description: "WePay CLI Framework",
version: We.version,
author: "WePay Devops",
about: "WePay CLI tool kit for executing every day tasks",
allow_unknown_args: true,
parse_double_dash: true,
subcommands: submodules()
)
end
# Loads submodule specs defined in config `submodule_specs`
defp submodules do
@submodule_specs
|> Enum.map(fn {k, v} -> apply(Module.concat([v, "CmdSpec"]), :spec, []) end)
|> List.flatten
end
end
If you were creating an auth module, the module structure would look like this:
├──lib
│ ├── auth
│ │ ├── cmd_spec.ex
│ │ └── jira.ex
│ ├── auth.ex
The cmd_spec.ex
is loaded into the @submodule_specs
above.
defmodule We.Auth.CmdSpec do
@moduledoc """
Command Spec for `we jira` module
"""
def spec do
{
:auth, [
name: "auth",
about: "Grant access tokens to WeTools from various services.",
allow_unknown_args: false,
parse_double_dash: true,
args: [
service: [
value_name: "service",
help: "Service name. e.g.: jira",
required: true,
parser: :string
]
]
]
}
end
end
The auth.ex module can be fairly generic to support additional services
added to the auth module.
defmodule We.Auth do
def process(optimus_args, subcommands) do
module = optimus_args.args[:service] |> String.capitalize
apply(Module.concat([__MODULE__, module]), :process,
[optimus_args, subcommands])
end
end
In this case the jira.ex
file stub would look like this.
defmodule We.Auth.Jira do
def process(optimus_args, subcommands) do
# do something
end
end
How we test the CLI
This tool is the gateway for our development workflow. Verification of the tool prior to any releases is extremely important. Each module has ExUnit
tests. If the functionality being tested produces IO for the terminal we are able to assert the output using ExUnit.CaptureIO
.
defmodule WeCommonsConfigTest do
use ExUnit.Case
import ExUnit.CaptureIO
alias WeCommons.Config, as: Config
setup do
WeCommons.Config.check
end
def config_cmd_spec do
WeTestHelper.cmd_spec_for(Config.CmdSpec.spec)
end
test "main with no args" do
parsed = config_cmd_spec() |> Optimus.parse!([])
exe_no_args = fn ->
Config.main(parsed)
end
assert capture_io(exe_no_args) =~ "No key value pairs"
end
end
For functional testing we use BATS with the bats-assert
and bats-support
libraries. We have our build system build the we
application then execute the command with the BATS specs.
#!/usr/bin/env bats
load 'test_helper/bats-support/load'
load 'test_helper/bats-assert/load'
we_cmd=./_build/we
@test "lists version" {
run $we_cmd --version
assert_output --regexp 'WePay CLI Framework [0-9]+\.[0-9]+\.[0-9]'
}
@test "garbage subcommands" {
run $we_cmd foo bar fizz buzz
assert_output --regexp 'Unknown Arguments'
}
What’s next
- We plan on rolling out more workflow commands to help with getting developer services into our CI/CD pipeline.
- Adding roles to the
we
command so that privileged users may have access to some commands that others may not. - Bootstrapping code for new services or upgrading the libraries in an existing service.
Overall, we have been fairly happy with Elixir. The documentation is excellent and the community is very supportive. I was able to get any information I needed from the Elixir Forum very quickly.