Current section

Files

Jump to
condukt lib condukt workflows compiler.ex
Raw

lib/condukt/workflows/compiler.ex

defmodule Condukt.Workflows.Compiler do
@moduledoc """
Normalizes `.exs` workflow files to workflow documents.
An `.exs` workflow is an Elixir script whose final expression
evaluates to a map describing the workflow. This is a low-level
generation escape hatch. Human-authored workflow files should
generally use HCL and let `Condukt.Workflows.HCLCompiler` produce the
same map.
Atom keys and atom values (other than `nil`, `true`, `false`) are
normalized to strings; the rest of the data must already match the
workflow document shape.
Standard Elixir features (`def` inside a `defmodule`, anonymous
functions, `for`, `if`, comprehensions, `Enum`, etc.) are available
for document generation. References between steps are
written as plain `${...}` expression strings.
# hello.exs
%{
inputs: %{name: %{type: :string}},
steps: %{greet: %{kind: :cmd, argv: ["echo", "Hello, ${inputs.name}"]}},
output: "${steps.greet.stdout}"
}
"""
@doc """
Reads, evaluates, and normalizes an `.exs` workflow file.
Returns `{:ok, decoded_map}` where the map is ready for workflow
document validation.
"""
def compile(path) when is_binary(path) do
with {:ok, source} <- read(path) do
compile_string(source, path)
end
end
@doc """
Evaluates an `.exs` workflow source string. `path` is used only for
error reporting.
"""
def compile_string(source, path) do
case eval(source, path) do
{:ok, value} when is_map(value) -> {:ok, normalize(value)}
{:ok, value} when is_list(value) -> normalize_keyword(value, path)
{:ok, _other} -> {:error, {:not_a_workflow, path, :result_must_be_a_map}}
{:error, _} = err -> err
end
end
defp read(path) do
case File.read(path) do
{:ok, source} -> {:ok, source}
{:error, reason} -> {:error, {:read_failed, path, reason}}
end
end
defp eval(source, path) do
parent = self()
token = make_ref()
{pid, monitor_ref} =
spawn_monitor(fn ->
{value, _bindings} = Code.eval_string(source, [], file: path)
send(parent, {token, {:ok, value}})
receive do
{^token, :ack} -> :ok
after
5_000 -> :ok
end
end)
receive do
{^token, result} ->
send(pid, {token, :ack})
Process.demonitor(monitor_ref, [:flush])
result
{:DOWN, ^monitor_ref, :process, ^pid, reason} ->
{:error, {:eval_failed, path, format_eval_failure(reason)}}
end
end
defp format_eval_failure({%{__exception__: true} = exception, _stacktrace}), do: Exception.message(exception)
defp format_eval_failure(%{__exception__: true} = exception), do: Exception.message(exception)
defp format_eval_failure(reason), do: inspect(reason)
defp normalize_keyword(list, path) do
if Keyword.keyword?(list) do
{:ok, list |> Map.new() |> normalize()}
else
{:error, {:not_a_workflow, path, :result_must_be_a_map}}
end
end
defp normalize(map) when is_map(map) and not is_struct(map) do
Map.new(map, fn {k, v} -> {to_string_key(k), normalize(v)} end)
end
defp normalize(list) when is_list(list) do
if Keyword.keyword?(list) and list != [] do
list |> Map.new() |> normalize()
else
Enum.map(list, &normalize/1)
end
end
defp normalize(atom) when is_atom(atom) and atom not in [nil, true, false], do: Atom.to_string(atom)
defp normalize(other), do: other
defp to_string_key(k) when is_binary(k), do: k
defp to_string_key(k) when is_atom(k), do: Atom.to_string(k)
end