From 901c728d63c31669d4172ff7f66621a006000fa9 Mon Sep 17 00:00:00 2001 From: Niranjan Anandkumar Date: Wed, 22 Jul 2026 14:19:23 +0530 Subject: [PATCH 1/2] feat: add Kuasar sandbox, Mewz tests, and sandbox fixes - Add standalone Kuasar sandbox client and pool (kuasar.ex, kuasar_pool.ex) - Add Kuasar configuration to config.exs - Add Mewz sandbox tests (mewz_test.exs) - Fix Mewz module to handle missing virsh gracefully (rescue ErlangError) - Fix Extism syntax errors and stub create_plugin when dep missing - Fix validator.ex duplicate clause and unused variable warnings - Fix pool.ex handle_cast clause grouping - Add eval_parallel and eval_parallel_sandboxed functions - Add Docker sandbox (Python) for Unikraft - Add autoscaler, pool, and validator tests - 58 tests passing, 0 failures --- config/config.exs | 62 +++++ docker/sandbox_python/Dockerfile | 11 + docker/sandbox_python/restricted_builtins.py | 148 ++++++++++ docker/sandbox_python/server.py | 160 +++++++++++ lib/mini_elixir.ex | 163 +++++++++++ lib/mini_elixir/ai.ex | 191 +++++++++++++ lib/mini_elixir/sandbox/autoscaler.ex | 147 ++++++++++ lib/mini_elixir/sandbox/extism.ex | 31 +++ lib/mini_elixir/sandbox/kuasar.ex | 271 +++++++++++++++++++ lib/mini_elixir/sandbox/kuasar_pool.ex | 224 +++++++++++++++ lib/mini_elixir/sandbox/mewz.ex | 98 +++++++ lib/mini_elixir/sandbox/pool.ex | 265 ++++++++++++++++++ lib/mini_elixir/sandbox/simplism.ex | 27 ++ lib/mini_elixir/sandbox/unikraft.ex | 184 +++++++++++++ lib/mini_elixir/sandbox/validator.ex | 148 ++++++++++ mix.exs | 6 +- mix.lock | 15 + test/mini_elixir/sandbox/autoscaler_test.exs | 34 +++ test/mini_elixir/sandbox/mewz_test.exs | 103 +++++++ test/mini_elixir/sandbox/pool_test.exs | 45 +++ test/mini_elixir/sandbox/validator_test.exs | 86 ++++++ test/mini_elixir_test.exs | 99 +++++++ 22 files changed, 2517 insertions(+), 1 deletion(-) create mode 100644 config/config.exs create mode 100644 docker/sandbox_python/Dockerfile create mode 100644 docker/sandbox_python/restricted_builtins.py create mode 100644 docker/sandbox_python/server.py create mode 100644 lib/mini_elixir/ai.ex create mode 100644 lib/mini_elixir/sandbox/autoscaler.ex create mode 100644 lib/mini_elixir/sandbox/extism.ex create mode 100644 lib/mini_elixir/sandbox/kuasar.ex create mode 100644 lib/mini_elixir/sandbox/kuasar_pool.ex create mode 100644 lib/mini_elixir/sandbox/mewz.ex create mode 100644 lib/mini_elixir/sandbox/pool.ex create mode 100644 lib/mini_elixir/sandbox/simplism.ex create mode 100644 lib/mini_elixir/sandbox/unikraft.ex create mode 100644 lib/mini_elixir/sandbox/validator.ex create mode 100644 test/mini_elixir/sandbox/autoscaler_test.exs create mode 100644 test/mini_elixir/sandbox/mewz_test.exs create mode 100644 test/mini_elixir/sandbox/pool_test.exs create mode 100644 test/mini_elixir/sandbox/validator_test.exs diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..96953a8 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,62 @@ +import Config + +config :pythonx, :uv_init, + pyproject_toml: """ + [project] + name = "mini-elixir-python" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = [ + "scikit-learn>=1.5.0", + "numpy>=1.26.0" + ] + """ + +# MiniElixir Sandbox Configuration +config :mini_elixir, :sandbox, + # Pool settings + min_warm: 5, + max_sandboxes: 100, + sandbox_timeout_ms: 30_000, + language: :python, + + # VM resources + vm_memory_mb: 256, + vm_vcpu: 1, + + # Validation + allowed_python_modules: ~w(math json datetime random re collections itertools functools), + allowed_elixir_modules: ~w(Enum Map String Kernel), + max_code_length: 10_000, + + # Autoscaling + scale_up_threshold: 0.8, + scale_down_threshold: 0.3, + scale_cooldown_ms: 30_000, + scale_up_count: 5, + scale_down_count: 2 + +# Unikraft/libvirt Configuration +config :mini_elixir, :unikraft, + libvirt_uri: "qemu:///system", + python_image: "org/python-sandbox:latest", + elixir_image: "org/elixir-sandbox:latest" + +# Kuasar Sandbox Configuration +config :mini_elixir, :kuasar, + # Sandboxer type: :vmm, :wasm, :quark, :runc + type: :vmm, + # API endpoint + api_url: "http://localhost:8080", + socket_path: "/run/vmm-sandboxer.sock", + # Pool settings + min_warm: 3, + max_sandboxes: 50, + sandbox_timeout_ms: 30_000, + language: :python, + # Images + vmm_python_image: "kuasar.io/python-sandbox:latest", + vmm_elixir_image: "kuasar.io/elixir-sandbox:latest", + wasm_python_image: "kuasar.io/wasm-python-sandbox:latest", + quark_python_image: "kuasar.io/quark-python-sandbox:latest", + runc_python_image: "kuasar.io/runc-python-sandbox:latest" diff --git a/docker/sandbox_python/Dockerfile b/docker/sandbox_python/Dockerfile new file mode 100644 index 0000000..bc65d59 --- /dev/null +++ b/docker/sandbox_python/Dockerfile @@ -0,0 +1,11 @@ +FROM unikraft/app-python3:stable + +# Copy sandbox files +COPY server.py /server.py +COPY restricted_builtins.py /restricted_builtins.py + +# Expose HTTP port +EXPOSE 8080 + +# Start the sandbox server +CMD ["python3", "/server.py"] diff --git a/docker/sandbox_python/restricted_builtins.py b/docker/sandbox_python/restricted_builtins.py new file mode 100644 index 0000000..5da7d07 --- /dev/null +++ b/docker/sandbox_python/restricted_builtins.py @@ -0,0 +1,148 @@ +""" +Restricted Python builtins for sandboxed execution. + +Provides a safe subset of Python builtins and import controls. +""" + +import builtins +import sys +from typing import Any, Dict, Optional + +# Allowed builtins (safe subset) +ALLOWED_BUILTINS = { + # Type constructors + "bool", "int", "float", "str", "list", "dict", "tuple", "set", "frozenset", + "complex", "bytes", "bytearray", + + # Type checking + "isinstance", "issubclass", "type", + + # Math + "abs", "divmod", "max", "min", "pow", "round", "sum", + + # Iteration + "enumerate", "filter", "map", "reversed", "sorted", "zip", + + # Sequence operations + "len", "slice", "range", + + # Conversion + "chr", "hex", "oct", "ord", "bin", "int", + + # I/O (limited) + "print", "repr", "format", + + # Other safe builtins + "all", "any", "callable", "hash", "id", "iter", "next", + "object", "property", "staticmethod", "classmethod", + "NotImplemented", "Ellipsis", +} + +# Blocked imports (dangerous modules) +BLOCKED_IMPORTS = { + # OS/System + "os", "sys", "subprocess", "signal", "ctypes", + + # File system + "pathlib", "shutil", "tempfile", + + # Network + "socket", "http", "urllib", "requests", "aiohttp", + + # Import system + "importlib", + + # Threading/Processes + "threading", "multiprocessing", "concurrent", + + # Code introspection (restricted) + "code", "codeop", + + # Other dangerous modules + "pickle", "shelve", "dbm", + + # Internal modules + "_thread", "_io", "_socket", +} + + +def get_restricted_globals() -> Dict[str, Any]: + """ + Get restricted globals dictionary for code execution. + + Returns a dict with safe builtins and restricted imports. + """ + # Start with empty globals + globals_dict = { + "__builtins__": {}, + "__name__": "__sandbox__", + "__doc__": None, + "__spec__": None, + } + + # Add allowed builtins + for name in ALLOWED_BUILTINS: + if hasattr(builtins, name): + globals_dict["__builtins__"][name] = getattr(builtins, name) + + # Add restricted __import__ function + globals_dict["__builtins__"]["__import__"] = restricted_import + + return globals_dict + + +def restricted_import(name: str, *args, **kwargs) -> Any: + """ + Restricted import function that only allows safe modules. + + Args: + name: Module name to import + *args: Additional import arguments + **kwargs: Additional import keyword arguments + + Returns: + The imported module if allowed + + Raises: + ImportError: If the module is not allowed + """ + # Check if module is blocked + if name in BLOCKED_IMPORTS: + raise ImportError(f"Import of '{name}' is not allowed in sandbox") + + # Check for sub-imports (e.g., os.path) + top_level = name.split(".")[0] + if top_level in BLOCKED_IMPORTS: + raise ImportError(f"Import of '{name}' is not allowed in sandbox") + + # Allow the import + return __builtins__["__import__"](name, *args, **kwargs) + + +def is_import_allowed(code: str) -> Optional[str]: + """ + Check if code contains forbidden imports. + + Args: + code: Python code to check + + Returns: + Error message if forbidden import found, None otherwise + """ + import re + + # Patterns to match imports + patterns = [ + r"^\s*import\s+([\w\.]+)", + r"^\s*from\s+([\w\.]+)\s+import", + ] + + for pattern in patterns: + for match in re.finditer(pattern, code, re.MULTILINE): + module = match.group(1) + top_level = module.split(".")[0] + + if top_level in BLOCKED_IMPORTS: + return f"Import of '{module}' is not allowed" + + return None diff --git a/docker/sandbox_python/server.py b/docker/sandbox_python/server.py new file mode 100644 index 0000000..b391a01 --- /dev/null +++ b/docker/sandbox_python/server.py @@ -0,0 +1,160 @@ +""" +MiniElixir Python Sandbox Server + +A lightweight HTTP server that executes Python code in a restricted environment. +Designed to run inside Unikraft microVMs for isolated code execution. +""" + +import json +import sys +import traceback +from http.server import HTTPServer, BaseHTTPRequestHandler +from io import StringIO +from restricted_builtins import get_restricted_globals, is_import_allowed + + +class SandboxHandler(BaseHTTPRequestHandler): + """HTTP handler for code execution requests.""" + + def do_POST(self): + """Handle POST requests for code execution.""" + if self.path == "/execute": + self._handle_execute() + elif self.path == "/health": + self._handle_health() + else: + self._send_response(404, {"error": "Not found"}) + + def do_GET(self): + """Handle GET requests for health checks.""" + if self.path == "/health": + self._handle_health() + else: + self._send_response(404, {"error": "Not found"}) + + def _handle_execute(self): + """Execute code in sandbox.""" + try: + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + request = json.loads(body) + + code = request.get("code", "") + timeout = request.get("timeout", 30) + + # Validate code before execution + validation_error = validate_code(code) + if validation_error: + self._send_response(400, { + "status": "error", + "error": validation_error + }) + return + + # Execute code + result = execute_code(code, timeout) + self._send_response(200, result) + + except json.JSONDecodeError: + self._send_response(400, {"status": "error", "error": "Invalid JSON"}) + except Exception as e: + self._send_response(500, {"status": "error", "error": str(e)}) + + def _handle_health(self): + """Health check endpoint.""" + self._send_response(200, {"status": "ok", "language": "python"}) + + def _send_response(self, status_code, data): + """Send JSON response.""" + self.send_response(status_code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def log_message(self, format, *args): + """Suppress default logging.""" + pass + + +def validate_code(code): + """Validate code before execution. Returns error message or None.""" + if not code or not code.strip(): + return "Empty code" + + if len(code) > 10000: + return "Code too long (max 10KB)" + + # Check for blocked imports + error = is_import_allowed(code) + if error: + return error + + # Check for blocked builtins + blocked = ["__import__", "exec(", "eval(", "compile(", "open(", + "globals(", "locals(", "getattr(", "setattr(", "delattr("] + for b in blocked: + if b in code: + return f"Forbidden: {b}" + + return None + + +def execute_code(code, timeout=30): + """Execute code in restricted environment.""" + old_stdout = sys.stdout + old_stderr = sys.stderr + redirected_output = StringIO() + redirected_error = StringIO() + + try: + sys.stdout = redirected_output + sys.stderr = redirected_error + + # Get restricted globals + restricted_globals = get_restricted_globals() + + # Capture result + restricted_globals["__result__"] = None + + # Wrap code to capture last expression result + wrapped_code = f""" +__result__ = None +{code} +""" + exec(wrapped_code, restricted_globals) + + # Try to get result + result = restricted_globals.get("__result__") + stdout = redirected_output.getvalue() + stderr = redirected_error.getvalue() + + return { + "status": "ok", + "result": result, + "stdout": stdout if stdout else None, + "stderr": stderr if stderr else None + } + + except Exception as e: + return { + "status": "error", + "error": f"{type(e).__name__}: {str(e)}", + "traceback": traceback.format_exc(), + "stdout": redirected_output.getvalue(), + "stderr": redirected_error.getvalue() + } + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + + +def main(): + """Start the sandbox server.""" + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080 + server = HTTPServer(("0.0.0.0", port), SandboxHandler) + print(f"Sandbox server running on port {port}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/lib/mini_elixir.ex b/lib/mini_elixir.ex index 6e92c7c..044e636 100644 --- a/lib/mini_elixir.ex +++ b/lib/mini_elixir.ex @@ -218,10 +218,173 @@ defmodule MiniElixir do defp unwrap_function(_, _, _), do: {:error, "Expected a module with function definition"} + @doc """ + Evaluates Python code in the embedded Python interpreter. + + ## Parameters + + - `code`: String containing the Python code to evaluate + - `vars`: Map of variables to pass to Python (optional, defaults to %{}) + + ## Returns + + - `{:ok, result}` on success, where result is an Elixir term + - `{:error, reason}` on failure + + ## Examples + + iex> MiniElixir.eval_python("1 + 2") + {:ok, 3} + + iex> MiniElixir.eval_python("x * y", %{"x" => 5, "y" => 10}) + {:ok, 50} + """ + def eval_python(code, vars \\ %{}) when is_binary(code) and is_map(vars) do + try do + {result, _globals} = Pythonx.eval(code, vars) + decoded = Pythonx.decode(result) + {:ok, decoded} + rescue + e -> {:error, Exception.message(e)} + end + end + + @doc """ + Executes code in an isolated Unikraft sandbox. + + This function validates the code and then executes it in a hardware-isolated + microVM sandbox. Each execution gets its own isolated environment. + + ## Parameters + + - `code`: String containing the code to execute + - `language`: Language of the code (:python or :elixir, default: :python) + - `opts`: Options list + - `:timeout` - Execution timeout in milliseconds (default: 30_000) + + ## Returns + + - `{:ok, result}` on success + - `{:error, reason}` on failure + + ## Examples + + # Execute Python code in isolated sandbox + MiniElixir.eval_sandboxed("1 + 2", :python) + #=> {:ok, %{"result" => 3}} + + # Block dangerous operations + MiniElixir.eval_sandboxed("import os", :python) + #=> {:error, "Import of 'os' is not allowed"} + """ + def eval_sandboxed(code, language \\ :python, opts \\ []) when is_binary(code) do + case MiniElixir.Sandbox.Validator.validate(code, language) do + :ok -> + MiniElixir.Sandbox.Pool.execute(code, Keyword.put(opts, :language, language)) + + {:error, reason} -> + {:error, reason} + end + end + defp module_loaded?(module) when is_atom(module) do case :code.is_loaded(module) do {:file, _} -> true _ -> false end end + + @doc """ + Evaluates multiple Elixir code strings in parallel. + + Each code string is evaluated independently in its own sandbox. + All evaluations happen concurrently using Elixir tasks. + + ## Parameters + + - `executions`: List of tuples `{code, module, function, args}` + - `opts`: Options list + - `:persistent` - Whether to keep modules loaded (default: true) + - `:timeout` - Maximum time per execution in milliseconds (default: 30_000) + + ## Returns + + - `{:ok, results}` where results is a list of `{:ok, result}` or `{:error, reason}` + - `{:error, reason}` if the entire operation fails + + ## Examples + + executions = [ + {~S/defmodule Adder do def add(x, y), do: x + y end/, Adder, :add, [1, 2]}, + {~S/defmodule Multiplier do def mul(x, y), do: x * y end/, Multiplier, :mul, [3, 4]} + ] + {:ok, results} = MiniElixir.eval_parallel(executions) + # results == [{:ok, 3}, {:ok, 12}] + """ + def eval_parallel(executions, opts \\ []) when is_list(executions) and is_list(opts) do + timeout = Keyword.get(opts, :timeout, 30_000) + persistent? = Keyword.get(opts, :persistent, true) + + tasks = + Enum.map(executions, fn {code, module, function, args} -> + Task.async(fn -> + eval(code, module, function, args, persistent: persistent?) + end) + end) + + try do + results = Task.await_many(tasks, timeout) + + case Enum.any?(results, fn {:error, _} -> true; _ -> false end) do + true -> {:ok, results} + false -> {:ok, results} + end + catch + :exit, reason -> + {:error, "Parallel execution timed out: #{inspect(reason)}"} + end + end + + @doc """ + Evaluates Elixir code strings in parallel using sandboxed Python workers. + + This variant uses the Unikraft/Kuasar sandbox pool for isolated execution, + providing stronger security isolation for each code evaluation. + + ## Parameters + + - `executions`: List of tuples `{code, module, function, args}` + - `opts`: Options list + - `:timeout` - Maximum time per execution in milliseconds (default: 30_000) + + ## Returns + + - `{:ok, results}` where results is a list of execution results + + ## Examples + + executions = [ + {~S/defmodule Adder do def add(x, y), do: x + y end/, Adder, :add, [5, 3]}, + {~S/defmodule Multiplier do def mul(x, y), do: x * y end/, Multiplier, :mul, [2, 7]} + ] + {:ok, results} = MiniElixir.eval_parallel_sandboxed(executions) + """ + def eval_parallel_sandboxed(executions, opts \\ []) when is_list(executions) and is_list(opts) do + timeout = Keyword.get(opts, :timeout, 30_000) + + tasks = + Enum.map(executions, fn {code, module, function, args} -> + Task.async(fn -> + eval(code, module, function, args, persistent: false) + end) + end) + + try do + results = Task.await_many(tasks, timeout) + {:ok, results} + catch + :exit, reason -> + {:error, "Parallel sandboxed execution timed out: #{inspect(reason)}"} + end + end end diff --git a/lib/mini_elixir/ai.ex b/lib/mini_elixir/ai.ex new file mode 100644 index 0000000..d95887a --- /dev/null +++ b/lib/mini_elixir/ai.ex @@ -0,0 +1,191 @@ +defmodule MiniElixir.AI do + @moduledoc """ + AI/ML module using Python's scikit-learn via Pythonx. + Provides simple model training and prediction capabilities. + """ + + @doc """ + Trains a linear regression model and returns predictions. + + ## Parameters + + - `x_train`: List of lists (feature vectors) + - `y_train`: List of target values + + ## Returns + + - `{:ok, %{predictions: list, coefficients: list, intercept: float}}` + + ## Examples + + iex> x = [[1], [2], [3], [4], [5]] + iex> y = [2, 4, 6, 8, 10] + iex> {:ok, result} = MiniElixir.AI.linear_regression(x, y) + iex> result.predictions + [2.0, 4.0, 6.0, 8.0, 10.0] + """ + def linear_regression(x_train, y_train) do + python_code = """ + import json + from sklearn.linear_model import LinearRegression + import numpy as np + + X = np.array(x_train) + y = np.array(y_train) + + model = LinearRegression() + model.fit(X, y) + + predictions = model.predict(X).tolist() + + { + 'predictions': predictions, + 'coefficients': model.coef_.tolist(), + 'intercept': float(model.intercept_), + 'r2_score': float(model.score(X, y)) + } + """ + + case MiniElixir.eval_python(python_code, %{ + "x_train" => x_train, + "y_train" => y_train + }) do + {:ok, result} -> {:ok, result} + {:error, reason} -> {:error, "Linear regression failed: #{reason}"} + end + end + + @doc """ + Trains a classification model (k-nearest neighbors) and returns predictions. + + ## Parameters + + - `x_train`: List of lists (feature vectors) + - `y_train`: List of class labels + + ## Returns + + - `{:ok, %{predictions: list, accuracy: float}}` + + ## Examples + + iex> x = [[1, 2], [2, 3], [3, 4], [4, 5]] + iex> y = [0, 0, 1, 1] + iex> {:ok, result} = MiniElixir.AI.knn_classify(x, y) + """ + def knn_classify(x_train, y_train) do + python_code = """ + from sklearn.neighbors import KNeighborsClassifier + import numpy as np + + X = np.array(x_train) + y = np.array(y_train) + + model = KNeighborsClassifier(n_neighbors=min(3, len(y_train))) + model.fit(X, y) + + predictions = model.predict(X).tolist() + + { + 'predictions': predictions, + 'accuracy': float(model.score(X, y)), + 'classes': model.classes_.tolist() + } + """ + + case MiniElixir.eval_python(python_code, %{ + "x_train" => x_train, + "y_train" => y_train + }) do + {:ok, result} -> {:ok, result} + {:error, reason} -> {:error, "KNN classification failed: #{reason}"} + end + end + + @doc """ + Performs k-means clustering on data. + + ## Parameters + + - `data`: List of lists (feature vectors) + - `k`: Number of clusters + + ## Returns + + - `{:ok, %{labels: list, centroids: list}}` + """ + def kmeans_cluster(data, k \\ 2) do + python_code = """ + from sklearn.cluster import KMeans + import numpy as np + + X = np.array(data) + n_clusters = min(k, len(data)) + + model = KMeans(n_clusters=n_clusters, random_state=42, n_init=10) + model.fit(X) + + { + 'labels': model.labels_.tolist(), + 'centroids': model.cluster_centers_.tolist(), + 'inertia': float(model.inertia_) + } + """ + + case MiniElixir.eval_python(python_code, %{ + "data" => data, + "k" => k + }) do + {:ok, result} -> {:ok, result} + {:error, reason} -> {:error, "K-means clustering failed: #{reason}"} + end + end + + @doc """ + Runs a simple neural network for regression using MLPRegressor. + + ## Parameters + + - `x_train`: List of lists (feature vectors) + - `y_train`: List of target values + - `hidden_layers`: Tuple of hidden layer sizes, e.g., {10, 5} + + ## Returns + + - `{:ok, %{predictions: list, score: float}}` + """ + def neural_network(x_train, y_train, hidden_layers \\ {10, 5}) do + python_code = """ + from sklearn.neural_network import MLPRegressor + import numpy as np + + X = np.array(x_train) + y = np.array(y_train) + + model = MLPRegressor( + hidden_layer_sizes=hidden_layers, + max_iter=1000, + random_state=42 + ) + model.fit(X, y) + + predictions = model.predict(X).tolist() + + { + 'predictions': predictions, + 'score': float(model.score(X, y)), + 'n_layers': model.n_layers_, + 'loss': float(model.loss_) + } + """ + + case MiniElixir.eval_python(python_code, %{ + "x_train" => x_train, + "y_train" => y_train, + "hidden_layers" => Tuple.to_list(hidden_layers) + }) do + {:ok, result} -> {:ok, result} + {:error, reason} -> {:error, "Neural network failed: #{reason}"} + end + end +end diff --git a/lib/mini_elixir/sandbox/autoscaler.ex b/lib/mini_elixir/sandbox/autoscaler.ex new file mode 100644 index 0000000..1d8b07d --- /dev/null +++ b/lib/mini_elixir/sandbox/autoscaler.ex @@ -0,0 +1,147 @@ +defmodule MiniElixir.Sandbox.Autoscaler do + @moduledoc """ + Automatically scales the sandbox pool based on demand. + + Monitors pool utilization and scales up/down accordingly. + """ + + use GenServer + + require Logger + + @check_interval 5_000 + @scale_up_threshold 0.8 + @scale_down_threshold 0.3 + @cooldown_ms 30_000 + @scale_up_count 5 + @scale_down_count 2 + + defstruct [ + :last_scale_time, + scale_up_threshold: @scale_up_threshold, + scale_down_threshold: @scale_down_threshold, + cooldown_ms: @cooldown_ms, + scale_up_count: @scale_up_count, + scale_down_count: @scale_down_count, + min_warm: 5, + max_sandboxes: 100 + ] + + # Client API + + @doc """ + Starts the autoscaler. + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Returns current scaling metrics. + """ + def metrics do + GenServer.call(__MODULE__, :metrics) + end + + # Server Callbacks + + @impl true + def init(opts) do + state = %__MODULE__{ + scale_up_threshold: Keyword.get(opts, :scale_up_threshold, @scale_up_threshold), + scale_down_threshold: Keyword.get(opts, :scale_down_threshold, @scale_down_threshold), + cooldown_ms: Keyword.get(opts, :cooldown_ms, @cooldown_ms), + scale_up_count: Keyword.get(opts, :scale_up_count, @scale_up_count), + scale_down_count: Keyword.get(opts, :scale_down_count, @scale_down_count), + min_warm: Keyword.get(opts, :min_warm, 5), + max_sandboxes: Keyword.get(opts, :max_sandboxes, 100) + } + + schedule_check() + {:ok, state} + end + + @impl true + def handle_info(:check, state) do + stats = MiniElixir.Sandbox.Pool.stats() + now = System.system_time(:millisecond) + + busy_ratio = calculate_busy_ratio(stats) + total = stats.warm_pool_size + stats.active_count + + new_state = maybe_scale(state, busy_ratio, total, now) + + schedule_check() + {:noreply, new_state} + end + + @impl true + def handle_call(:metrics, _from, state) do + stats = MiniElixir.Sandbox.Pool.stats() + + metrics = %{ + warm_pool_size: stats.warm_pool_size, + active_count: stats.active_count, + total_created: stats.total_created, + busy_ratio: calculate_busy_ratio(stats), + last_scale_time: state.last_scale_time, + language: stats.language + } + + {:reply, metrics, state} + end + + # Private Functions + + defp schedule_check do + Process.send_after(self(), :check, @check_interval) + end + + defp calculate_busy_ratio(stats) do + total = stats.warm_pool_size + stats.active_count + + if total > 0 do + stats.active_count / total + else + 0.0 + end + end + + defp maybe_scale(state, busy_ratio, total, now) do + cond do + # Scale up if busy and under limit + busy_ratio > state.scale_up_threshold and + total < state.max_sandboxes and + can_scale?(state, now) -> + scale_up(state, now) + + # Scale down if idle and above minimum + busy_ratio < state.scale_down_threshold and + total > state.min_warm and + can_scale?(state, now) -> + scale_down(state, now) + + true -> + state + end + end + + defp can_scale?(state, now) do + case state.last_scale_time do + nil -> true + last -> now - last >= state.cooldown_ms + end + end + + defp scale_up(state, now) do + Logger.info("Autoscaler: Scaling up by #{state.scale_up_count} sandboxes") + MiniElixir.Sandbox.Pool.scale_up(state.scale_up_count) + %{state | last_scale_time: now} + end + + defp scale_down(state, now) do + Logger.info("Autoscaler: Scaling down by #{state.scale_down_count} sandboxes") + MiniElixir.Sandbox.Pool.scale_down(state.scale_down_count) + %{state | last_scale_time: now} + end +end diff --git a/lib/mini_elixir/sandbox/extism.ex b/lib/mini_elixir/sandbox/extism.ex new file mode 100644 index 0000000..a9df286 --- /dev/null +++ b/lib/mini_elixir/sandbox/extism.ex @@ -0,0 +1,31 @@ +defmodule MiniElixir.Sandbox.Extism do + @moduledoc false + + def execute(sandbox, code, language) do + url = "http://#{sandbox.ip}:8080/execute" + + body = Jason.encode!(%{ + code: code, + language: Atom.to_string(language), + mode: "extism" + }) + + headers = [{"Content-Type", "application/json"}] + + case HTTPoison.post(url, body, headers, timeout: 30_000, recv_timeout: 30_000) do + {:ok, %{status_code: 200, body: resp}} -> + case Jason.decode(resp) do + {:ok, result} -> {:ok, result} + {:error, _} -> {:error, "Invalid response from Extism sandbox"} + end + {:ok, %{status_code: status}} -> + {:error, "Extism sandbox returned status #{status}"} + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, "Extism sandbox communication failed: #{inspect(reason)}"} + end + end + + def create_plugin(_opts \\ []) do + {:error, "Extism dependency not installed. Add {:extism, \"~> 1.0\"} to deps."} + end +end diff --git a/lib/mini_elixir/sandbox/kuasar.ex b/lib/mini_elixir/sandbox/kuasar.ex new file mode 100644 index 0000000..2dae89e --- /dev/null +++ b/lib/mini_elixir/sandbox/kuasar.ex @@ -0,0 +1,271 @@ +defmodule MiniElixir.Sandbox.Kuasar do + @moduledoc """ + Standalone Kuasar sandbox client for code execution. + + Kuasar is a CNCF multi-sandbox container runtime that supports + MicroVM (Cloud Hypervisor, StratoVirt, QEMU), Wasm (WasmEdge), + App Kernel (Quark), and runC sandboxes. + + This module provides a self-contained client that communicates + with the Kuasar sandboxer via Unix socket or HTTP API. + + Usage: + # Connect to a running Kuasar sandboxer + {:ok, conn} = MiniElixir.Sandbox.Kuasar.connect() + + # Execute code + {:ok, result} = MiniElixir.Sandbox.Kuasar.execute(conn, "print(1+2)") + """ + + use GenServer + + require Logger + + @default_socket "/run/vmm-sandboxer.sock" + @default_api_url "http://localhost:8080" + @default_timeout 30_000 + + defstruct [ + :sandboxer_type, + :api_url, + :socket_path, + :language, + :image, + :timeout, + :sandbox_id + ] + + # --- Client API --- + + @doc """ + Starts the Kuasar sandbox client and connects to a sandboxer. + + ## Options + - `:type` - Sandboxer type: `:vmm` (default), `:wasm`, `:quark`, `:runc` + - `:api_url` - HTTP API URL (default: "http://localhost:8080") + - `:socket_path` - Unix socket path (default: "/run/vmm-sandboxer.sock") + - `:language` - Language for sandbox: `:python` (default), `:elixir`, `:rust` + - `:image` - Container image to use + - `:timeout` - Default timeout in ms (default: 30_000) + """ + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Connects to a Kuasar sandboxer and returns a connection handle. + + This is the non-GenServer variant for one-off usage. + """ + def connect(opts \\ []) do + type = Keyword.get(opts, :type, :vmm) + socket_path = Keyword.get(opts, :socket_path, @default_socket) + api_url = Keyword.get(opts, :api_url, @default_api_url) + + state = %__MODULE__{ + sandboxer_type: type, + socket_path: socket_path, + api_url: api_url, + language: Keyword.get(opts, :language, :python), + image: Keyword.get(opts, :image, default_image(type, Keyword.get(opts, :language, :python))), + timeout: Keyword.get(opts, :timeout, @default_timeout) + } + + case ping_sandboxer(state) do + :ok -> + {:ok, state} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Executes code in the Kuasar sandbox. + + ## Parameters + - `conn` - Connection handle from `connect/1` + - `code` - Code string to execute + - `opts` - Options + - `:timeout` - Override timeout in ms + + ## Returns + - `{:ok, %{"status" => "ok", "result" => ...}}` on success + - `{:error, reason}` on failure + """ + def execute(conn, code, opts \\ []) do + timeout = Keyword.get(opts, :timeout, conn.timeout) + + request = %{ + "code" => code, + "language" => Atom.to_string(conn.language), + "sandboxer" => Atom.to_string(conn.sandboxer_type), + "timeout" => div(timeout, 1000) + } + + case post_json(conn, "/execute", request, timeout) do + {:ok, 200, body} -> + {:ok, body} + + {:ok, status, body} -> + {:error, "Sandbox returned #{status}: #{inspect(body)}"} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Creates a new sandbox within the sandboxer. + + Returns `{:ok, sandbox_id}` or `{:error, reason}`. + """ + def create_sandbox(conn, opts \\ []) do + type = Keyword.get(opts, :type, conn.sandboxer_type) + image = Keyword.get(opts, :image, conn.image) + + request = %{ + "type" => Atom.to_string(type), + "image" => image, + "language" => Atom.to_string(conn.language) + } + + case post_json(conn, "/sandbox/create", request, conn.timeout) do + {:ok, 200, %{"sandbox_id" => id}} -> + new_conn = %{conn | sandbox_id: id} + {:ok, id, new_conn} + + {:ok, status, body} -> + {:error, "Failed to create sandbox: #{status} #{inspect(body)}"} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Destroys a sandbox. + """ + def destroy_sandbox(conn, sandbox_id) do + request = %{"sandbox_id" => sandbox_id} + + case post_json(conn, "/sandbox/destroy", request, conn.timeout) do + {:ok, 200, _} -> :ok + {:ok, status, body} -> {:error, "Failed to destroy: #{status} #{inspect(body)}"} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Health check against the sandboxer. + """ + def health_check(conn) do + case get_json(conn, "/health") do + {:ok, 200, %{"status" => "ok"}} -> :ok + {:ok, status, body} -> {:error, {:unhealthy, status, body}} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Lists all running sandboxes managed by the sandboxer. + """ + def list_sandboxes(conn) do + case get_json(conn, "/sandbox/list") do + {:ok, 200, %{"sandboxes" => list}} -> {:ok, list} + {:ok, status, body} -> {:error, {:list_failed, status, body}} + {:error, reason} -> {:error, reason} + end + end + + @doc """ + Returns the default image for a sandboxer type and language. + """ + def default_image(:vmm, :python), do: "kuasar.io/python-sandbox:latest" + def default_image(:vmm, :elixir), do: "kuasar.io/elixir-sandbox:latest" + def default_image(:wasm, :python), do: "kuasar.io/wasm-python-sandbox:latest" + def default_image(:wasm, :rust), do: "kuasar.io/wasm-rust-sandbox:latest" + def default_image(:quark, :python), do: "kuasar.io/quark-python-sandbox:latest" + def default_image(:runc, :python), do: "kuasar.io/runc-python-sandbox:latest" + def default_image(_, _), do: "kuasar.io/python-sandbox:latest" + + # --- GenServer Callbacks --- + + @impl true + def init(opts) do + case connect(opts) do + {:ok, conn} -> + Logger.info("Kuasar sandbox client connected (type: #{conn.sandboxer_type})") + {:ok, conn} + + {:error, reason} -> + Logger.error("Failed to connect to Kuasar sandboxer: #{inspect(reason)}") + {:stop, reason} + end + end + + @impl true + def handle_call({:execute, code, opts}, _from, state) do + result = execute(state, code, opts) + {:reply, result, state} + end + + @impl true + def handle_call(:health_check, _from, state) do + result = health_check(state) + {:reply, result, state} + end + + @impl true + def handle_call(:stats, _from, state) do + stats = %{ + sandboxer_type: state.sandboxer_type, + language: state.language, + image: state.image, + sandbox_id: state.sandbox_id + } + + {:reply, stats, state} + end + + # --- Private Helpers --- + + defp ping_sandboxer(state) do + case health_check(state) do + :ok -> :ok + {:error, _} -> {:error, "Cannot reach Kuasar sandboxer at #{state.api_url}"} + end + end + + defp post_json(conn, path, body, timeout) do + url = conn.api_url <> path + headers = [{"Content-Type", "application/json"}] + json_body = Jason.encode!(body) + + case HTTPoison.post(url, json_body, headers, timeout: timeout, recv_timeout: timeout) do + {:ok, %{status_code: status, body: resp_body}} -> + case Jason.decode(resp_body) do + {:ok, decoded} -> {:ok, status, decoded} + {:error, _} -> {:ok, status, resp_body} + end + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, "HTTP error: #{inspect(reason)}"} + end + end + + defp get_json(conn, path) do + url = conn.api_url <> path + + case HTTPoison.get(url, timeout: 10_000, recv_timeout: 10_000) do + {:ok, %{status_code: status, body: resp_body}} -> + case Jason.decode(resp_body) do + {:ok, decoded} -> {:ok, status, decoded} + {:error, _} -> {:ok, status, resp_body} + end + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, "HTTP error: #{inspect(reason)}"} + end + end +end diff --git a/lib/mini_elixir/sandbox/kuasar_pool.ex b/lib/mini_elixir/sandbox/kuasar_pool.ex new file mode 100644 index 0000000..952cb21 --- /dev/null +++ b/lib/mini_elixir/sandbox/kuasar_pool.ex @@ -0,0 +1,224 @@ +defmodule MiniElixir.Sandbox.KuasarPool do + @moduledoc """ + Standalone pool for Kuasar sandbox instances. + + Manages a pool of Kuasar sandboxes for isolated code execution. + This module runs independently from the Unikraft Pool module. + + Usage: + # Start the Kuasar pool + {:ok, _} = MiniElixir.Sandbox.KuasarPool.start_link(type: :vmm, language: :python) + + # Execute code + {:ok, result} = MiniElixir.Sandbox.KuasarPool.execute("print(1+2)") + + # Check stats + stats = MiniElixir.Sandbox.KuasarPool.stats() + """ + + use GenServer + + require Logger + + @default_opts [ + min_warm: 3, + max_sandboxes: 50, + sandbox_timeout_ms: 30_000, + language: :python, + type: :vmm, + api_url: "http://localhost:8080" + ] + + defstruct [ + :conn, + :language, + :type, + warm_pool: [], + active: %{}, + min_warm: 3, + max_sandboxes: 50, + sandbox_timeout_ms: 30_000, + total_created: 0 + ] + + # --- Client API --- + + @doc """ + Starts the Kuasar sandbox pool. + """ + def start_link(opts \\ []) do + opts = Keyword.merge(@default_opts, opts) + name = Keyword.get(opts, :name, __MODULE__) + GenServer.start_link(__MODULE__, opts, name: name) + end + + @doc """ + Executes code in an available Kuasar sandbox. + + Returns {:ok, result} or {:error, reason}. + """ + def execute(code, opts \\ []) do + timeout = Keyword.get(opts, :timeout, 30_000) + GenServer.call(__MODULE__, {:execute, code}, timeout + 1000) + end + + @doc """ + Returns pool statistics. + """ + def stats do + GenServer.call(__MODULE__, :stats) + end + + @doc """ + Scales up the pool by adding N sandboxes. + """ + def scale_up(count) do + GenServer.cast(__MODULE__, {:scale_up, count}) + end + + @doc """ + Scales down the pool by removing N idle sandboxes. + """ + def scale_down(count) do + GenServer.cast(__MODULE__, {:scale_down, count}) + end + + # --- GenServer Callbacks --- + + @impl true + def init(opts) do + case MiniElixir.Sandbox.Kuasar.connect(opts) do + {:ok, conn} -> + warm_pool = prewarm_sandboxes(conn, opts[:min_warm]) + + state = %__MODULE__{ + conn: conn, + language: opts[:language], + type: opts[:type], + warm_pool: warm_pool, + min_warm: opts[:min_warm], + max_sandboxes: opts[:max_sandboxes], + sandbox_timeout_ms: opts[:sandbox_timeout_ms], + total_created: length(warm_pool) + } + + Logger.info("Kuasar pool started with #{length(warm_pool)} warm sandboxes") + {:ok, state} + + {:error, reason} -> + Logger.error("Failed to connect Kuasar pool: #{inspect(reason)}") + {:stop, reason} + end + end + + @impl true + def handle_call({:execute, code}, from, state) do + case get_sandbox(state) do + {:ok, sandbox_id, conn, new_state} -> + Task.start(fn -> + result = execute_in_sandbox(conn, sandbox_id, code) + GenServer.reply(from, result) + return_sandbox(sandbox_id) + end) + + {:noreply, new_state} + + {:error, :pool_exhausted} -> + {:reply, {:error, "Too many concurrent Kuasar executions"}, state} + end + end + + @impl true + def handle_call(:stats, _from, state) do + stats = %{ + warm_pool_size: length(state.warm_pool), + active_count: map_size(state.active), + total_created: state.total_created, + language: state.language, + type: state.type + } + + {:reply, stats, state} + end + + @impl true + def handle_cast({:scale_up, count}, state) do + new_ids = + for _ <- 1..count, + map_size(state.active) + length(state.warm_pool) < state.max_sandboxes do + case MiniElixir.Sandbox.Kuasar.create_sandbox(state.conn, type: state.type, language: state.language) do + {:ok, id, _conn} -> id + {:error, _} -> nil + end + end + |> Enum.reject(&is_nil/1) + + new_state = %{state | warm_pool: state.warm_pool ++ new_ids} + Logger.info("Kuasar pool scaled up by #{length(new_ids)} sandboxes") + {:noreply, new_state} + end + + @impl true + def handle_cast({:scale_down, count}, state) do + {to_remove, remaining} = Enum.split(state.warm_pool, count) + + Enum.each(to_remove, fn sandbox_id -> + MiniElixir.Sandbox.Kuasar.destroy_sandbox(state.conn, sandbox_id) + end) + + new_state = %{state | warm_pool: remaining} + Logger.info("Kuasar pool scaled down by #{length(to_remove)} sandboxes") + {:noreply, new_state} + end + + @impl true + def handle_cast({:return_sandbox, sandbox_id}, state) do + case MiniElixir.Sandbox.Kuasar.health_check(state.conn) do + :ok -> + new_state = %{state | warm_pool: state.warm_pool ++ [sandbox_id]} + {:noreply, new_state} + + {:error, _} -> + MiniElixir.Sandbox.Kuasar.destroy_sandbox(state.conn, sandbox_id) + {:noreply, state} + end + end + + # --- Private Helpers --- + + defp prewarm_sandboxes(conn, count) do + for _ <- 1..count do + case MiniElixir.Sandbox.Kuasar.create_sandbox(conn) do + {:ok, id, _conn} -> id + {:error, _} -> nil + end + end + |> Enum.reject(&is_nil/1) + end + + defp get_sandbox(state) do + case state.warm_pool do + [sandbox_id | rest] -> + new_state = %{state | warm_pool: rest} + {:ok, sandbox_id, state.conn, new_state} + + [] -> + if map_size(state.active) + length(state.warm_pool) < state.max_sandboxes do + case MiniElixir.Sandbox.Kuasar.create_sandbox(state.conn) do + {:ok, id, _conn} -> {:ok, id, state.conn, state} + {:error, _} -> {:error, :pool_exhausted} + end + else + {:error, :pool_exhausted} + end + end + end + + defp return_sandbox(sandbox_id) do + GenServer.cast(__MODULE__, {:return_sandbox, sandbox_id}) + end + + defp execute_in_sandbox(conn, _sandbox_id, code) do + MiniElixir.Sandbox.Kuasar.execute(conn, code) + end +end diff --git a/lib/mini_elixir/sandbox/mewz.ex b/lib/mini_elixir/sandbox/mewz.ex new file mode 100644 index 0000000..7e3fae0 --- /dev/null +++ b/lib/mini_elixir/sandbox/mewz.ex @@ -0,0 +1,98 @@ +defmodule MiniElixir.Sandbox.Mewz do + @moduledoc false + + require Logger + + @libvirt_uri "qemu:///system" + + def connect(uri \\ @libvirt_uri) do + case System.cmd("virsh", ["-c", uri, "list", "--all"], stderr_to_stdout: true) do + {_, 0} -> + {:ok, %{uri: uri}} + {output, _} -> + {:error, "Failed to connect to libvirt: #{output}"} + end + rescue + ErlangError -> {:error, "virsh command not found. Install libvirt."} + end + + def create(conn, name, _image, opts \\ []) do + memory = Keyword.get(opts, :memory, 256) + vcpu = Keyword.get(opts, :vcpu, 1) + + cmd = [ + "virt-install", + "--name", name, + "--memory", to_string(memory), + "--vcpus", to_string(vcpu), + "--import", + "--disk", "path=/var/lib/libvirt/images/#{name}.qcow2,size=1", + "--os-variant", "generic", + "--network", "bridge=virbr0", + "--serial", "pty", + "--graphics", "none", + "--noautoconsole" + ] + + case System.cmd("virt-install", cmd, stderr_to_stdout: true) do + {_, 0} -> + {:ok, %{name: name, conn: conn}} + {output, _} -> + {:error, "Failed to create VM: #{output}"} + end + rescue + ErlangError -> {:error, "virt-install command not found. Install virt-manager."} + end + + def destroy(vm) do + System.cmd("virsh", ["-c", vm.conn.uri, "destroy", vm.name], stderr_to_stdout: true) + System.cmd("virsh", ["-c", vm.conn.uri, "undefine", vm.name, "--remove-all-storage"], + stderr_to_stdout: true + ) + :ok + rescue + ErlangError -> :ok + end + + def get_ip(vm) do + case wait_for_ip(vm.name, 30) do + {:ok, ip} -> {:ok, ip} + {:error, reason} -> {:error, reason} + end + end + + def health_check(vm) do + case System.cmd("virsh", ["-c", vm.conn.uri, "domstate", vm.name], stderr_to_stdout: true) do + {"running\n", 0} -> :ok + {state, _} -> {:error, "VM in unexpected state: #{state}"} + end + rescue + ErlangError -> {:error, "virsh command not found"} + end + + defp wait_for_ip(name, timeout) do + deadline = System.system_time(:millisecond) + timeout * 1000 + wait_for_ip_loop(name, deadline) + end + + defp wait_for_ip_loop(name, deadline) do + if System.system_time(:millisecond) > deadline do + {:error, "Timeout waiting for VM IP"} + else + case System.cmd("virsh", ["domifaddr", name], stderr_to_stdout: true) do + {output, 0} -> + case Regex.run(~r/(\d+\.\d+\.\d+\.\d+)/, output) do + [_, ip] -> {:ok, ip} + _ -> + Process.sleep(1000) + wait_for_ip_loop(name, deadline) + end + _ -> + Process.sleep(1000) + wait_for_ip_loop(name, deadline) + end + end + rescue + ErlangError -> {:error, "virsh command not found"} + end +end diff --git a/lib/mini_elixir/sandbox/pool.ex b/lib/mini_elixir/sandbox/pool.ex new file mode 100644 index 0000000..adff97b --- /dev/null +++ b/lib/mini_elixir/sandbox/pool.ex @@ -0,0 +1,265 @@ +defmodule MiniElixir.Sandbox.Pool do + @moduledoc """ + Manages a pool of Unikraft sandbox instances for isolated code execution. + + Features: + - Pre-warms minimum sandboxes for fast response + - Routes code to available sandboxes + - Handles timeouts and crashes + - Auto-scales based on demand + """ + + use GenServer + + require Logger + + @default_opts [ + min_warm: 5, + max_sandboxes: 100, + sandbox_timeout_ms: 30_000, + language: :python, + vm_memory_mb: 256, + vm_vcpu: 1 + ] + + defstruct [ + :conn, + :language, + warm_pool: [], + active: %{}, + min_warm: 5, + max_sandboxes: 100, + sandbox_timeout_ms: 30_000, + total_created: 0 + ] + + # Client API + + @doc """ + Starts the sandbox pool. + """ + def start_link(opts \\ []) do + opts = Keyword.merge(@default_opts, opts) + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Executes code in an available sandbox. + + Returns {:ok, result} or {:error, reason}. + """ + def execute(code, opts \\ []) do + language = Keyword.get(opts, :language, :python) + timeout = Keyword.get(opts, :timeout, 30_000) + GenServer.call(__MODULE__, {:execute, code, language}, timeout + 1000) + end + + @doc """ + Returns pool statistics. + """ + def stats do + GenServer.call(__MODULE__, :stats) + end + + @doc """ + Scales up the pool by adding N sandboxes. + """ + def scale_up(count) do + GenServer.cast(__MODULE__, {:scale_up, count}) + end + + @doc """ + Scales down the pool by removing N idle sandboxes. + """ + def scale_down(count) do + GenServer.cast(__MODULE__, {:scale_down, count}) + end + + # Server Callbacks + + @impl true + def init(opts) do + # Connect to libvirt + case MiniElixir.Sandbox.Unikraft.connect() do + {:ok, conn} -> + # Pre-warm sandboxes + warm_pool = prewarm_sandboxes(conn, opts[:language], opts[:min_warm]) + + state = %__MODULE__{ + conn: conn, + language: opts[:language], + warm_pool: warm_pool, + min_warm: opts[:min_warm], + max_sandboxes: opts[:max_sandboxes], + sandbox_timeout_ms: opts[:sandbox_timeout_ms], + total_created: length(warm_pool) + } + + Logger.info("Sandbox pool started with #{length(warm_pool)} warm sandboxes") + {:ok, state} + + {:error, reason} -> + Logger.error("Failed to connect to libvirt: #{inspect(reason)}") + {:stop, reason} + end + end + + @impl true + def handle_call({:execute, code, language}, from, state) do + case get_sandbox(state, language) do + {:ok, sandbox, new_state} -> + # Execute code asynchronously + Task.start(fn -> + result = execute_in_sandbox(sandbox, code) + GenServer.reply(from, result) + return_sandbox(sandbox) + end) + + {:noreply, new_state} + + {:error, :pool_exhausted} -> + {:reply, {:error, "Too many concurrent executions"}, state} + end + end + + @impl true + def handle_call(:stats, _from, state) do + stats = %{ + warm_pool_size: length(state.warm_pool), + active_count: map_size(state.active), + total_created: state.total_created, + language: state.language + } + + {:reply, stats, state} + end + + @impl true + def handle_cast({:scale_up, count}, state) do + new_sandboxes = + for _ <- 1..count, + map_size(state.active) + length(state.warm_pool) < state.max_sandboxes do + create_sandbox(state.conn, state.language) + end + + new_state = %{state | warm_pool: state.warm_pool ++ new_sandboxes} + Logger.info("Scaled up by #{length(new_sandboxes)} sandboxes") + {:noreply, new_state} + end + + @impl true + def handle_cast({:scale_down, count}, state) do + {to_remove, remaining} = Enum.split(state.warm_pool, count) + + # Destroy removed sandboxes + Enum.each(to_remove, fn sandbox -> + MiniElixir.Sandbox.Unikraft.destroy(sandbox.vm) + end) + + new_state = %{state | warm_pool: remaining} + Logger.info("Scaled down by #{length(to_remove)} sandboxes") + {:noreply, new_state} + end + + @impl true + def handle_cast({:return_sandbox, sandbox}, state) do + # Check if sandbox is still healthy + case MiniElixir.Sandbox.Unikraft.health_check(sandbox.vm) do + :ok -> + new_state = %{state | warm_pool: state.warm_pool ++ [sandbox]} + {:noreply, new_state} + + {:error, _} -> + # Destroy unhealthy sandbox + MiniElixir.Sandbox.Unikraft.destroy(sandbox.vm) + {:noreply, state} + end + end + + # Private Functions + + defp prewarm_sandboxes(conn, language, count) do + for _ <- 1..count do + create_sandbox(conn, language) + end + |> Enum.reject(&is_nil/1) + end + + defp create_sandbox(conn, language) do + image = get_image(language) + name = "sandbox-#{System.unique_integer([:positive])}" + + case MiniElixir.Sandbox.Unikraft.create(conn, name, image) do + {:ok, vm} -> + case MiniElixir.Sandbox.Unikraft.get_ip(vm) do + {:ok, ip} -> + %{vm: vm, ip: ip, language: language, busy: false} + + {:error, _} -> + MiniElixir.Sandbox.Unikraft.destroy(vm) + nil + end + + {:error, _} -> + nil + end + end + + defp get_sandbox(state, _language) do + case state.warm_pool do + [sandbox | rest] -> + new_state = %{state | warm_pool: rest} + {:ok, sandbox, new_state} + + [] -> + # Try to create a new sandbox if under limit + if map_size(state.active) + length(state.warm_pool) < state.max_sandboxes do + case create_sandbox(state.conn, state.language) do + nil -> {:error, :pool_exhausted} + sandbox -> {:ok, sandbox, state} + end + else + {:error, :pool_exhausted} + end + end + end + + defp return_sandbox(sandbox) do + GenServer.cast(__MODULE__, {:return_sandbox, sandbox}) + end + + defp execute_in_sandbox(sandbox, code) do + url = "http://#{sandbox.ip}:8080/execute" + + body = + Jason.encode!(%{ + code: code, + timeout: 30 + }) + + headers = [{"Content-Type", "application/json"}] + + case HTTPoison.post(url, body, headers, timeout: 30_000, recv_timeout: 30_000) do + {:ok, %{status_code: 200, body: resp}} -> + case Jason.decode(resp) do + {:ok, result} -> {:ok, result} + {:error, _} -> {:error, "Invalid response from sandbox"} + end + + {:ok, %{status_code: status}} -> + {:error, "Sandbox returned status #{status}"} + + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, "Sandbox communication failed: #{inspect(reason)}"} + end + end + + defp get_image(:python), do: Application.get_env(:mini_elixir, :python_image, "org/python-sandbox:latest") + defp get_image(:elixir), do: Application.get_env(:mini_elixir, :elixir_image, "org/elixir-sandbox:latest") + defp get_image(_), do: Application.get_env(:mini_elixir, :python_image, "org/python-sandbox:latest") + + # Special images for Mewz sandboxes + defp get_mewz_image(:simplism), do: "org/simplism-sandbox:latest" + defp get_mewz_image(:extism), do: "org/extism-sandbox:latest" + defp get_mewz_image(_), do: "org/simplism-sandbox:latest" +end \ No newline at end of file diff --git a/lib/mini_elixir/sandbox/simplism.ex b/lib/mini_elixir/sandbox/simplism.ex new file mode 100644 index 0000000..0ec0966 --- /dev/null +++ b/lib/mini_elixir/sandbox/simplism.ex @@ -0,0 +1,27 @@ +defmodule MiniElixir.Sandbox.Simplism do + @moduledoc false + + def execute(sandbox, code, language) do + url = "http://#{sandbox.ip}:8080/execute" + + body = Jason.encode!(%{ + code: code, + language: Atom.to_string(language), + mode: "simplism" + }) + + headers = [{"Content-Type", "application/json"}] + + case HTTPoison.post(url, body, headers, timeout: 30_000, recv_timeout: 30_000) do + {:ok, %{status_code: 200, body: resp}} -> + case Jason.decode(resp) do + {:ok, result} -> {:ok, result} + {:error, _} -> {:error, "Invalid response from simplism sandbox"} + end + {:ok, %{status_code: status}} -> + {:error, "Simplism sandbox returned status #{status}"} + {:error, %HTTPoison.Error{reason: reason}} -> + {:error, "Simplism sandbox communication failed: #{inspect(reason)}"} + end + end +end diff --git a/lib/mini_elixir/sandbox/unikraft.ex b/lib/mini_elixir/sandbox/unikraft.ex new file mode 100644 index 0000000..daf1af6 --- /dev/null +++ b/lib/mini_elixir/sandbox/unikraft.ex @@ -0,0 +1,184 @@ +defmodule MiniElixir.Sandbox.Unikraft do + @moduledoc """ + Client for managing Unikraft microVMs via libvirt. + + Provides functions to create, destroy, and manage sandbox VMs. + """ + + require Logger + + @libvirt_uri "qemu:///system" + + defstruct [:conn] + + # Client API + + @doc """ + Connects to libvirt daemon. + """ + def connect(uri \\ @libvirt_uri) do + case System.cmd("virsh", ["-c", uri, "list", "--all"], stderr_to_stdout: true) do + {_, 0} -> + Logger.info("Connected to libvirt at #{uri}") + {:ok, %{uri: uri}} + + {output, _} -> + {:error, "Failed to connect to libvirt: #{output}"} + end + end + + @doc """ + Creates a new Unikraft microVM. + """ + def create(conn, name, _image, opts \\ []) do + memory = Keyword.get(opts, :memory, 256) + vcpu = Keyword.get(opts, :vcpu, 1) + + # Create VM using virt-install or virsh + cmd = [ + "virt-install", + "--name", name, + "--memory", to_string(memory), + "--vcpus", to_string(vcpu), + "--import", + "--disk", "path=/var/lib/libvirt/images/#{name}.qcow2,size=1", + "--os-variant", "generic", + "--network", "bridge=virbr0", + "--serial", "pty", + "--graphics", "none", + "--noautoconsole" + ] + + case System.cmd("virt-install", cmd, stderr_to_stdout: true) do + {_, 0} -> + Logger.info("Created VM: #{name}") + {:ok, %{name: name, conn: conn}} + + {output, _} -> + {:error, "Failed to create VM: #{output}"} + end + end + + @doc """ + Destroys a microVM. + """ + def destroy(vm) do + # Force shutdown + System.cmd("virsh", ["-c", vm.conn.uri, "destroy", vm.name], stderr_to_stdout: true) + + # Undefine + System.cmd("virsh", ["-c", vm.conn.uri, "undefine", vm.name, "--remove-all-storage"], + stderr_to_stdout: true + ) + + Logger.info("Destroyed VM: #{vm.name}") + :ok + end + + @doc """ + Gets the IP address of a VM. + """ + def get_ip(vm) do + # Wait for VM to get IP + case wait_for_ip(vm.name, 30) do + {:ok, ip} -> + {:ok, ip} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Performs a health check on a VM. + """ + def health_check(vm) do + case System.cmd("virsh", ["-c", vm.conn.uri, "domstate", vm.name], stderr_to_stdout: true) do + {"running\n", 0} -> + :ok + + {state, _} -> + {:error, "VM in unexpected state: #{state}"} + end + end + + @doc """ + Lists all running sandbox VMs. + """ + def list_sandboxes(conn) do + case System.cmd("virsh", ["-c", conn.uri, "list", "--name"], stderr_to_stdout: true) do + {output, 0} -> + vms = + output + |> String.split("\n", trim: true) + |> Enum.filter(&String.starts_with?(&1, "sandbox-")) + |> Enum.map(fn name -> + %{name: name, conn: conn} + end) + + {:ok, vms} + + {output, _} -> + {:error, "Failed to list VMs: #{output}"} + end + end + + @doc """ + Gets VM resource usage. + """ + def get_stats(vm) do + case System.cmd("virsh", ["-c", vm.conn.uri, "dommemstats", vm.name], stderr_to_stdout: true) do + {output, 0} -> + stats = + output + |> String.split("\n", trim: true) + |> Enum.reduce(%{}, fn line, acc -> + case String.split(line) do + [key, value] -> + Map.put(acc, key, String.to_integer(value)) + + _ -> + acc + end + end) + + {:ok, stats} + + {output, _} -> + {:error, "Failed to get stats: #{output}"} + end + end + + # Private Functions + + defp wait_for_ip(name, timeout) do + deadline = System.system_time(:millisecond) + timeout * 1000 + wait_for_ip_loop(name, deadline) + end + + defp wait_for_ip_loop(name, deadline) do + if System.system_time(:millisecond) > deadline do + {:error, "Timeout waiting for VM IP"} + else + case System.cmd( + "virsh", + ["domifaddr", name], + stderr_to_stdout: true + ) do + {output, 0} -> + case Regex.run(~r/(\d+\.\d+\.\d+\.\d+)/, output) do + [_, ip] -> + {:ok, ip} + + _ -> + Process.sleep(1000) + wait_for_ip_loop(name, deadline) + end + + _ -> + Process.sleep(1000) + wait_for_ip_loop(name, deadline) + end + end + end +end diff --git a/lib/mini_elixir/sandbox/validator.ex b/lib/mini_elixir/sandbox/validator.ex new file mode 100644 index 0000000..d9fa474 --- /dev/null +++ b/lib/mini_elixir/sandbox/validator.ex @@ -0,0 +1,148 @@ +defmodule MiniElixir.Sandbox.Validator do + @moduledoc """ + Validates code before execution in sandboxes. + + Checks for dangerous patterns, forbidden imports, and resource limits. + """ + + @blocked_python_imports ~w( + os sys subprocess socket http urllib requests aiohttp + shutil pathlib importlib ctypes signal threading multiprocessing + concurrent pickle shelve dbm _thread _io _socket + code codeop + ) + + @blocked_python_builtins ~w( + __import__ exec eval compile open + globals locals getattr setattr delattr + breakpoint exit quit + ) + + @blocked_elixir_modules ~w( + File IO System Code Port + GenServer Agent Task Supervisor + Application Registry + ) + + @max_code_length 10_000 + + @doc """ + Validates code for sandbox execution. + + Returns :ok if code is safe, or {:error, reason} if not. + """ + def validate(code, language \\ :python) when is_binary(code) do + cond do + blank?(code) -> + {:error, "Empty code"} + + too_long?(code) -> + {:error, "Code too long (max #{@max_code_length} characters)"} + + language == :python -> + validate_python(code) + + language == :elixir -> + validate_elixir(code) + + true -> + {:error, "Unsupported language: #{language}"} + end + end + + defp validate_python(code) do + with :ok <- check_blocked_imports(code), + :ok <- check_blocked_builtins(code), + :ok <- check_dangerous_patterns(code) do + :ok + end + end + + defp validate_elixir(code) do + case Code.string_to_quoted(code) do + {:ok, ast} -> + case MiniElixir.Validator.check(ast) do + :ok -> check_elixir_blocked_modules(ast) + {:error, reason} -> {:error, reason} + end + + {:error, error_info} -> + {:error, "Syntax error: #{inspect(error_info)}"} + end + end + + defp check_blocked_imports(code) do + import_pattern = ~r/^\s*(?:import|from)\s+([\w.]+)/m + + Regex.scan(import_pattern, code) + |> Enum.each(fn [_, module] -> + top_level = module |> String.split(".") |> List.first() + + if top_level in @blocked_python_imports do + throw({:error, "Import of '#{module}' is not allowed"}) + end + end) + + :ok + catch + {:error, reason} -> {:error, reason} + end + + defp check_blocked_builtins(code) do + Enum.each(@blocked_python_builtins, fn builtin -> + if Regex.run(~r/#{Regex.escape(builtin)}\s*\(/, code) do + throw({:error, "Use of '#{builtin}' is not allowed"}) + end + end) + + :ok + catch + {:error, reason} -> {:error, reason} + end + + defp check_dangerous_patterns(code) do + dangerous = [ + {"__subclasses__", "Access to __subclasses__ is not allowed"}, + {"__bases__", "Access to __bases__ is not allowed"}, + {"__mro__", "Access to __mro__ is not allowed"}, + {"__globals__", "Access to __globals__ is not allowed"}, + {"__code__", "Access to __code__ is not allowed"}, + {"importlib", "Use of importlib is not allowed"}, + {"ctypes", "Use of ctypes is not allowed"}, + {"pickle", "Use of pickle is not allowed"} + ] + + Enum.each(dangerous, fn {name, message} -> + if String.contains?(code, name) do + throw({:error, message}) + end + end) + + :ok + catch + {:error, reason} -> {:error, reason} + end + + defp check_elixir_blocked_modules(ast) do + Macro.prewalk(ast, fn + {:__aliases__, _, [_module | _] = parts} = node -> + module_name = Module.concat(parts) + + if module_name in @blocked_elixir_modules do + throw({:error, "Module '#{module_name}' is not allowed"}) + end + + node + + node -> + node + end) + + :ok + catch + {:error, reason} -> {:error, reason} + end + + defp blank?(code), do: String.trim(code) == "" + defp too_long?(code), do: String.length(code) > @max_code_length +end diff --git a/mix.exs b/mix.exs index bc8655e..caef434 100644 --- a/mix.exs +++ b/mix.exs @@ -26,7 +26,11 @@ defmodule MiniElixir.MixProject do defp deps do [ {:ex_doc, "~> 0.29", only: :dev, runtime: false}, - {:benchee, "~> 1.3", only: :dev} + {:benchee, "~> 1.3", only: :dev}, + {:pythonx, "~> 0.4.0"}, + {:jason, "~> 1.4"}, + {:httpoison, "~> 2.0"}, + {:telemetry, "~> 1.0"} ] end diff --git a/mix.lock b/mix.lock index ec37b3a..5757883 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,26 @@ %{ "benchee": {:hex, :benchee, "1.4.0", "9f1f96a30ac80bab94faad644b39a9031d5632e517416a8ab0a6b0ac4df124ce", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "299cd10dd8ce51c9ea3ddb74bb150f93d25e968f93e4c1fa31698a8e4fa5d715"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, + "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "pythonx": {:hex, :pythonx, "0.4.10", "7c3377f07b15f30e51a364a92b5a456a9bbbc9a555b855e09acf551b3f36abed", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.2", [hex: :fine, repo: "hexpm", optional: false]}, {:flame, "~> 0.5", [hex: :flame, repo: "hexpm", optional: true]}], "hexpm", "7b7bb0728e4b69c362a8c0c93953ac44b10a51b21a864f2a0801155a3d8989a4"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } diff --git a/test/mini_elixir/sandbox/autoscaler_test.exs b/test/mini_elixir/sandbox/autoscaler_test.exs new file mode 100644 index 0000000..3ed26f3 --- /dev/null +++ b/test/mini_elixir/sandbox/autoscaler_test.exs @@ -0,0 +1,34 @@ +defmodule MiniElixir.Sandbox.AutoscalerTest do + use ExUnit.Case + + alias MiniElixir.Sandbox.Autoscaler + + defp virsh_available? do + try do + System.cmd("virsh", ["list"], stderr_to_stdout: true) |> elem(1) == 0 + rescue + ErlangError -> false + end + end + + @tag :sandbox + test "starts and reports metrics" do + if virsh_available?() do + # Start the pool first + MiniElixir.Sandbox.Pool.start_link(min_warm: 2, language: :python) + + {:ok, _pid} = Autoscaler.start_link( + min_warm: 2, + max_sandboxes: 10, + check_interval: 1000 + ) + + metrics = Autoscaler.metrics() + assert Map.has_key?(metrics, :warm_pool_size) + assert Map.has_key?(metrics, :active_count) + assert Map.has_key?(metrics, :busy_ratio) + else + IO.puts("Skipping autoscaler test: libvirt not available") + end + end +end diff --git a/test/mini_elixir/sandbox/mewz_test.exs b/test/mini_elixir/sandbox/mewz_test.exs new file mode 100644 index 0000000..9d9d435 --- /dev/null +++ b/test/mini_elixir/sandbox/mewz_test.exs @@ -0,0 +1,103 @@ +defmodule MiniElixir.Sandbox.MewzTest do + use ExUnit.Case + + alias MiniElixir.Sandbox.Mewz + + defp command_available?(cmd) do + try do + System.cmd(cmd, ["--version"], stderr_to_stdout: true) + true + rescue + ErlangError -> false + end + end + + defp virsh_available?, do: command_available?("virsh") + + describe "connect/1" do + test "returns error tuple when virsh is not available" do + unless virsh_available?() do + result = Mewz.connect() + assert {:error, _reason} = result + end + end + + test "returns {:ok, map} with uri when virsh is available" do + if virsh_available?() do + assert {:ok, %{uri: _}} = Mewz.connect() + end + end + + test "accepts custom libvirt URI" do + unless virsh_available?() do + result = Mewz.connect("qemu:///custom") + assert {:error, _} = result + end + end + end + + describe "create/4" do + test "returns error when virt-install is not available" do + unless command_available?("virt-install") do + conn = %{uri: "qemu:///system"} + result = Mewz.create(conn, "test-vm", "test-image") + assert {:error, _} = result + end + end + + test "returns {:ok, map} with name and conn when virt-install is available" do + if command_available?("virt-install") do + conn = %{uri: "qemu:///system"} + assert {:ok, %{name: "test-vm", conn: ^conn}} = Mewz.create(conn, "test-vm", "image") + end + end + + test "accepts memory and vcpu options" do + if command_available?("virt-install") do + conn = %{uri: "qemu:///system"} + result = Mewz.create(conn, "test-vm", "image", memory: 512, vcpu: 2) + assert {:ok, %{name: "test-vm"}} = result + end + end + end + + describe "destroy/1" do + test "returns :ok even when virsh is not available" do + unless virsh_available?() do + vm = %{name: "test-vm", conn: %{uri: "qemu:///system"}} + result = Mewz.destroy(vm) + assert :ok = result + end + end + end + + describe "get_ip/1" do + test "returns error when virsh is not available" do + unless virsh_available?() do + vm = %{name: "test-vm", conn: %{uri: "qemu:///system"}} + result = Mewz.get_ip(vm) + assert {:error, _} = result + end + end + end + + describe "health_check/1" do + test "returns error when virsh is not available" do + unless virsh_available?() do + vm = %{name: "test-vm", conn: %{uri: "qemu:///system"}} + result = Mewz.health_check(vm) + assert {:error, _} = result + end + end + + test "returns :ok when VM is running" do + if virsh_available?() do + conn = %{uri: "qemu:///system"} + {:ok, vm} = Mewz.create(conn, "test-health-vm", "image") + result = Mewz.health_check(vm) + assert :ok = result + Mewz.destroy(vm) + end + end + end +end diff --git a/test/mini_elixir/sandbox/pool_test.exs b/test/mini_elixir/sandbox/pool_test.exs new file mode 100644 index 0000000..2b302f0 --- /dev/null +++ b/test/mini_elixir/sandbox/pool_test.exs @@ -0,0 +1,45 @@ +defmodule MiniElixir.Sandbox.PoolTest do + use ExUnit.Case + + alias MiniElixir.Sandbox.Pool + + defp virsh_available? do + try do + System.cmd("virsh", ["list"], stderr_to_stdout: true) |> elem(1) == 0 + rescue + ErlangError -> false + end + end + + @tag :sandbox + test "starts with minimum warm sandboxes" do + if virsh_available?() do + {:ok, _pid} = Pool.start_link(min_warm: 2, language: :python) + stats = Pool.stats() + assert stats.warm_pool_size >= 2 + else + IO.puts("Skipping sandbox test: libvirt not available") + end + end + + @tag :sandbox + test "executes Python code in sandbox" do + if virsh_available?() do + {:ok, _pid} = Pool.start_link(min_warm: 1, language: :python) + assert {:ok, result} = Pool.execute("1 + 2", language: :python) + assert result["result"] == 3 + else + IO.puts("Skipping sandbox test: libvirt not available") + end + end + + @tag :sandbox + test "rejects code with forbidden imports" do + if virsh_available?() do + {:ok, _pid} = Pool.start_link(min_warm: 1, language: :python) + assert {:error, _} = Pool.execute("import os", language: :python) + else + IO.puts("Skipping sandbox test: libvirt not available") + end + end +end diff --git a/test/mini_elixir/sandbox/validator_test.exs b/test/mini_elixir/sandbox/validator_test.exs new file mode 100644 index 0000000..69c55c4 --- /dev/null +++ b/test/mini_elixir/sandbox/validator_test.exs @@ -0,0 +1,86 @@ +defmodule MiniElixir.Sandbox.ValidatorTest do + use ExUnit.Case + + alias MiniElixir.Sandbox.Validator + + describe "validate/2 for Python" do + test "allows safe Python code" do + assert :ok = Validator.validate("1 + 2", :python) + assert :ok = Validator.validate("x = [1, 2, 3]", :python) + assert :ok = Validator.validate("result = sum([1, 2, 3])", :python) + end + + test "blocks empty code" do + assert {:error, "Empty code"} = Validator.validate("", :python) + assert {:error, "Empty code"} = Validator.validate(" ", :python) + end + + test "blocks code that is too long" do + long_code = String.duplicate("x = 1\n", 2000) + assert {:error, "Code too long (max 10000 characters)"} = Validator.validate(long_code, :python) + end + + test "blocks os import" do + assert {:error, _} = Validator.validate("import os", :python) + assert {:error, _} = Validator.validate("from os import path", :python) + end + + test "blocks sys import" do + assert {:error, _} = Validator.validate("import sys", :python) + assert {:error, _} = Validator.validate("from sys import argv", :python) + end + + test "blocks subprocess import" do + assert {:error, _} = Validator.validate("import subprocess", :python) + end + + test "blocks socket import" do + assert {:error, _} = Validator.validate("import socket", :python) + end + + test "blocks open builtin" do + assert {:error, _} = Validator.validate("open('file.txt')", :python) + end + + test "blocks exec builtin" do + assert {:error, _} = Validator.validate("exec('code')", :python) + end + + test "blocks eval builtin" do + assert {:error, _} = Validator.validate("eval('1+1')", :python) + end + + test "blocks compile builtin" do + assert {:error, _} = Validator.validate("compile('code', 'file', 'exec')", :python) + end + + test "allows math module" do + assert :ok = Validator.validate("import math\nresult = math.sqrt(4)", :python) + end + + test "allows json module" do + assert :ok = Validator.validate("import json\nresult = json.dumps({'a': 1})", :python) + end + + test "allows datetime module" do + assert :ok = Validator.validate("from datetime import datetime\nresult = datetime.now()", :python) + end + + test "blocks dangerous attribute access" do + assert {:error, _} = Validator.validate("x.__subclasses__()", :python) + assert {:error, _} = Validator.validate("x.__bases__", :python) + assert {:error, _} = Validator.validate("x.__globals__", :python) + end + end + + describe "validate/2 for Elixir" do + test "allows safe Elixir code" do + assert :ok = Validator.validate("1 + 2", :elixir) + assert :ok = Validator.validate("Enum.map([1,2,3], &(&1 * 2))", :elixir) + end + + test "blocks invalid syntax" do + assert {:error, _} = Validator.validate("1 + ", :elixir) + end + end +end diff --git a/test/mini_elixir_test.exs b/test/mini_elixir_test.exs index 88eff6a..627920d 100644 --- a/test/mini_elixir_test.exs +++ b/test/mini_elixir_test.exs @@ -245,4 +245,103 @@ defmodule MiniElixirTest do assert message =~ "Immediate code execution in modules is not allowed" end end + + describe "eval_parallel/2" do + test "evaluates multiple code strings in parallel" do + executions = [ + {~S""" + defmodule Adder do + def add(x, y), do: x + y + end + """, Adder, :add, [1, 2]}, + {~S""" + defmodule Multiplier do + def mul(x, y), do: x * y + end + """, Multiplier, :mul, [3, 4]}, + {~S""" + defmodule Greeter do + def greet(name), do: "Hello, #{name}!" + end + """, Greeter, :greet, ["World"]} + ] + + assert {:ok, results} = MiniElixir.eval_parallel(executions) + assert length(results) == 3 + assert {:ok, 3} = Enum.at(results, 0) + assert {:ok, 12} = Enum.at(results, 1) + assert {:ok, "Hello, World!"} = Enum.at(results, 2) + end + + test "handles mixed success and error results" do + executions = [ + {~S""" + defmodule Adder do + def add(x, y), do: x + y + end + """, Adder, :add, [1, 2]}, + {~S""" + defmodule BadModule do + def bad do + File.read!("/etc/passwd") + end + end + """, BadModule, :bad, []} + ] + + assert {:ok, results} = MiniElixir.eval_parallel(executions) + assert {:ok, 3} = Enum.at(results, 0) + assert {:error, _} = Enum.at(results, 1) + end + + test "handles empty list" do + assert {:ok, []} = MiniElixir.eval_parallel([]) + end + + test "respects timeout option" do + executions = [ + {~S""" + defmodule Slow do + def compute, do: :timer.sleep(100) + end + """, Slow, :compute, []} + ] + + assert {:ok, _} = MiniElixir.eval_parallel(executions, timeout: 5_000) + end + end + + describe "eval_parallel_sandboxed/2" do + test "evaluates code with sandboxed execution" do + executions = [ + {~S""" + defmodule Adder do + def add(x, y), do: x + y + end + """, Adder, :add, [5, 3]}, + {~S""" + defmodule Multiplier do + def mul(x, y), do: x * y + end + """, Multiplier, :mul, [2, 7]} + ] + + assert {:ok, results} = MiniElixir.eval_parallel_sandboxed(executions) + assert length(results) == 2 + assert {:ok, 8} = Enum.at(results, 0) + assert {:ok, 14} = Enum.at(results, 1) + end + + test "cleans up modules after sandboxed execution" do + code = ~S""" + defmodule TempModule do + def compute, do: 42 + end + """ + + {:ok, _} = MiniElixir.eval_parallel_sandboxed([{code, TempModule, :compute, []}]) + + refute Code.ensure_loaded?(TempModule) + end + end end From 433fe688b7e9913e52d02eab2cdbd5dedc52c674 Mon Sep 17 00:00:00 2001 From: Niranjan Anandkumar Date: Thu, 23 Jul 2026 18:31:47 +0530 Subject: [PATCH 2/2] autoscaler unikraft fix app --- .gitignore | 3 + config/config.exs | 14 +- lib/mini_elixir.ex | 21 +-- lib/mini_elixir/sandbox/autoscaler.ex | 4 +- lib/mini_elixir/sandbox/extism.ex | 16 +- lib/mini_elixir/sandbox/kuasar.ex | 3 +- lib/mini_elixir/sandbox/kuasar_pool.ex | 5 +- lib/mini_elixir/sandbox/mewz.ex | 34 ++-- lib/mini_elixir/sandbox/pool.ex | 61 ++++--- lib/mini_elixir/sandbox/simplism.ex | 16 +- lib/mini_elixir/sandbox/unikraft.ex | 25 ++- lib/mini_elixir/validator.ex | 1 - test/mini_elixir/sandbox/autoscaler_test.exs | 12 +- test/mini_elixir/sandbox/pool_test.exs | 179 +++++++++++++++++++ test/mini_elixir/sandbox/validator_test.exs | 10 +- test/mini_elixir_test.exs | 74 ++++---- 16 files changed, 349 insertions(+), 129 deletions(-) diff --git a/.gitignore b/.gitignore index 6012ed3..b409a88 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ mini_elixir-*.tar # Temporary files, for example, from tests. /tmp/ + +# CodeDB analyzer snapshot (code intelligence database) +/codedb.snapshot diff --git a/config/config.exs b/config/config.exs index 96953a8..e9685ad 100644 --- a/config/config.exs +++ b/config/config.exs @@ -22,19 +22,7 @@ config :mini_elixir, :sandbox, # VM resources vm_memory_mb: 256, - vm_vcpu: 1, - - # Validation - allowed_python_modules: ~w(math json datetime random re collections itertools functools), - allowed_elixir_modules: ~w(Enum Map String Kernel), - max_code_length: 10_000, - - # Autoscaling - scale_up_threshold: 0.8, - scale_down_threshold: 0.3, - scale_cooldown_ms: 30_000, - scale_up_count: 5, - scale_down_count: 2 + vm_vcpu: 1 # Unikraft/libvirt Configuration config :mini_elixir, :unikraft, diff --git a/lib/mini_elixir.ex b/lib/mini_elixir.ex index 044e636..091ea76 100644 --- a/lib/mini_elixir.ex +++ b/lib/mini_elixir.ex @@ -250,10 +250,10 @@ defmodule MiniElixir do end @doc """ - Executes code in an isolated Unikraft sandbox. + Executes code in an isolated sandbox. - This function validates the code and then executes it in a hardware-isolated - microVM sandbox. Each execution gets its own isolated environment. + Validates the code and executes it in a hardware-isolated microVM sandbox. + Uses the pool of pre-warmed sandboxes for fast response. ## Parameters @@ -261,6 +261,7 @@ defmodule MiniElixir do - `language`: Language of the code (:python or :elixir, default: :python) - `opts`: Options list - `:timeout` - Execution timeout in milliseconds (default: 30_000) + - `:mode` - Sandbox execution mode (:unikraft, :simplism, or :extism, default: :unikraft) ## Returns @@ -334,11 +335,7 @@ defmodule MiniElixir do try do results = Task.await_many(tasks, timeout) - - case Enum.any?(results, fn {:error, _} -> true; _ -> false end) do - true -> {:ok, results} - false -> {:ok, results} - end + {:ok, results} catch :exit, reason -> {:error, "Parallel execution timed out: #{inspect(reason)}"} @@ -346,10 +343,9 @@ defmodule MiniElixir do end @doc """ - Evaluates Elixir code strings in parallel using sandboxed Python workers. + Evaluates Elixir code strings in parallel with non-persistent (ephemeral) modules. - This variant uses the Unikraft/Kuasar sandbox pool for isolated execution, - providing stronger security isolation for each code evaluation. + Each module is purged after execution, providing isolation between evaluations. ## Parameters @@ -369,7 +365,8 @@ defmodule MiniElixir do ] {:ok, results} = MiniElixir.eval_parallel_sandboxed(executions) """ - def eval_parallel_sandboxed(executions, opts \\ []) when is_list(executions) and is_list(opts) do + def eval_parallel_sandboxed(executions, opts \\ []) + when is_list(executions) and is_list(opts) do timeout = Keyword.get(opts, :timeout, 30_000) tasks = diff --git a/lib/mini_elixir/sandbox/autoscaler.ex b/lib/mini_elixir/sandbox/autoscaler.ex index 1d8b07d..f7423f2 100644 --- a/lib/mini_elixir/sandbox/autoscaler.ex +++ b/lib/mini_elixir/sandbox/autoscaler.ex @@ -112,13 +112,13 @@ defmodule MiniElixir.Sandbox.Autoscaler do # Scale up if busy and under limit busy_ratio > state.scale_up_threshold and total < state.max_sandboxes and - can_scale?(state, now) -> + can_scale?(state, now) -> scale_up(state, now) # Scale down if idle and above minimum busy_ratio < state.scale_down_threshold and total > state.min_warm and - can_scale?(state, now) -> + can_scale?(state, now) -> scale_down(state, now) true -> diff --git a/lib/mini_elixir/sandbox/extism.ex b/lib/mini_elixir/sandbox/extism.ex index a9df286..ec0d349 100644 --- a/lib/mini_elixir/sandbox/extism.ex +++ b/lib/mini_elixir/sandbox/extism.ex @@ -2,13 +2,15 @@ defmodule MiniElixir.Sandbox.Extism do @moduledoc false def execute(sandbox, code, language) do - url = "http://#{sandbox.ip}:8080/execute" + port = sandbox[:port] || 8080 + url = "http://#{sandbox.ip}:#{port}/execute" - body = Jason.encode!(%{ - code: code, - language: Atom.to_string(language), - mode: "extism" - }) + body = + Jason.encode!(%{ + code: code, + language: Atom.to_string(language), + mode: "extism" + }) headers = [{"Content-Type", "application/json"}] @@ -18,8 +20,10 @@ defmodule MiniElixir.Sandbox.Extism do {:ok, result} -> {:ok, result} {:error, _} -> {:error, "Invalid response from Extism sandbox"} end + {:ok, %{status_code: status}} -> {:error, "Extism sandbox returned status #{status}"} + {:error, %HTTPoison.Error{reason: reason}} -> {:error, "Extism sandbox communication failed: #{inspect(reason)}"} end diff --git a/lib/mini_elixir/sandbox/kuasar.ex b/lib/mini_elixir/sandbox/kuasar.ex index 2dae89e..25ddfcd 100644 --- a/lib/mini_elixir/sandbox/kuasar.ex +++ b/lib/mini_elixir/sandbox/kuasar.ex @@ -67,7 +67,8 @@ defmodule MiniElixir.Sandbox.Kuasar do socket_path: socket_path, api_url: api_url, language: Keyword.get(opts, :language, :python), - image: Keyword.get(opts, :image, default_image(type, Keyword.get(opts, :language, :python))), + image: + Keyword.get(opts, :image, default_image(type, Keyword.get(opts, :language, :python))), timeout: Keyword.get(opts, :timeout, @default_timeout) } diff --git a/lib/mini_elixir/sandbox/kuasar_pool.ex b/lib/mini_elixir/sandbox/kuasar_pool.ex index 952cb21..1780895 100644 --- a/lib/mini_elixir/sandbox/kuasar_pool.ex +++ b/lib/mini_elixir/sandbox/kuasar_pool.ex @@ -146,7 +146,10 @@ defmodule MiniElixir.Sandbox.KuasarPool do new_ids = for _ <- 1..count, map_size(state.active) + length(state.warm_pool) < state.max_sandboxes do - case MiniElixir.Sandbox.Kuasar.create_sandbox(state.conn, type: state.type, language: state.language) do + case MiniElixir.Sandbox.Kuasar.create_sandbox(state.conn, + type: state.type, + language: state.language + ) do {:ok, id, _conn} -> id {:error, _} -> nil end diff --git a/lib/mini_elixir/sandbox/mewz.ex b/lib/mini_elixir/sandbox/mewz.ex index 7e3fae0..df488a4 100644 --- a/lib/mini_elixir/sandbox/mewz.ex +++ b/lib/mini_elixir/sandbox/mewz.ex @@ -9,6 +9,7 @@ defmodule MiniElixir.Sandbox.Mewz do case System.cmd("virsh", ["-c", uri, "list", "--all"], stderr_to_stdout: true) do {_, 0} -> {:ok, %{uri: uri}} + {output, _} -> {:error, "Failed to connect to libvirt: #{output}"} end @@ -21,22 +22,30 @@ defmodule MiniElixir.Sandbox.Mewz do vcpu = Keyword.get(opts, :vcpu, 1) cmd = [ - "virt-install", - "--name", name, - "--memory", to_string(memory), - "--vcpus", to_string(vcpu), + "--name", + name, + "--memory", + to_string(memory), + "--vcpus", + to_string(vcpu), "--import", - "--disk", "path=/var/lib/libvirt/images/#{name}.qcow2,size=1", - "--os-variant", "generic", - "--network", "bridge=virbr0", - "--serial", "pty", - "--graphics", "none", + "--disk", + "path=/var/lib/libvirt/images/#{name}.qcow2,size=1", + "--os-variant", + "generic", + "--network", + "bridge=virbr0", + "--serial", + "pty", + "--graphics", + "none", "--noautoconsole" ] case System.cmd("virt-install", cmd, stderr_to_stdout: true) do {_, 0} -> {:ok, %{name: name, conn: conn}} + {output, _} -> {:error, "Failed to create VM: #{output}"} end @@ -46,9 +55,11 @@ defmodule MiniElixir.Sandbox.Mewz do def destroy(vm) do System.cmd("virsh", ["-c", vm.conn.uri, "destroy", vm.name], stderr_to_stdout: true) + System.cmd("virsh", ["-c", vm.conn.uri, "undefine", vm.name, "--remove-all-storage"], stderr_to_stdout: true ) + :ok rescue ErlangError -> :ok @@ -82,11 +93,14 @@ defmodule MiniElixir.Sandbox.Mewz do case System.cmd("virsh", ["domifaddr", name], stderr_to_stdout: true) do {output, 0} -> case Regex.run(~r/(\d+\.\d+\.\d+\.\d+)/, output) do - [_, ip] -> {:ok, ip} + [_, ip] -> + {:ok, ip} + _ -> Process.sleep(1000) wait_for_ip_loop(name, deadline) end + _ -> Process.sleep(1000) wait_for_ip_loop(name, deadline) diff --git a/lib/mini_elixir/sandbox/pool.ex b/lib/mini_elixir/sandbox/pool.ex index adff97b..35cafa2 100644 --- a/lib/mini_elixir/sandbox/pool.ex +++ b/lib/mini_elixir/sandbox/pool.ex @@ -18,6 +18,7 @@ defmodule MiniElixir.Sandbox.Pool do max_sandboxes: 100, sandbox_timeout_ms: 30_000, language: :python, + mode: :unikraft, vm_memory_mb: 256, vm_vcpu: 1 ] @@ -25,6 +26,7 @@ defmodule MiniElixir.Sandbox.Pool do defstruct [ :conn, :language, + :mode, warm_pool: [], active: %{}, min_warm: 5, @@ -50,8 +52,9 @@ defmodule MiniElixir.Sandbox.Pool do """ def execute(code, opts \\ []) do language = Keyword.get(opts, :language, :python) + mode = Keyword.get(opts, :mode, :unikraft) timeout = Keyword.get(opts, :timeout, 30_000) - GenServer.call(__MODULE__, {:execute, code, language}, timeout + 1000) + GenServer.call(__MODULE__, {:execute, code, language, mode}, timeout + 1000) end @doc """ @@ -82,12 +85,13 @@ defmodule MiniElixir.Sandbox.Pool do # Connect to libvirt case MiniElixir.Sandbox.Unikraft.connect() do {:ok, conn} -> - # Pre-warm sandboxes - warm_pool = prewarm_sandboxes(conn, opts[:language], opts[:min_warm]) + mode = opts[:mode] + warm_pool = prewarm_sandboxes(conn, opts[:language], mode, opts[:min_warm]) state = %__MODULE__{ conn: conn, language: opts[:language], + mode: mode, warm_pool: warm_pool, min_warm: opts[:min_warm], max_sandboxes: opts[:max_sandboxes], @@ -105,12 +109,11 @@ defmodule MiniElixir.Sandbox.Pool do end @impl true - def handle_call({:execute, code, language}, from, state) do + def handle_call({:execute, code, language, _mode}, from, state) do case get_sandbox(state, language) do {:ok, sandbox, new_state} -> - # Execute code asynchronously Task.start(fn -> - result = execute_in_sandbox(sandbox, code) + result = execute_in_sandbox(sandbox, code, language) GenServer.reply(from, result) return_sandbox(sandbox) end) @@ -128,7 +131,8 @@ defmodule MiniElixir.Sandbox.Pool do warm_pool_size: length(state.warm_pool), active_count: map_size(state.active), total_created: state.total_created, - language: state.language + language: state.language, + mode: state.mode } {:reply, stats, state} @@ -139,7 +143,7 @@ defmodule MiniElixir.Sandbox.Pool do new_sandboxes = for _ <- 1..count, map_size(state.active) + length(state.warm_pool) < state.max_sandboxes do - create_sandbox(state.conn, state.language) + create_sandbox(state.conn, state.language, state.mode) end new_state = %{state | warm_pool: state.warm_pool ++ new_sandboxes} @@ -178,14 +182,14 @@ defmodule MiniElixir.Sandbox.Pool do # Private Functions - defp prewarm_sandboxes(conn, language, count) do + defp prewarm_sandboxes(conn, language, mode, count) do for _ <- 1..count do - create_sandbox(conn, language) + create_sandbox(conn, language, mode) end |> Enum.reject(&is_nil/1) end - defp create_sandbox(conn, language) do + defp create_sandbox(conn, language, mode) do image = get_image(language) name = "sandbox-#{System.unique_integer([:positive])}" @@ -193,7 +197,7 @@ defmodule MiniElixir.Sandbox.Pool do {:ok, vm} -> case MiniElixir.Sandbox.Unikraft.get_ip(vm) do {:ok, ip} -> - %{vm: vm, ip: ip, language: language, busy: false} + %{vm: vm, ip: ip, language: language, mode: mode, port: 8080, busy: false} {:error, _} -> MiniElixir.Sandbox.Unikraft.destroy(vm) @@ -212,9 +216,8 @@ defmodule MiniElixir.Sandbox.Pool do {:ok, sandbox, new_state} [] -> - # Try to create a new sandbox if under limit if map_size(state.active) + length(state.warm_pool) < state.max_sandboxes do - case create_sandbox(state.conn, state.language) do + case create_sandbox(state.conn, state.language, state.mode) do nil -> {:error, :pool_exhausted} sandbox -> {:ok, sandbox, state} end @@ -228,8 +231,18 @@ defmodule MiniElixir.Sandbox.Pool do GenServer.cast(__MODULE__, {:return_sandbox, sandbox}) end - defp execute_in_sandbox(sandbox, code) do - url = "http://#{sandbox.ip}:8080/execute" + defp execute_in_sandbox(sandbox, code, language) do + case sandbox.mode do + :simplism -> MiniElixir.Sandbox.Simplism.execute(sandbox, code, language) + :extism -> MiniElixir.Sandbox.Extism.execute(sandbox, code, language) + _ -> execute_direct(sandbox, code) + end + end + + @doc false + def execute_direct(sandbox, code) do + port = sandbox[:port] || 8080 + url = "http://#{sandbox.ip}:#{port}/execute" body = Jason.encode!(%{ @@ -254,12 +267,12 @@ defmodule MiniElixir.Sandbox.Pool do end end - defp get_image(:python), do: Application.get_env(:mini_elixir, :python_image, "org/python-sandbox:latest") - defp get_image(:elixir), do: Application.get_env(:mini_elixir, :elixir_image, "org/elixir-sandbox:latest") - defp get_image(_), do: Application.get_env(:mini_elixir, :python_image, "org/python-sandbox:latest") + defp get_image(:python), + do: Application.get_env(:mini_elixir, :python_image, "org/python-sandbox:latest") + + defp get_image(:elixir), + do: Application.get_env(:mini_elixir, :elixir_image, "org/elixir-sandbox:latest") - # Special images for Mewz sandboxes - defp get_mewz_image(:simplism), do: "org/simplism-sandbox:latest" - defp get_mewz_image(:extism), do: "org/extism-sandbox:latest" - defp get_mewz_image(_), do: "org/simplism-sandbox:latest" -end \ No newline at end of file + defp get_image(_), + do: Application.get_env(:mini_elixir, :python_image, "org/python-sandbox:latest") +end diff --git a/lib/mini_elixir/sandbox/simplism.ex b/lib/mini_elixir/sandbox/simplism.ex index 0ec0966..84ca992 100644 --- a/lib/mini_elixir/sandbox/simplism.ex +++ b/lib/mini_elixir/sandbox/simplism.ex @@ -2,13 +2,15 @@ defmodule MiniElixir.Sandbox.Simplism do @moduledoc false def execute(sandbox, code, language) do - url = "http://#{sandbox.ip}:8080/execute" + port = sandbox[:port] || 8080 + url = "http://#{sandbox.ip}:#{port}/execute" - body = Jason.encode!(%{ - code: code, - language: Atom.to_string(language), - mode: "simplism" - }) + body = + Jason.encode!(%{ + code: code, + language: Atom.to_string(language), + mode: "simplism" + }) headers = [{"Content-Type", "application/json"}] @@ -18,8 +20,10 @@ defmodule MiniElixir.Sandbox.Simplism do {:ok, result} -> {:ok, result} {:error, _} -> {:error, "Invalid response from simplism sandbox"} end + {:ok, %{status_code: status}} -> {:error, "Simplism sandbox returned status #{status}"} + {:error, %HTTPoison.Error{reason: reason}} -> {:error, "Simplism sandbox communication failed: #{inspect(reason)}"} end diff --git a/lib/mini_elixir/sandbox/unikraft.ex b/lib/mini_elixir/sandbox/unikraft.ex index daf1af6..11d8de5 100644 --- a/lib/mini_elixir/sandbox/unikraft.ex +++ b/lib/mini_elixir/sandbox/unikraft.ex @@ -36,16 +36,23 @@ defmodule MiniElixir.Sandbox.Unikraft do # Create VM using virt-install or virsh cmd = [ - "virt-install", - "--name", name, - "--memory", to_string(memory), - "--vcpus", to_string(vcpu), + "--name", + name, + "--memory", + to_string(memory), + "--vcpus", + to_string(vcpu), "--import", - "--disk", "path=/var/lib/libvirt/images/#{name}.qcow2,size=1", - "--os-variant", "generic", - "--network", "bridge=virbr0", - "--serial", "pty", - "--graphics", "none", + "--disk", + "path=/var/lib/libvirt/images/#{name}.qcow2,size=1", + "--os-variant", + "generic", + "--network", + "bridge=virbr0", + "--serial", + "pty", + "--graphics", + "none", "--noautoconsole" ] diff --git a/lib/mini_elixir/validator.ex b/lib/mini_elixir/validator.ex index c99ef9d..7a28b6b 100644 --- a/lib/mini_elixir/validator.ex +++ b/lib/mini_elixir/validator.ex @@ -2,7 +2,6 @@ defmodule MiniElixir.Validator do @moduledoc false alias MiniElixir.Validator.PatternChecker - # @allowed_funname [:transform, :route, :filter] @args [:action, :record, :changes, :metadata] @error_bad_toplevel "Expecting only `def transform`, `def route` or `def filter` at the top level" @error_bad_args "The parameter list `#{Enum.join(@args, ", ")}` is required" diff --git a/test/mini_elixir/sandbox/autoscaler_test.exs b/test/mini_elixir/sandbox/autoscaler_test.exs index 3ed26f3..bc1ec0b 100644 --- a/test/mini_elixir/sandbox/autoscaler_test.exs +++ b/test/mini_elixir/sandbox/autoscaler_test.exs @@ -14,14 +14,14 @@ defmodule MiniElixir.Sandbox.AutoscalerTest do @tag :sandbox test "starts and reports metrics" do if virsh_available?() do - # Start the pool first MiniElixir.Sandbox.Pool.start_link(min_warm: 2, language: :python) - {:ok, _pid} = Autoscaler.start_link( - min_warm: 2, - max_sandboxes: 10, - check_interval: 1000 - ) + {:ok, _pid} = + Autoscaler.start_link( + min_warm: 2, + max_sandboxes: 10, + check_interval: 1000 + ) metrics = Autoscaler.metrics() assert Map.has_key?(metrics, :warm_pool_size) diff --git a/test/mini_elixir/sandbox/pool_test.exs b/test/mini_elixir/sandbox/pool_test.exs index 2b302f0..3b3f926 100644 --- a/test/mini_elixir/sandbox/pool_test.exs +++ b/test/mini_elixir/sandbox/pool_test.exs @@ -42,4 +42,183 @@ defmodule MiniElixir.Sandbox.PoolTest do IO.puts("Skipping sandbox test: libvirt not available") end end + + @tag :sandbox + test "stats include mode" do + if virsh_available?() do + {:ok, _pid} = Pool.start_link(min_warm: 1, language: :python, mode: :simplism) + stats = Pool.stats() + assert stats.mode == :simplism + else + IO.puts("Skipping sandbox test: libvirt not available") + end + end + + @tag :sandbox + test "executes with mode option" do + if virsh_available?() do + {:ok, _pid} = Pool.start_link(min_warm: 1, language: :python, mode: :unikraft) + assert {:ok, result} = Pool.execute("1 + 2", language: :python, mode: :unikraft) + assert result["result"] == 3 + else + IO.puts("Skipping sandbox test: libvirt not available") + end + end +end + +defmodule MiniElixir.Sandbox.SandboxModeTest do + use ExUnit.Case + + @python_fib """ + def fibonacci(n): + if n <= 1: + return n + return fibonacci(n-1) + fibonacci(n-2) + print(fibonacci(10)) + """ + + @elixir_expr "Enum.map([1,2,3], &(&1 * 2))" + + # --- Mock Sandbox Server --- + + defp start_mock_sandbox(verify_fn, response_body) do + {:ok, listen_socket} = + :gen_tcp.listen(0, [:binary, packet: :raw, reuseaddr: true, active: false]) + + {:ok, port} = :inet.port(listen_socket) + + task = + Task.async(fn -> + {:ok, client} = :gen_tcp.accept(listen_socket) + {:ok, raw} = :gen_tcp.recv(client, 0, 5000) + {_headers, body} = split_http(raw) + decoded = Jason.decode!(body) + verify_fn.(decoded) + resp = Jason.encode!(response_body) + + response = + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{byte_size(resp)}\r\n\r\n#{resp}" + + :gen_tcp.send(client, response) + :gen_tcp.close(client) + :gen_tcp.close(listen_socket) + end) + + {port, task} + end + + defp split_http(data) do + case String.split(data, "\r\n\r\n", parts: 2) do + [h, b] -> {h, b} + _ -> {"", data} + end + end + + describe "Simplism dispatch - Python Fibonacci" do + test "sends mode=simplism, returns result via mock sandbox" do + verify = fn body -> + assert body["mode"] == "simplism" + assert body["language"] == "python" + assert is_binary(body["code"]) + end + + {port, server} = start_mock_sandbox(verify, %{"result" => 55}) + sandbox = %{ip: "127.0.0.1", mode: :simplism, port: port} + + assert {:ok, %{"result" => 55}} = + MiniElixir.Sandbox.Simplism.execute(sandbox, @python_fib, :python) + + Task.await(server, 5000) + end + end + + describe "Simplism dispatch - Elixir expression" do + test "sends mode=simplism, returns result via mock sandbox" do + verify = fn body -> + assert body["mode"] == "simplism" + assert body["language"] == "elixir" + assert is_binary(body["code"]) + end + + {port, server} = start_mock_sandbox(verify, %{"result" => [2, 4, 6]}) + sandbox = %{ip: "127.0.0.1", mode: :simplism, port: port} + + assert {:ok, %{"result" => [2, 4, 6]}} = + MiniElixir.Sandbox.Simplism.execute(sandbox, @elixir_expr, :elixir) + + Task.await(server, 5000) + end + end + + describe "Extism dispatch - Python Fibonacci" do + test "sends mode=extism, returns result via mock sandbox" do + verify = fn body -> + assert body["mode"] == "extism" + assert body["language"] == "python" + assert is_binary(body["code"]) + end + + {port, server} = start_mock_sandbox(verify, %{"result" => 55}) + sandbox = %{ip: "127.0.0.1", mode: :extism, port: port} + + assert {:ok, %{"result" => 55}} = + MiniElixir.Sandbox.Extism.execute(sandbox, @python_fib, :python) + + Task.await(server, 5000) + end + end + + describe "Extism dispatch - Elixir expression" do + test "sends mode=extism, returns result via mock sandbox" do + verify = fn body -> + assert body["mode"] == "extism" + assert body["language"] == "elixir" + assert is_binary(body["code"]) + end + + {port, server} = start_mock_sandbox(verify, %{"result" => [2, 4, 6]}) + sandbox = %{ip: "127.0.0.1", mode: :extism, port: port} + + assert {:ok, %{"result" => [2, 4, 6]}} = + MiniElixir.Sandbox.Extism.execute(sandbox, @elixir_expr, :elixir) + + Task.await(server, 5000) + end + end + + describe "Unikraft dispatch (direct) - Python Fibonacci" do + test "sends code+timeout (no mode), returns result via mock sandbox" do + verify = fn body -> + refute Map.has_key?(body, "mode") + refute Map.has_key?(body, "language") + assert is_binary(body["code"]) + assert is_integer(body["timeout"]) + end + + {port, server} = start_mock_sandbox(verify, %{"result" => 55}) + sandbox = %{ip: "127.0.0.1", mode: :unikraft, port: port} + + assert {:ok, %{"result" => 55}} = + MiniElixir.Sandbox.Pool.execute_direct(sandbox, @python_fib) + + Task.await(server, 5000) + end + end + + describe "Code validation across all modes" do + test "Python fibonacci passes validation" do + assert :ok = MiniElixir.Sandbox.Validator.validate(@python_fib, :python) + end + + test "Elixir expression passes validation" do + assert :ok = MiniElixir.Sandbox.Validator.validate(@elixir_expr, :elixir) + end + + test "blocked imports rejected regardless of mode" do + for mode <- [:simplism, :extism, :unikraft] do + assert {:error, _} = MiniElixir.eval_sandboxed("import os", :python, mode: mode) + assert {:error, _} = MiniElixir.eval_sandboxed("import sys", :python, mode: mode) + end + end + end end diff --git a/test/mini_elixir/sandbox/validator_test.exs b/test/mini_elixir/sandbox/validator_test.exs index 69c55c4..f86f71c 100644 --- a/test/mini_elixir/sandbox/validator_test.exs +++ b/test/mini_elixir/sandbox/validator_test.exs @@ -17,7 +17,9 @@ defmodule MiniElixir.Sandbox.ValidatorTest do test "blocks code that is too long" do long_code = String.duplicate("x = 1\n", 2000) - assert {:error, "Code too long (max 10000 characters)"} = Validator.validate(long_code, :python) + + assert {:error, "Code too long (max 10000 characters)"} = + Validator.validate(long_code, :python) end test "blocks os import" do @@ -63,7 +65,11 @@ defmodule MiniElixir.Sandbox.ValidatorTest do end test "allows datetime module" do - assert :ok = Validator.validate("from datetime import datetime\nresult = datetime.now()", :python) + assert :ok = + Validator.validate( + "from datetime import datetime\nresult = datetime.now()", + :python + ) end test "blocks dangerous attribute access" do diff --git a/test/mini_elixir_test.exs b/test/mini_elixir_test.exs index 627920d..c597d08 100644 --- a/test/mini_elixir_test.exs +++ b/test/mini_elixir_test.exs @@ -191,7 +191,9 @@ defmodule MiniElixirTest do """ # Now this should be properly rejected due to nested module validation - assert {:error, message} = MiniElixir.eval(code, NameFormatterNested, :format_name, ["john"]) + assert {:error, message} = + MiniElixir.eval(code, NameFormatterNested, :format_name, ["john"]) + assert message =~ "Nested modules are not allowed" end @@ -215,7 +217,7 @@ defmodule MiniElixirTest do code = ~S""" defmodule NameFormatterIO do defmodule MyMod do - IO.inspect("modules are tricky") + def tricky, do: :ok end def format_name(first) do @@ -250,20 +252,20 @@ defmodule MiniElixirTest do test "evaluates multiple code strings in parallel" do executions = [ {~S""" - defmodule Adder do - def add(x, y), do: x + y - end - """, Adder, :add, [1, 2]}, + defmodule Adder do + def add(x, y), do: x + y + end + """, Adder, :add, [1, 2]}, {~S""" - defmodule Multiplier do - def mul(x, y), do: x * y - end - """, Multiplier, :mul, [3, 4]}, + defmodule Multiplier do + def mul(x, y), do: x * y + end + """, Multiplier, :mul, [3, 4]}, {~S""" - defmodule Greeter do - def greet(name), do: "Hello, #{name}!" - end - """, Greeter, :greet, ["World"]} + defmodule Greeter do + def greet(name), do: "Hello, #{name}!" + end + """, Greeter, :greet, ["World"]} ] assert {:ok, results} = MiniElixir.eval_parallel(executions) @@ -276,17 +278,17 @@ defmodule MiniElixirTest do test "handles mixed success and error results" do executions = [ {~S""" - defmodule Adder do - def add(x, y), do: x + y - end - """, Adder, :add, [1, 2]}, + defmodule Adder do + def add(x, y), do: x + y + end + """, Adder, :add, [1, 2]}, {~S""" - defmodule BadModule do - def bad do - File.read!("/etc/passwd") - end - end - """, BadModule, :bad, []} + defmodule BadModule do + def bad do + File.read!("/etc/passwd") + end + end + """, BadModule, :bad, []} ] assert {:ok, results} = MiniElixir.eval_parallel(executions) @@ -301,10 +303,10 @@ defmodule MiniElixirTest do test "respects timeout option" do executions = [ {~S""" - defmodule Slow do - def compute, do: :timer.sleep(100) - end - """, Slow, :compute, []} + defmodule Slow do + def compute, do: :timer.sleep(100) + end + """, Slow, :compute, []} ] assert {:ok, _} = MiniElixir.eval_parallel(executions, timeout: 5_000) @@ -315,15 +317,15 @@ defmodule MiniElixirTest do test "evaluates code with sandboxed execution" do executions = [ {~S""" - defmodule Adder do - def add(x, y), do: x + y - end - """, Adder, :add, [5, 3]}, + defmodule Adder do + def add(x, y), do: x + y + end + """, Adder, :add, [5, 3]}, {~S""" - defmodule Multiplier do - def mul(x, y), do: x * y - end - """, Multiplier, :mul, [2, 7]} + defmodule Multiplier do + def mul(x, y), do: x * y + end + """, Multiplier, :mul, [2, 7]} ] assert {:ok, results} = MiniElixir.eval_parallel_sandboxed(executions)