Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sandbox-modal/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.egg-info/
*.pyc
12 changes: 12 additions & 0 deletions sandbox-modal/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
57 changes: 57 additions & 0 deletions sandbox-modal/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions sandbox-modal/iii.worker.yaml
Original file line number Diff line number Diff line change
@@ -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: []
39 changes: 39 additions & 0 deletions sandbox-modal/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Empty file added sandbox-modal/src/__init__.py
Empty file.
62 changes: 62 additions & 0 deletions sandbox-modal/src/config.py
Original file line number Diff line number Diff line change
@@ -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
125 changes: 125 additions & 0 deletions sandbox-modal/src/handlers.py
Original file line number Diff line number Diff line change
@@ -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}
67 changes: 67 additions & 0 deletions sandbox-modal/src/main.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading