2026-06-22 23:27:31 +02:00
|
|
|
"""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", "")
|
2026-06-23 16:52:29 +02:00
|
|
|
status.inputs["compose_cmd"] = handle.get("compose_cmd", "")
|
2026-06-22 23:27:31 +02:00
|
|
|
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", ""),
|
2026-06-23 16:52:29 +02:00
|
|
|
"compose_cmd": status.inputs.get("compose_cmd", ""),
|
2026-06-22 23:27:31 +02:00
|
|
|
}
|
|
|
|
|
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)
|