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

View File

@@ -0,0 +1,122 @@
"""Core sandbox establishment logic — harness-agnostic."""
from __future__ import annotations
from sandboxer.extensions.registry import load_extension, resolve_backend
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
from sandboxer.lifecycle.store import SandboxStore, utcnow
from sandboxer.models import (
Reachability,
SandboxCreateRequest,
SandboxState,
SandboxStatus,
)
from sandboxer.placement import resolve_host
from sandboxer.profiles.loader import load_profile
class SandboxManager:
def __init__(self, store: SandboxStore | None = None) -> None:
self.store = store or SandboxStore()
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
profile = load_profile(request.profile)
extension = load_extension(profile.extension)
backend = resolve_backend(extension)
resolved_host = resolve_host(profile, override=host)
now = utcnow()
status = SandboxStatus(
sandbox_id="pending",
profile_id=profile.id,
extension_id=extension.id,
state=SandboxState.REQUESTED,
consumer=request.consumer,
host=resolved_host,
inputs=dict(request.inputs),
created_at=now,
updated_at=now,
)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
status.state = SandboxState.PROVISIONING
status.updated_at = utcnow()
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
try:
handle = backend.provision(profile, request.inputs, resolved_host)
status.sandbox_id = handle["sandbox_id"]
status.inputs["compose_file"] = handle.get("compose_file", "")
status.inputs["ssh_user"] = handle.get("ssh_user", "")
reach = backend.wait_ready(handle)
status.reachability = Reachability(**reach)
status.state = SandboxState.READY
status.ready_at = utcnow()
status.updated_at = status.ready_at
self.store.save(status)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
return status
except Exception as exc:
status.state = SandboxState.FAILED
status.error = str(exc)
status.updated_at = utcnow()
if status.sandbox_id != "pending":
self.store.save(status)
emit_lifecycle_event(
status,
summary=f"Sandbox provision failed: {exc}",
event_type=event_type_for_state(status.state),
)
raise
def get(self, sandbox_id: str) -> SandboxStatus | None:
return self.store.get(sandbox_id)
def list(self) -> list[SandboxStatus]:
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
def destroy(self, sandbox_id: str) -> SandboxStatus:
status = self.store.get(sandbox_id)
if not status:
raise KeyError(f"Sandbox not found: {sandbox_id}")
if status.state == SandboxState.DESTROYED:
return status
profile = load_profile(status.profile_id)
extension = load_extension(profile.extension)
backend = resolve_backend(extension)
status.state = SandboxState.DESTROYING
status.updated_at = utcnow()
self.store.save(status)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
handle = {
"sandbox_id": status.sandbox_id,
"host": status.host or "",
"remote_dir": status.reachability.remote_dir if status.reachability else "",
"compose_project": status.reachability.compose_project if status.reachability else "",
"compose_file": status.inputs.get("compose_file", ""),
"ssh_user": status.inputs.get("ssh_user", ""),
}
backend.teardown(handle)
status.state = SandboxState.DESTROYED
status.destroyed_at = utcnow()
status.updated_at = status.destroyed_at
self.store.save(status)
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
return status
def recreate(self, sandbox_id: str) -> SandboxStatus:
existing = self.store.get(sandbox_id)
if not existing:
raise KeyError(f"Sandbox not found: {sandbox_id}")
request = SandboxCreateRequest(
profile=existing.profile_id,
inputs=dict(existing.inputs),
consumer=existing.consumer,
)
if existing.state != SandboxState.DESTROYED:
self.destroy(sandbox_id)
return self.create(request, host=existing.host)