feat: more clear definition and first base implementation for lua

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-23 12:05:12 +02:00
parent 3f5744308c
commit a449778b44
18 changed files with 859 additions and 16 deletions

View File

@@ -0,0 +1,121 @@
defmodule BDS.Scripting.JobRunner do
@moduledoc false
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
def cancel(pid) when is_pid(pid) do
GenServer.call(pid, :cancel)
end
@impl true
def init(opts) do
state = %{
job_id: Keyword.fetch!(opts, :job_id),
runtime: Keyword.fetch!(opts, :runtime),
source: Keyword.fetch!(opts, :source),
entrypoint: Keyword.fetch!(opts, :entrypoint),
args: Keyword.get(opts, :args, []),
opts: Keyword.get(opts, :opts, []),
task_pid: nil,
task_ref: nil,
completed?: false,
cancelled?: false
}
Process.flag(:trap_exit, true)
:ok = BDS.Scripting.JobStore.attach_runner(state.job_id, self())
{:ok, state, {:continue, :start_job}}
end
@impl true
def handle_continue(:start_job, state) do
:ok =
BDS.Scripting.JobStore.update_job(state.job_id, %{
status: :running,
started_at: DateTime.utc_now()
})
runner = self()
task =
Task.Supervisor.async_nolink(BDS.Scripting.TaskSupervisor, fn ->
state.runtime.execute(
state.source,
state.entrypoint,
state.args,
Keyword.put(state.opts, :on_progress, fn event ->
send(runner, {:job_progress, event})
end)
)
end)
{:noreply, %{state | task_pid: task.pid, task_ref: task.ref}}
end
@impl true
def handle_call(:cancel, _from, state) do
if is_pid(state.task_pid) do
Process.exit(state.task_pid, :kill)
end
:ok =
BDS.Scripting.JobStore.update_job(state.job_id, %{
status: :cancelled,
finished_at: DateTime.utc_now()
})
:ok = BDS.Scripting.JobStore.detach_runner(state.job_id)
{:stop, :normal, :ok, %{state | cancelled?: true}}
end
@impl true
def handle_info({:job_progress, progress}, state) do
:ok = BDS.Scripting.JobStore.update_job(state.job_id, %{progress: progress})
{:noreply, state}
end
def handle_info({ref, result}, %{task_ref: ref} = state) do
Process.demonitor(ref, [:flush])
unless state.cancelled? do
attrs =
case result do
{:ok, value} ->
%{status: :completed, result: value, finished_at: DateTime.utc_now()}
{:error, reason} ->
%{status: :failed, error: reason, finished_at: DateTime.utc_now()}
end
:ok = BDS.Scripting.JobStore.update_job(state.job_id, attrs)
:ok = BDS.Scripting.JobStore.detach_runner(state.job_id)
end
{:stop, :normal, %{state | completed?: true}}
end
def handle_info({:DOWN, ref, :process, _pid, reason}, %{task_ref: ref} = state) do
cond do
state.completed? or state.cancelled? ->
{:stop, :normal, state}
reason == :normal ->
{:noreply, state}
true ->
:ok =
BDS.Scripting.JobStore.update_job(state.job_id, %{
status: :failed,
error: reason,
finished_at: DateTime.utc_now()
})
:ok = BDS.Scripting.JobStore.detach_runner(state.job_id)
{:stop, :normal, state}
end
end
end

View File

@@ -0,0 +1,78 @@
defmodule BDS.Scripting.JobStore do
@moduledoc false
use GenServer
def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def put_job(job) when is_map(job) do
GenServer.call(__MODULE__, {:put_job, job})
end
def update_job(job_id, attrs) when is_binary(job_id) and is_map(attrs) do
GenServer.call(__MODULE__, {:update_job, job_id, attrs})
end
def attach_runner(job_id, pid) when is_binary(job_id) and is_pid(pid) do
GenServer.call(__MODULE__, {:attach_runner, job_id, pid})
end
def detach_runner(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:detach_runner, job_id})
end
def fetch_job(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:fetch_job, job_id})
end
def fetch_job!(job_id) when is_binary(job_id) do
case fetch_job(job_id) do
nil -> raise KeyError, key: job_id, term: :jobs
job -> job
end
end
def runner_for(job_id) when is_binary(job_id) do
GenServer.call(__MODULE__, {:runner_for, job_id})
end
@impl true
def init(_state) do
{:ok, %{jobs: %{}, runners: %{}}}
end
@impl true
def handle_call({:put_job, %{id: job_id} = job}, _from, state) do
next_state = put_in(state, [:jobs, job_id], job)
{:reply, :ok, next_state}
end
def handle_call({:update_job, job_id, attrs}, _from, state) do
next_state = update_in(state, [:jobs, job_id], fn
nil -> nil
job -> Map.merge(job, attrs)
end)
{:reply, :ok, next_state}
end
def handle_call({:attach_runner, job_id, pid}, _from, state) do
next_state = put_in(state, [:runners, job_id], pid)
{:reply, :ok, next_state}
end
def handle_call({:detach_runner, job_id}, _from, state) do
next_state = update_in(state.runners, &Map.delete(&1, job_id))
{:reply, :ok, %{state | runners: next_state}}
end
def handle_call({:fetch_job, job_id}, _from, state) do
{:reply, Map.get(state.jobs, job_id), state}
end
def handle_call({:runner_for, job_id}, _from, state) do
{:reply, Map.get(state.runners, job_id), state}
end
end

View File

@@ -0,0 +1,14 @@
defmodule BDS.Scripting.JobSupervisor do
@moduledoc false
use DynamicSupervisor
def start_link(_opts) do
DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end
@impl true
def init(:ok) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end

134
lib/bds/scripting/lua.ex Normal file
View File

@@ -0,0 +1,134 @@
defmodule BDS.Scripting.Lua do
@moduledoc """
Lua runtime adapter backed by Luerl.
Execution starts from a sandboxed Lua state. Host capabilities are explicit
and opt-in.
"""
@behaviour BDS.Scripting.Runtime
@impl true
def validate(source) when is_binary(source) do
case :luerl.load(source, :luerl_sandbox.init()) do
{:ok, _chunk, _state} ->
:ok
{:error, errors, warnings} ->
{:error, {:compile_error, %{errors: errors, warnings: warnings}}}
{:lua_error, error, _state} ->
{:error, {:lua_error, error}}
end
end
@impl true
def execute(source, entrypoint, args, opts)
when is_binary(source) and is_binary(entrypoint) and is_list(args) and is_list(opts) do
with {:ok, state} <- initial_state(opts),
{:ok, state} <- put_args(state, args),
{:ok, result, _state} <- run_entrypoint(source, entrypoint, state, opts) do
{:ok, unwrap_result(result)}
end
end
defp initial_state(opts) do
state = :luerl_sandbox.init()
capabilities = Keyword.get(opts, :capabilities, %{})
with {:ok, state} <- :luerl.set_table_keys_dec(["bds"], %{}, state),
{:ok, state} <- install_progress_callback(state, Keyword.get(opts, :on_progress)),
{:ok, state} <- install_capabilities(state, capabilities) do
{:ok, state}
end
end
defp install_progress_callback(state, nil), do: {:ok, state}
defp install_progress_callback(state, callback) when is_function(callback, 1) do
progress_function = fn args, current_state ->
decoded_args = :luerl.decode_list(args, current_state)
progress_event =
case decoded_args do
[payload | _] when is_map(payload) -> payload
[payload | _] -> normalize_progress_payload(payload)
[] -> %{}
end
callback.(progress_event)
:luerl.encode_list([true], current_state)
end
case :luerl.set_table_keys_dec(["bds", "report_progress"], progress_function, state) do
{:ok, next_state} -> {:ok, next_state}
error -> {:error, {:progress_callback_install_failed, error}}
end
end
defp install_progress_callback(_state, callback), do: {:error, {:invalid_progress_callback, callback}}
defp install_capabilities(state, capabilities) when capabilities in [%{}, []], do: {:ok, state}
defp install_capabilities(state, capabilities) when is_map(capabilities) do
Enum.reduce_while(capabilities, {:ok, state}, fn {name, function}, {:ok, current_state} ->
path = ["bds", to_string(name)]
case :luerl.set_table_keys_dec(path, function, current_state) do
{:ok, next_state} -> {:cont, {:ok, next_state}}
error -> {:halt, {:error, {:capability_install_failed, path, error}}}
end
end)
end
defp install_capabilities(_state, capabilities), do: {:error, {:invalid_capabilities, capabilities}}
defp normalize_progress_payload(payload) when is_list(payload) do
if Enum.all?(payload, &match?({key, _value} when is_binary(key) or is_atom(key), &1)) do
Map.new(payload, fn {key, value} -> {to_string(key), value} end)
else
%{value: payload}
end
end
defp normalize_progress_payload(payload), do: %{value: payload}
defp put_args(state, args) do
case Luerl.set_table_keys_dec(state, ["__bds_args__"], args) do
{:ok, next_state} -> {:ok, next_state}
error -> {:error, {:argument_encoding_failed, error}}
end
end
defp run_entrypoint(source, entrypoint, state, opts) do
script =
IO.iodata_to_binary([
source,
"\nreturn ",
entrypoint,
"(table.unpack(__bds_args__))\n"
])
case :luerl_sandbox.run(script, sandbox_flags(opts), state) do
{:ok, result, next_state} -> {:ok, result, next_state}
{:lua_error, error, _state} -> {:error, {:lua_error, error}}
{:error, {:reductions, count}} -> {:error, {:reductions_exceeded, count}}
{:error, :timeout} -> {:error, :timeout}
{:error, reason} -> {:error, reason}
end
end
defp sandbox_flags(opts) do
config = Application.fetch_env!(:bds, :scripting)
%{
max_time: Keyword.get(opts, :timeout, Keyword.fetch!(config, :timeout)),
max_reductions: Keyword.get(opts, :max_reductions, Keyword.fetch!(config, :max_reductions)),
spawn_opts: Keyword.get(opts, :spawn_opts, [])
}
end
defp unwrap_result([]), do: nil
defp unwrap_result([value]), do: value
defp unwrap_result(values), do: values
end

View File

@@ -0,0 +1,24 @@
defmodule BDS.Scripting.Runtime do
@moduledoc """
Behaviour for user-script runtimes hosted by bDS.
The runtime boundary is intentionally narrow: syntax validation and
bounded entrypoint execution.
"""
@type source :: String.t()
@type entrypoint :: String.t()
@type args :: [term()]
@type progress_event :: map()
@type progress_callback :: (progress_event() -> any())
@type execution_option ::
{:timeout, non_neg_integer() | :infinity}
| {:max_reductions, pos_integer() | :none}
| {:spawn_opts, [term()]}
| {:on_progress, progress_callback()}
| {:capabilities, map()}
@callback validate(source()) :: :ok | {:error, term()}
@callback execute(source(), entrypoint(), args(), [execution_option()]) ::
{:ok, term()} | {:error, term()}
end