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
123
src/sandboxer/extensions/compose_ssh.py
Normal file
123
src/sandboxer/extensions/compose_ssh.py
Normal 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)"
|
||||
)
|
||||
74
src/sandboxer/extensions/registry.py
Normal file
74
src/sandboxer/extensions/registry.py
Normal 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)
|
||||
71
src/sandboxer/extensions/ssh.py
Normal file
71
src/sandboxer/extensions/ssh.py
Normal 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}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue