Rambo v0.2.1 Rambo View Source

Ever stumped trying to get output from a port?

Rambo has one mission. Start your command, pipe standard input, send EOF and return with output.

Usage

Rambo.run("echo")
{:ok, %Rambo{status: 0, out: "\n", err: ""}}

If the command fails,

Rambo.run("printf")
{:error, %Rambo{status: 1, out: "", err: "usage: printf format [arguments ...]\n"}}

Send standard input to your command.

Rambo.run("cat", in: "rambo")
{:ok, %Rambo{status: 0, out: "rambo", err: ""}}

Pass arguments as a string or list of iodata.

Rambo.run("ls", "-la")
Rambo.run("ls", ["-l", "-a"])

Chain commands together. If one of them fails, the rest won’t be executed and the failing result is returned.

Rambo.run("ls") |> Rambo.run("sort") |> Rambo.run("head")

Logging

By default, Rambo streams standard error to the console as your command runs so you can spot errors before the command finishes.

Change this behaviour with the :log option.

Rambo.run("ls", log: :stderr) # default
Rambo.run("ls", log: :stdout) # stream stdout only
Rambo.run("ls", log: true)    # stream both
Rambo.run("ls", log: false)   # don’t log output

You can stream logs to any function. It receives {:stdout, binary} and {:stderr, binary} tuples whenever output is produced.

Rambo.run("echo", log: &IO.inspect/1)
{:stdout, "\n"}
{:ok, %Rambo{status: 0, out: "\n", err: ""}}

Kill

If your command is stuck, kill your command from another process and Rambo returns with any gathered results so far.

task = Task.async(fn ->
   Rambo.run("cat")
 end)

Rambo.kill(task.pid)

Task.await(task)
{:killed, %Rambo{status: nil, out: "", err: ""}}

Why?

Erlang ports do not work with programs that expect EOF to produce output. The only way to close standard input is to close the port, which also closes standard output preventing results from coming back to your app. This gotcha is marked Won’t Fix.

Design

When Rambo is asked to run a command, it creates a port to a shim. Then the shim runs your command, closes standard input and waits for output. After your command exits, its output is returned to your app before the port is closed and the shim exits.

+-----------------+       stdin
|          +------+------+ --> +---------+
|  Erlang  | Port | Shim |     | Command |
|          +------+------+ <-- +---------+
+-----------------+       stdout

If the Erlang node stops during the command, your command is killed and the shim exits to avoid creating orphans (process leak).

Rambo does not start a pool of processes nor support bidirectional communication with your commands. It is intentionally kept simple and lightweight to run transient jobs with minimal overhead, such as calling a Python or Node script to transform some data. For more complicated use cases, see other libraries below.

Caveats

You cannot call Rambo.run from a GenServer because Rambo uses receive, which interferes with GenServer’s receive loop. However, you can wrap the call in a Task.

task = Task.async(fn ->
  Rambo.run("thingamabob")
end)

Task.await(task)

Comparisons

Rambo is the lightest, easiest library to manage external commands. While small and focused, Rambo has some niceties not available elsewhere. You can chain commands and easily stream your command’s output to any function.

System.cmd

If you don’t need to pipe standard input to your external program, just use System.cmd.

Porcelain

Porcelain cannot send EOF to trigger output by default. The Goon driver must be installed separately to add this capability. Rambo ships with the required native binaries.

Goon is written in Go, a multithreaded runtime with a garbage collector. To be as lightweight as possible, Rambo’s shim is written in Rust. No garbage collector, no runtime overhead.

Most importantly, Porcelain currently leaks processes. Writing a new driver to replace Goon should fix it, but Porcelain appears to be abandoned so effort went into creating Rambo.

erlexec

erlexec is great if you want fine grain control over external programs.

Each external OS process is mirrored as an Erlang process, so you get asynchronous and bidirectional communication. You can kill your OS processes with any signal or monitor them for termination, among many powerful features.

Choose erlexec if you want a kitchen sink solution.

Installation

Add rambo to your list of dependencies in mix.exs:

def deps do
  [
    {:rambo, "~> 0.2"}
  ]
end

This package bundles macOS, Linux and Windows binaries (x86-64 architecture only). For other environments, install the Rust compiler or Rambo won’t compile.

To remove unused binaries, set :purge to true in your configuration.

config :rambo,
  purge: true

License

Rambo is released under MIT license.

Link to this section Summary

Functions

Stop by killing your command.

Runs command.

Runs command with arguments or options.

Runs command with arguments and options.

Link to this section Types

Link to this type

result()

View Source
result() :: {:ok, t()} | {:error, t() | String.t()}
Link to this type

t()

View Source
t() :: %Rambo{err: String.t(), out: String.t(), status: integer()}

Link to this section Functions

Link to this function

kill(pid)

View Source
kill(pid()) :: {:killed, t()}

Stop by killing your command.

Pass the pid of the process that called run/1. That process will return with {:killed, %Rambo{}} with results accumulated thus far.

Example

iex> task = Task.async(fn ->
...>   Rambo.run("cat")
...> end)
iex> Rambo.kill(task.pid)
iex> Task.await(task)
{:killed, %Rambo{status: nil}}
Link to this function

run(command)

View Source
run(command :: String.t() | result()) :: result()

Runs command.

Executes the command and returns {:ok, %Rambo{}} or {:error, reason}. reason is a string if the child process failed to start, or a %Rambo{} struct if the child process started successfully but exited with a non-zero status.

Multiple calls can be chained together with the |> pipe operator to simulate Unix pipes.

Rambo.run("ls") |> Rambo.run("sort") |> Rambo.run("head")

If any command did not exit with 0, the rest will not be executed and the last executed result is returned in an :error tuple.

See run/2 or run/3 to pass arguments or options.

Examples

iex> Rambo.run("echo")
{:ok, %Rambo{out: "\n", status: 0, err: ""}}
Link to this function

run(command, args_or_opts)

View Source
run(command :: String.t() | result(), args_or_opts :: args() | Keyword.t()) ::
  result()

Runs command with arguments or options.

Arguments can be a string or list of strings. See run/3 for options.

Examples

iex> Rambo.run("echo", "john")
{:ok, %Rambo{out: "john\n", status: 0}}

iex> Rambo.run("echo", ["-n", "john"])
{:ok, %Rambo{out: "john", status: 0}}

iex> Rambo.run("cat", in: "john")
{:ok, %Rambo{out: "john", status: 0}}
Link to this function

run(command, args, opts)

View Source
run(command :: String.t(), args :: args(), opts :: Keyword.t()) :: result()

Runs command with arguments and options.

Options

  • :in - pipe as standard input
  • :cd - the directory to run the command in
  • :env - map or list of tuples containing environment key-value as strings
  • :log - stream standard output or standard error to console or a function. May be :stdout, :stderr, true for both, false for neither, or a function with one arity. If a function is given, it will be passed {:stdout, output} or {:stderr, error} tuples. Defaults to :stderr.

Examples

iex> Rambo.run("/bin/sh", ["-c", "echo $JOHN"], env: %{"JOHN" => "rambo"})
{:ok, %Rambo{out: "rambo\n", status: 0}}

iex> Rambo.run("echo", "rambo", log: &IO.inspect/1)
{:ok, %Rambo{out: "rambo\n", status: 0}}