diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index f0bec68..6ffaaad 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -38,6 +38,7 @@ Sandbox CLI (v0): ```bash sandboxer create # canary self-deploy (profile.sandbox-canary) sandboxer create --profile profile.compose-e2e --input repo=/path/to/repo +sandboxer create --profile profile.vm-haskell-build --input vm=haskell-build --input repo=/path sandboxer get sandboxer list sandboxer destroy diff --git a/INTENT.md b/INTENT.md index c6c43b4..2c21072 100644 --- a/INTENT.md +++ b/INTENT.md @@ -356,8 +356,8 @@ follows evidence. 4. ~~**Profile catalog start**~~ — `profile.compose-e2e`, `profile.sandbox-canary` 5. ~~**Registry entry**~~ — `capability.execution.sandbox-provision` 6. ~~**Sibling integration notes**~~ — `docs/integrations/` -7. **Extension SDK sketch** — contract for P1 backends (vm-packer, Daytona OSS) -8. **wise-validator** — separate repo/workplan (SAND-WP-0003); not a sand-boxer dependency +7. ~~**Extension SDK sketch**~~ — done (`docs/extension-sdk.md`, `ext.vm-packer` attach mode) +8. ~~**wise-validator**~~ — sibling repo (SAND-WP-0003); not a sand-boxer dependency --- diff --git a/SCOPE.md b/SCOPE.md index 79c6ce5..193fb26 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -104,12 +104,10 @@ own tunnels or CAs. ## Current State - **Status:** v0 operational — self-hosted compose path proven on CoulombCore -- **Workplans finished:** SAND-WP-0001 (bootstrap), 0002 (meta-framework + - `ext.compose-ssh`), 0003 (wise-validator extraction, sibling repo), 0008 (host - telemetry / self-canary) +- **Workplans finished:** SAND-WP-0001–0005, 0008 (see `workplans/`) - **Package:** `src/sandboxer/` — CLI, manager, extensions, telemetry, HTTP API -- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary` -- **Extensions:** `ext.compose-ssh` only +- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary`, `profile.vm-haskell-build` +- **Extensions:** `ext.compose-ssh`, `ext.vm-packer` (attach mode) - **Registry:** `capability.execution.sandbox-provision` indexed (draft) - **Tests:** 26 pytest cases; `make check` green - **Sibling:** wise-validator ships `validate run` (SAND-WP-0003) @@ -145,7 +143,7 @@ cd ~/the-custodian && make e2e REPO=activity-core - ~~`make e2e REPO=` shim~~ — done (SAND-WP-0004; delegates to `validate run`) - TTL auto-expiry / `extend_ttl` enforcement -- `ext.vm-packer` / build-machines migration (SAND-WP-0005) +- ~~`ext.vm-packer` attach mode~~ — done (SAND-WP-0005); Packer build orchestration deferred - SaaS extensions (E2B, Modal) or payments layer (SAND-WP-0006) - Snapshot / restore / checkpoint profiles (SAND-WP-0007) - Formal ops-bridge tunnel attachment in reachability descriptor diff --git a/docs/extension-sdk.md b/docs/extension-sdk.md new file mode 100644 index 0000000..c1c6319 --- /dev/null +++ b/docs/extension-sdk.md @@ -0,0 +1,102 @@ +# Extension SDK + +Author guide for sand-boxer backend adapters. Version 0.1 — SAND-WP-0005. + +## Contract + +Every extension implements three methods: + +```text +provision(profile, inputs, host) → handle dict +wait_ready(handle) → reachability dict +teardown(handle) → cleanup report dict +``` + +Optional (SaaS, deferred): `estimate_cost(profile, duration) → MeterQuote` + +### Base class + +```python +from sandboxer.extensions.base import SandboxExtension + +class MyExtension(SandboxExtension): + def provision(self, profile, inputs, host): ... + def wait_ready(self, handle): ... + def teardown(self, handle): ... +``` + +Reference implementations: + +| Extension | Module | Mode | +|-----------|--------|------| +| `ext.compose-ssh` | `compose_ssh.py` | Remote compose stack | +| `ext.vm-packer` | `vm_packer.py` | Attach workspace on pre-built VM | + +## Registration + +1. Add `extensions/ext..yaml`: + +```yaml +id: ext.my-backend +title: My Backend +handler: sandboxer.extensions.my_backend:MyExtension +capabilities: + isolation_levels: [container] + pricing_model: self-hosted +config: + key: value +``` + +2. Add a profile binding in `profiles/profile..yaml` with `extension: ext.my-backend`. +3. Register capability metadata in `registry/` when ready for reuse-surface. + +Loader validates `capabilities.isolation_levels` and `capabilities.pricing_model` +at startup (`sandboxer.extensions.registry`). + +## Handle and reachability + +**Handle** (returned by `provision`, stored in manager): opaque dict passed to +`wait_ready` and `teardown`. Include at minimum: + +- `sandbox_id` +- `host` (placement host) +- Fields your extension needs for SSH/API (e.g. `remote_dir`, `vm_target`) + +**Reachability** (returned by `wait_ready`): exposed on `SandboxStatus.reachability`: + +- `ssh` — SSH destination string +- `remote_dir` — workspace path on remote +- `host` — placement host +- `compose_project` — compose-ssh only + +## Inputs convention + +Profiles declare semantics; extensions validate required `inputs` keys: + +| Extension | Required inputs | Optional | +|-----------|-----------------|----------| +| compose-ssh | `repo` | `sandbox_id` | +| vm-packer | `vm` or `ssh_target` | `repo`, `tunnel_port`, `ssh_port`, `workspace_dir` | + +Consumer attribution travels on `SandboxCreateRequest.consumer`, not extension inputs. + +## Testing + +Mock SSH/subprocess in unit tests. See `tests/test_compose_ssh.py`, `tests/test_vm_packer.py`. + +Pattern: + +```python +with patch.object(SSHConfig, "run", return_value=(0, "ready")): + ext = VMPackerExtension() + handle = ext.provision(profile, {"vm": "haskell-build"}, "localhost") +``` + +## Deferred + +| Feature | Workplan | +|---------|----------| +| Packer build orchestration from `create` | Future WP | +| SaaS adapters + `estimate_cost` | SAND-WP-0006 | +| Multi-backend routing engine | SAND-WP-0006 | +| Snapshot / restore hooks | SAND-WP-0007 | \ No newline at end of file diff --git a/docs/migration-build-machines.md b/docs/migration-build-machines.md new file mode 100644 index 0000000..c3d0237 --- /dev/null +++ b/docs/migration-build-machines.md @@ -0,0 +1,62 @@ +# Migration — build-machines → ext.vm-packer + +Maps `the-custodian/infra/build-machines/` to sand-boxer `profile.vm-haskell-build`. + +## What moved + +| Legacy (build-machines) | sand-boxer v0 | +|-------------------------|---------------| +| Packer OVA build | **Unchanged** — operator runs Packer in the-custodian | +| VM boot + build-agent registration | **Unchanged** — systemd agent on VM | +| `make remote-build PROJECT=` | `sandboxer create` + SSH into `reachability.remote_dir` | +| Isolated workspace `/build/` | `/build/sbx-/` per create | +| `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) | + +## v0 attach workflow + +1. Build/import VM per [build-machines README](~/the-custodian/infra/build-machines/README.md). +2. Ensure tunnel is up (`make bridge-status` in build-machines). +3. Create sand-boxer workspace: + +```bash +export SANDBOXER_VM_TUNNEL_PORT=12222 # or use SSH alias + +sandboxer create \ + --profile profile.vm-haskell-build \ + --input vm=haskell-build \ + --input repo=~/projects/my-haskell-app \ + --host localhost +``` + +4. Run builds on VM: + +```bash +ssh haskell-build "cd && source ~/.ghcup/env && cabal build all" +``` + +5. Destroy workspace (VM stays running): + +```bash +sandboxer destroy +``` + +## Inputs + +| Input | Purpose | +|-------|---------| +| `vm` | SSH config alias (e.g. `haskell-build`) | +| `ssh_target` | Alias for `vm` | +| `tunnel_port` | Local reverse-tunnel port (default via `SANDBOXER_VM_TUNNEL_PORT`) | +| `repo` | Optional rsync source to workspace | +| `workspace_dir` | Override workspace path on VM | + +## Not migrated yet + +- Automated Packer `create` trigger from sand-boxer API +- State Hub capability-catalog sync from build-agent (agent unchanged) +- Port registry automation (`port-registry.yml`) +- `make remote-build` Makefile targets in the-custodian (add shim in follow-on if needed) + +## Runbook + +`docs/runbooks/profile-vm-haskell-build.md` \ No newline at end of file diff --git a/docs/migration-gaps.md b/docs/migration-gaps.md index a226a93..a014ba9 100644 --- a/docs/migration-gaps.md +++ b/docs/migration-gaps.md @@ -28,9 +28,21 @@ Recorded after SAND-WP-0002-T10 remote verification on CoulombCore (`92.205.130. **e2e-framework migration arc complete** (provision: sand-boxer, validation: wise-validator, operator entry: `make e2e`). +## build-machines (SAND-WP-0005) — attach mode delivered + +| Legacy (`build-machines`) | sand-boxer today | Notes | +|---------------------------|------------------|-------| +| Packer OVA build | Operator-driven (unchanged) | Not triggered by `create` | +| `make remote-build` rsync + SSH | `sandboxer create --profile profile.vm-haskell-build` | Workspace `/build/sbx-/` | +| VM teardown | N/A | `destroy` removes workspace only; VM persists | +| Extension author contract | `docs/extension-sdk.md` | `SandboxExtension` base class | + +Deferred: Packer orchestration from API, `make remote-build` shim. + ## sand-boxer follow-ons | Item | Workplan | |------|----------| -| Self-canary + host telemetry | SAND-WP-0008 | -| Default `sandboxer create` without repo | SAND-WP-0008-T06 | \ No newline at end of file +| SaaS extensions + payments | SAND-WP-0006 | +| Snapshot / restore | SAND-WP-0007 | +| TTL enforcement + scheduled reap | TBD | \ No newline at end of file diff --git a/docs/runbooks/profile-vm-haskell-build.md b/docs/runbooks/profile-vm-haskell-build.md new file mode 100644 index 0000000..85c47fc --- /dev/null +++ b/docs/runbooks/profile-vm-haskell-build.md @@ -0,0 +1,50 @@ +# profile.vm-haskell-build — Runbook + +Attach an isolated Haskell build workspace on a pre-built VM (build-machines lineage). + +## Prerequisites + +- VM built/imported per `the-custodian/infra/build-machines/` +- SSH tunnel up (`make bridge-status` in build-machines) +- `~/.ssh/config` entry for `haskell-build` (or `tunnel_port` / `SANDBOXER_VM_TUNNEL_PORT`) +- `sandboxer` on PATH + +## Create workspace + +```bash +# Via SSH alias (recommended): +sandboxer create \ + --profile profile.vm-haskell-build \ + --input vm=haskell-build \ + --input repo=~/projects/my-app \ + --host localhost + +# Via tunnel port: +export SANDBOXER_VM_TUNNEL_PORT=12222 +sandboxer create \ + --profile profile.vm-haskell-build \ + --input vm=build@localhost \ + --input tunnel_port=12222 \ + --input repo=~/projects/my-app \ + --host localhost +``` + +## Build on VM + +Use `reachability.remote_dir` from create output: + +```bash +ssh haskell-build "cd /build/sbx- && source ~/.ghcup/env && cabal build all" +``` + +## Destroy + +```bash +sandboxer destroy +``` + +Removes workspace only; the VM keeps running. + +## Migration reference + +`docs/migration-build-machines.md` \ No newline at end of file diff --git a/extensions/README.md b/extensions/README.md index 4329ccb..778d302 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -3,6 +3,8 @@ Backend adapters for sandbox establishment. Each extension is declared in `ext..yaml` and implements `provision`, `wait_ready`, and `teardown`. +Author guide: `docs/extension-sdk.md` + ## ext.compose-ssh Self-hosted extension migrated from `the-custodian/e2e-framework/`. @@ -29,4 +31,19 @@ step. sand-boxer splits responsibilities: | health checks + test_command | wise-validator (SAND-WP-0003) | Interim workflow: `sandboxer create --profile profile.compose-e2e --input repo=...` -then run validation separately until wise-validator migration lands. \ No newline at end of file +then run validation separately until wise-validator migration lands. + +## ext.vm-packer + +Attach mode for pre-built VMs (`the-custodian/infra/build-machines/` lineage). + +**Provision:** SSH to VM alias or tunnel port → isolated workspace under `/build/sbx-/` +→ optional rsync of `repo` input. + +**wait_ready:** Confirms workspace directory exists on VM. + +**teardown:** Removes workspace only; VM persists. + +**Profile:** `profile.vm-haskell-build` — see `docs/runbooks/profile-vm-haskell-build.md`. + +Packer build / OVA import remains operator-driven (not triggered by `create`). \ No newline at end of file diff --git a/extensions/ext.vm-packer.yaml b/extensions/ext.vm-packer.yaml new file mode 100644 index 0000000..009b133 --- /dev/null +++ b/extensions/ext.vm-packer.yaml @@ -0,0 +1,16 @@ +id: ext.vm-packer +title: VM workspace (Packer lineage) +description: > + Attach an isolated workspace on a pre-built VM (the-custodian build-machines + lineage). v0 supports attach mode via SSH alias or tunnel port; Packer build + orchestration is operator-driven and deferred. +handler: sandboxer.extensions.vm_packer:VMPackerExtension +capabilities: + isolation_levels: [microvm] + regions: [] + persistence: true + pricing_model: self-hosted +config: + workspace_base: /build + ssh_user: build + ready_timeout_s: 30 \ No newline at end of file diff --git a/profiles/profile.vm-haskell-build.yaml b/profiles/profile.vm-haskell-build.yaml new file mode 100644 index 0000000..e67f255 --- /dev/null +++ b/profiles/profile.vm-haskell-build.yaml @@ -0,0 +1,35 @@ +id: profile.vm-haskell-build +version: "1.0.0" +extension: ext.vm-packer +isolation: + level: microvm +network: + default: deny + egress: [] +workspace: + mode: remote-canonical + access: rw +scope_default: session +ttl: + default: 8h + max: 24h + idle_reap: null +resources: + cpu: null + memory_mb: null +setup: + instructions: > + Requires a running build VM from the-custodian/infra/build-machines with SSH + tunnel or alias (e.g. haskell-build). Set SANDBOXER_VM_TUNNEL_PORT=12222 or + pass --input tunnel_port=12222. + secret_refs: [] +placement: + prefer: [localhost] + fallback: [workstation] +reachability: + tunnel: ops-bridge + identity: ops-warden +metadata: + cost_class: self-hosted + latency_class: standard + observability: none \ No newline at end of file diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index 72396f9..ffef014 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -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) diff --git a/src/sandboxer/extensions/base.py b/src/sandboxer/extensions/base.py new file mode 100644 index 0000000..cab28c5 --- /dev/null +++ b/src/sandboxer/extensions/base.py @@ -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.""" \ No newline at end of file diff --git a/src/sandboxer/extensions/compose_ssh.py b/src/sandboxer/extensions/compose_ssh.py index ae9e1d8..5116049 100644 --- a/src/sandboxer/extensions/compose_ssh.py +++ b/src/sandboxer/extensions/compose_ssh.py @@ -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) diff --git a/src/sandboxer/extensions/ssh.py b/src/sandboxer/extensions/ssh.py index dd53692..e9171e3 100644 --- a/src/sandboxer/extensions/ssh.py +++ b/src/sandboxer/extensions/ssh.py @@ -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: diff --git a/src/sandboxer/extensions/vm_packer.py b/src/sandboxer/extensions/vm_packer.py new file mode 100644 index 0000000..e66c95e --- /dev/null +++ b/src/sandboxer/extensions/vm_packer.py @@ -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", + } \ No newline at end of file diff --git a/tests/test_extension_base.py b/tests/test_extension_base.py new file mode 100644 index 0000000..8ff72b5 --- /dev/null +++ b/tests/test_extension_base.py @@ -0,0 +1,16 @@ +"""Extension SDK base class tests.""" + +from sandboxer.extensions.base import SandboxExtension +from sandboxer.extensions.compose_ssh import ComposeSSHExtension +from sandboxer.extensions.vm_packer import VMPackerExtension + + +def test_reference_extensions_subclass_base() -> None: + assert issubclass(ComposeSSHExtension, SandboxExtension) + assert issubclass(VMPackerExtension, SandboxExtension) + + +def test_new_sandbox_id_from_inputs() -> None: + assert SandboxExtension.new_sandbox_id({"sandbox_id": "fixed123"}) == "fixed123" + generated = SandboxExtension.new_sandbox_id({}) + assert len(generated) == 8 \ No newline at end of file diff --git a/tests/test_extension_registry.py b/tests/test_extension_registry.py new file mode 100644 index 0000000..7117709 --- /dev/null +++ b/tests/test_extension_registry.py @@ -0,0 +1,15 @@ +"""Extension registry loads vm-packer.""" + +from sandboxer.extensions.registry import load_all_extensions, load_extension + + +def test_load_vm_packer_extension() -> None: + ext = load_extension("ext.vm-packer") + assert ext.id == "ext.vm-packer" + assert "vm_packer" in ext.handler + + +def test_load_all_includes_vm_packer() -> None: + extensions = load_all_extensions() + assert "ext.compose-ssh" in extensions + assert "ext.vm-packer" in extensions \ No newline at end of file diff --git a/tests/test_ssh.py b/tests/test_ssh.py index 96e5b73..a045a87 100644 --- a/tests/test_ssh.py +++ b/tests/test_ssh.py @@ -10,4 +10,10 @@ def test_destination_uses_ssh_config_when_user_unset() -> None: def test_destination_includes_explicit_user() -> None: ssh = SSHConfig(host="92.205.130.254", user="tegwick") - assert ssh.destination == "tegwick@92.205.130.254" \ No newline at end of file + assert ssh.destination == "tegwick@92.205.130.254" + + +def test_ssh_base_includes_port() -> None: + ssh = SSHConfig(host="localhost", user="build", port=12222) + assert "-p" in ssh.ssh_base() + assert "12222" in ssh.ssh_base() \ No newline at end of file diff --git a/tests/test_vm_packer.py b/tests/test_vm_packer.py new file mode 100644 index 0000000..0f7bc5e --- /dev/null +++ b/tests/test_vm_packer.py @@ -0,0 +1,76 @@ +"""VMPackerExtension unit tests.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from sandboxer.extensions.vm_packer import VMPackerExtension +from sandboxer.models import Profile + + +def _profile() -> Profile: + return Profile.model_validate( + { + "id": "profile.vm-haskell-build", + "version": "1.0.0", + "extension": "ext.vm-packer", + } + ) + + +def test_provision_attach_via_alias(tmp_path: Path) -> None: + repo = tmp_path / "proj" + repo.mkdir() + ext = VMPackerExtension() + + with ( + patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "")), + patch("sandboxer.extensions.vm_packer.SSHConfig.rsync") as rsync, + ): + handle = ext.provision( + _profile(), + {"vm": "haskell-build", "repo": str(repo)}, + "localhost", + ) + + assert handle["vm_target"] == "haskell-build" + assert handle["remote_dir"].startswith("/build/sbx-") + rsync.assert_called_once() + + +def test_provision_requires_vm_input() -> None: + ext = VMPackerExtension() + with pytest.raises(ValueError, match="inputs.vm"): + ext.provision(_profile(), {}, "localhost") + + +def test_wait_ready_success() -> None: + ext = VMPackerExtension() + handle = { + "vm_host": "haskell-build", + "remote_dir": "/build/sbx-abc12345", + "host": "localhost", + "ssh_user": "build", + "ssh_port": "", + } + with patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "ready\n")): + reach = ext.wait_ready(handle) + assert reach["remote_dir"] == "/build/sbx-abc12345" + assert "haskell-build" in (reach["ssh"] or "") + + +def test_teardown_preserves_vm() -> None: + ext = VMPackerExtension() + handle = { + "vm_host": "localhost", + "remote_dir": "/build/sbx-deadbeef", + "ssh_user": "build", + "ssh_port": "12222", + } + with patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "")): + report = ext.teardown(handle) + assert report["vm_preserved"] == "true" + assert report["workspace_removed"] == "True" \ No newline at end of file diff --git a/workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md b/workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md new file mode 100644 index 0000000..cfa0a0b --- /dev/null +++ b/workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md @@ -0,0 +1,86 @@ +--- +id: SAND-WP-0005 +type: workplan +title: "Extension SDK and ext.vm-packer" +domain: infotech +repo: sand-boxer +status: finished +owner: codex +topic_slug: custodian +created: "2026-06-23" +updated: "2026-06-23" +--- + +# Extension SDK and ext.vm-packer + +Deliver INTENT near-term outcome #7 (extension SDK sketch) and begin +`infra/build-machines/` migration via `ext.vm-packer` attach mode. + +**Predecessor:** SAND-WP-0004 (e2e shim — finished) +**Follow-on:** SAND-WP-0006 (SaaS extensions + payments), SAND-WP-0007 (snapshots) + +## Extension SDK + +```task +id: SAND-WP-0005-T01 +status: done +priority: high +``` + +`SandboxExtension` base class (`src/sandboxer/extensions/base.py`), +`docs/extension-sdk.md` author guide. `ComposeSSHExtension` refactored to subclass base. + +## ext.vm-packer attach mode + +```task +id: SAND-WP-0005-T02 +status: done +priority: high +``` + +`VMPackerExtension` — SSH attach to pre-built VM, workspace under `/build/sbx-/`, +optional repo rsync, teardown removes workspace only. Registration: +`extensions/ext.vm-packer.yaml`. + +## profile.vm-haskell-build + +```task +id: SAND-WP-0005-T03 +status: done +priority: high +``` + +Profile + runbook for Haskell build VM (build-machines lineage). +`docs/migration-build-machines.md` maps legacy workflows. + +## SSH port support + +```task +id: SAND-WP-0005-T04 +status: done +priority: medium +``` + +`SSHConfig.port` for reverse-tunnel ports (12222). Manager stores `vm_target` / +`ssh_port` on destroy handle. + +## Tests + +```task +id: SAND-WP-0005-T05 +status: done +priority: high +``` + +Unit tests: `test_vm_packer.py`, `test_extension_base.py`, `test_extension_registry.py`. + +## Deferred + +```task +id: SAND-WP-0005-T06 +status: wait +priority: low +``` + +Packer build orchestration from `sandboxer create`; the-custodian `make remote-build` +shim; port-registry automation. \ No newline at end of file