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:
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