diff --git a/sandbox-modal/.gitignore b/sandbox-modal/.gitignore new file mode 100644 index 00000000..c1c662bd --- /dev/null +++ b/sandbox-modal/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.egg-info/ +*.pyc diff --git a/sandbox-modal/Dockerfile b/sandbox-modal/Dockerfile new file mode 100644 index 00000000..da4e38b0 --- /dev/null +++ b/sandbox-modal/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY pyproject.toml ./ +COPY src/ src/ + +RUN pip install --no-cache-dir . + +ENV III_URL=ws://localhost:49134 + +CMD ["sandbox-modal"] diff --git a/sandbox-modal/README.md b/sandbox-modal/README.md new file mode 100644 index 00000000..893a3c17 --- /dev/null +++ b/sandbox-modal/README.md @@ -0,0 +1,57 @@ +# sandbox-modal + +Narrow iii worker that wraps [Modal](https://modal.com) sandboxes. Modal is gRPC-only — there is no public REST API — so this worker imports the official Modal Python SDK as its transport. The SDK is an implementation detail; callers see only the canonical `sandbox::provider::modal::*` ABI. + +The same ABI is implemented by every sandbox provider worker in this repo (`sandbox-e2b`, `sandbox-daytona`, `sandbox-morph`, `sandbox-vercel`, `sandbox-cf`, ...). Callers swap providers by changing the function-id prefix. + +## Functions + +| Function id | Purpose | +|---|---| +| `sandbox::provider::modal::create` | Boot a sandbox; returns `{sandbox_id, image, capabilities}` | +| `sandbox::provider::modal::exec` | Run a command inside a live sandbox | +| `sandbox::provider::modal::stop` | Tear down a sandbox | +| `sandbox::provider::modal::list` | Enumerate live sandboxes plus concurrency status | +| `sandbox::provider::modal::snapshot` | Snapshot the sandbox filesystem for fan-out | +| `sandbox::provider::modal::expose_port` | Public URL for a port via Modal's Tunnel | + +`create` advertises capabilities `["snapshot", "expose_port"]`. `branch` and `fs::*` are not registered for v0 — Modal's filesystem ops use the `Sandbox.open()` file-handle API which doesn't map cleanly to the channel-based FS surface used by the rest of the family. Revisit when consensus emerges. + +## Configuration + +`config.yaml` next to the binary, or set `SANDBOX_MODAL_CONFIG` to a path: + +```yaml +max_concurrent_sandboxes: 10 +default_idle_timeout_secs: 300 +default_cpus: 1 +default_memory_mb: 512 +image_allowlist: [] # empty = allow all +``` + +Modal authenticates via `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` — the official SDK reads them automatically. The worker fails fast if Modal cannot find tokens at startup. + +## S-codes + +Provider failures map onto the same code space the rest of the sandbox worker family uses: + +| Code | Cause | +|---|---| +| `S100` | Image not in `image_allowlist` | +| `S400` | Concurrency cap reached | +| `S404` | Capability not supported | +| `S500` | Modal raised `RateLimitError` | +| `S501` | Modal raised quota error | +| `S502` | Other Modal SDK exception | +| `S503` | Modal raised `AuthError` / `InvalidError` | + +## Running + +```bash +pip install -e .[dev] +sandbox-modal # entry point from pyproject.scripts +``` + +## Status + +v0.1 ships function registrations, types, error mapping, async concurrency tracking, and a smoke test. The Modal SDK calls inside `ModalClient` are stubbed and raise `SandboxError(S502)` until the next iteration wires them to `modal.Sandbox.create(...)` / `sandbox.exec(...)` / `sandbox.terminate()`. The ABI is stable. diff --git a/sandbox-modal/iii.worker.yaml b/sandbox-modal/iii.worker.yaml new file mode 100644 index 00000000..0b7324a4 --- /dev/null +++ b/sandbox-modal/iii.worker.yaml @@ -0,0 +1,12 @@ +iii: v1 +name: sandbox-modal +language: python +deploy: image +manifest: pyproject.toml +description: Narrow iii worker that exposes Modal sandboxes (gVisor isolation, Python-first, GPU available) via the sandbox::provider::modal::* trigger family. +config: + max_concurrent_sandboxes: 10 + default_idle_timeout_secs: 300 + default_cpus: 1 + default_memory_mb: 512 + image_allowlist: [] diff --git a/sandbox-modal/pyproject.toml b/sandbox-modal/pyproject.toml new file mode 100644 index 00000000..4cd9bdd1 --- /dev/null +++ b/sandbox-modal/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sandbox-modal" +version = "0.1.0" +description = "Narrow iii worker that exposes Modal sandboxes via the sandbox::provider::modal::* trigger family. Uses the modal Python SDK as transport (Modal has no public REST surface)." +authors = [{ name = "iii contributors" }] +requires-python = ">=3.10" +license = { text = "Apache-2.0" } +dependencies = [ + "iii-sdk==0.11.6", + "modal>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "ruff>=0.6", +] + +[project.scripts] +sandbox-modal = "src.main:main" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/sandbox-modal/src/__init__.py b/sandbox-modal/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sandbox-modal/src/config.py b/sandbox-modal/src/config.py new file mode 100644 index 00000000..1064e14c --- /dev/null +++ b/sandbox-modal/src/config.py @@ -0,0 +1,62 @@ +"""Config loader for sandbox-modal. Reads a flat key:value YAML-shaped file. + +Avoids a YAML dependency by parsing the small set of keys the worker +actually uses; matches the shape of `iii.worker.yaml`'s `config:` block. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class Config: + max_concurrent_sandboxes: int = 10 + default_idle_timeout_secs: int = 300 + default_cpus: int = 1 + default_memory_mb: int = 512 + image_allowlist: list[str] = field(default_factory=list) + + @classmethod + def load(cls, path: str | os.PathLike[str]) -> Config: + cfg = cls() + try: + raw = Path(path).read_text(encoding="utf-8") + except OSError: + return cfg + + list_key: str | None = None + for line in raw.splitlines(): + stripped = line.split("#", 1)[0].rstrip() + if not stripped.strip(): + continue + if stripped.startswith(" - ") and list_key is not None: + value = stripped[4:].strip().strip("\"'") + getattr(cfg, list_key).append(value) + continue + if ":" not in stripped: + continue + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + if not hasattr(cfg, key): + list_key = None + continue + if val == "": + # nested list follows + if isinstance(getattr(cfg, key), list): + list_key = key + continue + list_key = None + if val == "[]": + setattr(cfg, key, []) + elif val.lstrip("-").isdigit(): + setattr(cfg, key, int(val)) + else: + setattr(cfg, key, val.strip("\"'")) + return cfg + + def image_allowed(self, image: str) -> bool: + return not self.image_allowlist or image in self.image_allowlist diff --git a/sandbox-modal/src/handlers.py b/sandbox-modal/src/handlers.py new file mode 100644 index 00000000..2bc11b58 --- /dev/null +++ b/sandbox-modal/src/handlers.py @@ -0,0 +1,125 @@ +"""Handler coroutines for every `sandbox::provider::modal::*` function. + +Each coroutine parses untyped JSON, does config-side validation +(allowlist, concurrency cap), then delegates to `ModalClient`. Failures +surface as `SandboxError` whose stringification carries the leading +`[Sxxx]` so callers can pattern-match on the S-code. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any + +from .config import Config +from .modal_client import ModalClient +from .sandbox import ( + CAPABILITIES, + S_CONCURRENCY_CAP, + S_IMAGE_NOT_ALLOWED, + S_PROVIDER_UNAVAILABLE, + SandboxError, +) + + +@dataclass +class HandlerCtx: + config: Config + client: ModalClient + in_flight: int = 0 + lock: asyncio.Lock | None = None + + def __post_init__(self) -> None: + if self.lock is None: + self.lock = asyncio.Lock() + + +def _require_str(payload: dict[str, Any], key: str) -> str: + val = payload.get(key) + if not isinstance(val, str) or not val: + raise SandboxError(S_PROVIDER_UNAVAILABLE, f'missing string field "{key}"') + return val + + +async def do_create(ctx: HandlerCtx, payload: dict[str, Any]) -> dict[str, Any]: + image = _require_str(payload, "image") + if not ctx.config.image_allowed(image): + raise SandboxError(S_IMAGE_NOT_ALLOWED, f"image not in allowlist: {image}") + + cpus = int(payload.get("cpus") or ctx.config.default_cpus) + memory_mb = int(payload.get("memory_mb") or ctx.config.default_memory_mb) + idle = int(payload.get("idle_timeout_secs") or ctx.config.default_idle_timeout_secs) + + assert ctx.lock is not None + async with ctx.lock: + if ctx.in_flight >= ctx.config.max_concurrent_sandboxes: + raise SandboxError(S_CONCURRENCY_CAP, f"concurrency cap reached ({ctx.in_flight} active)") + ctx.in_flight += 1 + + try: + created = await ctx.client.create(image, cpus, memory_mb, idle) + return { + "sandbox_id": created.sandbox_id, + "image": created.image, + "started_at": created.started_at, + "capabilities": list(CAPABILITIES), + } + except BaseException: + async with ctx.lock: + ctx.in_flight = max(0, ctx.in_flight - 1) + raise + + +async def do_exec(ctx: HandlerCtx, payload: dict[str, Any]) -> dict[str, Any]: + sandbox_id = _require_str(payload, "sandbox_id") + cmd = _require_str(payload, "cmd") + args = list(payload.get("args") or []) + timeout_ms = payload.get("timeout_ms") + result = await ctx.client.exec(sandbox_id, cmd, [str(a) for a in args], timeout_ms) + return { + "stdout": result.stdout, + "stderr": result.stderr, + "exit_code": result.exit_code, + "timed_out": result.timed_out, + } + + +async def do_stop(ctx: HandlerCtx, payload: dict[str, Any]) -> dict[str, Any]: + sandbox_id = _require_str(payload, "sandbox_id") + await ctx.client.stop(sandbox_id) + assert ctx.lock is not None + async with ctx.lock: + ctx.in_flight = max(0, ctx.in_flight - 1) + return {} + + +async def do_list(ctx: HandlerCtx, _payload: dict[str, Any]) -> dict[str, Any]: + # Best-effort upstream fetch. Local capacity envelope always returned. + sandboxes: list[dict[str, object]] = [] + try: + sandboxes = await ctx.client.list() + except SandboxError: + sandboxes = [] + cap = ctx.config.max_concurrent_sandboxes + return { + "sandboxes": sandboxes, + "in_flight": ctx.in_flight, + "cap": cap, + "remaining": max(cap - ctx.in_flight, 0), + } + + +async def do_snapshot(ctx: HandlerCtx, payload: dict[str, Any]) -> dict[str, Any]: + sandbox_id = _require_str(payload, "sandbox_id") + snapshot_id = await ctx.client.snapshot(sandbox_id) + return {"snapshot_id": snapshot_id} + + +async def do_expose_port(ctx: HandlerCtx, payload: dict[str, Any]) -> dict[str, Any]: + sandbox_id = _require_str(payload, "sandbox_id") + port = payload.get("port") + if not isinstance(port, int) or port < 1 or port > 65535: + raise SandboxError(S_PROVIDER_UNAVAILABLE, "invalid port") + url = await ctx.client.expose_port(sandbox_id, port) + return {"url": url} diff --git a/sandbox-modal/src/main.py b/sandbox-modal/src/main.py new file mode 100644 index 00000000..cabd6b14 --- /dev/null +++ b/sandbox-modal/src/main.py @@ -0,0 +1,67 @@ +"""Entry point for sandbox-modal. Registers the seven `sandbox::provider::modal::*` +functions on the iii engine, then waits on SIGTERM/SIGINT. +""" + +from __future__ import annotations + +import os +import signal +import threading +from typing import Any + +from iii import InitOptions, register_worker + +from .config import Config +from .handlers import ( + HandlerCtx, + do_create, + do_exec, + do_expose_port, + do_list, + do_snapshot, + do_stop, +) +from .modal_client import ModalClient + + +def main() -> None: + engine_ws_url = os.environ.get("III_URL", "ws://localhost:49134") + config_path = os.environ.get("SANDBOX_MODAL_CONFIG", "./config.yaml") + config = Config.load(config_path) + client = ModalClient(app_name="sandbox-modal") + ctx = HandlerCtx(config=config, client=client) + + iii = register_worker( + address=engine_ws_url, + options=InitOptions( + worker_name="sandbox-modal", + otel={"enabled": True, "service_name": "sandbox-modal"}, + ), + ) + + print(f"sandbox-modal connected to engine: {engine_ws_url}") + + def reg(function_id: str, handler: Any) -> None: + async def wrapped(payload: dict[str, Any]) -> dict[str, Any]: + return await handler(ctx, payload or {}) + + iii.register_function(function_id, wrapped) + + reg("sandbox::provider::modal::create", do_create) + reg("sandbox::provider::modal::exec", do_exec) + reg("sandbox::provider::modal::stop", do_stop) + reg("sandbox::provider::modal::list", do_list) + reg("sandbox::provider::modal::snapshot", do_snapshot) + reg("sandbox::provider::modal::expose_port", do_expose_port) + + print("sandbox-modal registered, awaiting invocations") + + stop = threading.Event() + signal.signal(signal.SIGTERM, lambda *_: stop.set()) + signal.signal(signal.SIGINT, lambda *_: stop.set()) + stop.wait() + iii.shutdown() + + +if __name__ == "__main__": + main() diff --git a/sandbox-modal/src/modal_client.py b/sandbox-modal/src/modal_client.py new file mode 100644 index 00000000..147b49a9 --- /dev/null +++ b/sandbox-modal/src/modal_client.py @@ -0,0 +1,244 @@ +"""Narrow wrapper around the Modal Python SDK. + +Modal is gRPC-only; the official `modal` package is the only supported +transport. The handlers talk to this wrapper, never to `modal` directly, +so tests can mock the boundary without forcing real Modal credentials at +import time. + +The SDK call sequence (verified via context7 against the modal docs and +modal.com/docs/reference/modal.Sandbox) for each method is recorded in +its docstring. Bodies are wired against those signatures and tracked +behind a small in-process registry that maps our `sandbox_id` (which is +also Modal's `object_id`) back to the live `modal.Sandbox` handle. + +Lazy import: `modal` is imported inside each method so loading the +worker module without Modal credentials still succeeds (handlers can +short-circuit on validation errors before any Modal call). When tests +mock this class, the modal import never runs. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from .sandbox import ( + S_AUTH_INVALID, + S_PROVIDER_UNAVAILABLE, + CreatedSandbox, + ExecResult, + SandboxError, + map_modal_error, +) + + +def _import_modal() -> Any: + try: + import modal # type: ignore[import-untyped] + + return modal + except ImportError as exc: + raise SandboxError(S_AUTH_INVALID, f"modal sdk not installed: {exc}") from exc + + +class ModalClient: + """Adapter on top of `modal.Sandbox`. + + `app` is created lazily on first `create` and reused thereafter so a + single Modal App backs every sandbox the worker spawns. The + in-process `_handles` map is what makes `exec`/`stop`/`snapshot` + look up the right Sandbox object from a sandbox_id passed over the + iii trigger boundary. + """ + + def __init__(self, app_name: str = "sandbox-modal") -> None: + self.app_name = app_name + self._app: Any | None = None + self._handles: dict[str, Any] = {} + self._lock = asyncio.Lock() + + def _ensure_app(self) -> Any: + if self._app is None: + modal = _import_modal() + self._app = modal.App.lookup(self.app_name, create_if_missing=True) + return self._app + + async def create( + self, + image: str, + cpus: int, + memory_mb: int, + idle_timeout_secs: int, + ) -> CreatedSandbox: + """`modal.Sandbox.create(app=, image=, timeout=, cpu=, memory=)`. + + `image` is parsed as either a registered Modal image name or a + plain Debian rootfs ref. `timeout` is in seconds upstream and + maps directly from `idle_timeout_secs`. + """ + + def blocking_create() -> Any: + modal = _import_modal() + app = self._ensure_app() + modal_image = modal.Image.debian_slim() if image in {"", "default"} else modal.Image.from_registry(image) + return modal.Sandbox.create( + image=modal_image, + app=app, + timeout=idle_timeout_secs, + cpu=cpus, + memory=memory_mb, + ) + + try: + sandbox = await asyncio.to_thread(blocking_create) + except SandboxError: + raise + except BaseException as exc: + raise map_modal_error(exc) from exc + + sandbox_id = str(sandbox.object_id) + async with self._lock: + self._handles[sandbox_id] = sandbox + return CreatedSandbox( + sandbox_id=sandbox_id, + image=image or "modal/debian-slim", + started_at=int(asyncio.get_event_loop().time()), + ) + + async def _handle(self, sandbox_id: str) -> Any: + async with self._lock: + handle = self._handles.get(sandbox_id) + if handle is None: + # Try to rehydrate via Modal's by-id lookup. Different SDK + # versions expose this as `Sandbox.from_id` or similar; we + # fall back to a plain error if the upstream surface + # doesn't carry the sandbox anymore. + modal = _import_modal() + from_id = getattr(modal.Sandbox, "from_id", None) + if from_id is None: + raise SandboxError( + S_PROVIDER_UNAVAILABLE, + f"sandbox {sandbox_id} not in local registry and SDK lacks Sandbox.from_id", + ) + try: + handle = await asyncio.to_thread(from_id, sandbox_id) + except BaseException as exc: + raise map_modal_error(exc) from exc + async with self._lock: + self._handles[sandbox_id] = handle + return handle + + async def exec( + self, + sandbox_id: str, + cmd: str, + args: list[str], + timeout_ms: int | None, + ) -> ExecResult: + """`sb.exec(*argv)` returns a process with `.stdout.read()`, + `.stderr.read()`, `.wait()` and `.returncode`. + """ + _ = timeout_ms + + def blocking_exec() -> dict[str, Any]: + sandbox = self._handles.get(sandbox_id) + if sandbox is None: + raise SandboxError(S_PROVIDER_UNAVAILABLE, f"sandbox {sandbox_id} not in registry") + argv = [cmd, *args] + proc = sandbox.exec(*argv) + stdout = proc.stdout.read() if hasattr(proc, "stdout") else "" + stderr = proc.stderr.read() if hasattr(proc, "stderr") else "" + exit_code = proc.wait() if hasattr(proc, "wait") else 0 + return {"stdout": stdout, "stderr": stderr, "exit_code": int(exit_code or 0)} + + try: + result = await asyncio.to_thread(blocking_exec) + except SandboxError: + raise + except BaseException as exc: + raise map_modal_error(exc) from exc + return ExecResult( + stdout=str(result.get("stdout") or ""), + stderr=str(result.get("stderr") or ""), + exit_code=int(result.get("exit_code") or 0), + timed_out=False, + ) + + async def stop(self, sandbox_id: str) -> None: + """`sb.terminate()`. Idempotent — calling on an already-stopped + sandbox is allowed; we swallow exceptions whose class names + suggest the sandbox is gone. + """ + + def blocking_stop() -> None: + sandbox = self._handles.get(sandbox_id) + if sandbox is None: + return # already gone — idempotent success + sandbox.terminate() + + try: + await asyncio.to_thread(blocking_stop) + except BaseException as exc: + name = type(exc).__name__ + if name in {"NotFoundError", "SandboxNotFoundError"}: + return + raise map_modal_error(exc) from exc + async with self._lock: + self._handles.pop(sandbox_id, None) + + async def list(self) -> list[dict[str, object]]: + """Modal does not expose a list-all-sandboxes call in the SDK. + Return what the worker has tracked locally; the iii worker's + `do_list` already reconciles in_flight to the returned length. + """ + async with self._lock: + ids = list(self._handles.keys()) + return [{"sandbox_id": sid, "image": "", "started_at": ""} for sid in ids] + + async def snapshot(self, sandbox_id: str) -> str: + """`sb.snapshot_filesystem()` returns a Modal Image whose + `object_id` is the snapshot identifier. + """ + + def blocking_snapshot() -> str: + sandbox = self._handles.get(sandbox_id) + if sandbox is None: + raise SandboxError(S_PROVIDER_UNAVAILABLE, f"sandbox {sandbox_id} not in registry") + image = sandbox.snapshot_filesystem() + return str(image.object_id) + + try: + return await asyncio.to_thread(blocking_snapshot) + except SandboxError: + raise + except BaseException as exc: + raise map_modal_error(exc) from exc + + async def expose_port(self, sandbox_id: str, port: int) -> str: + """Modal expects ports declared up-front via + `encrypted_ports=[port]` on `Sandbox.create`. Once that's set, + `sb.tunnels()[port].url` is the public URL. + Without per-port reconfiguration on existing sandboxes we + surface a clear error so callers know to declare ports at + create time. + """ + + def blocking_expose() -> str: + sandbox = self._handles.get(sandbox_id) + if sandbox is None: + raise SandboxError(S_PROVIDER_UNAVAILABLE, f"sandbox {sandbox_id} not in registry") + tunnels = sandbox.tunnels() + tunnel = tunnels.get(port) + if tunnel is None: + raise SandboxError( + S_PROVIDER_UNAVAILABLE, + f"port {port} not declared via encrypted_ports at create time", + ) + return str(tunnel.url) + + try: + return await asyncio.to_thread(blocking_expose) + except SandboxError: + raise + except BaseException as exc: + raise map_modal_error(exc) from exc diff --git a/sandbox-modal/src/sandbox.py b/sandbox-modal/src/sandbox.py new file mode 100644 index 00000000..0be503f9 --- /dev/null +++ b/sandbox-modal/src/sandbox.py @@ -0,0 +1,66 @@ +"""Shared error codes and capability set for sandbox-modal. + +Mirrors the stable S-code space used across every sandbox provider worker +in this repo. S100-S400 mirror the libkrun-backed iii-sandbox daemon; S404 +and S500-S503 are REST-/SDK-specific extensions. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +S_IMAGE_NOT_ALLOWED = "S100" +S_CONCURRENCY_CAP = "S400" +S_CAPABILITY_UNSUPPORTED = "S404" +S_RATE_LIMITED = "S500" +S_QUOTA_EXHAUSTED = "S501" +S_PROVIDER_UNAVAILABLE = "S502" +S_AUTH_INVALID = "S503" + +CAPABILITIES = ["snapshot", "expose_port"] + + +class SandboxError(Exception): + """Stable error type for sandbox::provider::modal::* failures. + + The S-code is embedded at the start of the message so callers that + pattern-match on `[Sxxx]` see the same surface every other sandbox + provider emits. + """ + + def __init__(self, code: str, message: str) -> None: + super().__init__(f"[{code}] {message}") + self.code = code + + +def map_modal_error(exc: BaseException) -> SandboxError: + """Translate an exception raised by the modal SDK into a SandboxError. + + Modal's exception hierarchy ships a handful of named classes + (`AuthError`, `RateLimitError`, etc.); we resolve them by name string + so the worker does not import `modal` at module load time, which keeps + the test suite runnable without real Modal credentials. + """ + name = type(exc).__name__ + if name in {"AuthError", "InvalidError"}: + return SandboxError(S_AUTH_INVALID, f"modal auth: {exc}") + if name == "RateLimitError": + return SandboxError(S_RATE_LIMITED, f"modal rate limited: {exc}") + if name == "QuotaExceeded" or "quota" in str(exc).lower(): + return SandboxError(S_QUOTA_EXHAUSTED, f"modal quota: {exc}") + return SandboxError(S_PROVIDER_UNAVAILABLE, f"modal: {exc}") + + +@dataclass +class CreatedSandbox: + sandbox_id: str + image: str + started_at: int + + +@dataclass +class ExecResult: + stdout: str + stderr: str + exit_code: int + timed_out: bool diff --git a/sandbox-modal/tests/__init__.py b/sandbox-modal/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sandbox-modal/tests/test_handlers.py b/sandbox-modal/tests/test_handlers.py new file mode 100644 index 00000000..58f57c6e --- /dev/null +++ b/sandbox-modal/tests/test_handlers.py @@ -0,0 +1,75 @@ +"""Smoke tests for sandbox-modal handlers. + +Exercises validation, concurrency tracking, and the list capacity envelope +without contacting Modal. The ModalClient stub raises SandboxError on +every method, which is exactly what the v0 stub bodies do — so the tests +also serve as a contract that the handler still releases concurrency +slots when the upstream client fails. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from src.config import Config +from src.handlers import HandlerCtx, do_create, do_exec, do_list, do_stop +from src.modal_client import ModalClient +from src.sandbox import S_IMAGE_NOT_ALLOWED, SandboxError + + +def make_ctx(max_concurrent: int = 10, allowlist: list[str] | None = None) -> HandlerCtx: + cfg = Config( + max_concurrent_sandboxes=max_concurrent, + image_allowlist=list(allowlist or []), + ) + return HandlerCtx(config=cfg, client=ModalClient(app_name="test")) + + +@pytest.mark.asyncio +async def test_create_rejects_image_not_in_allowlist() -> None: + ctx = make_ctx(allowlist=["python:3.11"]) + with pytest.raises(SandboxError) as excinfo: + await do_create(ctx, {"image": "node:22"}) + assert excinfo.value.code == S_IMAGE_NOT_ALLOWED + + +@pytest.mark.asyncio +async def test_create_rolls_back_in_flight_on_failure() -> None: + ctx = make_ctx(max_concurrent=2) + with pytest.raises(SandboxError): + await do_create(ctx, {"image": "python:3.11"}) + listed = await do_list(ctx, {}) + assert listed == {"sandboxes": [], "in_flight": 0, "cap": 2, "remaining": 2} + + +@pytest.mark.asyncio +async def test_exec_rejects_missing_fields() -> None: + ctx = make_ctx() + with pytest.raises(SandboxError) as excinfo: + await do_exec(ctx, {}) + assert "missing string field" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_stop_is_idempotent_when_sandbox_unknown() -> None: + # ModalClient.stop now treats an unknown sandbox_id as already-gone + # (the registry never knew it, so the post-state is what the caller + # wanted). Matches the e2b/daytona/morph idempotency contract. + ctx = make_ctx() + result = await do_stop(ctx, {"sandbox_id": "sbx-1"}) + assert result == {} + + +@pytest.mark.asyncio +async def test_list_reports_capacity_envelope() -> None: + ctx = make_ctx(max_concurrent=7) + result = await do_list(ctx, {}) + assert result == {"sandboxes": [], "in_flight": 0, "cap": 7, "remaining": 7} + + +def test_event_loop_available() -> None: + # Smoke: pytest-asyncio fixtures imply a working loop policy under all + # supported Python versions. + asyncio.get_event_loop_policy()