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:
parent
2760ef2373
commit
952cebf2e9
21 changed files with 966 additions and 34 deletions
|
|
@ -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")
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue