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.