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:
parent
c8126672ee
commit
cec0fc6348
20 changed files with 679 additions and 16 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
34
src/sandboxer/extensions/base.py
Normal file
34
src/sandboxer/extensions/base.py
Normal 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."""
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
125
src/sandboxer/extensions/vm_packer.py
Normal file
125
src/sandboxer/extensions/vm_packer.py
Normal 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",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue