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:
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,7 @@ from unittest.mock import patch
from fastapi.testclient import TestClient
from sandboxer.api.app import app
from sandboxer.models import ActorType, Consumer, SandboxState, SandboxStatus
from sandboxer.models import ActorType, Consumer, SandboxState, SandboxStatus, SnapshotRecord
def test_list_sandboxes_empty() -> None:
@@ -46,4 +46,46 @@ def test_create_sandbox() -> None:
},
)
assert resp.status_code == 200
assert resp.json()["sandbox_id"] == "abc12345"
assert resp.json()["sandbox_id"] == "abc12345"
def test_snapshot_sandbox() -> None:
from datetime import UTC, datetime
record = SnapshotRecord(
snapshot_id="snap12345678",
sandbox_id="abc12345",
profile_id="profile.compose-checkpoint",
extension_id="ext.compose-ssh",
host="coulombcore",
created_at=datetime.now(UTC),
)
with patch("sandboxer.api.app._manager") as mgr:
mgr.snapshot.return_value = record
client = TestClient(app)
resp = client.post("/v1/sandboxes/abc12345/snapshot")
assert resp.status_code == 200
assert resp.json()["snapshot_id"] == "snap12345678"
def test_restore_snapshot() -> None:
from datetime import UTC, datetime
status = SandboxStatus(
sandbox_id="restored1",
profile_id="profile.compose-checkpoint",
extension_id="ext.compose-ssh",
state=SandboxState.READY,
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
with patch("sandboxer.api.app._manager") as mgr:
mgr.restore.return_value = status
client = TestClient(app)
resp = client.post(
"/v1/snapshots/snap12345678/restore",
json={"consumer": {"actor": "adm", "project": "sand-boxer"}},
)
assert resp.status_code == 200
assert resp.json()["sandbox_id"] == "restored1"

View File

@@ -1,6 +1,21 @@
"""Compose command configuration."""
"""Compose command configuration and snapshot hooks."""
from unittest.mock import patch
import pytest
from sandboxer.extensions.compose_ssh import ComposeSSHExtension
from sandboxer.models import Profile
def _profile() -> Profile:
return Profile.model_validate(
{
"id": "profile.compose-checkpoint",
"version": "1.0.0",
"extension": "ext.compose-ssh",
}
)
def test_compose_cmd_from_config() -> None:
@@ -11,4 +26,72 @@ def test_compose_cmd_from_config() -> None:
def test_compose_cmd_env_override(monkeypatch) -> None:
monkeypatch.setenv("SANDBOXER_COMPOSE_CMD", "nerdctl compose")
ext = ComposeSSHExtension({"compose_cmd": "docker compose"})
assert ext._compose_bin() == "nerdctl compose"
assert ext._compose_bin() == "nerdctl compose"
def test_supports_snapshots() -> None:
ext = ComposeSSHExtension()
assert ext.supports_snapshots() is True
def test_snapshot_creates_remote_tarball() -> None:
ext = ComposeSSHExtension({"base_dir": "/tmp/sandboxer"})
handle = {
"sandbox_id": "abc12345",
"host": "coulombcore",
"remote_dir": "/tmp/sandboxer/abc12345",
"compose_file": "docker-compose.yml",
"compose_project": "sbx-e2e-abc12345",
"ssh_user": "root",
}
def fake_run(cmd, *, timeout=60):
if "tar czf" in cmd:
return 0, ""
if "stat" in cmd:
return 0, "2048"
return 0, ""
with patch.object(ext, "_ssh_for_handle") as ssh_factory:
ssh = ssh_factory.return_value
ssh.run.side_effect = fake_run
meta = ext.snapshot(handle)
assert meta["artifact_path"].endswith(".tar.gz")
assert meta["snapshot_id"]
assert meta["size_bytes"] == "2048"
def test_restore_from_snapshot_extracts_and_compose_up() -> None:
ext = ComposeSSHExtension({"base_dir": "/tmp/sandboxer"})
snapshot_meta = {
"snapshot_id": "snap12345678",
"artifact_path": "/tmp/sandboxer/snapshots/snap12345678.tar.gz",
"host": "coulombcore",
"compose_file": "docker-compose.yml",
"ssh_user": "root",
}
with patch("sandboxer.extensions.compose_ssh.SSHConfig.from_env") as ssh_factory:
ssh = ssh_factory.return_value
ssh.run.return_value = (0, "")
ssh.user = "root"
handle = ext.restore_from_snapshot(_profile(), snapshot_meta, {}, "coulombcore")
assert handle["sandbox_id"]
assert handle["remote_dir"].endswith(handle["sandbox_id"])
calls = [c.args[0] for c in ssh.run.call_args_list]
assert any("tar xzf" in c for c in calls)
assert any("up -d" in c for c in calls)
def test_restore_cross_host_not_supported() -> None:
ext = ComposeSSHExtension()
snapshot_meta = {
"snapshot_id": "snap1",
"artifact_path": "/tmp/snap.tar.gz",
"host": "host-a",
"compose_file": "docker-compose.yml",
}
with pytest.raises(NotImplementedError, match="cross-host"):
ext.restore_from_snapshot(_profile(), snapshot_meta, {}, "host-b")

View File

@@ -1,5 +1,7 @@
"""Extension SDK base class tests."""
import pytest
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.compose_ssh import ComposeSSHExtension
from sandboxer.extensions.vm_packer import VMPackerExtension
@@ -13,4 +15,21 @@ def test_reference_extensions_subclass_base() -> None:
def test_new_sandbox_id_from_inputs() -> None:
assert SandboxExtension.new_sandbox_id({"sandbox_id": "fixed123"}) == "fixed123"
generated = SandboxExtension.new_sandbox_id({})
assert len(generated) == 8
assert len(generated) == 8
def test_default_snapshot_not_supported() -> None:
class MinimalExtension(SandboxExtension):
def provision(self, profile, inputs, host):
return {}
def wait_ready(self, handle):
return {}
def teardown(self, handle):
return {}
ext = MinimalExtension()
assert ext.supports_snapshots() is False
with pytest.raises(NotImplementedError):
ext.snapshot({})

172
tests/test_snapshots.py Normal file
View File

@@ -0,0 +1,172 @@
"""Snapshot store and manager checkpoint tests."""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import patch
import pytest
from sandboxer.core.manager import SandboxManager
from sandboxer.lifecycle.store import SandboxStore
from sandboxer.models import (
ActorType,
Consumer,
Reachability,
SandboxCreateRequest,
SandboxState,
SandboxStatus,
SnapshotRecord,
)
from sandboxer.snapshots.store import SnapshotStore
class SnapshotBackend:
def supports_snapshots(self) -> bool:
return True
def provision(self, profile, inputs, host):
return {
"sandbox_id": "test1234",
"host": host,
"remote_dir": "/tmp/sandboxer/test1234",
"compose_project": "sbx-e2e-test1234",
"compose_file": "docker-compose.yml",
"ssh_user": "root",
}
def wait_ready(self, handle):
return {
"ssh": f"root@{handle['host']}",
"remote_dir": handle["remote_dir"],
"compose_project": handle["compose_project"],
"host": handle["host"],
}
def teardown(self, handle):
return {"compose_removed": "True", "remote_dir_removed": "True"}
def snapshot(self, handle):
return {
"snapshot_id": "snap12345678",
"artifact_path": "/tmp/sandboxer/snapshots/snap12345678.tar.gz",
"host": handle["host"],
"size_bytes": "4096",
}
def restore_from_snapshot(self, profile, snapshot_meta, inputs, host):
return {
"sandbox_id": "restored1",
"host": host,
"remote_dir": "/tmp/sandboxer/restored1",
"compose_project": "sbx-e2e-restored1",
"compose_file": "docker-compose.yml",
"ssh_user": "root",
}
@pytest.fixture
def store(tmp_path: Path) -> SandboxStore:
return SandboxStore(path=tmp_path / "sandboxes.json")
@pytest.fixture
def snapshots(tmp_path: Path) -> SnapshotStore:
return SnapshotStore(path=tmp_path / "snapshots.json")
def _ready_status(sandbox_id: str = "test1234") -> SandboxStatus:
now = datetime.now(UTC)
return SandboxStatus(
sandbox_id=sandbox_id,
profile_id="profile.compose-checkpoint",
extension_id="ext.compose-ssh",
state=SandboxState.READY,
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
host="coulombcore",
reachability=Reachability(
ssh="root@coulombcore",
remote_dir="/tmp/sandboxer/test1234",
compose_project="sbx-e2e-test1234",
host="coulombcore",
),
inputs={
"repo": "/tmp/repo",
"compose_file": "docker-compose.yml",
"ssh_user": "root",
},
created_at=now,
updated_at=now,
ready_at=now,
)
def test_snapshot_store_roundtrip(snapshots: SnapshotStore) -> None:
now = datetime.now(UTC)
record = SnapshotRecord(
snapshot_id="snap12345678",
sandbox_id="test1234",
profile_id="profile.compose-checkpoint",
extension_id="ext.compose-ssh",
host="coulombcore",
artifact_path="/tmp/snap.tar.gz",
created_at=now,
)
snapshots.save(record)
loaded = snapshots.get("snap12345678")
assert loaded is not None
assert loaded.sandbox_id == "test1234"
def test_manager_snapshot_and_restore(store: SandboxStore, snapshots: SnapshotStore) -> None:
store.save(_ready_status())
manager = SandboxManager(store=store, snapshots=snapshots)
backend = SnapshotBackend()
with (
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
patch("sandboxer.core.manager.load_extension"),
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
patch("sandboxer.core.manager.load_profile"),
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
):
record = manager.snapshot("test1234", name="pre-test")
assert record.snapshot_id == "snap12345678"
assert record.name == "pre-test"
assert record.size_bytes == 4096
status = manager.restore("snap12345678")
assert status.state == SandboxState.READY
assert status.sandbox_id == "restored1"
assert status.inputs.get("restored_from") == "snap12345678"
def test_snapshot_requires_ready(store: SandboxStore, snapshots: SnapshotStore) -> None:
status = _ready_status()
status.state = SandboxState.PROVISIONING
store.save(status)
manager = SandboxManager(store=store, snapshots=snapshots)
with pytest.raises(RuntimeError, match="ready"):
manager.snapshot("test1234")
def test_create_snapshot_restore_flow(store: SandboxStore, snapshots: SnapshotStore) -> None:
manager = SandboxManager(store=store, snapshots=snapshots)
backend = SnapshotBackend()
request = SandboxCreateRequest(
profile="profile.compose-checkpoint",
inputs={"repo": "/tmp/repo"},
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
)
with (
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
):
created = manager.create(request)
record = manager.snapshot(created.sandbox_id)
restored = manager.restore(record.snapshot_id)
assert restored.sandbox_id == "restored1"