generated from coulomb/repo-seed
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:
122
src/sandboxer/core/manager.py
Normal file
122
src/sandboxer/core/manager.py
Normal 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)
|
||||
Reference in New Issue
Block a user