sand-boxer/src/sandboxer/core/manager.py

178 lines
7.1 KiB
Python
Raw Normal View History

"""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
from sandboxer.telemetry.export import export_telemetry
from sandboxer.telemetry.introspection import (
build_introspection_report,
collect_host_snapshot,
profile_wants_telemetry,
)
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)
wants_telemetry = profile_wants_telemetry(profile)
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
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))
provision_before = None
if wants_telemetry:
provision_before = collect_host_snapshot(resolved_host)
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", "")
status.inputs["compose_cmd"] = handle.get("compose_cmd", "")
status.inputs["ssh_port"] = handle.get("ssh_port", "")
status.inputs["vm_target"] = handle.get("vm_target", "")
status.inputs["vm_host"] = handle.get("vm_host", "")
reach = backend.wait_ready(handle)
status.reachability = Reachability(**reach)
status.state = SandboxState.READY
status.ready_at = utcnow()
status.updated_at = status.ready_at
if wants_telemetry and provision_before:
provision_after = collect_host_snapshot(resolved_host)
report = build_introspection_report(
host=resolved_host,
sandbox_id=status.sandbox_id,
profile=profile,
provision_before=provision_before,
provision_after=provision_after,
store=self.store,
base_dir=base_dir,
)
status.telemetry = report.model_dump(mode="json")
export_telemetry(report)
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)
wants_telemetry = profile_wants_telemetry(profile)
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
destroy_before = None
if wants_telemetry and status.host:
destroy_before = collect_host_snapshot(status.host)
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", ""),
"compose_cmd": status.inputs.get("compose_cmd", ""),
"ssh_port": status.inputs.get("ssh_port", ""),
"vm_target": status.inputs.get("vm_target", ""),
"vm_host": status.inputs.get("vm_host", ""),
}
backend.teardown(handle)
status.state = SandboxState.DESTROYED
status.destroyed_at = utcnow()
status.updated_at = status.destroyed_at
if wants_telemetry and destroy_before and status.host:
destroy_after = collect_host_snapshot(status.host)
report = build_introspection_report(
host=status.host,
sandbox_id=status.sandbox_id,
profile=profile,
destroy_before=destroy_before,
destroy_after=destroy_after,
store=self.store,
base_dir=base_dir,
)
status.telemetry = report.model_dump(mode="json")
export_telemetry(report)
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)