Implement SAND-WP-0005: extension SDK and ext.vm-packer

Add SandboxExtension base class, extension SDK docs, vm-packer attach mode
for build-machines VMs, profile.vm-haskell-build, SSH port support, tests,
and migration docs.
This commit is contained in:
tegwick 2026-06-24 01:47:07 +02:00
parent c8126672ee
commit cec0fc6348
20 changed files with 679 additions and 16 deletions

View file

@ -61,6 +61,9 @@ class SandboxManager:
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", "")
reach = backend.wait_ready(handle)
status.reachability = Reachability(**reach)
status.state = SandboxState.READY
@ -133,6 +136,9 @@ class SandboxManager:
"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", ""),
}
backend.teardown(handle)

View file

@ -0,0 +1,34 @@
"""Extension author SDK — base contract for sandbox backends."""
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from typing import Any
from sandboxer.models import Profile
class SandboxExtension(ABC):
"""Base class for self-hosted and SaaS sandbox extensions."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
self.config: dict[str, Any] = config or {}
@staticmethod
def new_sandbox_id(inputs: dict[str, str]) -> str:
return inputs.get("sandbox_id") or str(uuid.uuid4())[:8]
@abstractmethod
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
"""Create or attach sandbox resources. Returns a handle dict for later ops."""
@abstractmethod
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
"""Confirm reachability. Returns reachability descriptor fields."""
@abstractmethod
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
"""Release sandbox resources. Returns cleanup report fields."""

View file

@ -3,21 +3,22 @@
from __future__ import annotations
import os
import uuid
from pathlib import Path
from typing import Any
import yaml
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.ssh import SSHConfig
from sandboxer.models import Profile
class ComposeSSHExtension:
class ComposeSSHExtension(SandboxExtension):
"""Provision isolated compose stacks on a remote host via SSH."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
cfg = config or {}
super().__init__(config)
cfg = self.config
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer")
self.ssh_user: str | None = cfg.get("ssh_user")
self.compose_timeout_s: int = int(cfg.get("compose_timeout_s", 180))
@ -44,7 +45,7 @@ class ComposeSSHExtension:
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]
sandbox_id = self.new_sandbox_id(inputs)
remote_dir = f"{self.base_dir}/{sandbox_id}"
ssh = SSHConfig.from_env(host, user=self.ssh_user or None)

View file

@ -13,6 +13,7 @@ class SSHConfig:
host: str
user: str | None = None
key: str | None = None
port: int | None = None
connect_timeout: int = 15
@property
@ -47,6 +48,8 @@ class SSHConfig:
]
if self.key:
args += ["-i", self.key]
if self.port:
args += ["-p", str(self.port)]
args.append(self.destination)
return args
@ -73,6 +76,8 @@ class SSHConfig:
ssh_cmd = "ssh -o StrictHostKeyChecking=no"
if self.key:
ssh_cmd = f"ssh -i {self.key} -o StrictHostKeyChecking=no"
if self.port:
ssh_cmd += f" -p {self.port}"
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:

View file

@ -0,0 +1,125 @@
"""ext.vm-packer — attach to pre-built VMs (build-machines lineage).
v0 supports **attach** mode only: connect via SSH to an existing VM (tunnel alias,
localhost:port, or direct host). Creates an isolated workspace directory; teardown
removes the workspace, not the VM.
Full Packer build / OVA import orchestration is deferred operators still build
images via the-custodian/infra/build-machines/ workflows.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.ssh import SSHConfig
from sandboxer.models import Profile
class VMPackerExtension(SandboxExtension):
"""Attach sandbox workspace on a pre-provisioned VM."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
super().__init__(config)
self.workspace_base: str = self.config.get("workspace_base", "/build")
self.default_user: str | None = self.config.get("ssh_user")
self.ready_timeout_s: int = int(self.config.get("ready_timeout_s", 30))
def _ssh_from_handle(self, handle: dict[str, str]) -> SSHConfig:
port_raw = handle.get("ssh_port") or os.environ.get("SANDBOXER_VM_SSH_PORT")
port = int(port_raw) if port_raw else None
user = handle.get("ssh_user") or self.default_user
host = handle.get("vm_host") or handle.get("host") or "localhost"
return SSHConfig(
host=host,
user=user,
key=os.environ.get("SANDBOXER_SSH_KEY"),
port=port,
)
def _resolve_vm_host(
self, inputs: dict[str, str], placement_host: str
) -> tuple[str, int | None]:
"""Return SSH host and optional port for attach mode."""
if inputs.get("ssh_port"):
return inputs.get("vm_host") or placement_host or "localhost", int(inputs["ssh_port"])
if inputs.get("tunnel_port"):
return "localhost", int(inputs["tunnel_port"])
env_port = os.environ.get("SANDBOXER_VM_TUNNEL_PORT")
if env_port and (placement_host in ("localhost", "127.0.0.1", "workstation")):
return "localhost", int(env_port)
return inputs.get("vm_host") or placement_host, None
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
vm_target = inputs.get("vm") or inputs.get("ssh_target")
if not vm_target:
raise ValueError("inputs.vm or inputs.ssh_target is required for ext.vm-packer")
sandbox_id = self.new_sandbox_id(inputs)
remote_dir = inputs.get("workspace_dir") or f"{self.workspace_base}/sbx-{sandbox_id}"
vm_host, ssh_port = self._resolve_vm_host(inputs, host)
ssh_user = inputs.get("ssh_user") or self.default_user
if "@" in vm_target or "." not in vm_target:
ssh = SSHConfig(host=vm_target, user=ssh_user, port=ssh_port)
connect_host = vm_target
else:
ssh = SSHConfig(host=vm_host, user=ssh_user, port=ssh_port)
connect_host = vm_host
rc, out = ssh.run(f"mkdir -p {remote_dir}")
if rc != 0:
raise RuntimeError(f"Failed to create workspace on VM: {out}")
repo_path_str = ""
repo = inputs.get("repo")
if repo:
repo_path = Path(repo).expanduser().resolve()
if not repo_path.exists():
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
ssh.rsync(repo_path, remote_dir)
repo_path_str = str(repo_path)
return {
"sandbox_id": sandbox_id,
"host": host,
"vm_host": connect_host,
"vm_target": vm_target,
"remote_dir": remote_dir,
"ssh_user": ssh.user or "",
"ssh_port": str(ssh.port) if ssh.port else "",
"mode": inputs.get("mode", "attach"),
"repo": repo_path_str,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
ssh = self._ssh_from_handle(handle)
remote_dir = handle["remote_dir"]
cmd = f"test -d {remote_dir} && echo ready"
rc, out = ssh.run(cmd, timeout=self.ready_timeout_s)
if rc != 0 or "ready" not in out:
raise RuntimeError(f"VM workspace not ready: {out}")
return {
"ssh": ssh.target,
"remote_dir": remote_dir,
"host": handle.get("host"),
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
remote_dir = handle.get("remote_dir")
cleaned_dir = False
if remote_dir:
ssh = self._ssh_from_handle(handle)
rc, _ = ssh.run(f"rm -rf {remote_dir}", timeout=60)
cleaned_dir = rc == 0
return {
"workspace_removed": str(cleaned_dir),
"remote_dir": remote_dir or "",
"vm_preserved": "true",
}