Skip to content

Commit

Permalink
Minion implementation
Browse files Browse the repository at this point in the history
This is a squashing of a bunch of previous commits.  None of the commit
messages were particularly useful.
  • Loading branch information
dantswain authored and geowa4 committed Feb 6, 2017
1 parent 1e44231 commit 9432594
Show file tree
Hide file tree
Showing 15 changed files with 525 additions and 31 deletions.
65 changes: 62 additions & 3 deletions README.md
Expand Up @@ -30,6 +30,65 @@ Used for internal error testing.

Prints a list of connected minion nodes.

### "!list-skills node@host"

Lists the skills available from the minion `node@host`

### "!get-minion-info node@host"

Gets some information about the minion `node@host`.

### Skills from JSON

Skills can be loaded from a JSON file. The format is as follows.

```
[
{
"description": "A brief description of what this skill does",
"verb": "do",
"noun": "thing",
"command": "/bin/cat /some/file.txt"
}
]
```

When minion `node@host` loads this skill, it can be executed from Slack by the
_invocation_ `do-thing node@host` (`verb-noun nodename@nodehost`). The skills
JSON file can be specified when running a minion via the `MINION_SKILLS`
environment variable.

Note, the master node may also be provided with a skills file.

## Running Medera

**Requirements**: Elixir, Postgres, and a Slack API token.

To run the master node in interactive mode:

## Basic architecture

A Medera installation constists of two components:

1. Medera master node - A single (for now) node that connects to Slack and
provides a web front-end.
2. Medera minions - One or more Elixir nodes that connect to the master. The
master node is also a minion.

## Slack commands

### "Hi"

Hello, world!

### ["I am error"](https://en.wikipedia.org/wiki/I_am_Error)

Used for internal error testing.

### "!list-minions"

Prints a list of connected minion nodes.

## Running Medera

**Requirements**: Elixir, Postgres, and a Slack API token.
Expand All @@ -38,19 +97,19 @@ To run the master node in interactive mode:

```
mix deps.get
SLACK_API_TOKEN=xoxb-000000000000-000000000000000000000000 iex --cookie medera --sname medera@localhost -S mix phoenix.server
SLACK_API_TOKEN=xoxb-000000000000-000000000000000000000000 MINION_SKILLS=./test/fixture/skills/master_test_skills.json iex --cookie medera --sname medera@localhost -S mix phoenix.server
```

To run a minion interactively:

```
MEDERA_MASTER=medera@localhost MEDERA_MINION=true iex --cookie medera --sname minion@localhost -S mix
MINION_SKILLS=./test/fixture/skills/test_skills.json MEDERA_MASTER=medera@localhost MEDERA_MINION=true iex --cookie medera --sname minion@localhost -S mix
```

To run a minion in the background:

```
./scripts/start_minion.sh minion@localhost medera@localhost >& minion.log &
./scripts/start_minion.sh minion@localhost medera@localhost ./test/fixtures/skills/test_skills.json >& minion.log &
```

To stop a minion:
Expand Down
1 change: 1 addition & 0 deletions config/config.exs
Expand Up @@ -9,6 +9,7 @@ use Mix.Config
config :medera,
web_enabled: System.get_env("MEDERA_MINION") == nil,
master_node: System.get_env("MEDERA_MASTER"),
minion_skills: System.get_env("MINION_SKILLS"),
ecto_repos: [Medera.Repo],
# slack API token - e.g., xoxp-numbers-and-hex
slack_api_token: System.get_env("SLACK_API_TOKEN"),
Expand Down
53 changes: 53 additions & 0 deletions lib/medera/command_parser.ex
@@ -0,0 +1,53 @@
defmodule Medera.CommandParser do
@moduledoc """
Parses a command and resolves it against the registry of skills
"""

alias Medera.Minion.Skill
alias Medera.Slack.Event

@type error_t :: :no_node | {:invalid_node, Skill.t}

@doc """
Parse the given text and resolve it against the given list of skills,
returning a matching skill on success.
"""
@spec parse_command(binary, Event.t, [Skill.t]) ::
{:ok, Skill.t} | {:error, error_t}
def parse_command(text, _event = %Event{}, skills) do
[command | remainder] = String.split(text)
parse_command_arguments(Map.get(skills, command), remainder)
end

defp parse_command_arguments(nil, _), do: {:error, :no_match}
defp parse_command_arguments([skill], args) do
if skill.node_required do
case args do
[] -> {:error, :no_node}
[node | _other_args] ->
if Skill.valid_node?(skill, node) do
{:ok, skill}
else
{:error, {:invalid_node, skill}}
end
end
else
{:ok, skill}
end
end
defp parse_command_arguments(_matching_skills, []) do
# if more than one skill matched, the skill is on more than one node,
# but we didn't provide a node, so there's no matching node
{:error, :no_node}
end
defp parse_command_arguments(matching_skills, [node | _other_args]) do
matching_skill = matching_skills
|> Enum.find(fn(skill) -> Skill.valid_node?(skill, node) end)

if matching_skill do
{:ok, matching_skill}
else
{:error, :no_node}
end
end
end
43 changes: 43 additions & 0 deletions lib/medera/minion.ex
Expand Up @@ -4,13 +4,56 @@ defmodule Medera.Minion do
"""

alias Medera.Minion.Registry
alias Medera.Minion.Skill

require Logger

@doc "Returns a list of minion nodes"
@spec list() :: [atom]
def list do
Registry.list_minions |> Enum.map(&:erlang.node/1)
end

@doc "Returns info about the minion"
@spec info :: binary
def info() do
"Hi, I am #{inspect Node.self()}."
end

@doc "Returns a list of skills for all minions"
@spec list_skills :: [Skill.t]
def list_skills() do
Registry.list_skills()
end

@doc """
Dispatch a skill to the appropriate minion
This makes a remote call to the node that owns the skill
"""
@spec dispatch(Skill.t) :: term
def dispatch(skill = %Skill{}) do
# probably eventually want to use distributed tasks or something like that
# here (or even a worker pool)
:rpc.call(skill.node, Medera.Minion, :dispatch_local, [skill])
end

@doc """
Executes the given skill on this minion node
"""
@spec dispatch_local(Skill.t) :: term
def dispatch_local(skill = %Skill{}) do
case skill.command do
command when is_binary(command) ->
command
|> String.to_charlist
|> :os.cmd
|> List.to_string
f when is_function(f, 0) ->
f.()
end
end

@doc "Returns the configured master node"
@spec master_node :: atom
def master_node do
Expand Down
10 changes: 5 additions & 5 deletions lib/medera/minion/connection.ex
Expand Up @@ -5,17 +5,17 @@ defmodule Medera.Minion.Connection do
Automatically detects when the master goes down and reconnects
"""

alias Medera.Minion
alias Medera.Minion.Registry
alias Medera.Minion.Skill

# i.e., timeout now
@no_dwell 0
# how long to wait after startup to attempt a connection
@init_dwell 10
# how long to wait to retry connection
@connect_dwell 100


alias Medera.Minion
alias Medera.Minion.Registry

require Logger

use GenServer
Expand Down Expand Up @@ -48,7 +48,7 @@ defmodule Medera.Minion.Connection do
def handle_info(:timeout, :unregistered) do
:global.sync
if :minion_registry in :global.registered_names do
registry = Registry.register
registry = Registry.register(Skill.load_map!())
Process.monitor(registry)
Logger.info("Registered with master node #{inspect Minion.master_node()}")
{:noreply, :connected}
Expand Down
60 changes: 51 additions & 9 deletions lib/medera/minion/registry.ex
Expand Up @@ -5,9 +5,14 @@ defmodule Medera.Minion.Registry do
Automatically detects minion disconnects and removes them from the list
"""

alias Medera.Minion.Skill

defmodule State do
@moduledoc false
defstruct(minions: MapSet.new)
defstruct([
minions: MapSet.new,
invocations: %{}
])
end

require Logger
Expand All @@ -17,7 +22,14 @@ defmodule Medera.Minion.Registry do
@doc "Start in a supervision tree"
@spec start_link() :: GenServer.on_start
def start_link do
GenServer.start_link(__MODULE__, [], name: {:global, :minion_registry})
init_skills = Skill.master_node_skills
|> Skill.to_map

GenServer.start_link(
__MODULE__,
[init_skills],
name: {:global, :minion_registry}
)
end

@doc """
Expand All @@ -26,9 +38,12 @@ defmodule Medera.Minion.Registry do
Returns the pid of the registry for monitoring. Connection uses this to
detect disconnects
"""
@spec register() :: pid
def register() do
GenServer.call({:global, :minion_registry}, {:register, self()})
@spec register(map) :: pid
def register(invocations) do
GenServer.call(
{:global, :minion_registry},
{:register, self(), invocations}
)
end

@doc false # see Minion.list
Expand All @@ -37,21 +52,48 @@ defmodule Medera.Minion.Registry do
GenServer.call({:global, :minion_registry}, :list)
end

def init([]) do
{:ok, %State{}}
@doc false # See Minion.list_skills
@spec list_skills :: [Skill.t]
def list_skills() do
GenServer.call({:global, :minion_registry}, :list_skills)
end

def handle_call({:register, registeree}, _from, state) do
def init([init_skills]) do
{:ok, %State{invocations: merge_invocations(%{}, init_skills)}}
end

def handle_call({:register, registeree, invocations}, _from, state) do
Logger.info("Node #{inspect :erlang.node(registeree)} connected")
Process.monitor(registeree)
{:reply, self(), %{state | minions: MapSet.put(state.minions, registeree)}}
state_out = %{state |
minions: MapSet.put(state.minions, registeree),
invocations: merge_invocations(state.invocations, invocations)
}
{:reply, self(), state_out}
end
def handle_call(:list, _from, state) do
{:reply, MapSet.to_list(state.minions), state}
end
def handle_call(:list_skills, _from, state) do
{:reply, state.invocations, state}
end

def handle_info({:DOWN, _ref, :process, pid, _}, state) do
Logger.info("Node #{inspect :erlang.node(pid)} disconnected")
{:noreply, %{state | minions: MapSet.delete(state.minions, pid)}}
end

defp merge_invocations(inv1, inv2) do
inv2
|> Enum.reduce(
inv1,
fn({inv, skill}, acc_inv) ->
Map.update(acc_inv, inv, [skill], &merge_skills(&1, skill))
end
)
end

defp merge_skills(existing, skill) do
[skill] ++ Enum.reject(existing, fn(e) -> e.node == skill.node end)
end
end

0 comments on commit 9432594

Please sign in to comment.