Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHNAGELOG.md → CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ https://github.com/bitcrowd/sshkit.ex/compare/v0.0.1...HEAD

* Renamed response from remotely executed commands from 'normal' to 'stdout' [#34]
* Renamed `SSHKit.pwd` to `SSHKit.path` [#33] Thanks @brienw for the idea
* `SSH.run/3` accepts an optional `uuid` option and returns this `uuid` with the output

### New features:

* Support basic SCP up-/downloads
* Added documentation https://hexdocs.pm/sshkit/SSHKit.html
* Basic output formatting (default is silent - no change)

### Fixes:

Expand Down
28 changes: 24 additions & 4 deletions lib/sshkit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ defmodule SSHKit do
See `path/2`, `umask/2`, `user/2`, `group/2`, or `env/2`
for details on how to modify a context.

* `hash_fun` can be specified in the options as the function/1 to be called
with the `%SSHKit.Host{}` and return a unique identifier.

## Example

Create an execution context for two hosts.
Expand All @@ -91,11 +94,13 @@ defmodule SSHKit do
context = SSHKit.context(hosts)
```
"""
def context(hosts) do
def context(hosts, opts \\ []) do
hash_fun = Keyword.get(opts, :hash_fun, &set_hash/1)
hosts =
hosts
|> List.wrap
|> Enum.map(&host/1)
|> Enum.map(hash_fun)
%Context{hosts: hosts}
end

Expand Down Expand Up @@ -214,9 +219,12 @@ defmodule SSHKit do

@doc ~S"""
Executes a command within the given context.
Returns a list of tuples of the form `{:ok, output, exit_code}`.
Returns a list of tuples of the form `{:ok, output, exit_code, uuid}`.
There is one tuple per connected host a command was executed at.

* `uuid` is the unique identifier that represents a host.
It is set by `context/2` based on a simple hashing algorithm.

* `exit_code` is the number with which the executed command returns.
If things went well, that usually is `0`.

Expand Down Expand Up @@ -249,19 +257,31 @@ defmodule SSHKit do
assert "Hello World!\n" == stdout
```
"""
def run(context, command) do
def run(context, command, opts \\ []) do
cmd = Context.build(context, command)
formatter = Keyword.get(opts, :formatter, SSHKit.Formatters.SilentFormatter)

run = fn host ->
formatter.puts_connect(host)
{:ok, conn} = SSH.connect(host.name, host.options)
res = SSH.run(conn, cmd)
res = SSH.run(conn, cmd, formatter: formatter, uuid: host.uuid)
:ok = SSH.close(conn)
res
end

Enum.map(context.hosts, run)
end

def set_hash(host = %Host{name: name}) do
new_hash =
:sha
|> :crypto.hash(name)
|> Base.encode16()
|> String.slice(0..7)

%{host | uuid: new_hash}
end

# def upload(context, path, options \\ []) do
# …
# # resolve remote relative to context path
Expand Down
19 changes: 19 additions & 0 deletions lib/sshkit/formatter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule SSHKit.Formatter do
@moduledoc """
Output formatting functions for connections.

To create a new formatter, `use SSHKit.Formatter` and then define your own functions that are
listed as callbacks below.
"""

defmacro __using__(_opts) do
quote do
@behaviour SSHKit.Formatter
alias SSHKit.Host
end
end

@callback puts_connect(%SSHKit.Host{}) :: nil
@callback puts_exec(String.t, String.t) :: nil
@callback puts_receive(String.t, :stdout | :stderr, String.t) :: nil
end
43 changes: 43 additions & 0 deletions lib/sshkit/formatters/pretty_formatter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
defmodule SSHKit.Formatters.PrettyFormatter do
@moduledoc """
Formatter that uses `IO.ANSI` to colorize connection details.
"""

use SSHKit.Formatter

def puts_connect(host = %Host{}) do
host.uuid
|> wrap_uuid()
|> List.flatten(["Connecting to ", IO.ANSI.bright, host.name, IO.ANSI.reset, "\n"])
|> IO.write()
end

def puts_exec(uuid, command) do
uuid
|> wrap_uuid()
|> List.flatten(["Running ", IO.ANSI.yellow, command, IO.ANSI.reset, "\n"])
|> IO.write()
end

def puts_receive(uuid, type, message) do
String.trim_trailing(message) <> "\n"
|> String.split("\n")
|> Enum.drop(-1)
|> Enum.map(&(wrap_uuid(uuid) ++ [color_std(type), &1, IO.ANSI.reset, "\n"]))
|> IO.write()
end

defp color_std(type) do
case type do
:stderr -> IO.ANSI.red
_ -> IO.ANSI.green
end
end

defp wrap_uuid(uuid) do
case uuid do
nil -> []
_ -> ["[", IO.ANSI.green, uuid, IO.ANSI.reset, "] "]
end
end
end
13 changes: 13 additions & 0 deletions lib/sshkit/formatters/silent_formatter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule SSHKit.Formatters.SilentFormatter do
@moduledoc """
Formatter that doesn't output anything (black hole).
"""

use SSHKit.Formatter

def puts_connect(_host), do: nil

def puts_exec(_uuid, _command), do: nil

def puts_receive(_uuid, _type, _message), do: nil
end
2 changes: 1 addition & 1 deletion lib/sshkit/host.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ defmodule SSHKit.Host do
|> SSHKit.run("mkdir my_dir")
```
"""
defstruct [:name, :options]
defstruct [:name, :options, :uuid]
end
23 changes: 15 additions & 8 deletions lib/sshkit/ssh.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule SSHKit.SSH do

```
{:ok, conn} = SSHKit.SSH.connect("eg.io", user: "me")
{:ok, output, status} = SSHKit.SSH.run(conn, "uptime")
{:ok, output, status, uuid} = SSHKit.SSH.run(conn, "uptime")
:ok = SSHKit.SSH.close(conn)

Enum.each(output, fn
Expand Down Expand Up @@ -55,9 +55,9 @@ defmodule SSHKit.SSH do
@doc """
Executes a command on the remote and aggregates incoming messages.

Using the default handler, returns `{:ok, output, status}` or `{:error,
reason}`. By default, command output is captured into a list of tuples of the
form `{:stdout, data}` or `{:stderr, data}`.
Using the default handler, returns `{:ok, output, status, uuid}` or `{:error,
reason}`. By default, command output is captured into a list of tuples of
the form `{:stdout, data}` or `{:stderr, data}`.

A custom handler function can be provided to handle channel messages.

Expand All @@ -69,21 +69,26 @@ defmodule SSHKit.SSH do
* `:timeout` - maximum wait time between messages, defautls to `:infinity`
* `:fun` - handler function passed to `SSHKit.SSH.Channel.loop/4`
* `:acc` - initial accumulator value used in the loop
* `:uuid` - unique identifier for the output
* `:formatter` - formatter module to use, defaults to `SSHKit.Formatters.SilentFormatter`

## Example

```
{:ok, output, status} = SSHKit.SSH.run(conn, "uptime")
{:ok, output, status, uuid} = SSHKit.SSH.run(conn, "uptime")
IO.inspect(output)
```
"""
def run(connection, command, options \\ []) do
timeout = Keyword.get(options, :timeout, :infinity)
acc = Keyword.get(options, :acc, {:cont, {[], nil}})
fun = Keyword.get(options, :fun, &capture/2)
uuid = Keyword.get(options, :uuid, nil)
formatter = Keyword.get(options, :formatter, SSHKit.Formatters.SilentFormatter)
fun = Keyword.get(options, :fun, &capture(&1, &2, uuid, formatter))

case Channel.open(connection, timeout: timeout) do
{:ok, channel} ->
formatter.puts_exec(uuid, command)
case Channel.exec(channel, command, timeout) do
:success ->
channel
Expand All @@ -98,16 +103,18 @@ defmodule SSHKit.SSH do
end
end

defp capture(message, state = {buffer, status}) do
defp capture(message, state = {buffer, status}, uuid, formatter) do
next = case message do
{:data, _, 0, data} ->
formatter.puts_receive(uuid, :stdout, data)
{[{:stdout, data} | buffer], status}
{:data, _, 1, data} ->
formatter.puts_receive(uuid, :stderr, data)
{[{:stderr, data} | buffer], status}
{:exit_status, _, code} ->
{buffer, code}
{:closed, _} ->
{:ok, Enum.reverse(buffer), status}
{:ok, Enum.reverse(buffer), status, uuid}
_ ->
state
end
Expand Down
12 changes: 11 additions & 1 deletion test/sshkit/ssh_functional_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@ defmodule SSHKit.SSHFunctionalTest do
test "opens a connection with username and password", %{hosts: [host]} do
options = [port: host.port, user: host.user, password: host.password]
{:ok, conn} = SSH.connect(host.ip, Keyword.merge(@defaults, options))
{:ok, data, status} = SSH.run(conn, "whoami")
{:ok, data, status, uuid} = SSH.run(conn, "whoami")

assert [stdout: "#{host.user}\n"] == data
assert 0 = status
assert nil == uuid
end

@tag boot: 1
test "allows passing uuid as option", %{hosts: [host]} do
options = [port: host.port, user: host.user, password: host.password]
{:ok, conn} = SSH.connect(host.ip, Keyword.merge(@defaults, options))
{:ok, _data, _status, uuid} = SSH.run(conn, "whoami", uuid: "DEADBEEF")

assert "DEADBEEF" == uuid
end
end
54 changes: 44 additions & 10 deletions test/sshkit_functional_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,31 @@ defmodule SSHKitFunctionalTest do
})
end

def build_context(host1, host2) do
hashing_fun = fn(host) ->
%{host | uuid: "DEADBEEF:" <> host.options[:port]}
end

SSHKit.context([
{
host1.ip,
options(port: "5560",
user: host1.user,
password: host1.password,
timeout: 5000
)
},
{
host2.ip,
options(port: "5570",
user: host2.user,
password: host2.password,
timeout: 5000
)
},
], hash_fun: hashing_fun)
end

defp stdio(output, type) do
output
|> Keyword.get_values(type)
Expand All @@ -31,37 +56,46 @@ defmodule SSHKitFunctionalTest do
def stdout(output), do: stdio(output, :stdout)
def stderr(output), do: stdio(output, :stderr)

@tag boot: 2
test "context", %{hosts: [host1, host2]} do
context = build_context(host1, host2)
assert "DEADBEEF:5560" == List.first(context.hosts).uuid
assert "DEADBEEF:5570" == List.last(context.hosts).uuid
end

@tag boot: 1
test "connects", %{hosts: [host]} do
[{:ok, output, 0}] = SSHKit.run(build_context(host), "whoami")
context = build_context(host)
[{:ok, output, 0, uuid}] = SSHKit.run(context, "whoami")

name = String.trim(stdout(output))
assert name == host.user
assert uuid == List.first(context.hosts).uuid
end

@tag boot: 1
test "run", %{hosts: [host]} do
context = build_context(host)

[{:ok, output, status}] = SSHKit.run(context, "pwd")
[{:ok, output, status, _uuid}] = SSHKit.run(context, "pwd")
assert status == 0
output = stdout(output)
assert output == "/home/me\n"

[{:ok, output, status}] = SSHKit.run(context, "ls non-existing")
[{:ok, output, status, _uuid}] = SSHKit.run(context, "ls non-existing")
assert status == 1
output = stderr(output)
assert output =~ "ls: non-existing: No such file or directory"

[{:ok, output, status}] = SSHKit.run(context, "does-not-exist")
[{:ok, output, status, _uuid}] = SSHKit.run(context, "does-not-exist")
assert status == 127
output = stderr(output)
assert output =~ "'does-not-exist': No such file or directory"
end

@tag boot: 1
test "env", %{hosts: [host]} do
[{:ok, output, status}] =
[{:ok, output, status, _uuid}] =
host
|> build_context
|> SSHKit.env(%{"PATH" => "$HOME/.rbenv/shims:$PATH", "NODE_ENV" => "production"})
Expand All @@ -81,7 +115,7 @@ defmodule SSHKitFunctionalTest do
|> SSHKit.umask("077")
SSHKit.run(context, "mkdir my_dir")
SSHKit.run(context, "touch my_file")
[{:ok, output, status}] = SSHKit.run(context, "ls -la")
[{:ok, output, status, _uuid}] = SSHKit.run(context, "ls -la")

assert status == 0
output = stdout(output)
Expand All @@ -96,7 +130,7 @@ defmodule SSHKitFunctionalTest do
|> build_context
|> SSHKit.path("/var/log")

[{:ok, output, status}] = SSHKit.run(context, "pwd")
[{:ok, output, status, _uuid}] = SSHKit.run(context, "pwd")
assert status == 0
output = stdout(output)
assert output == "/var/log\n"
Expand All @@ -112,7 +146,7 @@ defmodule SSHKitFunctionalTest do
|> build_context
|> SSHKit.user("despicable_me")

[{:ok, output, status}] = SSHKit.run(context, "whoami")
[{:ok, output, status, _uuid}] = SSHKit.run(context, "whoami")
output = stdout(output)
assert output == "despicable_me\n"
assert status == 0
Expand All @@ -133,7 +167,7 @@ defmodule SSHKitFunctionalTest do
|> SSHKit.user("gru")
|> SSHKit.group("villains")

[{:ok, output, status}] = SSHKit.run(context, "id -gn gru")
[{:ok, output, status, _uuid}] = SSHKit.run(context, "id -gn gru")
output = stdout(output)
assert output == "villains\n"
assert status == 0
Expand All @@ -144,7 +178,7 @@ defmodule SSHKitFunctionalTest do
|> SSHKit.user("gru")
|> SSHKit.group("minion_owners")

[{:ok, output, status}] = SSHKit.run(context, "id -gn gru")
[{:ok, output, status, _uuid}] = SSHKit.run(context, "id -gn gru")
output = stdout(output)
assert output == "minion_owners\n"
assert status == 0
Expand Down