feat: snapshot/restore checkpoints (SAND-WP-0007)

Add workspace checkpoint API with SnapshotStore, extension hooks on
compose-ssh and saas-stub, manager orchestration, CLI/HTTP surface,
profile.compose-checkpoint, and docs/tests.
This commit is contained in:
tegwick 2026-06-24 07:57:40 +02:00
parent 2760ef2373
commit 952cebf2e9
21 changed files with 966 additions and 34 deletions

View file

@ -5,7 +5,12 @@ from __future__ import annotations
from fastapi import FastAPI, HTTPException
from sandboxer.core.manager import SandboxManager
from sandboxer.models import SandboxCreateRequest, SandboxStatus
from sandboxer.models import (
SandboxCreateRequest,
SandboxStatus,
SnapshotRecord,
SnapshotRestoreRequest,
)
app = FastAPI(title="sand-boxer", version="0.0.0")
_manager = SandboxManager()
@ -37,4 +42,44 @@ def destroy_sandbox(sandbox_id: str) -> SandboxStatus:
try:
return _manager.destroy(sandbox_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/v1/sandboxes/{sandbox_id}/snapshot", response_model=SnapshotRecord)
def snapshot_sandbox(
sandbox_id: str,
name: str | None = None,
) -> SnapshotRecord:
try:
return _manager.snapshot(sandbox_id, name=name)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/v1/snapshots/{snapshot_id}/restore", response_model=SandboxStatus)
def restore_snapshot(
snapshot_id: str,
request: SnapshotRestoreRequest | None = None,
) -> SandboxStatus:
req = request or SnapshotRestoreRequest()
try:
return _manager.restore(snapshot_id, host=req.host, consumer=req.consumer)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except (ValueError, Exception) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/v1/snapshots", response_model=list[SnapshotRecord])
def list_snapshots(sandbox_id: str | None = None) -> list[SnapshotRecord]:
return _manager.list_snapshots(sandbox_id=sandbox_id)
@app.get("/v1/snapshots/{snapshot_id}", response_model=SnapshotRecord)
def get_snapshot(snapshot_id: str) -> SnapshotRecord:
record = _manager.get_snapshot(snapshot_id)
if not record:
raise HTTPException(status_code=404, detail="snapshot not found")
return record

View file

@ -28,6 +28,8 @@ inspect_app = typer.Typer(help="Host introspection without provisioning.")
app.add_typer(inspect_app, name="inspect")
credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
app.add_typer(credits_app, name="credits")
snapshots_app = typer.Typer(help="Workspace checkpoint snapshots.")
app.add_typer(snapshots_app, name="snapshots")
@app.callback()
@ -142,6 +144,58 @@ def sandbox_destroy(sandbox_id: str) -> None:
_print_telemetry_summary(status.telemetry)
@app.command("snapshot")
def sandbox_snapshot(
sandbox_id: str,
name: Annotated[str | None, typer.Option(help="Optional snapshot label")] = None,
) -> None:
"""Create a workspace checkpoint from a ready sandbox."""
manager = SandboxManager()
try:
record = manager.snapshot(sandbox_id, name=name)
except (KeyError, RuntimeError) as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(code=1) from exc
_print_json(record.model_dump(mode="json"))
@app.command("restore")
def sandbox_restore(
snapshot_id: str,
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
actor: Annotated[str, typer.Option(help="Consumer actor type")] = "adm",
project: Annotated[str, typer.Option(help="Calling project id")] = "sand-boxer",
) -> None:
"""Provision a new sandbox from a snapshot checkpoint."""
manager = SandboxManager()
consumer = Consumer(actor=ActorType(actor), project=project)
try:
status = manager.restore(snapshot_id, host=host, consumer=consumer)
except (KeyError, ValueError, Exception) as exc:
typer.echo(f"Error: {exc}", err=True)
raise typer.Exit(code=1) from exc
_print_json(status.model_dump(mode="json"))
@snapshots_app.command("list")
def snapshots_list(
sandbox_id: Annotated[str | None, typer.Option(help="Filter by source sandbox")] = None,
) -> None:
"""List stored snapshot checkpoints."""
items = SandboxManager().list_snapshots(sandbox_id=sandbox_id)
_print_json([s.model_dump(mode="json") for s in items])
@snapshots_app.command("get")
def snapshots_get(snapshot_id: str) -> None:
"""Get snapshot metadata by id."""
record = SandboxManager().get_snapshot(snapshot_id)
if not record:
typer.echo(f"Snapshot not found: {snapshot_id}", err=True)
raise typer.Exit(code=1)
_print_json(record.model_dump(mode="json"))
@app.command("recreate")
def sandbox_recreate(sandbox_id: str) -> None:
"""Destroy and reprovision from stored inputs."""

View file

@ -6,17 +6,20 @@ from sandboxer.extensions.registry import load_extension, resolve_backend
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
from sandboxer.lifecycle.store import SandboxStore, utcnow
from sandboxer.models import (
Consumer,
MeterRecord,
Reachability,
SandboxCreateRequest,
SandboxState,
SandboxStatus,
SnapshotRecord,
)
from sandboxer.payments.credits import CreditsStore
from sandboxer.payments.metering import estimate_cost, settle_usage
from sandboxer.placement import resolve_host
from sandboxer.profiles.loader import load_profile
from sandboxer.routing.resolver import resolve_extension
from sandboxer.snapshots.store import SnapshotStore
from sandboxer.telemetry.export import export_telemetry
from sandboxer.telemetry.introspection import (
build_introspection_report,
@ -30,9 +33,27 @@ class SandboxManager:
self,
store: SandboxStore | None = None,
credits: CreditsStore | None = None,
snapshots: SnapshotStore | None = None,
) -> None:
self.store = store or SandboxStore()
self.credits = credits or CreditsStore()
self.snapshots = snapshots or SnapshotStore()
@staticmethod
def _handle_from_status(status: SandboxStatus) -> dict[str, str]:
return {
"sandbox_id": status.sandbox_id,
"host": status.host or "",
"remote_dir": status.reachability.remote_dir if status.reachability else "",
"compose_project": status.reachability.compose_project if status.reachability else "",
"compose_file": status.inputs.get("compose_file", ""),
"ssh_user": status.inputs.get("ssh_user", ""),
"compose_cmd": status.inputs.get("compose_cmd", ""),
"ssh_port": status.inputs.get("ssh_port", ""),
"vm_target": status.inputs.get("vm_target", ""),
"vm_host": status.inputs.get("vm_host", ""),
"endpoint": status.inputs.get("endpoint", ""),
}
def _resolved_host(self, profile, extension, host_override: str | None) -> str:
if extension.capabilities.pricing_model == "metered":
@ -157,19 +178,7 @@ class SandboxManager:
self.store.save(status)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
handle = {
"sandbox_id": status.sandbox_id,
"host": status.host or "",
"remote_dir": status.reachability.remote_dir if status.reachability else "",
"compose_project": status.reachability.compose_project if status.reachability else "",
"compose_file": status.inputs.get("compose_file", ""),
"ssh_user": status.inputs.get("ssh_user", ""),
"compose_cmd": status.inputs.get("compose_cmd", ""),
"ssh_port": status.inputs.get("ssh_port", ""),
"vm_target": status.inputs.get("vm_target", ""),
"vm_host": status.inputs.get("vm_host", ""),
"endpoint": status.inputs.get("endpoint", ""),
}
handle = self._handle_from_status(status)
backend.teardown(handle)
status.state = SandboxState.DESTROYED
@ -218,4 +227,140 @@ class SandboxManager:
)
if existing.state != SandboxState.DESTROYED:
self.destroy(sandbox_id)
return self.create(request, host=existing.host)
return self.create(request, host=existing.host)
def snapshot(self, sandbox_id: str, *, name: str | None = None) -> SnapshotRecord:
status = self.store.get(sandbox_id)
if not status:
raise KeyError(f"Sandbox not found: {sandbox_id}")
if status.state != SandboxState.READY:
raise RuntimeError(
f"Sandbox must be ready to snapshot, got {status.state.value}"
)
extension = load_extension(status.extension_id)
backend = resolve_backend(extension)
if not backend.supports_snapshots():
raise RuntimeError(f"Extension {extension.id} does not support snapshots")
handle = self._handle_from_status(status)
meta = backend.snapshot(handle)
size_raw = meta.get("size_bytes", "")
size_bytes = int(size_raw) if size_raw.isdigit() else None
record = SnapshotRecord(
snapshot_id=meta["snapshot_id"],
sandbox_id=sandbox_id,
profile_id=status.profile_id,
extension_id=status.extension_id,
host=status.host or meta.get("host", ""),
artifact_path=meta.get("artifact_path", ""),
handle=handle,
inputs=dict(status.inputs),
consumer=status.consumer,
name=name,
size_bytes=size_bytes,
created_at=utcnow(),
)
self.snapshots.save(record)
emit_lifecycle_event(
status,
summary=f"Snapshot {record.snapshot_id} created from sandbox {sandbox_id}",
event_type="milestone",
)
return record
def get_snapshot(self, snapshot_id: str) -> SnapshotRecord | None:
return self.snapshots.get(snapshot_id)
def list_snapshots(self, *, sandbox_id: str | None = None) -> list[SnapshotRecord]:
items = self.snapshots.list_all()
if sandbox_id:
items = [s for s in items if s.sandbox_id == sandbox_id]
return sorted(items, key=lambda s: s.created_at, reverse=True)
def restore(
self,
snapshot_id: str,
*,
host: str | None = None,
consumer: Consumer | None = None,
) -> SandboxStatus:
record = self.snapshots.get(snapshot_id)
if not record:
raise KeyError(f"Snapshot not found: {snapshot_id}")
profile = load_profile(record.profile_id)
extension = load_extension(record.extension_id)
backend = resolve_backend(extension)
if not backend.supports_snapshots():
raise RuntimeError(f"Extension {extension.id} does not support restore")
resolved_host = host or record.host
if not resolved_host:
resolved_host = resolve_host(profile)
use_consumer = consumer or record.consumer
if not use_consumer:
raise ValueError("consumer required for restore (not stored on snapshot)")
now = utcnow()
status = SandboxStatus(
sandbox_id="pending",
profile_id=record.profile_id,
extension_id=record.extension_id,
state=SandboxState.REQUESTED,
consumer=use_consumer,
host=resolved_host,
inputs=dict(record.inputs),
created_at=now,
updated_at=now,
)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
status.state = SandboxState.PROVISIONING
status.updated_at = utcnow()
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
snapshot_meta = {
"snapshot_id": record.snapshot_id,
"artifact_path": record.artifact_path,
"host": record.host,
**record.handle,
}
try:
handle = backend.restore_from_snapshot(
profile, snapshot_meta, record.inputs, resolved_host
)
status.sandbox_id = handle["sandbox_id"]
status.inputs["compose_file"] = handle.get("compose_file", "")
status.inputs["ssh_user"] = handle.get("ssh_user", "")
status.inputs["compose_cmd"] = handle.get("compose_cmd", "")
status.inputs["ssh_port"] = handle.get("ssh_port", "")
status.inputs["vm_target"] = handle.get("vm_target", "")
status.inputs["vm_host"] = handle.get("vm_host", "")
status.inputs["endpoint"] = handle.get("endpoint", "")
status.inputs["restored_from"] = record.snapshot_id
reach = backend.wait_ready(handle)
status.reachability = Reachability(**reach)
status.state = SandboxState.READY
status.ready_at = utcnow()
status.updated_at = status.ready_at
self.store.save(status)
emit_lifecycle_event(
status,
summary=f"Sandbox restored from snapshot {snapshot_id}",
event_type=event_type_for_state(status.state),
)
return status
except Exception as exc:
status.state = SandboxState.FAILED
status.error = str(exc)
status.updated_at = utcnow()
if status.sandbox_id != "pending":
self.store.save(status)
emit_lifecycle_event(
status,
summary=f"Snapshot restore failed: {exc}",
event_type=event_type_for_state(status.state),
)
raise

View file

@ -45,4 +45,22 @@ class SandboxExtension(ABC):
def meter_actual(self, handle: dict[str, str], *, duration_s: float) -> float | None:
"""Optional post-destroy actual cost in USD."""
return None
return None
def supports_snapshots(self) -> bool:
"""Whether this extension implements checkpoint snapshot/restore."""
return False
def snapshot(self, handle: dict[str, str]) -> dict[str, str]:
"""Capture workspace checkpoint. Returns snapshot metadata including snapshot_id."""
raise NotImplementedError(f"{type(self).__name__} does not support snapshots")
def restore_from_snapshot(
self,
profile: Profile,
snapshot_meta: dict[str, str],
inputs: dict[str, str],
host: str,
) -> dict[str, str]:
"""Provision a new sandbox from a prior checkpoint."""
raise NotImplementedError(f"{type(self).__name__} does not support restore")

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import os
import uuid
from pathlib import Path
from typing import Any
@ -35,6 +36,89 @@ class ComposeSSHExtension(SandboxExtension):
def _is_podman_compose(self) -> bool:
return self._compose_bin().startswith("podman-compose")
def supports_snapshots(self) -> bool:
return True
def _ssh_for_handle(self, handle: dict[str, str]) -> SSHConfig:
ssh_user = handle.get("ssh_user") or self.ssh_user or None
return SSHConfig.from_env(handle["host"], user=ssh_user)
def snapshot(self, handle: dict[str, str]) -> dict[str, str]:
remote_dir = handle["remote_dir"]
snapshot_id = str(uuid.uuid4())[:12]
snapshot_dir = f"{self.base_dir}/snapshots"
artifact = f"{snapshot_dir}/{snapshot_id}.tar.gz"
ssh = self._ssh_for_handle(handle)
rc, out = ssh.run(f"mkdir -p {snapshot_dir}")
if rc != 0:
raise RuntimeError(f"Failed to create snapshot dir: {out}")
rc, out = ssh.run(f"tar czf {artifact} -C {remote_dir} .", timeout=300)
if rc != 0:
raise RuntimeError(f"snapshot tar failed: {out}")
rc, out = ssh.run(f"stat -c %s {artifact} 2>/dev/null || stat -f %z {artifact}")
size_bytes = int(out.strip()) if rc == 0 and out.strip().isdigit() else None
return {
"snapshot_id": snapshot_id,
"artifact_path": artifact,
"host": handle["host"],
"remote_dir": remote_dir,
"compose_file": handle.get("compose_file", ""),
"compose_project": handle.get("compose_project", ""),
"ssh_user": handle.get("ssh_user", ""),
"compose_cmd": handle.get("compose_cmd") or self._compose_bin(),
"size_bytes": str(size_bytes) if size_bytes is not None else "",
}
def restore_from_snapshot(
self,
profile: Profile,
snapshot_meta: dict[str, str],
inputs: dict[str, str],
host: str,
) -> dict[str, str]:
artifact_host = snapshot_meta.get("host") or host
if artifact_host != host:
raise NotImplementedError("cross-host restore is not supported in v0")
sandbox_id = self.new_sandbox_id(inputs)
remote_dir = f"{self.base_dir}/{sandbox_id}"
artifact = snapshot_meta["artifact_path"]
compose_file = snapshot_meta.get("compose_file") or inputs.get("compose_file", "")
if not compose_file:
raise ValueError("snapshot missing compose_file")
ssh_user = snapshot_meta.get("ssh_user") or self.ssh_user or None
ssh = SSHConfig.from_env(host, user=ssh_user)
rc, out = ssh.run(f"mkdir -p {remote_dir}")
if rc != 0:
raise RuntimeError(f"Failed to create remote dir: {out}")
rc, out = ssh.run(f"tar xzf {artifact} -C {remote_dir}", timeout=300)
if rc != 0:
raise RuntimeError(f"snapshot extract failed: {out}")
project_name = f"sbx-{profile.id.split('.')[-1]}-{sandbox_id}"
compose_cmd = snapshot_meta.get("compose_cmd") or self._compose_bin()
up_cmd = self._compose_invocation(remote_dir, project_name, compose_file, "up -d")
rc, out = ssh.run(up_cmd, timeout=self.compose_timeout_s)
if rc != 0:
raise RuntimeError(f"compose up after restore failed: {out}")
return {
"sandbox_id": sandbox_id,
"host": host,
"remote_dir": remote_dir,
"compose_project": project_name,
"compose_file": compose_file,
"ssh_user": ssh.user or "",
"compose_cmd": compose_cmd,
}
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:

View file

@ -6,6 +6,7 @@ fallback without E2B/Modal credentials.
from __future__ import annotations
import uuid
from typing import Any
from sandboxer.extensions.base import SandboxExtension
@ -41,6 +42,32 @@ class SaaSStubExtension(SandboxExtension):
hours = max(duration_s / 3600.0, 1 / 3600)
return round(self.session_fee_usd + hours * self.rate_usd_per_hour, 4)
def supports_snapshots(self) -> bool:
return True
def snapshot(self, handle: dict[str, str]) -> dict[str, str]:
snapshot_id = str(uuid.uuid4())[:12]
return {
"snapshot_id": snapshot_id,
"artifact_path": "",
"host": handle.get("host", self.provider),
"endpoint": handle.get("endpoint", ""),
"sandbox_id": handle.get("sandbox_id", ""),
"stub": "true",
}
def restore_from_snapshot(
self,
profile: Profile,
snapshot_meta: dict[str, str],
inputs: dict[str, str],
host: str,
) -> dict[str, str]:
merged = dict(inputs)
if snapshot_meta.get("endpoint"):
merged.setdefault("restore_from", snapshot_meta["endpoint"])
return self.provision(profile, merged, host)
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:

View file

@ -170,4 +170,24 @@ class SandboxStatus(BaseModel):
created_at: datetime
updated_at: datetime
ready_at: datetime | None = None
destroyed_at: datetime | None = None
destroyed_at: datetime | None = None
class SnapshotRestoreRequest(BaseModel):
host: str | None = None
consumer: Consumer | None = None
class SnapshotRecord(BaseModel):
snapshot_id: str
sandbox_id: str
profile_id: str
extension_id: str
host: str
artifact_path: str = ""
handle: dict[str, str] = Field(default_factory=dict)
inputs: dict[str, str] = Field(default_factory=dict)
consumer: Consumer | None = None
name: str | None = None
size_bytes: int | None = None
created_at: datetime

View file

@ -0,0 +1,5 @@
"""Snapshot checkpoint persistence."""
from sandboxer.snapshots.store import SnapshotStore
__all__ = ["SnapshotStore"]

View file

@ -0,0 +1,47 @@
"""Persistent snapshot index (JSON file)."""
from __future__ import annotations
import json
import os
from pathlib import Path
from sandboxer.models import SnapshotRecord
def _default_store_path() -> Path:
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
return base / "sandboxer" / "snapshots.json"
class SnapshotStore:
def __init__(self, path: Path | None = None) -> None:
self.path = path or _default_store_path()
self.path.parent.mkdir(parents=True, exist_ok=True)
def _read(self) -> dict[str, dict]:
if not self.path.exists():
return {}
return json.loads(self.path.read_text())
def _write(self, data: dict[str, dict]) -> None:
self.path.write_text(json.dumps(data, indent=2, default=str))
def save(self, record: SnapshotRecord) -> None:
data = self._read()
data[record.snapshot_id] = record.model_dump(mode="json")
self._write(data)
def get(self, snapshot_id: str) -> SnapshotRecord | None:
raw = self._read().get(snapshot_id)
if not raw:
return None
return SnapshotRecord.model_validate(raw)
def list_all(self) -> list[SnapshotRecord]:
return [SnapshotRecord.model_validate(v) for v in self._read().values()]
def delete(self, snapshot_id: str) -> None:
data = self._read()
data.pop(snapshot_id, None)
self._write(data)