generated from coulomb/repo-seed
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:
@@ -38,6 +38,7 @@ Sandbox CLI (v0):
|
|||||||
```bash
|
```bash
|
||||||
sandboxer create # canary self-deploy (profile.sandbox-canary)
|
sandboxer create # canary self-deploy (profile.sandbox-canary)
|
||||||
sandboxer create --profile profile.compose-e2e --input repo=/path/to/repo
|
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 <id>
|
sandboxer get <id>
|
||||||
sandboxer list
|
sandboxer list
|
||||||
sandboxer destroy <id>
|
sandboxer destroy <id>
|
||||||
|
|||||||
@@ -356,8 +356,8 @@ follows evidence.
|
|||||||
4. ~~**Profile catalog start**~~ — `profile.compose-e2e`, `profile.sandbox-canary`
|
4. ~~**Profile catalog start**~~ — `profile.compose-e2e`, `profile.sandbox-canary`
|
||||||
5. ~~**Registry entry**~~ — `capability.execution.sandbox-provision`
|
5. ~~**Registry entry**~~ — `capability.execution.sandbox-provision`
|
||||||
6. ~~**Sibling integration notes**~~ — `docs/integrations/`
|
6. ~~**Sibling integration notes**~~ — `docs/integrations/`
|
||||||
7. **Extension SDK sketch** — contract for P1 backends (vm-packer, Daytona OSS)
|
7. ~~**Extension SDK sketch**~~ — done (`docs/extension-sdk.md`, `ext.vm-packer` attach mode)
|
||||||
8. **wise-validator** — separate repo/workplan (SAND-WP-0003); not a sand-boxer dependency
|
8. ~~**wise-validator**~~ — sibling repo (SAND-WP-0003); not a sand-boxer dependency
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
10
SCOPE.md
10
SCOPE.md
@@ -104,12 +104,10 @@ own tunnels or CAs.
|
|||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- **Status:** v0 operational — self-hosted compose path proven on CoulombCore
|
- **Status:** v0 operational — self-hosted compose path proven on CoulombCore
|
||||||
- **Workplans finished:** SAND-WP-0001 (bootstrap), 0002 (meta-framework +
|
- **Workplans finished:** SAND-WP-0001–0005, 0008 (see `workplans/`)
|
||||||
`ext.compose-ssh`), 0003 (wise-validator extraction, sibling repo), 0008 (host
|
|
||||||
telemetry / self-canary)
|
|
||||||
- **Package:** `src/sandboxer/` — CLI, manager, extensions, telemetry, HTTP API
|
- **Package:** `src/sandboxer/` — CLI, manager, extensions, telemetry, HTTP API
|
||||||
- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary`
|
- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary`, `profile.vm-haskell-build`
|
||||||
- **Extensions:** `ext.compose-ssh` only
|
- **Extensions:** `ext.compose-ssh`, `ext.vm-packer` (attach mode)
|
||||||
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
||||||
- **Tests:** 26 pytest cases; `make check` green
|
- **Tests:** 26 pytest cases; `make check` green
|
||||||
- **Sibling:** wise-validator ships `validate run` (SAND-WP-0003)
|
- **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`)
|
- ~~`make e2e REPO=` shim~~ — done (SAND-WP-0004; delegates to `validate run`)
|
||||||
- TTL auto-expiry / `extend_ttl` enforcement
|
- 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)
|
- SaaS extensions (E2B, Modal) or payments layer (SAND-WP-0006)
|
||||||
- Snapshot / restore / checkpoint profiles (SAND-WP-0007)
|
- Snapshot / restore / checkpoint profiles (SAND-WP-0007)
|
||||||
- Formal ops-bridge tunnel attachment in reachability descriptor
|
- Formal ops-bridge tunnel attachment in reachability descriptor
|
||||||
|
|||||||
102
docs/extension-sdk.md
Normal file
102
docs/extension-sdk.md
Normal file
@@ -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.<name>.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.<slug>.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 |
|
||||||
62
docs/migration-build-machines.md
Normal file
62
docs/migration-build-machines.md
Normal file
@@ -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/<project>` | `/build/sbx-<sandbox_id>/` 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 <remote_dir> && source ~/.ghcup/env && cabal build all"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Destroy workspace (VM stays running):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandboxer destroy <sandbox_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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`
|
||||||
@@ -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:
|
**e2e-framework migration arc complete** (provision: sand-boxer, validation:
|
||||||
wise-validator, operator entry: `make e2e`).
|
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-<id>/` |
|
||||||
|
| 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
|
## sand-boxer follow-ons
|
||||||
|
|
||||||
| Item | Workplan |
|
| Item | Workplan |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| Self-canary + host telemetry | SAND-WP-0008 |
|
| SaaS extensions + payments | SAND-WP-0006 |
|
||||||
| Default `sandboxer create` without repo | SAND-WP-0008-T06 |
|
| Snapshot / restore | SAND-WP-0007 |
|
||||||
|
| TTL enforcement + scheduled reap | TBD |
|
||||||
50
docs/runbooks/profile-vm-haskell-build.md
Normal file
50
docs/runbooks/profile-vm-haskell-build.md
Normal file
@@ -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-<id> && source ~/.ghcup/env && cabal build all"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Destroy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandboxer destroy <sandbox_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Removes workspace only; the VM keeps running.
|
||||||
|
|
||||||
|
## Migration reference
|
||||||
|
|
||||||
|
`docs/migration-build-machines.md`
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
Backend adapters for sandbox establishment. Each extension is declared in
|
Backend adapters for sandbox establishment. Each extension is declared in
|
||||||
`ext.<name>.yaml` and implements `provision`, `wait_ready`, and `teardown`.
|
`ext.<name>.yaml` and implements `provision`, `wait_ready`, and `teardown`.
|
||||||
|
|
||||||
|
Author guide: `docs/extension-sdk.md`
|
||||||
|
|
||||||
## ext.compose-ssh
|
## ext.compose-ssh
|
||||||
|
|
||||||
Self-hosted extension migrated from `the-custodian/e2e-framework/`.
|
Self-hosted extension migrated from `the-custodian/e2e-framework/`.
|
||||||
@@ -30,3 +32,18 @@ step. sand-boxer splits responsibilities:
|
|||||||
|
|
||||||
Interim workflow: `sandboxer create --profile profile.compose-e2e --input repo=...`
|
Interim workflow: `sandboxer create --profile profile.compose-e2e --input repo=...`
|
||||||
then run validation separately until wise-validator migration lands.
|
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-<id>/`
|
||||||
|
→ 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`).
|
||||||
16
extensions/ext.vm-packer.yaml
Normal file
16
extensions/ext.vm-packer.yaml
Normal file
@@ -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
|
||||||
35
profiles/profile.vm-haskell-build.yaml
Normal file
35
profiles/profile.vm-haskell-build.yaml
Normal file
@@ -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
|
||||||
@@ -61,6 +61,9 @@ class SandboxManager:
|
|||||||
status.inputs["compose_file"] = handle.get("compose_file", "")
|
status.inputs["compose_file"] = handle.get("compose_file", "")
|
||||||
status.inputs["ssh_user"] = handle.get("ssh_user", "")
|
status.inputs["ssh_user"] = handle.get("ssh_user", "")
|
||||||
status.inputs["compose_cmd"] = handle.get("compose_cmd", "")
|
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)
|
reach = backend.wait_ready(handle)
|
||||||
status.reachability = Reachability(**reach)
|
status.reachability = Reachability(**reach)
|
||||||
status.state = SandboxState.READY
|
status.state = SandboxState.READY
|
||||||
@@ -133,6 +136,9 @@ class SandboxManager:
|
|||||||
"compose_file": status.inputs.get("compose_file", ""),
|
"compose_file": status.inputs.get("compose_file", ""),
|
||||||
"ssh_user": status.inputs.get("ssh_user", ""),
|
"ssh_user": status.inputs.get("ssh_user", ""),
|
||||||
"compose_cmd": status.inputs.get("compose_cmd", ""),
|
"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)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
from sandboxer.extensions.base import SandboxExtension
|
||||||
from sandboxer.extensions.ssh import SSHConfig
|
from sandboxer.extensions.ssh import SSHConfig
|
||||||
from sandboxer.models import Profile
|
from sandboxer.models import Profile
|
||||||
|
|
||||||
|
|
||||||
class ComposeSSHExtension:
|
class ComposeSSHExtension(SandboxExtension):
|
||||||
"""Provision isolated compose stacks on a remote host via SSH."""
|
"""Provision isolated compose stacks on a remote host via SSH."""
|
||||||
|
|
||||||
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
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.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer")
|
||||||
self.ssh_user: str | None = cfg.get("ssh_user")
|
self.ssh_user: str | None = cfg.get("ssh_user")
|
||||||
self.compose_timeout_s: int = int(cfg.get("compose_timeout_s", 180))
|
self.compose_timeout_s: int = int(cfg.get("compose_timeout_s", 180))
|
||||||
@@ -44,7 +45,7 @@ class ComposeSSHExtension:
|
|||||||
if not repo_path.exists():
|
if not repo_path.exists():
|
||||||
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
|
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}"
|
remote_dir = f"{self.base_dir}/{sandbox_id}"
|
||||||
ssh = SSHConfig.from_env(host, user=self.ssh_user or None)
|
ssh = SSHConfig.from_env(host, user=self.ssh_user or None)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class SSHConfig:
|
|||||||
host: str
|
host: str
|
||||||
user: str | None = None
|
user: str | None = None
|
||||||
key: str | None = None
|
key: str | None = None
|
||||||
|
port: int | None = None
|
||||||
connect_timeout: int = 15
|
connect_timeout: int = 15
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -47,6 +48,8 @@ class SSHConfig:
|
|||||||
]
|
]
|
||||||
if self.key:
|
if self.key:
|
||||||
args += ["-i", self.key]
|
args += ["-i", self.key]
|
||||||
|
if self.port:
|
||||||
|
args += ["-p", str(self.port)]
|
||||||
args.append(self.destination)
|
args.append(self.destination)
|
||||||
return args
|
return args
|
||||||
|
|
||||||
@@ -73,6 +76,8 @@ class SSHConfig:
|
|||||||
ssh_cmd = "ssh -o StrictHostKeyChecking=no"
|
ssh_cmd = "ssh -o StrictHostKeyChecking=no"
|
||||||
if self.key:
|
if self.key:
|
||||||
ssh_cmd = f"ssh -i {self.key} -o StrictHostKeyChecking=no"
|
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}/"]
|
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)
|
result = subprocess.run(rsync_args, capture_output=True, text=True, timeout=timeout)
|
||||||
if result.returncode != 0:
|
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",
|
||||||
|
}
|
||||||
16
tests/test_extension_base.py
Normal file
16
tests/test_extension_base.py
Normal file
@@ -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
|
||||||
15
tests/test_extension_registry.py
Normal file
15
tests/test_extension_registry.py
Normal file
@@ -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
|
||||||
@@ -11,3 +11,9 @@ def test_destination_uses_ssh_config_when_user_unset() -> None:
|
|||||||
def test_destination_includes_explicit_user() -> None:
|
def test_destination_includes_explicit_user() -> None:
|
||||||
ssh = SSHConfig(host="92.205.130.254", user="tegwick")
|
ssh = SSHConfig(host="92.205.130.254", user="tegwick")
|
||||||
assert ssh.destination == "tegwick@92.205.130.254"
|
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()
|
||||||
76
tests/test_vm_packer.py
Normal file
76
tests/test_vm_packer.py
Normal file
@@ -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"
|
||||||
86
workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md
Normal file
86
workplans/SAND-WP-0005-extension-sdk-and-vm-packer.md
Normal file
@@ -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-<id>/`,
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user