generated from coulomb/repo-seed
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:
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
172
tests/test_snapshots.py
Normal 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"
|
||||
Reference in New Issue
Block a user