Current section

Files

Jump to
realbook lib realbook.ex
Raw

lib/realbook.ex

defmodule Realbook do
@moduledoc """
## Connecting to remote servers
Realbook provides be default two connection APIs, one of which
(`:local`) can be used to provision locally. The other (`:ssh`)
can be used to provision a remote host. In order to use the `SSH`
api, generally, you must have passwordless ssh keys installed in
the remote server. This default can be overridden in the `connect!/2`
function by providing options that correspond to `SSH.connect/2`
options.
"""
defstruct [
dictionary: [],
conn: nil,
module: nil,
stage: nil,
completed: [],
hostname: ""
]
@typedoc false
@type stage_t :: :preverify | :play | :postverify
@typedoc false
@type t :: %__MODULE__{
dictionary: keyword(),
conn: term,
module: module,
stage: stage_t,
completed: [module],
hostname: String.t
}
alias Realbook.Storage
#######################################################################
## Core functions
@spec connect!(atom | module, keyword) :: :ok
@doc """
initiates a connection, bound to this process, using the specified
module. You may also use a shorthand for the connection.
## Examples
### using explicit naming
```
Realbook.connect!(Realbook.Adapters.Local)
```
### using shorthand names
```
Realbook.connect!(:ssh, host: host_ip, user: "admin")
```
"""
def connect!(module!, opts \\ []) do
Code.ensure_loaded?(module!)
module! = if function_exported?(module!, :connect, 1) do
module!
else
select(module!)
end
case module!.connect(opts) do
{:ok, conn} ->
hostname = Keyword.get(opts, :alt_name, module!.name(opts))
Storage.update(
conn: conn,
module: module!,
hostname: hostname)
conn
{:error, reason} when is_binary(reason) or is_atom(reason) ->
raise Realbook.ConnectionError, message: "error connecting: #{reason}"
end
end
@spec select(atom) :: module
defp select(:ssh), do: Realbook.Adapters.SSH
defp select(module) do
Module.concat(Realbook.Adapters, module |> Atom.to_string |> Macro.camelize)
end
@spec run(Path.t) :: :ok | no_return
@doc """
loads a script from the suppiled path, compiles it into a Realbook module and
executes it.
If the module already exists, then the existing module will be run without
recompilation.
## Warning:
This does not currently check if the script has changed prior to deciding not
to recompile, but that safety check may be revised in the future.
"""
def run(path) do
:realbook
|> Application.fetch_env!(:script_dir)
|> Path.join(path)
|> File.read!
|> eval(path)
:ok
end
@spec eval(iodata, Path.t, keyword) :: :ok
@doc """
generates an Elixir module corresponding to a realbook script string
or iodata; then evaluates the module, bound to this process.
If you provide only the script without a file, then the module will
be an `anonymous` realbook module.
It is generally not recommended to use this function directly, but
it may be useful for user debugging purposes or ad-hoc testing via
the Elixir REPL.
Only use this function if you know what you are doing.
"""
def eval(realbook, file \\ "nofile", options \\ [line: 0])
def eval(realbook, file, options) when is_binary(realbook) do
module = compile(realbook, file, options)
keys = Realbook.Dictionary.keys()
# check to make sure the conn exists
Storage.props(:conn) || raise "can't run realbook on #{inspect self()}: not connected"
# check to make sure that all of the required keys exist in the module
:attributes
|> module.__info__()
|> Keyword.get(:required_variables)
|> Enum.each(fn
{key, spec} ->
key in keys ||
raise KeyError,
message: "key #{inspect key} not found, expected by #{spec.file} (line #{spec.line})"
end)
# check to make sure that all of the required assets from the module exist
Enum.each(module.__info__(:attributes)[:required_assets], fn
asset ->
# given that we have an attribute, raise if it doesn't exist.
:realbook
|> Application.get_env(:asset_dir)
|> Kernel.||(raise "the realbook #{realbook} requires assets, and no asset dir has been specified.")
|> Path.join(asset.path)
|> File.exists?
|> unless do
raise Realbook.AssetError,
module: module,
path: asset.path,
file: asset.file,
line: asset.line
end
end)
module.__exec__()
:ok
end
def eval(module, _file, _line) when is_atom(module) do
module.__exec__()
:ok
end
@doc false
# private api, provided as an entrypoint for testing purposes.
@spec compile(iodata, Path.t, keyword) :: module
def compile(realbook, file, options \\ [line: 0]) do
offset = Keyword.get(options, :line, 0)
realbook
|> Code.string_to_quoted!(
existing_atoms: :safe,
line: 1 + offset,
file: file)
|> modulewrap(file, options)
end
##########################################################################
## Dictionary functions
@spec set(keyword) :: :ok
@doc """
puts keys into the Realbook dictionary.
This is a key-value store which stores "variables" for your Realbook
scripts. Note that these key/values are stored in an ets table under the
Realbook caller's process pid.
Typically, you will run `set/1` prior to executing the Realbook script to
satisfy all parameters that the it must have at runtime. The Realbook script
performs a compile-time check to identify all necessary parameters and will
refuse to run unless these parameters have been assigned.
Note that a spawned task will not have access to the Realbook key/value
store of its parent. This may change in the future.
"""
defdelegate set(keyword), to: Realbook.Dictionary
@spec get(atom, any) :: term
@doc """
retrieves a value from the Realbook dictionary by its corresponding key.
See `set/1` for details on how the key/values are stored.
"""
defdelegate get(key, default \\ nil), to: Realbook.Dictionary
@doc false
# private api, this function is def public for testing purposes.
def modulewrap(ast, file, options) do
{module, name} = case {file, options[:module]} do
{_, module} when is_atom(module) and not is_nil(module) ->
tag = ast |> :erlang.phash2 |> :erlang.term_to_binary |> Base.encode16
{Module.concat(module, "Anonymous#{tag}"), nil}
{"nofile", _} ->
tag = ast |> :erlang.phash2 |> :erlang.term_to_binary |> Base.encode16
{Module.concat(Realbook.Scripts, "Anonymous#{tag}"), nil}
{path, _} ->
basename = String.trim(path, ".exs")
baselist = basename
|> String.split(~r/[\.\/]/)
|> Enum.map(&Macro.camelize/1)
module = Module.concat([Realbook.Scripts | baselist])
{module, basename}
end
# take out a lock on the compiler.
:locked = Realbook.Semaphore.lock({:compiler, module})
cond do
# check to see if this module already exists.
function_exported?(module, :__info__, 1) ->
Realbook.Semaphore.unlock({:compiler, module})
module
true ->
module
|> module_ast(ast, name)
|> Code.compile_quoted("#{file}")
Realbook.Semaphore.unlock({:compiler, module})
module
end
end
@doc false
def module_ast(module, ast, name) do
quote do
defmodule unquote(module) do
use Realbook, name: unquote(name)
unquote(ast)
end
end
end
defmacro __using__(options) do
quote do
import Realbook.Macros
import Realbook.Commands
require Logger
Realbook.Macros.create_accumulated_attribute(__MODULE__, :required_variables)
Realbook.Macros.create_accumulated_attribute(__MODULE__, :provides_variables)
Realbook.Macros.create_accumulated_attribute(__MODULE__, :required_assets)
def __name__, do: unquote(options[:name])
def __label__ do
if name = __name__() do
"Realbook #{name}"
else
"anonymous Realbook"
end
end
# generate the exec function.
Realbook.Macros.__exec__()
@before_compile Realbook.Macros
end
end
###########################################################################
## SIGILS
@doc """
Compile and execute a realbook starting at this point in the code.
This form doesn't perform any interpolation.
This is the recommended entry point for realbooks, though
you can also use `run/1` to directly run a realbook file.
## Example
```elixir
defmodule MyRealbookEntryModule do
# ...
def run_realbooks do
~B\"""
requires ~w(realbook1 realbook2 realbook2)
verify false
play do
# ...
log "running realbooks"
end
\"""
end
end
```
"""
defmacro sigil_B({:<<>>, _meta, [definition]}, []) do
file = __CALLER__.file
line = __CALLER__.line
quote bind_quoted: [definition: definition, file: file, line: line, module: __CALLER__.module] do
Realbook.eval(definition, file, line: line, module: module)
end
end
@doc """
like `sigil_B/2` but lets you interpolate values from the surrounding
context.
"""
defmacro sigil_b(code = {:<<>>, _meta, _}, []) do
file = __CALLER__.file
line = __CALLER__.line - 1
quote bind_quoted: [code: code, file: file, line: line, module: __CALLER__.module] do
Realbook.eval(code, file, line: line, module: module)
end
end
###########################################################################
## PRIVATE API. may be moved out of this module at any time.
@doc false
# private API.
@spec stage() :: stage_t
def stage, do: Storage.props(:stage)
@doc false
# private API. Do not use.
@spec stage(stage_t) :: :ok
def stage(stage) do
Storage.update(stage: stage)
end
@doc false
# private API. Do not use.
# registers a module as having been completed.
@spec complete(module) :: :ok
def complete(module) do
Storage.update(:completed, &[module | &1])
end
@doc false
# private API, do not use.
# creates a temporary directory, useful for testing.
@spec tmp_dir!() :: Path.t
def tmp_dir! do
System.tmp_dir!
|> Path.join(".realbook")
|> Path.join(Base.encode16(<<Enum.random(0..0xFFFFFFFF)::32>>))
end
end