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:
parent
b0a57cf9d3
commit
d6d3155792
28 changed files with 1796 additions and 15 deletions
62
src/sandboxer/lifecycle/state_hub.py
Normal file
62
src/sandboxer/lifecycle/state_hub.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""State Hub lifecycle event emission."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from sandboxer.models import SandboxState, SandboxStatus
|
||||
|
||||
_DEFAULT_HUB = "http://127.0.0.1:8000"
|
||||
|
||||
|
||||
def hub_url() -> str:
|
||||
return os.environ.get("STATE_HUB_URL", _DEFAULT_HUB)
|
||||
|
||||
|
||||
def emit_lifecycle_event(
|
||||
status: SandboxStatus,
|
||||
*,
|
||||
summary: str | None = None,
|
||||
event_type: str = "note",
|
||||
author: str = "sandboxer",
|
||||
) -> dict[str, Any] | None:
|
||||
if os.environ.get("SANDBOXER_NO_STATE_HUB", "").lower() in ("1", "true", "yes"):
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"event_type": event_type,
|
||||
"summary": summary or f"Sandbox {status.sandbox_id} → {status.state.value}",
|
||||
"author": author,
|
||||
"detail": {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"profile_id": status.profile_id,
|
||||
"extension_id": status.extension_id,
|
||||
"host": status.host,
|
||||
"consumer": status.consumer.model_dump(),
|
||||
"actor_type": status.consumer.actor.value,
|
||||
"state": status.state.value,
|
||||
"reachability": status.reachability.model_dump() if status.reachability else None,
|
||||
"timestamps": {
|
||||
"created_at": status.created_at.isoformat(),
|
||||
"updated_at": status.updated_at.isoformat(),
|
||||
"ready_at": status.ready_at.isoformat() if status.ready_at else None,
|
||||
"destroyed_at": status.destroyed_at.isoformat() if status.destroyed_at else None,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
response = httpx.post(f"{hub_url()}/progress/", json=payload, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def event_type_for_state(state: SandboxState) -> str:
|
||||
if state in (SandboxState.READY, SandboxState.DESTROYED):
|
||||
return "milestone"
|
||||
return "note"
|
||||
52
src/sandboxer/lifecycle/store.py
Normal file
52
src/sandboxer/lifecycle/store.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Persistent sandbox status store (JSON file)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sandboxer.models import SandboxStatus
|
||||
|
||||
|
||||
def _default_store_path() -> Path:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||
return base / "sandboxer" / "sandboxes.json"
|
||||
|
||||
|
||||
class SandboxStore:
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or _default_store_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read(self) -> dict[str, dict]:
|
||||
if not self.path.exists():
|
||||
return {}
|
||||
return json.loads(self.path.read_text())
|
||||
|
||||
def _write(self, data: dict[str, dict]) -> None:
|
||||
self.path.write_text(json.dumps(data, indent=2, default=str))
|
||||
|
||||
def save(self, status: SandboxStatus) -> None:
|
||||
data = self._read()
|
||||
data[status.sandbox_id] = status.model_dump(mode="json")
|
||||
self._write(data)
|
||||
|
||||
def get(self, sandbox_id: str) -> SandboxStatus | None:
|
||||
raw = self._read().get(sandbox_id)
|
||||
if not raw:
|
||||
return None
|
||||
return SandboxStatus.model_validate(raw)
|
||||
|
||||
def list_all(self) -> list[SandboxStatus]:
|
||||
return [SandboxStatus.model_validate(v) for v in self._read().values()]
|
||||
|
||||
def delete(self, sandbox_id: str) -> None:
|
||||
data = self._read()
|
||||
data.pop(sandbox_id, None)
|
||||
self._write(data)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
Loading…
Add table
Add a link
Reference in a new issue