"""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 ( MeterRecord, Reachability, SandboxCreateRequest, SandboxState, SandboxStatus, ) from sandboxer.payments.credits import CreditsStore from sandboxer.payments.metering import estimate_cost, settle_usage from sandboxer.placement import resolve_host from sandboxer.profiles.loader import load_profile from sandboxer.routing.resolver import resolve_extension 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, credits: CreditsStore | None = None, ) -> None: self.store = store or SandboxStore() self.credits = credits or CreditsStore() def _resolved_host(self, profile, extension, host_override: str | None) -> str: if extension.capabilities.pricing_model == "metered": return extension.config.get("provider", "saas") return resolve_host(profile, override=host_override) def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus: profile = load_profile(request.profile) extension = resolve_extension(profile, request.inputs, host_override=host) backend = resolve_backend(extension) resolved_host = self._resolved_host(profile, extension, host) wants_telemetry = profile_wants_telemetry(profile) base_dir = extension.config.get("base_dir", "/tmp/sandboxer") quote = estimate_cost(extension, profile, request.inputs) meter_record: MeterRecord | None = None if quote: if not self.credits.can_afford(quote.estimated_usd): raise RuntimeError( f"Insufficient credits: need {quote.estimated_usd:.4f} USD, " f"balance {self.credits.balance():.4f} USD" ) meter_record = MeterRecord( pricing_model="metered", estimate_usd=quote.estimated_usd, ) 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), meter=meter_record, 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 and extension.capabilities.pricing_model != "metered": 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", "") status.inputs["endpoint"] = handle.get("endpoint", "") 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(status.extension_id) 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 and extension.capabilities.pricing_model != "metered": 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", ""), "endpoint": status.inputs.get("endpoint", ""), } backend.teardown(handle) status.state = SandboxState.DESTROYED status.destroyed_at = utcnow() status.updated_at = status.destroyed_at settled = settle_usage(status, extension, handle, destroyed_at=status.destroyed_at) if settled and settled.pricing_model == "metered" and settled.actual_usd: self.credits.debit(settled.actual_usd) status.meter = settled emit_lifecycle_event( status, summary=( f"Sandbox metered: {settled.actual_usd:.4f} USD " f"({settled.duration_s:.0f}s, ext={extension.id})" ), event_type="note", ) 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)