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:
tegwick 2026-06-22 23:27:31 +02:00
parent b0a57cf9d3
commit d6d3155792
28 changed files with 1796 additions and 15 deletions

View file

@ -0,0 +1,123 @@
"""ext.compose-ssh — SSH + docker compose provisioning (e2e-framework lineage)."""
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any
import yaml
from sandboxer.extensions.ssh import SSHConfig
from sandboxer.models import Profile
class ComposeSSHExtension:
"""Provision isolated compose stacks on a remote host via SSH."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
cfg = config or {}
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer")
self.ssh_user: str = cfg.get("ssh_user", "root")
self.compose_timeout_s: int = int(cfg.get("compose_timeout_s", 180))
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
repo = inputs.get("repo")
if not repo:
raise ValueError("inputs.repo is required for profile.compose-e2e")
repo_path = Path(repo).expanduser().resolve()
if not repo_path.exists():
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
sandbox_id = inputs.get("sandbox_id") or str(uuid.uuid4())[:8]
remote_dir = f"{self.base_dir}/{sandbox_id}"
ssh = SSHConfig.from_env(host, user=self.ssh_user)
rc, out = ssh.run(f"mkdir -p {remote_dir}")
if rc != 0:
raise RuntimeError(f"Failed to create remote dir: {out}")
ssh.rsync(repo_path, remote_dir)
compose_file = self._resolve_compose_file(repo_path)
project_name = f"sbx-{profile.id.split('.')[-1]}-{sandbox_id}"
up_cmd = (
f"cd {remote_dir} && "
f"docker compose -p {project_name} -f {compose_file} up -d"
)
rc, out = ssh.run(up_cmd, timeout=self.compose_timeout_s)
if rc != 0:
raise RuntimeError(f"docker compose up failed: {out}")
return {
"sandbox_id": sandbox_id,
"host": host,
"remote_dir": remote_dir,
"compose_project": project_name,
"compose_file": compose_file,
"repo": str(repo_path),
"ssh_user": ssh.user,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
"""Confirm compose services are running (no HTTP health polling)."""
ssh = SSHConfig.from_env(handle["host"], user=handle.get("ssh_user", self.ssh_user))
project = handle["compose_project"]
remote_dir = handle["remote_dir"]
compose_file = handle["compose_file"]
cmd = (
f"cd {remote_dir} && "
f"docker compose -p {project} -f {compose_file} ps --status running -q"
)
rc, out = ssh.run(cmd, timeout=60)
if rc != 0 or not out.strip():
raise RuntimeError(f"compose services not running: {out}")
return {
"ssh": ssh.target,
"remote_dir": remote_dir,
"compose_project": project,
"host": handle["host"],
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
ssh = SSHConfig.from_env(handle["host"], user=handle.get("ssh_user", self.ssh_user))
project = handle.get("compose_project")
remote_dir = handle.get("remote_dir")
compose_file = handle.get("compose_file")
cleaned_compose = False
if project and remote_dir and compose_file:
down_cmd = (
f"cd {remote_dir} && "
f"docker compose -p {project} -f {compose_file} "
f"down -v --remove-orphans 2>&1 || true"
)
ssh.run(down_cmd, timeout=60)
cleaned_compose = True
cleaned_dir = False
if remote_dir:
rc, _ = ssh.run(f"rm -rf {remote_dir}", timeout=30)
cleaned_dir = rc == 0
return {
"compose_removed": str(cleaned_compose),
"remote_dir_removed": str(cleaned_dir),
"remote_dir": remote_dir or "",
}
def _resolve_compose_file(self, repo_path: Path) -> str:
e2e_yml = repo_path / "e2e" / "e2e.yml"
if e2e_yml.exists():
raw = yaml.safe_load(e2e_yml.read_text())
if raw and raw.get("compose_file"):
return raw["compose_file"]
for candidate in ("docker-compose.dev.yml", "docker-compose.yml", "compose.yml"):
if (repo_path / candidate).exists():
return candidate
raise FileNotFoundError(
f"No compose file found in {repo_path} (expected e2e/e2e.yml or docker-compose*.yml)"
)

View file

@ -0,0 +1,74 @@
"""Extension discovery, validation, and handler resolution."""
from __future__ import annotations
import importlib
from pathlib import Path
from typing import Protocol
import yaml
from sandboxer.models import Extension, Profile
_REPO_ROOT = Path(__file__).resolve().parents[3]
_EXTENSIONS_DIR = _REPO_ROOT / "extensions"
_REQUIRED_CAPABILITY_FIELDS = ("isolation_levels", "pricing_model")
class ExtensionBackend(Protocol):
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]: ...
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]: ...
def teardown(self, handle: dict[str, str]) -> dict[str, str]: ...
def extensions_dir() -> Path:
return _EXTENSIONS_DIR
def _validate_extension_caps(ext: Extension) -> None:
caps = ext.capabilities
for field in _REQUIRED_CAPABILITY_FIELDS:
if not getattr(caps, field, None):
raise ValueError(f"Extension {ext.id} missing capability field: {field}")
if not ext.handler:
raise ValueError(f"Extension {ext.id} missing handler")
def load_extension(extension_id: str, *, extensions_root: Path | None = None) -> Extension:
root = extensions_root or _EXTENSIONS_DIR
path = root / f"{extension_id}.yaml"
if not path.exists():
raise FileNotFoundError(f"Extension not found: {extension_id} ({path})")
raw = yaml.safe_load(path.read_text())
ext = Extension.model_validate(raw)
if ext.id != extension_id:
raise ValueError(f"Extension id mismatch: file {extension_id}, content {ext.id}")
_validate_extension_caps(ext)
return ext
def load_all_extensions(*, extensions_root: Path | None = None) -> dict[str, Extension]:
root = extensions_root or _EXTENSIONS_DIR
extensions: dict[str, Extension] = {}
if not root.exists():
return extensions
for path in sorted(root.glob("*.yaml")):
ext = load_extension(path.stem, extensions_root=root)
if ext.id in extensions:
raise ValueError(f"Duplicate extension id: {ext.id}")
extensions[ext.id] = ext
return extensions
def resolve_backend(extension: Extension) -> ExtensionBackend:
module_path, _, attr = extension.handler.partition(":")
if not attr:
raise ValueError(f"Invalid handler for {extension.id}: {extension.handler}")
module = importlib.import_module(module_path)
cls = getattr(module, attr)
return cls(extension.config)

View file

@ -0,0 +1,71 @@
"""SSH and rsync helpers for self-hosted extensions."""
from __future__ import annotations
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
@dataclass
class SSHConfig:
host: str
user: str = "root"
key: str | None = None
connect_timeout: int = 15
@property
def target(self) -> str:
return f"{self.user}@{self.host}"
@classmethod
def from_env(cls, host: str, *, user: str | None = None, key: str | None = None) -> SSHConfig:
return cls(
host=host,
user=user or os.environ.get("SANDBOXER_SSH_USER", "root"),
key=key or os.environ.get("SANDBOXER_SSH_KEY"),
)
def ssh_base(self) -> list[str]:
args = [
"ssh",
"-o",
"StrictHostKeyChecking=no",
"-o",
"BatchMode=yes",
"-o",
f"ConnectTimeout={self.connect_timeout}",
]
if self.key:
args += ["-i", self.key]
args.append(self.target)
return args
def run(self, cmd: str, *, timeout: int = 60) -> tuple[int, str]:
result = subprocess.run(
self.ssh_base() + [cmd],
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout + result.stderr
def rsync(self, local_path: Path, remote_dir: str, *, timeout: int = 120) -> None:
rsync_args = [
"rsync",
"-az",
"--delete",
"--exclude=.git",
"--exclude=__pycache__",
"--exclude=*.pyc",
"--exclude=.venv",
"--exclude=node_modules",
]
ssh_cmd = "ssh -o StrictHostKeyChecking=no"
if self.key:
ssh_cmd = f"ssh -i {self.key} -o StrictHostKeyChecking=no"
rsync_args += ["-e", ssh_cmd, f"{local_path}/", f"{self.target}:{remote_dir}/"]
result = subprocess.run(rsync_args, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
raise RuntimeError(f"rsync failed: {result.stdout + result.stderr}")