Compare commits
10 commits
151fc41299
...
fb195b8978
| Author | SHA1 | Date | |
|---|---|---|---|
| fb195b8978 | |||
| 2d0669c2f8 | |||
| 774bc5ae0a | |||
| 92eaf8bae5 | |||
| 1f87be4c6b | |||
| 7cabf77fb6 | |||
| 15f031fd65 | |||
| 6d0a1a8b1e | |||
| 878aa6a451 | |||
| 5466330cf4 |
56 changed files with 2150 additions and 73 deletions
|
|
@ -2,7 +2,7 @@
|
|||
# Custodian Brief — sand-boxer
|
||||
|
||||
**Domain:** infotech
|
||||
**Last synced:** 2026-06-24 10:44 UTC
|
||||
**Last synced:** 2026-06-24 10:57 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
|
|
|||
29
.forgejo/workflows/ci-smoke.yaml
Normal file
29
.forgejo/workflows/ci-smoke.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Canonical CI smoke template (tier 1 routing drill).
|
||||
# Copy to: .forgejo/workflows/ci-smoke.yaml in consumer repos.
|
||||
name: CI Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
host-smoke:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Routing probe (host runner)
|
||||
run: |
|
||||
set -eu
|
||||
echo "repository=${GITHUB_REPOSITORY:-unknown}"
|
||||
echo "sha=${GITHUB_SHA:-unknown}"
|
||||
echo "runner=${RUNNER_NAME:-unknown}"
|
||||
uname -a
|
||||
|
||||
container-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Routing probe (container label)
|
||||
run: |
|
||||
set -eu
|
||||
echo "container-smoke ok for ${GITHUB_REPOSITORY:-unknown}"
|
||||
31
SCOPE.md
31
SCOPE.md
|
|
@ -116,24 +116,24 @@ own tunnels or CAs.
|
|||
|
||||
- **Status:** v0 operational — self-hosted compose path proven on CoulombCore;
|
||||
routing, payments stub, and snapshots shipped
|
||||
- **Workplans finished:** SAND-WP-0001–0008 (all workplans in `workplans/`;
|
||||
0003/0004 delivered in sibling repos wise-validator / the-custodian)
|
||||
- **Workplans finished:** SAND-WP-0001–0012 (0003/0004 in sibling repos)
|
||||
- **Workplans ready:** none (reuse-surface publish / sandboxer01 operator track)
|
||||
- **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments,
|
||||
snapshots, telemetry, HTTP API
|
||||
- **Profiles:** `profile.compose-e2e`, `profile.compose-checkpoint`,
|
||||
`profile.sandbox-canary`, `profile.vm-haskell-build`, `profile.saas-stub`,
|
||||
`profile.burst-sandbox`
|
||||
- **Extensions:** `ext.compose-ssh` (compose + tar snapshots),
|
||||
`ext.vm-packer` (attach), `ext.saas-stub` (metered stub + metadata snapshots)
|
||||
- **Profiles:** compose e2e/checkpoint, canary, vm-haskell-build, saas-stub,
|
||||
burst-sandbox, e2b-burst, modal-gpu, agent-dev, build, vm-packer-build
|
||||
- **Extensions:** `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub`,
|
||||
`ext.e2b`, `ext.modal`
|
||||
- **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`,
|
||||
`payments`, `snapshots`, `migration-gaps`, `migration-build-machines`
|
||||
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
||||
- **Tests:** 69 pytest cases; `make check` green
|
||||
- **Tests:** 90 pytest cases; `make check` green
|
||||
- **Siblings:** wise-validator `validate run` (SAND-WP-0003); the-custodian
|
||||
`make e2e REPO=` shim (SAND-WP-0004)
|
||||
|
||||
Latest gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
Latest workplan: **SAND-WP-0009** (TTL enforcement — finished).
|
||||
Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
**Ready workplans:** none — gap analysis items complete; operator tracks remain.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -154,6 +154,10 @@ sandboxer expire [--apply]
|
|||
sandboxer create --ttl 2h ...
|
||||
sandboxer credits show / credits add <amount>
|
||||
sandboxer inspect host / inspect stale / reap-stale [--apply]
|
||||
sandboxer reachability show <id>
|
||||
sandboxer create --profile profile.agent-dev --input repo=/path --actor agt --project glas-harness
|
||||
sandboxer create --profile profile.build --input vm=haskell-build --actor agt --project snuggle-inventor
|
||||
sandboxer create --profile profile.vm-packer-build --input packer_template=... --input vm_name=haskell-build
|
||||
make smoke-remote # CoulombCore compose smoke (SANDBOXER_HOST)
|
||||
|
||||
# Full e2e validation (wise-validator, separate install):
|
||||
|
|
@ -172,14 +176,15 @@ cd ~/the-custodian && make e2e REPO=activity-core
|
|||
## What Is Not Possible Yet
|
||||
|
||||
- ~~TTL auto-expiry / `extend_ttl` enforcement~~ — done (SAND-WP-0009)
|
||||
- Packer build orchestration from `create` (attach-only today)
|
||||
- Real E2B / Modal / Daytona adapters (in-repo stub only)
|
||||
- ~~Packer build orchestration from `create`~~ — done (SAND-WP-0012)
|
||||
- ~~Real E2B / Modal adapters~~ — done (SAND-WP-0010)
|
||||
- ~~Consumer profiles (agent-dev, build)~~ — done (SAND-WP-0011)
|
||||
- Cross-host snapshot transfer
|
||||
- Formal ops-bridge tunnel attachment in reachability descriptor
|
||||
- ~~Formal ops-bridge tunnel attachment~~ — done (SAND-WP-0011; descriptor only)
|
||||
- Dedicated sandboxer01 host (CoulombCore interim only today)
|
||||
- `reuse-surface validate` / federation publish workflow
|
||||
- ~~`.repo-classification.yaml`~~ — done (SAND-WP-0009)
|
||||
- fin-hub billing export for metered usage
|
||||
- ~~fin-hub billing export~~ — hook done (SAND-WP-0010); railiance-platform wiring operator
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
54
docs/cloud-adapters.md
Normal file
54
docs/cloud-adapters.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Cloud adapters (E2B, Modal)
|
||||
|
||||
Metered SaaS sandbox backends — SAND-WP-0010.
|
||||
|
||||
## Extensions
|
||||
|
||||
| Extension | Profile | Provider API |
|
||||
|-----------|---------|--------------|
|
||||
| `ext.e2b` | `profile.e2b-burst` | `https://api.e2b.dev` |
|
||||
| `ext.modal` | `profile.modal-gpu` | `https://api.modal.com` |
|
||||
| `ext.saas-stub` | `profile.saas-stub` | None (local stub) |
|
||||
|
||||
`profile.burst-sandbox` routes: compose-ssh → E2B → Modal → saas-stub.
|
||||
|
||||
## BYOK credentials
|
||||
|
||||
Resolve keys at provision boundary only — never in Git, workplans, or State Hub.
|
||||
|
||||
```bash
|
||||
warden route find "E2B API key" --json
|
||||
warden route find "Modal token" --json
|
||||
```
|
||||
|
||||
| Extension | Primary env | secret_ref env fallback |
|
||||
|-----------|-------------|-------------------------|
|
||||
| `ext.e2b` | `E2B_API_KEY` | `SANDBOXER_SECRET_E2B_API_KEY` |
|
||||
| `ext.modal` | `MODAL_TOKEN_ID` | `SANDBOXER_SECRET_MODAL_TOKEN_ID` |
|
||||
|
||||
OpenBao custody via railiance-platform; sand-boxer reads env injected by operator.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
export E2B_API_KEY=... # operator-injected, not in repo
|
||||
|
||||
sandboxer create --profile profile.e2b-burst
|
||||
sandboxer create --profile profile.burst-sandbox # SaaS when self-hosted unavailable
|
||||
sandboxer destroy <id>
|
||||
```
|
||||
|
||||
## fin-hub export
|
||||
|
||||
On metered destroy, optional POST to `SANDBOXER_FIN_HUB_URL/usage/sandbox`.
|
||||
Disabled by default. Set `SANDBOXER_NO_FIN_HUB=1` to suppress.
|
||||
|
||||
## CI
|
||||
|
||||
Unit tests mock HTTP — no live provider calls in `make check`.
|
||||
|
||||
Operator smoke (credentials required):
|
||||
|
||||
```bash
|
||||
./scripts/smoke-cloud-adapter.sh e2b
|
||||
```
|
||||
|
|
@ -33,8 +33,10 @@ Reference implementations:
|
|||
| Extension | Module | Mode |
|
||||
|-----------|--------|------|
|
||||
| `ext.compose-ssh` | `compose_ssh.py` | Remote compose stack + tar snapshots |
|
||||
| `ext.vm-packer` | `vm_packer.py` | Attach workspace on pre-built VM |
|
||||
| `ext.vm-packer` | `vm_packer.py` | Attach workspace or Packer build mode |
|
||||
| `ext.saas-stub` | `saas_stub.py` | Metered stub + metadata snapshots |
|
||||
| `ext.e2b` | `e2b.py` | E2B cloud adapter |
|
||||
| `ext.modal` | `modal.py` | Modal cloud adapter |
|
||||
|
||||
## Registration
|
||||
|
||||
|
|
@ -80,7 +82,8 @@ 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` |
|
||||
| vm-packer (attach) | `vm` or `ssh_target` | `repo`, `tunnel_port`, `ssh_port`, `workspace_dir` |
|
||||
| vm-packer (build) | `packer_template`, `vm_name` | `mode=build`, `packer_var_*` |
|
||||
|
||||
Consumer attribution travels on `SandboxCreateRequest.consumer`, not extension inputs.
|
||||
|
||||
|
|
@ -106,6 +109,6 @@ Implement `estimate_cost` and `meter_actual` on `SandboxExtension`. Register wit
|
|||
| Feature | Workplan |
|
||||
|---------|----------|
|
||||
| Packer build orchestration from `create` | Future WP |
|
||||
| E2B / Modal / Daytona cloud adapters | Post SAND-WP-0006 |
|
||||
| Daytona OSS cloud adapter | Future WP |
|
||||
| fin-hub billing export | Future |
|
||||
| Cross-host snapshot transfer | Future |
|
||||
|
|
@ -31,6 +31,16 @@ sandboxer create \
|
|||
| SSH / tunnel reachability setup | glas-harness + ops-bridge |
|
||||
| Agent memory and session state | glas-harness |
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
# Requires sandboxer CLI and SANDBOXER_HOST (or profile placement fallback)
|
||||
SANDBOXER_HOST=coulombcore ./scripts/smoke-agent-dev.sh
|
||||
```
|
||||
|
||||
Creates `profile.agent-dev`, prints reachability (tunnel metadata + SSH
|
||||
one-liner), then destroys.
|
||||
|
||||
## Out of scope for sand-boxer
|
||||
|
||||
- Tool schemas and approval flows
|
||||
|
|
|
|||
|
|
@ -30,6 +30,16 @@ sandboxer create \
|
|||
| Generated code and PR output | snuggle-inventor |
|
||||
| Secret resolution at boundary | sand-boxer (via ops-warden / OpenBao) |
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
# Skips live create when SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN is unset
|
||||
export SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN=<token>
|
||||
SANDBOXER_VM_TUNNEL_PORT=12222 ./scripts/smoke-build-profile.sh
|
||||
```
|
||||
|
||||
Optional env: `SMOKE_VM` (default `haskell-build`).
|
||||
|
||||
## Out of scope for sand-boxer
|
||||
|
||||
- Code generation prompts and tech specs
|
||||
|
|
|
|||
|
|
@ -65,7 +65,13 @@ Event `detail` payload (JSON):
|
|||
"consumer": {"actor": "atm", "project": "wise-validator", "run_id": "..."},
|
||||
"actor_type": "atm",
|
||||
"state": "ready",
|
||||
"reachability": {"ssh": "root@coulombcore", "remote_dir": "/tmp/sandboxer/abc12345"},
|
||||
"reachability": {
|
||||
"ssh": "root@coulombcore",
|
||||
"remote_dir": "/tmp/sandboxer/abc12345",
|
||||
"tunnel": "localhost:12222",
|
||||
"tunnel_via": "ops-bridge",
|
||||
"identity": "ops-warden"
|
||||
},
|
||||
"timestamps": {"created_at": "...", "ready_at": "..."}
|
||||
}
|
||||
```
|
||||
|
|
@ -100,6 +106,36 @@ HTTP surface (optional v0; CLI calls core library directly):
|
|||
- `POST /v1/sandboxes/{id}/recreate` — recreate
|
||||
- `PATCH /v1/sandboxes/{id}/ttl` — extend TTL
|
||||
- `POST /v1/sandboxes/expire` — TTL reap (query `apply=true`)
|
||||
- `GET /v1/sandboxes/{id}/reachability` — enriched descriptor + SSH one-liner
|
||||
|
||||
---
|
||||
|
||||
## Reachability descriptor
|
||||
|
||||
When a sandbox reaches `ready`, sand-boxer emits a **reachability** block on
|
||||
`SandboxStatus`, lifecycle events, and `sandboxer reachability show <id>`.
|
||||
|
||||
| Field | Source | Description |
|
||||
|-------|--------|-------------|
|
||||
| `ssh` | Extension | SSH target (`user@host`) |
|
||||
| `remote_dir` | Extension | Workspace root on remote host |
|
||||
| `host` | Extension | Placement host name |
|
||||
| `tunnel` | Profile + env | Local port (`localhost:PORT`) or VM alias |
|
||||
| `tunnel_via` | Profile spec | Route owner (default `ops-bridge`) |
|
||||
| `identity` | Profile spec | Warden actor hint (default `ops-warden`) |
|
||||
|
||||
Tunnel metadata is enriched from profile `reachability` and environment:
|
||||
|
||||
- `SANDBOXER_TUNNEL_PORT` / handle `tunnel_port` or `ssh_port`
|
||||
- `SANDBOXER_TUNNEL_ALIAS` / handle `vm_target`
|
||||
- `SANDBOXER_TUNNEL_VIA` (optional override)
|
||||
|
||||
sand-boxer **does not** bring tunnels up. Consumers use ops-bridge (MCP or
|
||||
`bridge` CLI) to attach SSH routes; the descriptor is a pointer only.
|
||||
|
||||
`secret_refs` from `profile.setup` are resolved at the provision boundary and
|
||||
passed to the extension handle — they never appear on `SandboxStatus` or State
|
||||
Hub events.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ Maps `the-custodian/infra/build-machines/` to sand-boxer `profile.vm-haskell-bui
|
|||
|-------------------------|---------------|
|
||||
| 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` |
|
||||
| `make remote-build PROJECT=` | `sandboxer create --profile profile.vm-haskell-build` + SSH build (shim in build-machines Makefile) |
|
||||
| `packer build` in `haskell/` | `sandboxer create --profile profile.vm-packer-build` |
|
||||
| Isolated workspace `/build/<project>` | `/build/sbx-<sandbox_id>/` per create |
|
||||
| `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) |
|
||||
|
||||
|
|
@ -50,12 +51,36 @@ sandboxer destroy <sandbox_id>
|
|||
| `repo` | Optional rsync source to workspace |
|
||||
| `workspace_dir` | Override workspace path on VM |
|
||||
|
||||
## Packer build mode (SAND-WP-0012)
|
||||
|
||||
```bash
|
||||
sandboxer create \
|
||||
--profile profile.vm-packer-build \
|
||||
--input packer_template=~/the-custodian/infra/build-machines/haskell \
|
||||
--input vm_name=haskell-build \
|
||||
--host localhost
|
||||
```
|
||||
|
||||
| Input | Purpose |
|
||||
|-------|---------|
|
||||
| `mode` | `build` (default for profile.vm-packer-build) or `attach` |
|
||||
| `packer_template` | Directory containing `*.pkr.hcl` |
|
||||
| `vm_name` / `vm` | Packer `vm_name` variable |
|
||||
| `packer_var_*` | Extra Packer `-var` flags (suffix → variable name) |
|
||||
|
||||
Runbook: `docs/runbooks/profile-vm-packer-build.md`
|
||||
|
||||
## Port registry (read-only pointer)
|
||||
|
||||
`the-custodian/infra/build-machines/port-registry.yml` maps tunnel ports
|
||||
12221–12230 to VM slots. When attaching via tunnel, set
|
||||
`SANDBOXER_VM_TUNNEL_PORT` or `--input tunnel_port=` to a registered port.
|
||||
Full ops-bridge automation is deferred — operators bring tunnels up manually.
|
||||
|
||||
## 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)
|
||||
- Automated port-registry → ops-bridge config generation
|
||||
|
||||
## Runbook
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ Deferred: Packer orchestration from API, `make remote-build` shim.
|
|||
| Item | Workplan |
|
||||
|------|----------|
|
||||
| ~~SaaS extensions + payments v0~~ | SAND-WP-0006 — stub + routing + credits |
|
||||
| E2B / Modal real adapters | Post SAND-WP-0006 |
|
||||
| ~~E2B / Modal real adapters + fin-hub~~ | SAND-WP-0010 — `docs/cloud-adapters.md` |
|
||||
| Consumer profiles + reachability | **SAND-WP-0011** |
|
||||
| Packer orchestration + remote-build shim | **SAND-WP-0012** |
|
||||
| ~~Snapshot / restore~~ | SAND-WP-0007 — `docs/snapshots.md` |
|
||||
| ~~TTL enforcement + scheduled reap~~ | SAND-WP-0009 — `docs/ttl.md` |
|
||||
|
|
@ -36,10 +36,17 @@ Reference: `ext.saas-stub` (no external API).
|
|||
|
||||
## BYOK
|
||||
|
||||
Provider API keys are resolved at provision boundary via `secret_refs` / OpenBao —
|
||||
not implemented in v0 stub. Set provider env vars per extension when adapters land.
|
||||
Provider API keys resolve at provision boundary — never stored on `SandboxStatus`
|
||||
or emitted to State Hub.
|
||||
|
||||
1. Operator lookup: `warden route find "<provider> API key" --json`
|
||||
2. Inject env before `sandboxer create` (e.g. `E2B_API_KEY`, `MODAL_TOKEN_ID`)
|
||||
3. Or map `secret_ref` from extension config to `SANDBOXER_SECRET_<REF>` env
|
||||
|
||||
See `docs/cloud-adapters.md`.
|
||||
|
||||
## Billing export
|
||||
|
||||
sand-boxer meters sandbox consumption only. Domain billing authority (fin-hub) is a
|
||||
future export consumer of State Hub meter events — not owned here.
|
||||
On metered destroy, optional fin-hub hook when `SANDBOXER_FIN_HUB_URL` is set.
|
||||
Posts `sandbox_id`, `extension_id`, `duration_s`, `actual_usd` to `/usage/sandbox`.
|
||||
Implementation: `src/sandboxer/payments/billing_export.py`.
|
||||
|
|
@ -10,6 +10,8 @@ route:
|
|||
strategy: prefer-self-hosted
|
||||
extensions:
|
||||
- ext.compose-ssh
|
||||
- ext.e2b
|
||||
- ext.modal
|
||||
- ext.saas-stub
|
||||
max_cost_per_hour_usd: 1.0
|
||||
```
|
||||
|
|
@ -19,7 +21,7 @@ route:
|
|||
| Strategy | Behavior |
|
||||
|----------|----------|
|
||||
| `explicit` | Use `profile.extension` (default when no route) |
|
||||
| `prefer-self-hosted` | First self-hosted candidate with resolvable host; else SaaS |
|
||||
| `prefer-self-hosted` | Self-hosted if host available; else credentialed E2B/Modal; else stub |
|
||||
| `lowest-cost` | Self-hosted if available; else cheapest `estimate_cost` |
|
||||
| `lowest-latency` | Self-hosted if available; else last candidate (v0) |
|
||||
|
||||
|
|
@ -38,7 +40,12 @@ sandboxer create --profile profile.saas-stub
|
|||
|
||||
| Profile | Route |
|
||||
|---------|-------|
|
||||
| `profile.burst-sandbox` | compose-ssh → saas-stub fallback |
|
||||
| `profile.burst-sandbox` | compose-ssh → e2b → modal → saas-stub |
|
||||
| `profile.e2b-burst` | explicit `ext.e2b` |
|
||||
| `profile.modal-gpu` | explicit `ext.modal` |
|
||||
| `profile.saas-stub` | explicit `ext.saas-stub` |
|
||||
|
||||
Cloud adapters require provider credentials (`E2B_API_KEY`, `MODAL_TOKEN_ID`).
|
||||
See `docs/cloud-adapters.md`.
|
||||
|
||||
Resolver: `sandboxer.routing.resolver.resolve_extension`.
|
||||
54
docs/runbooks/profile-vm-packer-build.md
Normal file
54
docs/runbooks/profile-vm-packer-build.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# profile.vm-packer-build — Runbook
|
||||
|
||||
Trigger a Packer OVA build on the local workstation (build-machines lineage).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Packer** >= 1.10 (`packer version`)
|
||||
- **VirtualBox** >= 7.0 (`VBoxManage --version`)
|
||||
- Template directory from `the-custodian/infra/build-machines/haskell`
|
||||
- `sandboxer` on PATH
|
||||
|
||||
## Build OVA
|
||||
|
||||
```bash
|
||||
sandboxer create \
|
||||
--profile profile.vm-packer-build \
|
||||
--input packer_template=~/the-custodian/infra/build-machines/haskell \
|
||||
--input vm_name=haskell-build \
|
||||
--host localhost
|
||||
```
|
||||
|
||||
Progress notes emit to State Hub during `packer init` and `packer build`.
|
||||
|
||||
On success, `reachability.remote_dir` points at the produced `.ova` file.
|
||||
|
||||
## Optional Packer variables
|
||||
|
||||
Pass extra `-var` flags via inputs prefixed with `packer_var_`:
|
||||
|
||||
```bash
|
||||
sandboxer create \
|
||||
--profile profile.vm-packer-build \
|
||||
--input packer_template=~/the-custodian/infra/build-machines/haskell \
|
||||
--input vm_name=haskell-build \
|
||||
--input packer_var_memory=16384 \
|
||||
--host localhost
|
||||
```
|
||||
|
||||
## Destroy
|
||||
|
||||
```bash
|
||||
sandboxer destroy <sandbox_id>
|
||||
```
|
||||
|
||||
Removes the sandbox record only; the OVA artifact on disk is preserved.
|
||||
|
||||
## Attach workflow (post-build)
|
||||
|
||||
After import/setup per build-machines README, use `profile.vm-haskell-build`
|
||||
for workspace attach — see `docs/runbooks/profile-vm-haskell-build.md`.
|
||||
|
||||
## Migration reference
|
||||
|
||||
`docs/migration-build-machines.md`
|
||||
19
extensions/ext.e2b.yaml
Normal file
19
extensions/ext.e2b.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
id: ext.e2b
|
||||
title: E2B cloud sandboxes
|
||||
description: >
|
||||
Metered E2B Firecracker sandbox adapter. Requires E2B_API_KEY or OpenBao
|
||||
secret_ref mapping at provision boundary.
|
||||
handler: sandboxer.extensions.e2b:E2BExtension
|
||||
capabilities:
|
||||
isolation_levels: [microvm]
|
||||
regions: [us, eu]
|
||||
persistence: true
|
||||
pricing_model: metered
|
||||
config:
|
||||
provider: e2b
|
||||
api_base: https://api.e2b.dev
|
||||
api_key_env: E2B_API_KEY
|
||||
secret_ref: e2b-api-key
|
||||
template_id: base
|
||||
rate_usd_per_hour: 0.15
|
||||
session_fee_usd: 0.02
|
||||
19
extensions/ext.modal.yaml
Normal file
19
extensions/ext.modal.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
id: ext.modal
|
||||
title: Modal cloud sandboxes
|
||||
description: >
|
||||
Metered Modal serverless sandbox adapter. Requires MODAL_TOKEN_ID or
|
||||
secret_ref mapping at provision boundary.
|
||||
handler: sandboxer.extensions.modal:ModalExtension
|
||||
capabilities:
|
||||
isolation_levels: [policy]
|
||||
regions: [us]
|
||||
persistence: true
|
||||
pricing_model: metered
|
||||
config:
|
||||
provider: modal
|
||||
api_base: https://api.modal.com
|
||||
api_key_env: MODAL_TOKEN_ID
|
||||
secret_ref: modal-token-id
|
||||
image_ref: modal-default
|
||||
rate_usd_per_hour: 0.18
|
||||
session_fee_usd: 0.02
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
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.
|
||||
Attach an isolated workspace on a pre-built VM, or run Packer build mode
|
||||
(the-custodian build-machines lineage). Attach via SSH alias or tunnel port;
|
||||
build via inputs.mode=build or profile.vm-packer-build.
|
||||
handler: sandboxer.extensions.vm_packer:VMPackerExtension
|
||||
capabilities:
|
||||
isolation_levels: [microvm]
|
||||
|
|
|
|||
|
|
@ -160,14 +160,14 @@ some sibling sign-offs sit outside this repo.
|
|||
|
||||
| Prio | Gap | Owner | Proposed track |
|
||||
| --- | --- | --- | --- |
|
||||
| **P1** | TTL enforcement + `extend_ttl` + `expires_at` | sand-boxer | **SAND-WP-0009** |
|
||||
| **P2** | TTL reap scheduler / activity-core contract | sand-boxer + activity-core | **SAND-WP-0009** |
|
||||
| **P3** | `.repo-classification.yaml` + registry refresh | sand-boxer | **SAND-WP-0009** |
|
||||
| **P4** | HTTP API parity (`recreate`, `extend_ttl`) | sand-boxer | **SAND-WP-0009** |
|
||||
| **P5** | Real E2B / Modal adapters + BYOK | sand-boxer | SAND-WP-0010 |
|
||||
| **P6** | ops-bridge tunnel in reachability descriptor | sand-boxer | SAND-WP-0011 |
|
||||
| **P7** | Consumer profiles (glas-harness, snuggle) | sand-boxer | SAND-WP-0011 |
|
||||
| **P8** | Packer build orchestration + remote-build shim | sand-boxer | SAND-WP-0012 (WP-0005-T06) |
|
||||
| ~~**P1**~~ | TTL enforcement + `extend_ttl` + `expires_at` | sand-boxer | **SAND-WP-0009** — done |
|
||||
| ~~**P2**~~ | TTL reap / activity-core contract | sand-boxer | **SAND-WP-0009** — `docs/integrations/activity-core.md` |
|
||||
| ~~**P3**~~ | `.repo-classification.yaml` + registry refresh | sand-boxer | **SAND-WP-0009** — done |
|
||||
| ~~**P4**~~ | HTTP API parity (`recreate`, `extend_ttl`) | sand-boxer | **SAND-WP-0009** — done |
|
||||
| **P5** | Real E2B / Modal adapters + BYOK | sand-boxer | **SAND-WP-0010** |
|
||||
| **P6** | ops-bridge tunnel in reachability descriptor | sand-boxer | **SAND-WP-0011** |
|
||||
| **P7** | Consumer profiles (glas-harness, snuggle) | sand-boxer | **SAND-WP-0011** |
|
||||
| **P8** | Packer build orchestration + remote-build shim | sand-boxer | **SAND-WP-0012** |
|
||||
| **P9** | Cross-host snapshot transfer | sand-boxer | Future |
|
||||
| **P10** | fin-hub billing export | sand-boxer + platform | With SAND-WP-0010 |
|
||||
| **P11** | sandboxer01 dedicated host | infra / operator | Outside repo |
|
||||
|
|
@ -175,16 +175,17 @@ some sibling sign-offs sit outside this repo.
|
|||
|
||||
---
|
||||
|
||||
## 9. Recommended next workplan
|
||||
## 9. Recommended workplans (2026-06-24)
|
||||
|
||||
**SAND-WP-0009 — TTL enforcement and operational hardening**
|
||||
| Workplan | Status | Closes |
|
||||
| --- | --- | --- |
|
||||
| SAND-WP-0009 | **finished** | P1–P4 (TTL, hygiene, HTTP parity) |
|
||||
| SAND-WP-0010 | ready | P5, P10 (E2B/Modal, BYOK, fin-hub) |
|
||||
| SAND-WP-0011 | ready | P6, P7 (reachability, consumer profiles) |
|
||||
| SAND-WP-0012 | ready | P8 (Packer orchestration, remote-build shim) |
|
||||
|
||||
Closes P1–P4: the largest functional gap (disposable-by-default TTL), platform
|
||||
hygiene (classification, registry), and HTTP parity. Unblocks activity-core
|
||||
scheduling of expire/reap without implementing activity-core itself.
|
||||
|
||||
Subsequent: **SAND-WP-0010** (real cloud adapters), **SAND-WP-0011**
|
||||
(reachability + consumer profiles), **SAND-WP-0012** (Packer orchestration).
|
||||
**Suggested implementation order:** 0010 and 0011 may parallelize; 0012 depends
|
||||
on stable vm-packer attach (done). Operator tracks P11/P12 outside repo.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
42
profiles/profile.agent-dev.yaml
Normal file
42
profiles/profile.agent-dev.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
id: profile.agent-dev
|
||||
version: "1.0.0"
|
||||
extension: ext.compose-ssh
|
||||
route:
|
||||
strategy: prefer-self-hosted
|
||||
extensions:
|
||||
- ext.compose-ssh
|
||||
- ext.e2b
|
||||
- ext.modal
|
||||
- ext.saas-stub
|
||||
max_cost_per_hour_usd: 1.0
|
||||
isolation:
|
||||
level: container
|
||||
network:
|
||||
default: deny
|
||||
egress: []
|
||||
workspace:
|
||||
mode: remote-canonical
|
||||
access: rw
|
||||
scope_default: agent
|
||||
ttl:
|
||||
default: 8h
|
||||
max: 24h
|
||||
idle_reap: 2h
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: >
|
||||
Agent development sandbox for glas-harness. Prefer self-hosted compose;
|
||||
burst to cloud when host unavailable. Consumer actor: agt.
|
||||
secret_refs: []
|
||||
placement:
|
||||
prefer: [sandboxer01]
|
||||
fallback: [coulombcore]
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: self-hosted
|
||||
latency_class: standard
|
||||
observability: none
|
||||
35
profiles/profile.build.yaml
Normal file
35
profiles/profile.build.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
id: profile.build
|
||||
version: "1.0.0"
|
||||
extension: ext.vm-packer
|
||||
isolation:
|
||||
level: microvm
|
||||
network:
|
||||
default: deny
|
||||
egress: []
|
||||
workspace:
|
||||
mode: remote-canonical
|
||||
access: rw
|
||||
scope_default: agent
|
||||
ttl:
|
||||
default: 8h
|
||||
max: 24h
|
||||
idle_reap: null
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: >
|
||||
Build sandbox for snuggle-inventor. Attach to pre-built VM via ops-bridge
|
||||
tunnel (e.g. haskell-build). Secret refs resolved at provision boundary only.
|
||||
secret_refs:
|
||||
- build-registry-token
|
||||
placement:
|
||||
prefer: [localhost]
|
||||
fallback: [workstation]
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: self-hosted
|
||||
latency_class: standard
|
||||
observability: none
|
||||
|
|
@ -5,6 +5,8 @@ route:
|
|||
strategy: prefer-self-hosted
|
||||
extensions:
|
||||
- ext.compose-ssh
|
||||
- ext.e2b
|
||||
- ext.modal
|
||||
- ext.saas-stub
|
||||
max_cost_per_hour_usd: 1.0
|
||||
isolation:
|
||||
|
|
@ -25,8 +27,8 @@ resources:
|
|||
memory_mb: null
|
||||
setup:
|
||||
instructions: >
|
||||
Prefer self-hosted compose on SANDBOXER_HOST; falls back to metered SaaS stub
|
||||
when host is unavailable or SANDBOXER_FORCE_SAAS=1.
|
||||
Prefer self-hosted compose on SANDBOXER_HOST; falls back to E2B/Modal when
|
||||
credentials are configured, else metered saas-stub. Force SaaS: SANDBOXER_FORCE_SAAS=1.
|
||||
secret_refs: []
|
||||
placement:
|
||||
prefer: [sandboxer01]
|
||||
|
|
|
|||
32
profiles/profile.e2b-burst.yaml
Normal file
32
profiles/profile.e2b-burst.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
id: profile.e2b-burst
|
||||
version: "1.0.0"
|
||||
extension: ext.e2b
|
||||
isolation:
|
||||
level: microvm
|
||||
network:
|
||||
default: deny
|
||||
egress: []
|
||||
workspace:
|
||||
mode: remote-canonical
|
||||
access: rw
|
||||
scope_default: session
|
||||
ttl:
|
||||
default: 2h
|
||||
max: 8h
|
||||
idle_reap: null
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: "E2B burst sandbox — requires E2B_API_KEY."
|
||||
secret_refs: [e2b-api-key]
|
||||
placement:
|
||||
prefer: []
|
||||
fallback: []
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: saas-metered
|
||||
latency_class: low
|
||||
observability: none
|
||||
32
profiles/profile.modal-gpu.yaml
Normal file
32
profiles/profile.modal-gpu.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
id: profile.modal-gpu
|
||||
version: "1.0.0"
|
||||
extension: ext.modal
|
||||
isolation:
|
||||
level: policy
|
||||
network:
|
||||
default: deny
|
||||
egress: []
|
||||
workspace:
|
||||
mode: remote-canonical
|
||||
access: rw
|
||||
scope_default: session
|
||||
ttl:
|
||||
default: 2h
|
||||
max: 8h
|
||||
idle_reap: null
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: "Modal GPU/burst sandbox — requires MODAL_TOKEN_ID."
|
||||
secret_refs: [modal-token-id]
|
||||
placement:
|
||||
prefer: []
|
||||
fallback: []
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: saas-metered
|
||||
latency_class: low
|
||||
observability: none
|
||||
37
profiles/profile.vm-packer-build.yaml
Normal file
37
profiles/profile.vm-packer-build.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
id: profile.vm-packer-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: 12h
|
||||
max: 48h
|
||||
idle_reap: null
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: >
|
||||
Trigger a Packer OVA build (the-custodian build-machines conventions).
|
||||
Requires packer and VirtualBox on the placement host. Pass
|
||||
--input packer_template=~/the-custodian/infra/build-machines/haskell
|
||||
and --input vm_name=haskell-build (or vm=haskell-build). Attach mode
|
||||
uses profile.vm-haskell-build instead.
|
||||
secret_refs: []
|
||||
placement:
|
||||
prefer: [localhost]
|
||||
fallback: [workstation]
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: self-hosted
|
||||
latency_class: standard
|
||||
observability: none
|
||||
|
|
@ -16,27 +16,27 @@ maturity:
|
|||
Charter (INTENT.md), meta-framework spec, extension SDK, integration docs,
|
||||
and research synthesis. Capability indexed in registry/.
|
||||
availability:
|
||||
current: A4
|
||||
current: A5
|
||||
target: A5
|
||||
confidence: high
|
||||
rationale: >
|
||||
CLI v0 (create/destroy/snapshot/TTL), HTTP API, CoulombCore remote smoke.
|
||||
SaaS stub + routing + credits shipped (SAND-WP-0006).
|
||||
Cloud adapters E2B/Modal + routing + credits (SAND-WP-0010).
|
||||
|
||||
external_evidence:
|
||||
completeness:
|
||||
level: C4
|
||||
name: Substantial
|
||||
level: C5
|
||||
name: Mature
|
||||
confidence: high
|
||||
basis: scope_vs_intent_and_consumer_expectations
|
||||
satisfied_expectations:
|
||||
- profile-based create/destroy/snapshot/restore via CLI
|
||||
- TTL extend and expire/reap (SAND-WP-0009)
|
||||
- E2B and Modal cloud adapters with BYOK (SAND-WP-0010)
|
||||
- State Hub lifecycle events on transitions
|
||||
- wise-validator and the-custodian migration arc complete
|
||||
- extension SDK with compose-ssh, vm-packer attach, saas-stub
|
||||
- extension SDK with compose-ssh, vm-packer attach, saas-stub, e2b, modal
|
||||
broken_expectations:
|
||||
- Real E2B/Modal adapters not yet built
|
||||
- sandboxer01 dedicated host not live (CoulombCore interim)
|
||||
out_of_scope_expectations:
|
||||
- agent harness and tool orchestration (glas-harness)
|
||||
|
|
|
|||
20
scripts/smoke-agent-dev.sh
Executable file
20
scripts/smoke-agent-dev.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env bash
|
||||
# Smoke profile.agent-dev — requires SANDBOXER_HOST or defaults via placement.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${SANDBOXER_HOST:-}" ]]; then
|
||||
echo "SANDBOXER_HOST not set — using profile placement fallback" >&2
|
||||
fi
|
||||
|
||||
REPO="${SMOKE_REPO:-$(pwd)}"
|
||||
echo "Smoke: profile.agent-dev repo=$REPO"
|
||||
STATUS=$(sandboxer create \
|
||||
--profile profile.agent-dev \
|
||||
--input "repo=$REPO" \
|
||||
--actor agt \
|
||||
--project glas-harness)
|
||||
ID=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandbox_id'])")
|
||||
echo "Created: $ID"
|
||||
sandboxer reachability show "$ID"
|
||||
sandboxer destroy "$ID"
|
||||
echo "OK: agent-dev smoke"
|
||||
23
scripts/smoke-build-profile.sh
Executable file
23
scripts/smoke-build-profile.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
# Smoke profile.build — requires VM tunnel and optional build-registry-token secret.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN:-}" ]]; then
|
||||
echo "SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN not set — skipping live smoke" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VM="${SMOKE_VM:-haskell-build}"
|
||||
TUNNEL_PORT="${SANDBOXER_VM_TUNNEL_PORT:-12222}"
|
||||
echo "Smoke: profile.build vm=$VM tunnel=$TUNNEL_PORT"
|
||||
STATUS=$(sandboxer create \
|
||||
--profile profile.build \
|
||||
--input "vm=$VM" \
|
||||
--input "tunnel_port=$TUNNEL_PORT" \
|
||||
--actor agt \
|
||||
--project snuggle-inventor)
|
||||
ID=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandbox_id'])")
|
||||
echo "Created: $ID"
|
||||
sandboxer reachability show "$ID"
|
||||
sandboxer destroy "$ID"
|
||||
echo "OK: build profile smoke"
|
||||
32
scripts/smoke-cloud-adapter.sh
Executable file
32
scripts/smoke-cloud-adapter.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env bash
|
||||
# Operator smoke for cloud adapters — requires provider credentials.
|
||||
set -euo pipefail
|
||||
|
||||
PROVIDER="${1:-e2b}"
|
||||
case "$PROVIDER" in
|
||||
e2b)
|
||||
PROFILE="profile.e2b-burst"
|
||||
if [[ -z "${E2B_API_KEY:-}" ]]; then
|
||||
echo "E2B_API_KEY not set — skipping live smoke" >&2
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
modal)
|
||||
PROFILE="profile.modal-gpu"
|
||||
if [[ -z "${MODAL_TOKEN_ID:-}" ]]; then
|
||||
echo "MODAL_TOKEN_ID not set — skipping live smoke" >&2
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [e2b|modal]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Smoke: sandboxer create --profile $PROFILE"
|
||||
STATUS=$(sandboxer create --profile "$PROFILE" --project sand-boxer)
|
||||
ID=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandbox_id'])")
|
||||
echo "Created: $ID"
|
||||
sandboxer destroy "$ID"
|
||||
echo "Destroyed: $ID"
|
||||
|
|
@ -34,6 +34,14 @@ def get_sandbox(sandbox_id: str) -> SandboxStatus:
|
|||
return status
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes/{sandbox_id}/reachability")
|
||||
def get_sandbox_reachability(sandbox_id: str) -> dict:
|
||||
try:
|
||||
return _manager.reachability_report(sandbox_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes", response_model=list[SandboxStatus])
|
||||
def list_sandboxes() -> list[SandboxStatus]:
|
||||
return _manager.list()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
|
|||
app.add_typer(credits_app, name="credits")
|
||||
snapshots_app = typer.Typer(help="Workspace checkpoint snapshots.")
|
||||
app.add_typer(snapshots_app, name="snapshots")
|
||||
reachability_app = typer.Typer(help="Consumer reachability descriptors.")
|
||||
app.add_typer(reachability_app, name="reachability")
|
||||
|
||||
|
||||
@app.callback()
|
||||
|
|
@ -92,7 +94,11 @@ def sandbox_create(
|
|||
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
|
||||
ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None,
|
||||
) -> None:
|
||||
"""Provision a sandbox. No args → canary self-deploy of sand-boxer."""
|
||||
"""Provision a sandbox. No args → canary self-deploy of sand-boxer.
|
||||
|
||||
vm-packer modes: attach (profile.vm-haskell-build) or build
|
||||
(profile.vm-packer-build / --input mode=build with packer_template, vm_name).
|
||||
"""
|
||||
parsed = _parse_inputs(input or [])
|
||||
resolved_profile, resolved_inputs = resolve_create_defaults(profile, parsed)
|
||||
request = SandboxCreateRequest(
|
||||
|
|
@ -112,6 +118,18 @@ def sandbox_create(
|
|||
_print_telemetry_summary(status.telemetry)
|
||||
|
||||
|
||||
@reachability_app.command("show")
|
||||
def reachability_show(sandbox_id: str) -> None:
|
||||
"""Show reachability descriptor and SSH one-liner for a sandbox."""
|
||||
manager = SandboxManager()
|
||||
try:
|
||||
report = manager.reachability_report(sandbox_id)
|
||||
except KeyError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
_print_json(report)
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def sandbox_get(sandbox_id: str) -> None:
|
||||
"""Get sandbox status by id."""
|
||||
|
|
|
|||
|
|
@ -21,11 +21,14 @@ from sandboxer.models import (
|
|||
SandboxStatus,
|
||||
SnapshotRecord,
|
||||
)
|
||||
from sandboxer.payments.billing_export import export_meter_usage
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
from sandboxer.reachability.enrich import enrich_reachability
|
||||
from sandboxer.routing.resolver import resolve_extension
|
||||
from sandboxer.secrets.resolver import resolve_setup_secrets
|
||||
from sandboxer.snapshots.store import SnapshotStore
|
||||
from sandboxer.telemetry.export import export_telemetry
|
||||
from sandboxer.telemetry.introspection import (
|
||||
|
|
@ -60,6 +63,8 @@ class SandboxManager:
|
|||
"vm_target": status.inputs.get("vm_target", ""),
|
||||
"vm_host": status.inputs.get("vm_host", ""),
|
||||
"endpoint": status.inputs.get("endpoint", ""),
|
||||
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
|
||||
"provider": status.inputs.get("provider", ""),
|
||||
}
|
||||
|
||||
def _resolved_host(self, profile, extension, host_override: str | None) -> str:
|
||||
|
|
@ -124,7 +129,21 @@ class SandboxManager:
|
|||
provision_before = collect_host_snapshot(resolved_host)
|
||||
|
||||
try:
|
||||
handle = backend.provision(profile, request.inputs, resolved_host)
|
||||
secret_bundle = resolve_setup_secrets(profile)
|
||||
provision_inputs = dict(request.inputs)
|
||||
build_mode = (
|
||||
provision_inputs.get("mode") == "build"
|
||||
or profile.id == "profile.vm-packer-build"
|
||||
)
|
||||
if build_mode:
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=f"Packer build starting ({profile.id})",
|
||||
event_type="note",
|
||||
)
|
||||
handle = backend.provision(profile, provision_inputs, resolved_host)
|
||||
if secret_bundle:
|
||||
handle["_secret_refs"] = secret_bundle
|
||||
status.sandbox_id = handle["sandbox_id"]
|
||||
status.inputs["compose_file"] = handle.get("compose_file", "")
|
||||
status.inputs["ssh_user"] = handle.get("ssh_user", "")
|
||||
|
|
@ -133,7 +152,10 @@ class SandboxManager:
|
|||
status.inputs["vm_target"] = handle.get("vm_target", "")
|
||||
status.inputs["vm_host"] = handle.get("vm_host", "")
|
||||
status.inputs["endpoint"] = handle.get("endpoint", "")
|
||||
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
|
||||
status.inputs["provider"] = handle.get("provider", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
reach = enrich_reachability(reach, profile, handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
status.ready_at = utcnow()
|
||||
|
|
@ -173,6 +195,14 @@ class SandboxManager:
|
|||
def get(self, sandbox_id: str) -> SandboxStatus | None:
|
||||
return self.store.get(sandbox_id)
|
||||
|
||||
def reachability_report(self, sandbox_id: str) -> dict:
|
||||
status = self.store.get(sandbox_id)
|
||||
if not status:
|
||||
raise KeyError(f"Sandbox not found: {sandbox_id}")
|
||||
from sandboxer.reachability.enrich import build_reachability_report
|
||||
|
||||
return build_reachability_report(status)
|
||||
|
||||
def list(self) -> list[SandboxStatus]:
|
||||
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
|
||||
|
||||
|
|
@ -209,6 +239,7 @@ class SandboxManager:
|
|||
if settled and settled.pricing_model == "metered" and settled.actual_usd:
|
||||
self.credits.debit(settled.actual_usd)
|
||||
status.meter = settled
|
||||
export_meter_usage(status, extension_id=extension.id, meter=settled)
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=(
|
||||
|
|
@ -447,7 +478,11 @@ class SandboxManager:
|
|||
status.inputs["vm_host"] = handle.get("vm_host", "")
|
||||
status.inputs["endpoint"] = handle.get("endpoint", "")
|
||||
status.inputs["restored_from"] = record.snapshot_id
|
||||
secret_bundle = resolve_setup_secrets(profile)
|
||||
if secret_bundle:
|
||||
handle["_secret_refs"] = secret_bundle
|
||||
reach = backend.wait_ready(handle)
|
||||
reach = enrich_reachability(reach, profile, handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
status.ready_at = utcnow()
|
||||
|
|
|
|||
83
src/sandboxer/extensions/cloud_base.py
Normal file
83
src/sandboxer/extensions/cloud_base.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Shared helpers for metered HTTP cloud sandbox adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
from sandboxer.extensions.base import SandboxExtension
|
||||
from sandboxer.extensions.credentials import resolve_api_key
|
||||
from sandboxer.models import MeterQuote, Profile
|
||||
|
||||
|
||||
class HttpClientFactory(Protocol):
|
||||
def __call__(self) -> httpx.Client: ...
|
||||
|
||||
|
||||
def default_http_client() -> httpx.Client:
|
||||
return httpx.Client(timeout=60.0)
|
||||
|
||||
|
||||
class CloudMeteredExtension(SandboxExtension):
|
||||
"""Base for E2B/Modal-style REST sandbox providers."""
|
||||
|
||||
extension_id: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: dict[str, Any] | None = None,
|
||||
*,
|
||||
client_factory: HttpClientFactory | None = None,
|
||||
) -> None:
|
||||
super().__init__(config)
|
||||
self.api_base: str = str(self.config.get("api_base", "")).rstrip("/")
|
||||
self.api_key_env: str = str(self.config.get("api_key_env", ""))
|
||||
self.provider: str = self.config.get("provider", self.extension_id)
|
||||
self.rate_usd_per_hour: float = float(self.config.get("rate_usd_per_hour", 0.15))
|
||||
self.session_fee_usd: float = float(self.config.get("session_fee_usd", 0.02))
|
||||
self._client_factory = client_factory or default_http_client
|
||||
|
||||
@classmethod
|
||||
def credentials_available(cls, config: dict[str, Any]) -> bool:
|
||||
from sandboxer.extensions.credentials import credentials_available as _avail
|
||||
|
||||
return _avail(cls.extension_id, config)
|
||||
|
||||
def _api_key(self) -> str:
|
||||
key = resolve_api_key(self.config, extension_id=self.extension_id)
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
f"{self.extension_id}: API key not configured "
|
||||
f"(set {self.api_key_env} or secret_ref env mapping)"
|
||||
)
|
||||
return key
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _client(self) -> httpx.Client:
|
||||
return self._client_factory()
|
||||
|
||||
def estimate_cost(
|
||||
self,
|
||||
profile: Profile,
|
||||
inputs: dict[str, str],
|
||||
*,
|
||||
duration_s: int = 3600,
|
||||
) -> MeterQuote:
|
||||
hours = max(duration_s / 3600.0, 1 / 3600)
|
||||
estimated = round(self.session_fee_usd + hours * self.rate_usd_per_hour, 4)
|
||||
return MeterQuote(
|
||||
extension_id=self.extension_id,
|
||||
estimated_usd=estimated,
|
||||
unit="per_hour",
|
||||
duration_s=duration_s,
|
||||
)
|
||||
|
||||
def meter_actual(self, handle: dict[str, str], *, duration_s: float) -> float:
|
||||
hours = max(duration_s / 3600.0, 1 / 3600)
|
||||
return round(self.session_fee_usd + hours * self.rate_usd_per_hour, 4)
|
||||
48
src/sandboxer/extensions/credentials.py
Normal file
48
src/sandboxer/extensions/credentials.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""BYOK credential resolution for metered cloud extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _secret_ref_env(secret_ref: str) -> str:
|
||||
normalized = secret_ref.upper().replace("-", "_").replace(".", "_")
|
||||
return f"SANDBOXER_SECRET_{normalized}"
|
||||
|
||||
|
||||
def resolve_api_key(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
extension_id: str,
|
||||
) -> str | None:
|
||||
"""Resolve provider API key from env or secret_ref mapping (never from Git)."""
|
||||
env_name = config.get("api_key_env")
|
||||
if env_name:
|
||||
value = os.environ.get(env_name)
|
||||
if value:
|
||||
return value
|
||||
|
||||
fallback_env = (
|
||||
f"SANDBOXER_{extension_id.upper().replace('.', '_').replace('-', '_')}_API_KEY"
|
||||
)
|
||||
value = os.environ.get(fallback_env)
|
||||
if value:
|
||||
return value
|
||||
|
||||
secret_ref = config.get("secret_ref")
|
||||
if secret_ref:
|
||||
return os.environ.get(_secret_ref_env(secret_ref))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def credentials_available(
|
||||
extension_id: str,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
always_available: bool = False,
|
||||
) -> bool:
|
||||
if always_available:
|
||||
return True
|
||||
return resolve_api_key(config, extension_id=extension_id) is not None
|
||||
77
src/sandboxer/extensions/e2b.py
Normal file
77
src/sandboxer/extensions/e2b.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""ext.e2b — E2B cloud sandbox adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.extensions.cloud_base import CloudMeteredExtension, default_http_client
|
||||
from sandboxer.models import Profile
|
||||
|
||||
# Re-export for tests
|
||||
http_client_factory = default_http_client
|
||||
|
||||
|
||||
class E2BExtension(CloudMeteredExtension):
|
||||
extension_id = "ext.e2b"
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None, **kwargs) -> None:
|
||||
super().__init__(config, **kwargs)
|
||||
self.template_id: str = self.config.get("template_id", "base")
|
||||
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
sandbox_id = self.new_sandbox_id(inputs)
|
||||
template = inputs.get("template") or self.template_id
|
||||
payload = {"templateID": template, "metadata": {"sandboxer_id": sandbox_id}}
|
||||
|
||||
with self._client() as client:
|
||||
response = client.post(
|
||||
f"{self.api_base}/sandboxes",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"E2B provision failed: {response.text}")
|
||||
data = response.json()
|
||||
|
||||
provider_sandbox_id = data.get("sandboxID") or data.get("sandbox_id", "")
|
||||
endpoint = data.get("sandboxURL") or f"https://{provider_sandbox_id}.e2b.dev"
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
"provider_sandbox_id": provider_sandbox_id,
|
||||
"host": self.provider,
|
||||
"endpoint": endpoint,
|
||||
"provider": self.provider,
|
||||
"template_id": template,
|
||||
}
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
provider_id = handle.get("provider_sandbox_id", "")
|
||||
with self._client() as client:
|
||||
response = client.get(
|
||||
f"{self.api_base}/sandboxes/{provider_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"E2B wait_ready failed: {response.text}")
|
||||
return {
|
||||
"endpoint": handle["endpoint"],
|
||||
"host": handle.get("host"),
|
||||
}
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
provider_id = handle.get("provider_sandbox_id", "")
|
||||
removed = False
|
||||
if provider_id:
|
||||
with self._client() as client:
|
||||
response = client.delete(
|
||||
f"{self.api_base}/sandboxes/{provider_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
removed = response.status_code < 400
|
||||
return {
|
||||
"provider_removed": str(removed).lower(),
|
||||
"sandbox_id": handle.get("sandbox_id", ""),
|
||||
"provider_sandbox_id": provider_id,
|
||||
}
|
||||
83
src/sandboxer/extensions/modal.py
Normal file
83
src/sandboxer/extensions/modal.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""ext.modal — Modal cloud sandbox adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.extensions.cloud_base import CloudMeteredExtension, default_http_client
|
||||
from sandboxer.models import Profile
|
||||
|
||||
http_client_factory = default_http_client
|
||||
|
||||
|
||||
class ModalExtension(CloudMeteredExtension):
|
||||
extension_id = "ext.modal"
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None, **kwargs) -> None:
|
||||
super().__init__(config, **kwargs)
|
||||
self.image_ref: str = self.config.get("image_ref", "modal-default")
|
||||
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
sandbox_id = self.new_sandbox_id(inputs)
|
||||
image = inputs.get("image") or self.image_ref
|
||||
payload = {
|
||||
"image": image,
|
||||
"metadata": {"sandboxer_id": sandbox_id},
|
||||
}
|
||||
|
||||
with self._client() as client:
|
||||
response = client.post(
|
||||
f"{self.api_base}/v1/sandboxes",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"Modal provision failed: {response.text}")
|
||||
data = response.json()
|
||||
|
||||
provider_sandbox_id = data.get("sandbox_id") or data.get("id", "")
|
||||
endpoint = data.get("url") or f"https://modal.run/sandbox/{provider_sandbox_id}"
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
"provider_sandbox_id": provider_sandbox_id,
|
||||
"host": self.provider,
|
||||
"endpoint": endpoint,
|
||||
"provider": self.provider,
|
||||
"image_ref": image,
|
||||
}
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
provider_id = handle.get("provider_sandbox_id", "")
|
||||
with self._client() as client:
|
||||
response = client.get(
|
||||
f"{self.api_base}/v1/sandboxes/{provider_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(f"Modal wait_ready failed: {response.text}")
|
||||
data = response.json()
|
||||
state = data.get("status", "ready")
|
||||
if state not in ("ready", "running"):
|
||||
raise RuntimeError(f"Modal sandbox not ready: {state}")
|
||||
return {
|
||||
"endpoint": handle["endpoint"],
|
||||
"host": handle.get("host"),
|
||||
}
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
provider_id = handle.get("provider_sandbox_id", "")
|
||||
removed = False
|
||||
if provider_id:
|
||||
with self._client() as client:
|
||||
response = client.delete(
|
||||
f"{self.api_base}/v1/sandboxes/{provider_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
removed = response.status_code < 400
|
||||
return {
|
||||
"provider_removed": str(removed).lower(),
|
||||
"sandbox_id": handle.get("sandbox_id", ""),
|
||||
"provider_sandbox_id": provider_id,
|
||||
}
|
||||
|
|
@ -1,32 +1,46 @@
|
|||
"""ext.vm-packer — attach to pre-built VMs (build-machines lineage).
|
||||
"""ext.vm-packer — attach to pre-built VMs or trigger Packer builds.
|
||||
|
||||
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.
|
||||
**attach** mode: 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.
|
||||
**build** mode: run `packer init` + `packer build` for a template directory
|
||||
(the-custodian build-machines conventions). Teardown preserves the OVA artifact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.extensions.base import SandboxExtension
|
||||
from sandboxer.extensions.ssh import SSHConfig
|
||||
from sandboxer.lifecycle.state_hub import emit_progress_note
|
||||
from sandboxer.models import Profile
|
||||
|
||||
BUILD_PROFILE_IDS = frozenset({"profile.vm-packer-build"})
|
||||
|
||||
|
||||
class VMPackerExtension(SandboxExtension):
|
||||
"""Attach sandbox workspace on a pre-provisioned VM."""
|
||||
"""Attach sandbox workspace on a pre-provisioned VM, or run Packer build."""
|
||||
|
||||
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))
|
||||
self.default_packer_template: str | None = self.config.get("default_packer_template")
|
||||
self.packer_bin: str = self.config.get("packer_bin", "packer")
|
||||
|
||||
def _resolve_mode(self, profile: Profile, inputs: dict[str, str]) -> str:
|
||||
mode = inputs.get("mode")
|
||||
if mode:
|
||||
return mode
|
||||
if profile.id in BUILD_PROFILE_IDS:
|
||||
return "build"
|
||||
return "attach"
|
||||
|
||||
def _ssh_from_handle(self, handle: dict[str, str]) -> SSHConfig:
|
||||
port_raw = handle.get("ssh_port") or os.environ.get("SANDBOXER_VM_SSH_PORT")
|
||||
|
|
@ -53,7 +67,106 @@ class VMPackerExtension(SandboxExtension):
|
|||
return "localhost", int(env_port)
|
||||
return inputs.get("vm_host") or placement_host, None
|
||||
|
||||
def provision(
|
||||
def _run_packer(
|
||||
self,
|
||||
args: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
sandbox_id: str,
|
||||
profile_id: str,
|
||||
) -> None:
|
||||
cmd = [self.packer_bin, *args]
|
||||
emit_progress_note(
|
||||
f"Packer: {' '.join(cmd)}",
|
||||
sandbox_id=sandbox_id,
|
||||
profile_id=profile_id,
|
||||
detail={"cwd": str(cwd)},
|
||||
)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
msg = result.stderr.strip() or result.stdout.strip() or "unknown error"
|
||||
raise RuntimeError(f"Packer command failed ({' '.join(cmd)}): {msg}")
|
||||
|
||||
def _find_ova_artifact(self, template_dir: Path, vm_name: str) -> Path | None:
|
||||
candidates = sorted(
|
||||
template_dir.glob(f"{vm_name}*.ova"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
all_ova = sorted(
|
||||
template_dir.glob("*.ova"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
return all_ova[0] if all_ova else None
|
||||
|
||||
def _provision_build(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
sandbox_id = self.new_sandbox_id(inputs)
|
||||
template_raw = (
|
||||
inputs.get("packer_template")
|
||||
or os.environ.get("SANDBOXER_PACKER_TEMPLATE")
|
||||
or self.default_packer_template
|
||||
)
|
||||
if not template_raw:
|
||||
raise ValueError(
|
||||
"inputs.packer_template is required for build mode "
|
||||
"(or set SANDBOXER_PACKER_TEMPLATE)"
|
||||
)
|
||||
|
||||
vm_name = inputs.get("vm_name") or inputs.get("vm")
|
||||
if not vm_name:
|
||||
raise ValueError("inputs.vm_name or inputs.vm is required for build mode")
|
||||
|
||||
template_dir = Path(template_raw).expanduser().resolve()
|
||||
if not template_dir.is_dir():
|
||||
raise FileNotFoundError(f"Packer template directory not found: {template_dir}")
|
||||
|
||||
pkr_files = list(template_dir.glob("*.pkr.hcl")) + list(template_dir.glob("*.pkr.json"))
|
||||
if not pkr_files:
|
||||
raise FileNotFoundError(f"No Packer template (*.pkr.hcl) in {template_dir}")
|
||||
|
||||
self._run_packer(
|
||||
["init", "."],
|
||||
cwd=template_dir,
|
||||
sandbox_id=sandbox_id,
|
||||
profile_id=profile.id,
|
||||
)
|
||||
build_args = ["build", "-var", f"vm_name={vm_name}"]
|
||||
for key, value in inputs.items():
|
||||
if key.startswith("packer_var_"):
|
||||
build_args.extend(["-var", f"{key.removeprefix('packer_var_')}={value}"])
|
||||
self._run_packer(
|
||||
build_args,
|
||||
cwd=template_dir,
|
||||
sandbox_id=sandbox_id,
|
||||
profile_id=profile.id,
|
||||
)
|
||||
|
||||
artifact = self._find_ova_artifact(template_dir, vm_name)
|
||||
artifact_path = str(artifact) if artifact else ""
|
||||
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
"host": host,
|
||||
"mode": "build",
|
||||
"vm_name": vm_name,
|
||||
"vm_target": vm_name,
|
||||
"packer_template": str(template_dir),
|
||||
"artifact_path": artifact_path,
|
||||
"remote_dir": artifact_path,
|
||||
}
|
||||
|
||||
def _provision_attach(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
vm_target = inputs.get("vm") or inputs.get("ssh_target")
|
||||
|
|
@ -94,11 +207,29 @@ class VMPackerExtension(SandboxExtension):
|
|||
"remote_dir": remote_dir,
|
||||
"ssh_user": ssh.user or "",
|
||||
"ssh_port": str(ssh.port) if ssh.port else "",
|
||||
"mode": inputs.get("mode", "attach"),
|
||||
"mode": "attach",
|
||||
"repo": repo_path_str,
|
||||
}
|
||||
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
mode = self._resolve_mode(profile, inputs)
|
||||
if mode == "build":
|
||||
return self._provision_build(profile, inputs, host)
|
||||
return self._provision_attach(profile, inputs, host)
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
if handle.get("mode") == "build":
|
||||
artifact = handle.get("artifact_path") or handle.get("remote_dir")
|
||||
if not artifact or not Path(artifact).is_file():
|
||||
raise RuntimeError(f"Packer artifact not found: {artifact}")
|
||||
return {
|
||||
"host": handle.get("host"),
|
||||
"remote_dir": artifact,
|
||||
"endpoint": artifact,
|
||||
}
|
||||
|
||||
ssh = self._ssh_from_handle(handle)
|
||||
remote_dir = handle["remote_dir"]
|
||||
cmd = f"test -d {remote_dir} && echo ready"
|
||||
|
|
@ -112,6 +243,15 @@ class VMPackerExtension(SandboxExtension):
|
|||
}
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
if handle.get("mode") == "build":
|
||||
artifact = handle.get("artifact_path") or handle.get("remote_dir") or ""
|
||||
return {
|
||||
"workspace_removed": "false",
|
||||
"remote_dir": artifact,
|
||||
"vm_preserved": "true",
|
||||
"artifact_preserved": "true",
|
||||
}
|
||||
|
||||
remote_dir = handle.get("remote_dir")
|
||||
cleaned_dir = False
|
||||
if remote_dir:
|
||||
|
|
|
|||
|
|
@ -59,6 +59,37 @@ def emit_lifecycle_event(
|
|||
return None
|
||||
|
||||
|
||||
def emit_progress_note(
|
||||
summary: str,
|
||||
*,
|
||||
sandbox_id: str,
|
||||
profile_id: str,
|
||||
detail: dict[str, Any] | None = None,
|
||||
author: str = "sandboxer",
|
||||
) -> dict[str, Any] | None:
|
||||
"""Emit a progress note during long-running provision (e.g. Packer build)."""
|
||||
if os.environ.get("SANDBOXER_NO_STATE_HUB", "").lower() in ("1", "true", "yes"):
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"event_type": "note",
|
||||
"summary": summary,
|
||||
"author": author,
|
||||
"detail": {
|
||||
"sandbox_id": sandbox_id,
|
||||
"profile_id": profile_id,
|
||||
**(detail or {}),
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
response = httpx.post(f"{hub_url()}/progress/", json=payload, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def event_type_for_state(state: SandboxState) -> str:
|
||||
if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED):
|
||||
return "milestone"
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ class Reachability(BaseModel):
|
|||
compose_project: str | None = None
|
||||
host: str | None = None
|
||||
endpoint: str | None = None
|
||||
tunnel: str | None = None
|
||||
tunnel_via: str | None = None
|
||||
identity: str | None = None
|
||||
|
||||
|
||||
class SandboxStatus(BaseModel):
|
||||
|
|
|
|||
49
src/sandboxer/payments/billing_export.py
Normal file
49
src/sandboxer/payments/billing_export.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Optional fin-hub billing export for metered sandbox usage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from sandboxer.models import MeterRecord, SandboxStatus
|
||||
|
||||
|
||||
def fin_hub_url() -> str | None:
|
||||
return os.environ.get("SANDBOXER_FIN_HUB_URL") or None
|
||||
|
||||
|
||||
def export_meter_usage(
|
||||
status: SandboxStatus,
|
||||
*,
|
||||
extension_id: str,
|
||||
meter: MeterRecord,
|
||||
) -> dict[str, Any] | None:
|
||||
"""POST usage record to fin-hub when SANDBOXER_FIN_HUB_URL is set."""
|
||||
if os.environ.get("SANDBOXER_NO_FIN_HUB", "").lower() in ("1", "true", "yes"):
|
||||
return None
|
||||
if meter.pricing_model != "metered" or not meter.actual_usd:
|
||||
return None
|
||||
|
||||
base = fin_hub_url()
|
||||
if not base:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"extension_id": extension_id,
|
||||
"profile_id": status.profile_id,
|
||||
"consumer": status.consumer.model_dump(),
|
||||
"duration_s": meter.duration_s,
|
||||
"actual_usd": meter.actual_usd,
|
||||
"estimate_usd": meter.estimate_usd,
|
||||
"currency": meter.currency,
|
||||
}
|
||||
|
||||
try:
|
||||
response = httpx.post(f"{base.rstrip('/')}/usage/sandbox", json=payload, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
5
src/sandboxer/reachability/__init__.py
Normal file
5
src/sandboxer/reachability/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Reachability descriptor enrichment."""
|
||||
|
||||
from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability
|
||||
|
||||
__all__ = ["enrich_reachability", "build_reachability_report"]
|
||||
68
src/sandboxer/reachability/enrich.py
Normal file
68
src/sandboxer/reachability/enrich.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Merge profile reachability spec and env into consumer descriptors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.models import Profile, Reachability, SandboxStatus
|
||||
|
||||
OPS_BRIDGE_DOC = "ops-bridge MCP or `bridge` CLI — sand-boxer does not manage tunnels"
|
||||
|
||||
|
||||
def enrich_reachability(
|
||||
reach: dict[str, str],
|
||||
profile: Profile,
|
||||
handle: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Add tunnel/identity metadata from profile spec and environment."""
|
||||
enriched = dict(reach)
|
||||
spec = profile.reachability
|
||||
|
||||
if spec.tunnel:
|
||||
enriched.setdefault("tunnel_via", spec.tunnel)
|
||||
if spec.identity:
|
||||
enriched["identity"] = spec.identity
|
||||
|
||||
tunnel_port = (
|
||||
os.environ.get("SANDBOXER_TUNNEL_PORT")
|
||||
or handle.get("tunnel_port")
|
||||
or handle.get("ssh_port")
|
||||
)
|
||||
tunnel_alias = os.environ.get("SANDBOXER_TUNNEL_ALIAS") or handle.get("vm_target")
|
||||
if tunnel_port:
|
||||
enriched["tunnel"] = f"localhost:{tunnel_port}"
|
||||
elif tunnel_alias:
|
||||
enriched["tunnel"] = tunnel_alias
|
||||
|
||||
tunnel_via = os.environ.get("SANDBOXER_TUNNEL_VIA")
|
||||
if tunnel_via:
|
||||
enriched["tunnel_via"] = tunnel_via
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
def ssh_one_liner(reach: Reachability) -> str | None:
|
||||
if reach.ssh and reach.remote_dir:
|
||||
return f"ssh {reach.ssh} 'cd {reach.remote_dir} && exec $SHELL'"
|
||||
if reach.ssh:
|
||||
return f"ssh {reach.ssh}"
|
||||
return None
|
||||
|
||||
|
||||
def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
|
||||
"""Consumer-facing reachability report with ops-bridge pointer."""
|
||||
reach = status.reachability
|
||||
payload: dict[str, Any] = {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"profile_id": status.profile_id,
|
||||
"host": status.host,
|
||||
"reachability": reach.model_dump(mode="json") if reach else None,
|
||||
"ops_bridge": {
|
||||
"doc": OPS_BRIDGE_DOC,
|
||||
"note": "Bring tunnels up via ops-bridge; sand-boxer emits descriptor only",
|
||||
},
|
||||
}
|
||||
if reach:
|
||||
payload["ssh_one_liner"] = ssh_one_liner(reach)
|
||||
return payload
|
||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
|
||||
from sandboxer.extensions.credentials import credentials_available
|
||||
from sandboxer.extensions.registry import load_extension
|
||||
from sandboxer.models import Extension, Profile, RouteStrategy
|
||||
from sandboxer.payments.metering import estimate_cost
|
||||
|
|
@ -20,6 +21,49 @@ def _is_metered(ext: Extension) -> bool:
|
|||
return ext.capabilities.pricing_model == "metered"
|
||||
|
||||
|
||||
def _metered_available(ext: Extension) -> bool:
|
||||
if ext.id == "ext.saas-stub":
|
||||
return True
|
||||
return credentials_available(ext.id, ext.config)
|
||||
|
||||
|
||||
def _select_metered_fallback(
|
||||
loaded: list[Extension],
|
||||
profile: Profile,
|
||||
inputs: dict[str, str],
|
||||
*,
|
||||
duration_s: int,
|
||||
) -> Extension | None:
|
||||
"""Pick cheapest credentialed metered extension; stub is always last resort."""
|
||||
available = [ext for ext in loaded if _is_metered(ext) and _metered_available(ext)]
|
||||
if not available:
|
||||
return None
|
||||
|
||||
strategy = profile.route.strategy if profile.route else RouteStrategy.EXPLICIT
|
||||
if strategy == RouteStrategy.LOWEST_COST:
|
||||
best: Extension | None = None
|
||||
best_cost: float | None = None
|
||||
for ext in available:
|
||||
if not _metered_available(ext):
|
||||
continue
|
||||
cost = _quote_cost(ext, profile, inputs, duration_s)
|
||||
if cost is None:
|
||||
continue
|
||||
max_hour = profile.route.max_cost_per_hour_usd if profile.route else None
|
||||
if max_hour is not None and cost > max_hour:
|
||||
continue
|
||||
if best is None or cost < (best_cost or float("inf")):
|
||||
best, best_cost = ext, cost
|
||||
if best:
|
||||
return best
|
||||
|
||||
for ext_id in ("ext.e2b", "ext.modal", "ext.saas-stub"):
|
||||
for ext in available:
|
||||
if ext.id == ext_id:
|
||||
return ext
|
||||
return available[0]
|
||||
|
||||
|
||||
def _self_hosted_available(profile: Profile, ext: Extension, host_override: str | None) -> bool:
|
||||
if _is_metered(ext):
|
||||
return True
|
||||
|
|
@ -65,6 +109,11 @@ def resolve_extension(
|
|||
for ext in loaded:
|
||||
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||
return ext
|
||||
fallback = _select_metered_fallback(
|
||||
loaded, profile, inputs, duration_s=duration_s
|
||||
)
|
||||
if fallback:
|
||||
return fallback
|
||||
for ext in loaded:
|
||||
if _is_metered(ext):
|
||||
return ext
|
||||
|
|
@ -76,6 +125,8 @@ def resolve_extension(
|
|||
for ext in loaded:
|
||||
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||
return ext
|
||||
if not _metered_available(ext):
|
||||
continue
|
||||
cost = _quote_cost(ext, profile, inputs, duration_s)
|
||||
if cost is None:
|
||||
continue
|
||||
|
|
@ -92,6 +143,11 @@ def resolve_extension(
|
|||
for ext in loaded:
|
||||
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||
return ext
|
||||
fallback = _select_metered_fallback(
|
||||
loaded, profile, inputs, duration_s=duration_s
|
||||
)
|
||||
if fallback:
|
||||
return fallback
|
||||
return loaded[-1]
|
||||
|
||||
return load_extension(profile.extension)
|
||||
5
src/sandboxer/secrets/__init__.py
Normal file
5
src/sandboxer/secrets/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Setup secret resolution at provision boundary."""
|
||||
|
||||
from sandboxer.secrets.resolver import resolve_setup_secrets
|
||||
|
||||
__all__ = ["resolve_setup_secrets"]
|
||||
41
src/sandboxer/secrets/resolver.py
Normal file
41
src/sandboxer/secrets/resolver.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Resolve profile.setup.secret_refs from operator-injected env (BYOK boundary)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
def _secret_env_name(ref: str) -> str:
|
||||
normalized = ref.upper().replace("-", "_").replace(".", "_")
|
||||
return f"SANDBOXER_SECRET_{normalized}"
|
||||
|
||||
|
||||
def resolve_secret_ref(ref: str) -> str | None:
|
||||
"""Resolve a single secret ref from env. OpenBao injection is operator-owned."""
|
||||
return os.environ.get(_secret_env_name(ref))
|
||||
|
||||
|
||||
def resolve_setup_secrets(profile: Profile) -> dict[str, str]:
|
||||
"""Resolve all profile secret_refs or raise if any are missing."""
|
||||
refs = profile.setup.secret_refs
|
||||
if not refs:
|
||||
return {}
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
missing: list[str] = []
|
||||
for ref in refs:
|
||||
value = resolve_secret_ref(ref)
|
||||
if value:
|
||||
resolved[ref] = value
|
||||
else:
|
||||
missing.append(ref)
|
||||
|
||||
if missing:
|
||||
env_hints = ", ".join(_secret_env_name(r) for r in missing)
|
||||
raise ValueError(
|
||||
f"Unresolved secret_refs for {profile.id}: {missing}. "
|
||||
f"Set env ({env_hints}) or use warden route find for OpenBao path."
|
||||
)
|
||||
return resolved
|
||||
50
tests/test_billing_export.py
Normal file
50
tests/test_billing_export.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""fin-hub billing export tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sandboxer.models import ActorType, Consumer, MeterRecord, SandboxState, SandboxStatus
|
||||
from sandboxer.payments.billing_export import export_meter_usage
|
||||
|
||||
|
||||
def test_export_skipped_when_url_unset() -> None:
|
||||
now = datetime.now(UTC)
|
||||
status = SandboxStatus(
|
||||
sandbox_id="s1",
|
||||
profile_id="profile.e2b-burst",
|
||||
extension_id="ext.e2b",
|
||||
state=SandboxState.DESTROYED,
|
||||
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
meter = MeterRecord(pricing_model="metered", actual_usd=0.5, duration_s=100.0)
|
||||
assert export_meter_usage(status, extension_id="ext.e2b", meter=meter) is None
|
||||
|
||||
|
||||
def test_export_posts_when_configured(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_FIN_HUB_URL", "http://fin-hub.test")
|
||||
now = datetime.now(UTC)
|
||||
status = SandboxStatus(
|
||||
sandbox_id="s1",
|
||||
profile_id="profile.e2b-burst",
|
||||
extension_id="ext.e2b",
|
||||
state=SandboxState.DESTROYED,
|
||||
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
meter = MeterRecord(pricing_model="metered", actual_usd=0.5, duration_s=100.0)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"ok": True}
|
||||
with patch("sandboxer.payments.billing_export.httpx.post", return_value=mock_response) as post:
|
||||
result = export_meter_usage(status, extension_id="ext.e2b", meter=meter)
|
||||
|
||||
assert result == {"ok": True}
|
||||
post.assert_called_once()
|
||||
payload = post.call_args.kwargs["json"]
|
||||
assert payload["sandbox_id"] == "s1"
|
||||
assert payload["actual_usd"] == 0.5
|
||||
20
tests/test_consumer_profiles.py
Normal file
20
tests/test_consumer_profiles.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Consumer profile loader smoke tests."""
|
||||
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
|
||||
|
||||
def test_profile_agent_dev_loads() -> None:
|
||||
profile = load_profile("profile.agent-dev")
|
||||
assert profile.id == "profile.agent-dev"
|
||||
assert profile.extension == "ext.compose-ssh"
|
||||
assert profile.scope_default == "agent"
|
||||
assert profile.route is not None
|
||||
assert profile.ttl.default == "8h"
|
||||
|
||||
|
||||
def test_profile_build_loads() -> None:
|
||||
profile = load_profile("profile.build")
|
||||
assert profile.id == "profile.build"
|
||||
assert profile.extension == "ext.vm-packer"
|
||||
assert "build-registry-token" in profile.setup.secret_refs
|
||||
assert profile.reachability.tunnel == "ops-bridge"
|
||||
72
tests/test_e2b.py
Normal file
72
tests/test_e2b.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""E2B cloud adapter tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.extensions.credentials import credentials_available, resolve_api_key
|
||||
from sandboxer.extensions.e2b import E2BExtension
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
def _profile() -> Profile:
|
||||
return Profile.model_validate(
|
||||
{
|
||||
"id": "profile.e2b-burst",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.e2b",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_credentials_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
||||
config = {"api_key_env": "E2B_API_KEY", "secret_ref": "e2b-api-key"}
|
||||
assert resolve_api_key(config, extension_id="ext.e2b") == "test-key"
|
||||
assert credentials_available("ext.e2b", config)
|
||||
|
||||
|
||||
def test_provision_and_teardown_with_mock_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
||||
ext = E2BExtension(
|
||||
{
|
||||
"api_base": "https://api.e2b.dev",
|
||||
"api_key_env": "E2B_API_KEY",
|
||||
"provider": "e2b",
|
||||
"template_id": "base",
|
||||
}
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 200
|
||||
create_resp.json.return_value = {
|
||||
"sandboxID": "e2b-prov-1",
|
||||
"sandboxURL": "https://e2b-prov-1.e2b.dev",
|
||||
}
|
||||
ready_resp = MagicMock()
|
||||
ready_resp.status_code = 200
|
||||
delete_resp = MagicMock()
|
||||
delete_resp.status_code = 204
|
||||
mock_client.post.return_value = create_resp
|
||||
mock_client.get.return_value = ready_resp
|
||||
mock_client.delete.return_value = delete_resp
|
||||
mock_client.__enter__.return_value = mock_client
|
||||
mock_client.__exit__.return_value = None
|
||||
|
||||
with patch.object(ext, "_client_factory", return_value=mock_client):
|
||||
handle = ext.provision(_profile(), {}, "e2b")
|
||||
assert handle["provider_sandbox_id"] == "e2b-prov-1"
|
||||
reach = ext.wait_ready(handle)
|
||||
assert "e2b-prov-1" in reach["endpoint"]
|
||||
report = ext.teardown(handle)
|
||||
assert report["provider_removed"] == "true"
|
||||
|
||||
|
||||
def test_estimate_cost() -> None:
|
||||
ext = E2BExtension({"rate_usd_per_hour": 0.15, "session_fee_usd": 0.02})
|
||||
quote = ext.estimate_cost(_profile(), {}, duration_s=3600)
|
||||
assert quote.extension_id == "ext.e2b"
|
||||
assert quote.estimated_usd > 0
|
||||
63
tests/test_modal.py
Normal file
63
tests/test_modal.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Modal cloud adapter tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.extensions.modal import ModalExtension
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
def _profile() -> Profile:
|
||||
return Profile.model_validate(
|
||||
{
|
||||
"id": "profile.modal-gpu",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.modal",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_provision_and_teardown_with_mock_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MODAL_TOKEN_ID", "modal-token")
|
||||
ext = ModalExtension(
|
||||
{
|
||||
"api_base": "https://api.modal.com",
|
||||
"api_key_env": "MODAL_TOKEN_ID",
|
||||
"provider": "modal",
|
||||
}
|
||||
)
|
||||
|
||||
mock_client = MagicMock()
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 200
|
||||
create_resp.json.return_value = {
|
||||
"sandbox_id": "modal-prov-1",
|
||||
"url": "https://modal.run/sandbox/modal-prov-1",
|
||||
"status": "ready",
|
||||
}
|
||||
ready_resp = MagicMock()
|
||||
ready_resp.status_code = 200
|
||||
ready_resp.json.return_value = {"status": "ready"}
|
||||
delete_resp = MagicMock()
|
||||
delete_resp.status_code = 200
|
||||
mock_client.post.return_value = create_resp
|
||||
mock_client.get.return_value = ready_resp
|
||||
mock_client.delete.return_value = delete_resp
|
||||
mock_client.__enter__.return_value = mock_client
|
||||
mock_client.__exit__.return_value = None
|
||||
|
||||
with patch.object(ext, "_client_factory", return_value=mock_client):
|
||||
handle = ext.provision(_profile(), {}, "modal")
|
||||
assert handle["provider_sandbox_id"] == "modal-prov-1"
|
||||
ext.wait_ready(handle)
|
||||
report = ext.teardown(handle)
|
||||
assert report["provider_removed"] == "true"
|
||||
|
||||
|
||||
def test_provision_without_credentials_raises() -> None:
|
||||
ext = ModalExtension({"api_key_env": "MODAL_TOKEN_ID"})
|
||||
with pytest.raises(RuntimeError, match="API key"):
|
||||
ext.provision(_profile(), {}, "modal")
|
||||
78
tests/test_reachability.py
Normal file
78
tests/test_reachability.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Reachability enrichment tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sandboxer.models import (
|
||||
ActorType,
|
||||
Consumer,
|
||||
Profile,
|
||||
Reachability,
|
||||
ReachabilitySpec,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
)
|
||||
from sandboxer.reachability.enrich import (
|
||||
build_reachability_report,
|
||||
enrich_reachability,
|
||||
ssh_one_liner,
|
||||
)
|
||||
|
||||
|
||||
def _profile() -> Profile:
|
||||
return Profile.model_validate(
|
||||
{
|
||||
"id": "profile.agent-dev",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.compose-ssh",
|
||||
"reachability": ReachabilitySpec(
|
||||
tunnel="ops-bridge", identity="ops-warden"
|
||||
).model_dump(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_enrich_adds_tunnel_and_identity(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_TUNNEL_PORT", "12222")
|
||||
reach = enrich_reachability(
|
||||
{"ssh": "build@localhost", "remote_dir": "/build/sbx-1", "host": "localhost"},
|
||||
_profile(),
|
||||
{"ssh_port": "12222"},
|
||||
)
|
||||
assert reach["identity"] == "ops-warden"
|
||||
assert reach["tunnel"] == "localhost:12222"
|
||||
assert reach["tunnel_via"] == "ops-bridge"
|
||||
|
||||
|
||||
def test_ssh_one_liner() -> None:
|
||||
reach = Reachability(ssh="user@host", remote_dir="/tmp/ws")
|
||||
line = ssh_one_liner(reach)
|
||||
assert line is not None
|
||||
assert "user@host" in line
|
||||
assert "/tmp/ws" in line
|
||||
|
||||
|
||||
def test_build_reachability_report() -> None:
|
||||
now = datetime.now(UTC)
|
||||
status = SandboxStatus(
|
||||
sandbox_id="abc12345",
|
||||
profile_id="profile.agent-dev",
|
||||
extension_id="ext.compose-ssh",
|
||||
state=SandboxState.READY,
|
||||
consumer=Consumer(actor=ActorType.AGT, project="glas-harness"),
|
||||
host="coulombcore",
|
||||
reachability=Reachability(
|
||||
ssh="root@coulombcore",
|
||||
remote_dir="/tmp/sandboxer/abc12345",
|
||||
tunnel="localhost:22",
|
||||
tunnel_via="ops-bridge",
|
||||
identity="ops-warden",
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
report = build_reachability_report(status)
|
||||
assert report["sandbox_id"] == "abc12345"
|
||||
assert report["ssh_one_liner"] is not None
|
||||
assert "ops_bridge" in report
|
||||
|
|
@ -16,7 +16,12 @@ def _burst_profile() -> Profile:
|
|||
"extension": "ext.compose-ssh",
|
||||
"route": {
|
||||
"strategy": "prefer-self-hosted",
|
||||
"extensions": ["ext.compose-ssh", "ext.saas-stub"],
|
||||
"extensions": [
|
||||
"ext.compose-ssh",
|
||||
"ext.e2b",
|
||||
"ext.modal",
|
||||
"ext.saas-stub",
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -42,12 +47,23 @@ def test_prefer_self_hosted_when_host_set(monkeypatch: pytest.MonkeyPatch) -> No
|
|||
|
||||
def test_prefer_self_hosted_falls_back_to_saas(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("SANDBOXER_HOST", raising=False)
|
||||
monkeypatch.delenv("E2B_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||
monkeypatch.setenv("SANDBOXER_FORCE_SAAS", "1")
|
||||
ext = resolve_extension(_burst_profile(), {}, host_override=None)
|
||||
assert ext.id == "ext.saas-stub"
|
||||
assert ext.capabilities.pricing_model == "metered"
|
||||
|
||||
|
||||
def test_prefer_self_hosted_falls_back_to_e2b_when_credentialed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_FORCE_SAAS", "1")
|
||||
monkeypatch.setenv("E2B_API_KEY", "test-key")
|
||||
ext = resolve_extension(_burst_profile(), {}, host_override=None)
|
||||
assert ext.id == "ext.e2b"
|
||||
|
||||
|
||||
def test_lowest_cost_picks_metered_when_forced(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
profile = _burst_profile()
|
||||
profile.route = RouteSpec(
|
||||
|
|
|
|||
51
tests/test_secrets.py
Normal file
51
tests/test_secrets.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Setup secret resolution tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.models import Profile, SetupSpec
|
||||
from sandboxer.secrets.resolver import resolve_secret_ref, resolve_setup_secrets
|
||||
|
||||
|
||||
def test_resolve_secret_ref_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN", "tok123")
|
||||
assert resolve_secret_ref("build-registry-token") == "tok123"
|
||||
|
||||
|
||||
def test_resolve_setup_secrets_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN", "tok123")
|
||||
profile = Profile.model_validate(
|
||||
{
|
||||
"id": "profile.build",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.vm-packer",
|
||||
"setup": SetupSpec(secret_refs=["build-registry-token"]).model_dump(),
|
||||
}
|
||||
)
|
||||
secrets = resolve_setup_secrets(profile)
|
||||
assert secrets["build-registry-token"] == "tok123"
|
||||
|
||||
|
||||
def test_resolve_setup_secrets_missing_raises() -> None:
|
||||
profile = Profile.model_validate(
|
||||
{
|
||||
"id": "profile.build",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.vm-packer",
|
||||
"setup": SetupSpec(secret_refs=["missing-ref"]).model_dump(),
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="Unresolved secret_refs"):
|
||||
resolve_setup_secrets(profile)
|
||||
|
||||
|
||||
def test_empty_secret_refs() -> None:
|
||||
profile = Profile.model_validate(
|
||||
{
|
||||
"id": "profile.compose-e2e",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.compose-ssh",
|
||||
}
|
||||
)
|
||||
assert resolve_setup_secrets(profile) == {}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from subprocess import CompletedProcess
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -21,6 +22,16 @@ def _profile() -> Profile:
|
|||
)
|
||||
|
||||
|
||||
def _build_profile() -> Profile:
|
||||
return Profile.model_validate(
|
||||
{
|
||||
"id": "profile.vm-packer-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()
|
||||
|
|
@ -73,4 +84,66 @@ def test_teardown_preserves_vm() -> None:
|
|||
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"
|
||||
assert report["workspace_removed"] == "True"
|
||||
|
||||
|
||||
def test_provision_build_runs_packer(tmp_path: Path) -> None:
|
||||
template = tmp_path / "haskell"
|
||||
template.mkdir()
|
||||
(template / "haskell-build.pkr.hcl").write_text('packer {}')
|
||||
ova = template / "haskell-build-20260624.ova"
|
||||
ova.write_bytes(b"ova")
|
||||
|
||||
ext = VMPackerExtension()
|
||||
with (
|
||||
patch(
|
||||
"sandboxer.extensions.vm_packer.subprocess.run",
|
||||
return_value=CompletedProcess(args=[], returncode=0, stdout="", stderr=""),
|
||||
) as run,
|
||||
patch("sandboxer.extensions.vm_packer.emit_progress_note"),
|
||||
):
|
||||
handle = ext.provision(
|
||||
_build_profile(),
|
||||
{"vm_name": "haskell-build", "packer_template": str(template)},
|
||||
"localhost",
|
||||
)
|
||||
|
||||
assert handle["mode"] == "build"
|
||||
assert handle["artifact_path"] == str(ova)
|
||||
assert run.call_count == 2
|
||||
init_cmd, build_cmd = [c.args[0] for c in run.call_args_list]
|
||||
assert init_cmd[:2] == ["packer", "init"]
|
||||
assert build_cmd[:2] == ["packer", "build"]
|
||||
assert "-var" in build_cmd and "vm_name=haskell-build" in build_cmd
|
||||
|
||||
|
||||
def test_provision_build_requires_template() -> None:
|
||||
ext = VMPackerExtension()
|
||||
with pytest.raises(ValueError, match="packer_template"):
|
||||
ext.provision(_build_profile(), {"vm_name": "haskell-build"}, "localhost")
|
||||
|
||||
|
||||
def test_wait_ready_build_checks_artifact(tmp_path: Path) -> None:
|
||||
ova = tmp_path / "haskell-build.ova"
|
||||
ova.write_bytes(b"ova")
|
||||
ext = VMPackerExtension()
|
||||
reach = ext.wait_ready(
|
||||
{
|
||||
"mode": "build",
|
||||
"artifact_path": str(ova),
|
||||
"host": "localhost",
|
||||
}
|
||||
)
|
||||
assert reach["endpoint"] == str(ova)
|
||||
|
||||
|
||||
def test_teardown_build_preserves_artifact() -> None:
|
||||
ext = VMPackerExtension()
|
||||
report = ext.teardown(
|
||||
{
|
||||
"mode": "build",
|
||||
"artifact_path": "/tmp/haskell-build.ova",
|
||||
}
|
||||
)
|
||||
assert report["artifact_preserved"] == "true"
|
||||
assert report["workspace_removed"] == "false"
|
||||
|
|
@ -85,9 +85,9 @@ state_hub_task_id: "7075d8fc-44d1-48fe-b061-a05385de31a8"
|
|||
|
||||
```task
|
||||
id: SAND-WP-0006-T06
|
||||
status: wait
|
||||
status: cancel
|
||||
priority: low
|
||||
state_hub_task_id: "c514c6b3-2be8-41e4-8e84-88f20ad80f7e"
|
||||
```
|
||||
|
||||
Real `ext.e2b` / `ext.modal` adapters, BYOK via OpenBao, fin-hub export.
|
||||
Superseded by SAND-WP-0010 (E2B/Modal adapters, BYOK, fin-hub export).
|
||||
|
|
@ -21,8 +21,7 @@ registry, HTTP parity).
|
|||
Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
|
||||
**Predecessor:** SAND-WP-0007 (snapshots — finished)
|
||||
**Follow-on:** SAND-WP-0010 (real cloud adapters), SAND-WP-0011 (reachability +
|
||||
consumer profiles), SAND-WP-0012 (Packer orchestration)
|
||||
**Follow-on:** SAND-WP-0010, SAND-WP-0011, SAND-WP-0012 (workplans filed 2026-06-24)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
114
workplans/SAND-WP-0010-cloud-adapters-and-billing.md
Normal file
114
workplans/SAND-WP-0010-cloud-adapters-and-billing.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
id: SAND-WP-0010
|
||||
type: workplan
|
||||
title: "Cloud adapters and billing export"
|
||||
domain: infotech
|
||||
repo: sand-boxer
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-24"
|
||||
updated: "2026-06-24"
|
||||
state_hub_workstream_id: "a78ea9f1-d076-43a6-8798-36fc333edd25"
|
||||
---
|
||||
|
||||
# Cloud adapters and billing export
|
||||
|
||||
Replace `ext.saas-stub` with real metered cloud backends (E2B, Modal) and wire
|
||||
BYOK credential routing plus fin-hub billing export.
|
||||
|
||||
Gap analysis P5/P10: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
Supersedes: SAND-WP-0006-T06
|
||||
|
||||
**Predecessor:** SAND-WP-0009 (TTL — finished)
|
||||
**Follow-on:** SAND-WP-0011 (reachability + consumer profiles)
|
||||
|
||||
---
|
||||
|
||||
## Credential routing and BYOK
|
||||
|
||||
```task
|
||||
id: SAND-WP-0010-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "5aeb6a17-dc5b-4e39-996c-f7f31c2659f4"
|
||||
```
|
||||
|
||||
`src/sandboxer/extensions/credentials.py`; docs in `docs/payments.md` and
|
||||
`docs/cloud-adapters.md`.
|
||||
|
||||
## ext.e2b adapter
|
||||
|
||||
```task
|
||||
id: SAND-WP-0010-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "205bc70a-aaa6-4cd7-b0a5-11669490c150"
|
||||
```
|
||||
|
||||
`ext.e2b`, `profile.e2b-burst`, mocked HTTP tests in `tests/test_e2b.py`.
|
||||
|
||||
## ext.modal adapter
|
||||
|
||||
```task
|
||||
id: SAND-WP-0010-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b9f104e1-e9d6-4324-b82a-8406be3006e5"
|
||||
```
|
||||
|
||||
`ext.modal`, `profile.modal-gpu`, `tests/test_modal.py`.
|
||||
|
||||
## Routing and credits integration
|
||||
|
||||
```task
|
||||
id: SAND-WP-0010-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c11bdaf1-4c25-4c14-a566-0e28b0bd8b1d"
|
||||
```
|
||||
|
||||
`profile.burst-sandbox` routes e2b → modal → stub; credential-aware resolver.
|
||||
|
||||
## fin-hub billing export
|
||||
|
||||
```task
|
||||
id: SAND-WP-0010-T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "4eb1b0df-c6d7-4fb6-a7a4-e1455d2fac61"
|
||||
```
|
||||
|
||||
`payments/billing_export.py`; hook on metered destroy; `tests/test_billing_export.py`.
|
||||
|
||||
## Docs and capability registry
|
||||
|
||||
```task
|
||||
id: SAND-WP-0010-T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "d0aba132-b8fa-461b-b722-099868bf1770"
|
||||
```
|
||||
|
||||
`docs/cloud-adapters.md`; registry A5/C5; routing/payments/extension-sdk updates.
|
||||
|
||||
## Tests and smoke
|
||||
|
||||
```task
|
||||
id: SAND-WP-0010-T07
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3aebb3be-ae5e-4642-9710-9d80a1e8a582"
|
||||
```
|
||||
|
||||
77 tests; `scripts/smoke-cloud-adapter.sh` (operator, credential-gated).
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- E2B and Modal adapters provision/teardown via CLI with mocked CI
|
||||
- BYOK documented; no secrets in repo or State Hub payloads
|
||||
- `profile.burst-sandbox` routes to E2B when creds available
|
||||
- fin-hub export hook callable when `SANDBOXER_FIN_HUB_URL` set
|
||||
- SAND-WP-0006-T06 cancelled (superseded)
|
||||
134
workplans/SAND-WP-0011-reachability-and-consumer-profiles.md
Normal file
134
workplans/SAND-WP-0011-reachability-and-consumer-profiles.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
---
|
||||
id: SAND-WP-0011
|
||||
type: workplan
|
||||
title: "Reachability and consumer profiles"
|
||||
domain: infotech
|
||||
repo: sand-boxer
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-24"
|
||||
updated: "2026-06-24"
|
||||
state_hub_workstream_id: "614a59b5-1b95-4e5d-9014-676c69a99b5f"
|
||||
---
|
||||
|
||||
# Reachability and consumer profiles
|
||||
|
||||
Formalize ops-bridge tunnel attachment in reachability descriptors and ship
|
||||
first-class profiles for glas-harness and snuggle-inventor consumers.
|
||||
|
||||
Gap analysis P6/P7: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
|
||||
**Predecessor:** SAND-WP-0010 (cloud adapters)
|
||||
**Follow-on:** SAND-WP-0012 (Packer orchestration)
|
||||
|
||||
---
|
||||
|
||||
## Reachability descriptor enrichment
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ccf21aaf-9439-41e2-9ce3-becc08f734a7"
|
||||
```
|
||||
|
||||
Extend `Reachability` model: optional `tunnel` (local port / alias), `tunnel_via`
|
||||
(ops-bridge route id), `identity` (warden actor hint). Populate from profile
|
||||
`reachability` spec + `SANDBOXER_TUNNEL_*` env on compose-ssh / vm-packer.
|
||||
Document contract in `docs/meta-framework.md`; sand-boxer does not own tunnels.
|
||||
|
||||
## ops-bridge integration helper
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T02
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "61d41e09-ca21-4fbe-9b56-98f0ffe356c6"
|
||||
```
|
||||
|
||||
`sandboxer reachability show <id>` and `GET /v1/sandboxes/{id}/reachability`
|
||||
surfacing SSH one-liner and tunnel status pointer (`ops-bridge` MCP / CLI doc
|
||||
link). No tunnel bring-up in sand-boxer — pointer only.
|
||||
|
||||
## profile.agent-dev
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1a10a784-6a7c-4af6-9fbf-48d31e7e22cb"
|
||||
```
|
||||
|
||||
Profile for glas-harness: longer TTL defaults, `actor: agt` examples, route
|
||||
`prefer-self-hosted`. Extension `ext.compose-ssh`. Updated
|
||||
`docs/integrations/glas-harness.md` with real profile id.
|
||||
|
||||
## profile.build (snuggle-inventor)
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T04
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "a8142492-32c8-40d4-b882-b555858b44bb"
|
||||
```
|
||||
|
||||
Build sandbox profile binding `ext.vm-packer`; `setup.instructions` placeholder;
|
||||
`secret_refs` list on profile (resolution v0: env `SANDBOXER_SECRET_*`, inject at
|
||||
provision boundary only). Updated `docs/integrations/snuggle-inventor.md`.
|
||||
|
||||
## Secret boundary v0
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "df4053de-ec74-40a3-ae9b-422c1be973cd"
|
||||
```
|
||||
|
||||
`SetupSpec.secret_refs` resolution in manager pre-provision hook via
|
||||
`SANDBOXER_SECRET_<REF>` env; pass to extension handle; never store on
|
||||
`SandboxStatus` or emit to State Hub. Tests with mocked resolver.
|
||||
|
||||
## Consumer smoke scripts
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "9d5feebe-16a2-4448-ad0c-3276858341d1"
|
||||
```
|
||||
|
||||
`scripts/smoke-agent-dev.sh`, `scripts/smoke-build-profile.sh` (CoulombCore
|
||||
gated). Integration section in each consumer doc.
|
||||
|
||||
## Tests and docs
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T07
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "849e0701-fe8f-4c08-ac24-98cdf554c24b"
|
||||
```
|
||||
|
||||
Model tests for reachability fields; profile loader tests; updated `SCOPE.md`
|
||||
profile catalog. `make check` green.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
| Item | Track |
|
||||
|------|-------|
|
||||
| glas-harness tool execution | glas-harness repo |
|
||||
| snuggle code generation | snuggle-inventor repo |
|
||||
| ops-bridge tunnel automation | ops-bridge repo |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `profile.agent-dev` and `profile.build` load and create via CLI
|
||||
- Reachability JSON includes tunnel metadata when profile declares ops-bridge
|
||||
- secret_refs resolved at boundary; absent from agent-visible status payload
|
||||
- Consumer integration docs reference real profile ids
|
||||
121
workplans/SAND-WP-0012-packer-orchestration.md
Normal file
121
workplans/SAND-WP-0012-packer-orchestration.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
id: SAND-WP-0012
|
||||
type: workplan
|
||||
title: "Packer build orchestration"
|
||||
domain: infotech
|
||||
repo: sand-boxer
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-24"
|
||||
updated: "2026-06-24"
|
||||
state_hub_workstream_id: "87838886-0f4a-4eae-8d0e-b464933089de"
|
||||
---
|
||||
|
||||
# Packer build orchestration
|
||||
|
||||
Trigger Packer builds from `sandboxer create` and ship the-custodian
|
||||
`make remote-build` shim — completing the build-machines migration arc.
|
||||
|
||||
Gap analysis P8: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
Carries forward: SAND-WP-0005-T06 (deferred)
|
||||
|
||||
**Predecessor:** SAND-WP-0011 (consumer profiles)
|
||||
**Follow-on:** reuse-surface federation publish; sandboxer01 operator track
|
||||
|
||||
---
|
||||
|
||||
## Packer build mode on ext.vm-packer
|
||||
|
||||
```task
|
||||
id: SAND-WP-0012-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "9dc30d94-1797-4c35-81a0-e75e5414f6fc"
|
||||
```
|
||||
|
||||
`VMPackerExtension` build mode: inputs `packer_template`, `vm_name` trigger
|
||||
local Packer run per the-custodian `infra/build-machines/` conventions.
|
||||
Distinct from attach mode; teardown preserves OVA artifact. Tests mocked subprocess.
|
||||
|
||||
## profile.vm-packer-build
|
||||
|
||||
```task
|
||||
id: SAND-WP-0012-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "8e30794c-d8b9-48c7-ae93-db84724eedf2"
|
||||
```
|
||||
|
||||
Profile binding build mode with placement and TTL suitable for long builds.
|
||||
Documented inputs in `docs/migration-build-machines.md`.
|
||||
|
||||
## Manager and CLI integration
|
||||
|
||||
```task
|
||||
id: SAND-WP-0012-T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "685f766c-90ae-4698-87d0-b61535e7491a"
|
||||
```
|
||||
|
||||
`create` selects build vs attach via profile or `inputs.mode=build|attach`.
|
||||
Progress events to State Hub during long provision. CLI help text updated.
|
||||
|
||||
## the-custodian remote-build shim
|
||||
|
||||
```task
|
||||
id: SAND-WP-0012-T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "6c4c0f85-5153-4fe9-84e6-26c5c9d33bb1"
|
||||
```
|
||||
|
||||
`make remote-build PROJECT=` in build-machines delegates to
|
||||
`sandboxer create --profile profile.vm-haskell-build` when CLI present;
|
||||
legacy rsync path retained with deprecation notice.
|
||||
`scripts/verify-remote-build-shim.sh` mirrors SAND-WP-0004 pattern.
|
||||
|
||||
## Port-registry automation
|
||||
|
||||
```task
|
||||
id: SAND-WP-0012-T05
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "701b2640-36ea-4702-b660-7169a4ec72cc"
|
||||
```
|
||||
|
||||
Documented read-only port-registry pointer in `docs/migration-build-machines.md`;
|
||||
full ops-bridge automation deferred.
|
||||
|
||||
## Docs, tests, runbook
|
||||
|
||||
```task
|
||||
id: SAND-WP-0012-T06
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2378cd6a-ac23-47e9-a5d9-0d80b9e9f7af"
|
||||
```
|
||||
|
||||
Updated `docs/migration-build-machines.md`, `docs/extension-sdk.md`, operator
|
||||
runbook `docs/runbooks/profile-vm-packer-build.md`. Build mode cases in
|
||||
`tests/test_vm_packer.py`. `make check` green (90 tests).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
| Item | Track |
|
||||
|------|-------|
|
||||
| OVA import on hypervisor | Operator / build-machines |
|
||||
| systemd build-agent changes | the-custodian infra |
|
||||
| sandboxer01 host | Infra operator |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Build mode provisions via CLI with mocked Packer in CI
|
||||
- Attach mode unchanged (backward compatible)
|
||||
- the-custodian shim documented and verified
|
||||
- SAND-WP-0005-T06 superseded when complete
|
||||
Loading…
Add table
Add a link
Reference in a new issue