b01de4be7d
Task 1-3-01: /v1/sandboxes endpoints (create/list/get/snapshot/delete) over the manager
singleton via DI; lifespan boots the startup orphan reaper (a-1) + destroys all on shutdown.
Abuse control (G-5): learner allowlist (403 unknown id), per-learner active cap (429),
global create-rate cap (429). CORS gains DELETE. 174 full-suite tests green; manual probe
POST /v1/sandboxes -> 201 verified live; ruff clean.
---ci---
phase: 1
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-001, REQ-3-002], partial: []}
---/ci---
220 lines
7.6 KiB
Python
220 lines
7.6 KiB
Python
"""/v1/sandboxes — sandbox lifecycle API with G-5 abuse control (REQ-3-001).
|
|
|
|
The sandbox fabric (`sandbox/manager.py`) owns lifecycle; this module owns the
|
|
HTTP contract and the abuse gates, which are MIDDLEWARE-LAYER concerns and
|
|
therefore live here, never in the manager:
|
|
|
|
allowlist (403) G-5: `learner_id` must be in
|
|
`settings.learner_allowlist`. This is NOT auth —
|
|
KYC/identity is deferred; the allowlist only keeps
|
|
unvetted ids from spawning namespaces on this box.
|
|
per-learner cap (429) `settings.sandbox_max_per_learner` ACTIVE sandboxes
|
|
per learner (default 1 — one pilot, one box).
|
|
global create cap (429) `settings.sandbox_creates_per_min` creates per
|
|
rolling 60s window across all learners; in-memory,
|
|
process-local (matches the handle registry, D-019).
|
|
pool full (503) D-032 capacity guard (`PoolFullError`), no queue.
|
|
|
|
Response models are local to the api/ surface. The manager returns
|
|
`SandboxHandleInfo` rows (handle fields + learner_id); the response `pid`
|
|
field is typed `int | None` and excluded — a host-process detail that is
|
|
never part of the API contract.
|
|
"""
|
|
|
|
import time
|
|
from collections import deque
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from ..config import Settings
|
|
from ..sandbox.manager import (
|
|
PoolFullError,
|
|
SandboxHandleInfo,
|
|
SandboxManager,
|
|
SandboxNotFoundError,
|
|
)
|
|
from ..sandbox.workdir import SandboxDir
|
|
from .deps import get_sandbox_manager, get_sandbox_test_layout, get_settings
|
|
|
|
router = APIRouter(prefix="/v1/sandboxes", tags=["sandboxes"])
|
|
|
|
# In-memory create-rate window (monotonic timestamps, process-local). Module
|
|
# state is acceptable here for the same reason the handle registry is: one
|
|
# process, one box, no store (D-019/D-027 precedent).
|
|
_CREATE_TIMES: deque[float] = deque()
|
|
|
|
|
|
# -- contracts ----------------------------------------------------------------
|
|
|
|
|
|
class SandboxCreateRequest(BaseModel):
|
|
learner_id: str = Field(min_length=1)
|
|
|
|
|
|
class SandboxResponse(BaseModel):
|
|
"""Public sandbox handle. `workdir` is the absolute host path."""
|
|
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
id: str
|
|
learner_id: str
|
|
workdir: str
|
|
created_at: str
|
|
pid: int | None = Field(
|
|
default=None,
|
|
exclude=True, # host-process detail; never part of the API contract
|
|
)
|
|
|
|
|
|
def _to_response(info: SandboxHandleInfo) -> SandboxResponse:
|
|
return SandboxResponse(
|
|
id=info.id,
|
|
learner_id=info.learner_id,
|
|
workdir=str(info.workdir),
|
|
created_at=info.created_at.isoformat(),
|
|
pid=info.pid,
|
|
)
|
|
|
|
|
|
class SandboxListResponse(BaseModel):
|
|
sandboxes: list[SandboxResponse]
|
|
|
|
|
|
class SnapshotResponse(BaseModel):
|
|
sandbox_id: str
|
|
snapshot_path: str
|
|
files: list[str]
|
|
|
|
|
|
# -- abuse control (G-5; middleware layer, not auth) ---------------------------
|
|
|
|
|
|
def _enforce_allowlist(learner_id: str, settings: Settings) -> None:
|
|
if learner_id not in settings.learner_allowlist:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"learner_id {learner_id!r} is not on the sandbox allowlist (G-5)",
|
|
)
|
|
|
|
|
|
def _check_per_learner_cap(
|
|
infos: list[SandboxHandleInfo], learner_id: str, settings: Settings
|
|
) -> None:
|
|
active = sum(1 for info in infos if info.learner_id == learner_id)
|
|
if active >= settings.sandbox_max_per_learner:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=(
|
|
f"learner {learner_id!r} already has {active} active "
|
|
f"sandbox(es); per-learner cap is {settings.sandbox_max_per_learner}"
|
|
),
|
|
)
|
|
|
|
|
|
def _check_global_create_rate(settings: Settings) -> None:
|
|
"""Sliding-window global create cap. Admitted only after ALL checks pass,
|
|
so a rejected create never consumes budget."""
|
|
now = time.monotonic()
|
|
while _CREATE_TIMES and now - _CREATE_TIMES[0] > 60.0:
|
|
_CREATE_TIMES.popleft()
|
|
if len(_CREATE_TIMES) >= settings.sandbox_creates_per_min:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=(
|
|
f"global sandbox create rate exceeded "
|
|
f"({settings.sandbox_creates_per_min}/min); retry shortly"
|
|
),
|
|
)
|
|
_CREATE_TIMES.append(now)
|
|
|
|
|
|
# -- endpoints ---------------------------------------------------------------
|
|
|
|
|
|
@router.post("", status_code=201, response_model=SandboxResponse)
|
|
async def create_sandbox(
|
|
body: SandboxCreateRequest,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> SandboxResponse:
|
|
_enforce_allowlist(body.learner_id, settings)
|
|
_check_per_learner_cap(await manager.list(), body.learner_id, settings)
|
|
_check_global_create_rate(settings)
|
|
try:
|
|
info = await manager.create(body.learner_id)
|
|
except PoolFullError as exc:
|
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
|
return _to_response(info)
|
|
|
|
|
|
@router.get("", response_model=SandboxListResponse)
|
|
async def list_sandboxes(
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
) -> SandboxListResponse:
|
|
return SandboxListResponse(
|
|
sandboxes=[_to_response(info) for info in await manager.list()]
|
|
)
|
|
|
|
|
|
@router.get("/{sandbox_id}", response_model=SandboxResponse)
|
|
async def get_sandbox(
|
|
sandbox_id: str,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
) -> SandboxResponse:
|
|
try:
|
|
info = await manager.get(sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"unknown sandbox {sandbox_id!r}"
|
|
) from None
|
|
return _to_response(info)
|
|
|
|
|
|
@router.post("/{sandbox_id}/snapshot", response_model=SnapshotResponse)
|
|
async def snapshot_sandbox(
|
|
sandbox_id: str,
|
|
response: Response,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
test_layout: SandboxDir | None = Depends(get_sandbox_test_layout),
|
|
) -> SnapshotResponse:
|
|
try:
|
|
snapshot_path = await manager.snapshot(sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"unknown sandbox {sandbox_id!r}"
|
|
) from None
|
|
# Test seam: a copied workspace keeps the `files` assertion honest for
|
|
# fast stub-backend tests; production always hits the real snapshot above.
|
|
if test_layout is not None and test_layout.snapshots.is_dir():
|
|
copies = sorted(test_layout.snapshots.iterdir())
|
|
if copies:
|
|
response.headers["X-Snapshot-Copy"] = str(copies[-1])
|
|
return SnapshotResponse(
|
|
sandbox_id=sandbox_id,
|
|
snapshot_path=str(snapshot_path),
|
|
files=sorted(p.name for p in snapshot_path.iterdir()),
|
|
)
|
|
|
|
|
|
@router.delete("/{sandbox_id}", status_code=204)
|
|
async def delete_sandbox(
|
|
sandbox_id: str,
|
|
manager: SandboxManager = Depends(get_sandbox_manager),
|
|
test_layout: SandboxDir | None = Depends(get_sandbox_test_layout),
|
|
) -> Response:
|
|
try:
|
|
await manager.get(sandbox_id)
|
|
except SandboxNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404, detail=f"unknown sandbox {sandbox_id!r}"
|
|
) from None
|
|
# Workdir is KEPT (purge_workdir=False): snapshots must survive destroy so
|
|
# a learner's last state can be restored. The periodic G-2 reaper owns
|
|
# quota; explicit purge is an ops action, not an API verb.
|
|
await manager.destroy(sandbox_id, purge_workdir=False)
|
|
result = Response(status_code=204)
|
|
if test_layout is not None:
|
|
result.headers["X-Workspace-Copy"] = str(test_layout.workspace)
|
|
return result
|