Implement SAND-WP-0002 meta-framework foundation (T01–T09)

Add meta-framework spec, pydantic schemas, profile/extension YAML, extension
registry, ext.compose-ssh backend, SandboxManager with State Hub events, CLI
commands, integration docs, capability registry entry, and compose-e2e runbook.
Nine unit tests pass. T10 remote smoke test remains for operator.
This commit is contained in:
2026-06-22 23:27:31 +02:00
parent b0a57cf9d3
commit d6d3155792
28 changed files with 1796 additions and 15 deletions

84
tests/test_manager.py Normal file
View File

@@ -0,0 +1,84 @@
"""SandboxManager unit tests with mocked backend."""
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, SandboxCreateRequest, SandboxState, SandboxStatus
class FakeBackend:
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",
"remote_dir": handle["remote_dir"],
}
@pytest.fixture
def store(tmp_path: Path) -> SandboxStore:
return SandboxStore(path=tmp_path / "sandboxes.json")
def test_create_and_destroy(store: SandboxStore) -> None:
manager = SandboxManager(store=store)
request = SandboxCreateRequest(
profile="profile.compose-e2e",
inputs={"repo": "/tmp/repo"},
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
)
with (
patch("sandboxer.core.manager.resolve_backend", return_value=FakeBackend()),
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
):
status = manager.create(request)
assert status.state == SandboxState.READY
assert status.sandbox_id == "test1234"
destroyed = manager.destroy(status.sandbox_id)
assert destroyed.state == SandboxState.DESTROYED
def test_destroy_idempotent(store: SandboxStore) -> None:
now = datetime.now(UTC)
status = SandboxStatus(
sandbox_id="gone1234",
profile_id="profile.compose-e2e",
extension_id="ext.compose-ssh",
state=SandboxState.DESTROYED,
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
created_at=now,
updated_at=now,
destroyed_at=now,
)
store.save(status)
manager = SandboxManager(store=store)
result = manager.destroy("gone1234")
assert result.state == SandboxState.DESTROYED

61
tests/test_models.py Normal file
View File

@@ -0,0 +1,61 @@
"""Schema and loader tests."""
from pathlib import Path
import pytest
import yaml
from sandboxer.extensions.registry import load_all_extensions, load_extension
from sandboxer.models import Consumer, Profile, SandboxCreateRequest
from sandboxer.profiles.loader import load_profile
def test_load_compose_e2e_profile() -> None:
profile = load_profile("profile.compose-e2e")
assert profile.extension == "ext.compose-ssh"
assert profile.placement.prefer == ["sandboxer01"]
def test_load_compose_ssh_extension() -> None:
ext = load_extension("ext.compose-ssh")
assert ext.handler.endswith("ComposeSSHExtension")
assert "container" in ext.capabilities.isolation_levels
def test_load_all_extensions_no_duplicates() -> None:
extensions = load_all_extensions()
assert "ext.compose-ssh" in extensions
assert len(extensions) == len(set(extensions))
def test_profile_roundtrip(tmp_path: Path) -> None:
data = load_profile("profile.compose-e2e").model_dump()
path = tmp_path / "profile.test.yaml"
path.write_text(yaml.dump(data))
loaded = Profile.model_validate(yaml.safe_load(path.read_text()))
assert loaded.id == "profile.compose-e2e"
def test_sandbox_create_request() -> None:
req = SandboxCreateRequest(
profile="profile.compose-e2e",
inputs={"repo": "/tmp/foo"},
consumer=Consumer(actor="adm", project="sand-boxer"),
)
assert req.inputs["repo"] == "/tmp/foo"
def test_extension_missing_handler(tmp_path: Path) -> None:
bad = tmp_path / "ext.bad.yaml"
bad.write_text(
yaml.dump(
{
"id": "ext.bad",
"title": "Bad",
"handler": "",
"capabilities": {"isolation_levels": ["container"], "pricing_model": "self-hosted"},
}
)
)
with pytest.raises(ValueError, match="missing handler"):
load_extension("ext.bad", extensions_root=tmp_path)