generated from coulomb/repo-seed
Implement SAND-WP-0006: SaaS payments, routing, and ext.saas-stub
Add credits store, metering on create/destroy, extension routing resolver, metered SaaS stub extension, burst/saas profiles, credits CLI, docs, and tests.
This commit is contained in:
@@ -39,6 +39,8 @@ Sandbox CLI (v0):
|
|||||||
sandboxer create # canary self-deploy (profile.sandbox-canary)
|
sandboxer create # canary self-deploy (profile.sandbox-canary)
|
||||||
sandboxer create --profile profile.compose-e2e --input repo=/path/to/repo
|
sandboxer create --profile profile.compose-e2e --input repo=/path/to/repo
|
||||||
sandboxer create --profile profile.vm-haskell-build --input vm=haskell-build --input repo=/path
|
sandboxer create --profile profile.vm-haskell-build --input vm=haskell-build --input repo=/path
|
||||||
|
sandboxer create --profile profile.saas-stub # metered SaaS stub
|
||||||
|
sandboxer credits show / credits add 10.00
|
||||||
sandboxer get <id>
|
sandboxer get <id>
|
||||||
sandboxer list
|
sandboxer list
|
||||||
sandboxer destroy <id>
|
sandboxer destroy <id>
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ follows evidence.
|
|||||||
5. ~~**Registry entry**~~ — `capability.execution.sandbox-provision`
|
5. ~~**Registry entry**~~ — `capability.execution.sandbox-provision`
|
||||||
6. ~~**Sibling integration notes**~~ — `docs/integrations/`
|
6. ~~**Sibling integration notes**~~ — `docs/integrations/`
|
||||||
7. ~~**Extension SDK sketch**~~ — done (`docs/extension-sdk.md`, `ext.vm-packer` attach mode)
|
7. ~~**Extension SDK sketch**~~ — done (`docs/extension-sdk.md`, `ext.vm-packer` attach mode)
|
||||||
|
9. ~~**SaaS extensions + payments v0**~~ — done (`ext.saas-stub`, routing, credits — SAND-WP-0006)
|
||||||
8. ~~**wise-validator**~~ — sibling repo (SAND-WP-0003); not a sand-boxer dependency
|
8. ~~**wise-validator**~~ — sibling repo (SAND-WP-0003); not a sand-boxer dependency
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
5
SCOPE.md
5
SCOPE.md
@@ -107,7 +107,8 @@ own tunnels or CAs.
|
|||||||
- **Workplans finished:** SAND-WP-0001–0005, 0008 (see `workplans/`)
|
- **Workplans finished:** SAND-WP-0001–0005, 0008 (see `workplans/`)
|
||||||
- **Package:** `src/sandboxer/` — CLI, manager, extensions, telemetry, HTTP API
|
- **Package:** `src/sandboxer/` — CLI, manager, extensions, telemetry, HTTP API
|
||||||
- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary`, `profile.vm-haskell-build`
|
- **Profiles:** `profile.compose-e2e`, `profile.sandbox-canary`, `profile.vm-haskell-build`
|
||||||
- **Extensions:** `ext.compose-ssh`, `ext.vm-packer` (attach mode)
|
- **Extensions:** `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub` (metered)
|
||||||
|
- **Routing + payments:** `docs/routing.md`, `docs/payments.md`, `sandboxer credits`
|
||||||
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
||||||
- **Tests:** 26 pytest cases; `make check` green
|
- **Tests:** 26 pytest cases; `make check` green
|
||||||
- **Sibling:** wise-validator ships `validate run` (SAND-WP-0003)
|
- **Sibling:** wise-validator ships `validate run` (SAND-WP-0003)
|
||||||
@@ -144,7 +145,7 @@ cd ~/the-custodian && make e2e REPO=activity-core
|
|||||||
- ~~`make e2e REPO=` shim~~ — done (SAND-WP-0004; delegates to `validate run`)
|
- ~~`make e2e REPO=` shim~~ — done (SAND-WP-0004; delegates to `validate run`)
|
||||||
- TTL auto-expiry / `extend_ttl` enforcement
|
- TTL auto-expiry / `extend_ttl` enforcement
|
||||||
- ~~`ext.vm-packer` attach mode~~ — done (SAND-WP-0005); Packer build orchestration deferred
|
- ~~`ext.vm-packer` attach mode~~ — done (SAND-WP-0005); Packer build orchestration deferred
|
||||||
- SaaS extensions (E2B, Modal) or payments layer (SAND-WP-0006)
|
- Real E2B / Modal adapters (stub + payments v0 done in SAND-WP-0006)
|
||||||
- Snapshot / restore / checkpoint profiles (SAND-WP-0007)
|
- Snapshot / restore / checkpoint profiles (SAND-WP-0007)
|
||||||
- Formal ops-bridge tunnel attachment in reachability descriptor
|
- Formal ops-bridge tunnel attachment in reachability descriptor
|
||||||
- Dedicated sandboxer01 host (CoulombCore interim only today)
|
- Dedicated sandboxer01 host (CoulombCore interim only today)
|
||||||
|
|||||||
@@ -92,11 +92,16 @@ with patch.object(SSHConfig, "run", return_value=(0, "ready")):
|
|||||||
handle = ext.provision(profile, {"vm": "haskell-build"}, "localhost")
|
handle = ext.provision(profile, {"vm": "haskell-build"}, "localhost")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Metered extensions (SAND-WP-0006)
|
||||||
|
|
||||||
|
Implement `estimate_cost` and `meter_actual` on `SandboxExtension`. Register with
|
||||||
|
`pricing_model: metered`. See `docs/payments.md` and `ext.saas-stub`.
|
||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
|
|
||||||
| Feature | Workplan |
|
| Feature | Workplan |
|
||||||
|---------|----------|
|
|---------|----------|
|
||||||
| Packer build orchestration from `create` | Future WP |
|
| Packer build orchestration from `create` | Future WP |
|
||||||
| SaaS adapters + `estimate_cost` | SAND-WP-0006 |
|
| E2B / Modal / Daytona cloud adapters | Post SAND-WP-0006 |
|
||||||
| Multi-backend routing engine | SAND-WP-0006 |
|
| fin-hub billing export | Future |
|
||||||
| Snapshot / restore hooks | SAND-WP-0007 |
|
| Snapshot / restore hooks | SAND-WP-0007 |
|
||||||
@@ -153,7 +153,7 @@ When multiple extensions satisfy a profile capability:
|
|||||||
| `lowest-latency` | Closest region / host wins |
|
| `lowest-latency` | Closest region / host wins |
|
||||||
| `explicit` | Profile names a single `extension`; no auto-routing |
|
| `explicit` | Profile names a single `extension`; no auto-routing |
|
||||||
|
|
||||||
v0 resolves `profile.extension` directly — routing engine deferred to SAND-WP-0006.
|
Routing engine v0: `sandboxer.routing.resolver` — see `docs/routing.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ Deferred: Packer orchestration from API, `make remote-build` shim.
|
|||||||
|
|
||||||
| Item | Workplan |
|
| Item | Workplan |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| SaaS extensions + payments | SAND-WP-0006 |
|
| ~~SaaS extensions + payments v0~~ | SAND-WP-0006 — stub + routing + credits |
|
||||||
|
| E2B / Modal real adapters | Post SAND-WP-0006 |
|
||||||
| Snapshot / restore | SAND-WP-0007 |
|
| Snapshot / restore | SAND-WP-0007 |
|
||||||
| TTL enforcement + scheduled reap | TBD |
|
| TTL enforcement + scheduled reap | TBD |
|
||||||
45
docs/payments.md
Normal file
45
docs/payments.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Payments and metering
|
||||||
|
|
||||||
|
Version 0.1 — SAND-WP-0006. OpenRouter-style credits for metered SaaS extensions.
|
||||||
|
|
||||||
|
## Credits store
|
||||||
|
|
||||||
|
Path: `~/.local/share/sandboxer/credits.json` (override via test injection).
|
||||||
|
|
||||||
|
| Env | Default |
|
||||||
|
|-----|---------|
|
||||||
|
| `SANDBOXER_DEFAULT_CREDITS` | `10.0` USD on first use |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandboxer credits show
|
||||||
|
sandboxer credits add 25.00
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metered lifecycle
|
||||||
|
|
||||||
|
1. **create** — `estimate_cost` on metered extension; block if balance insufficient
|
||||||
|
2. **ready** — `SandboxStatus.meter.estimate_usd` recorded
|
||||||
|
3. **destroy** — `meter_actual` (or prorated estimate); debit credits; State Hub note event
|
||||||
|
|
||||||
|
Self-hosted extensions (`pricing_model: self-hosted`) skip credits.
|
||||||
|
|
||||||
|
## Extension contract
|
||||||
|
|
||||||
|
Metered extensions implement on `SandboxExtension`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def estimate_cost(self, profile, inputs, *, duration_s=3600) -> MeterQuote | None
|
||||||
|
def meter_actual(self, handle, *, duration_s: float) -> float | None
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
44
docs/routing.md
Normal file
44
docs/routing.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Extension routing
|
||||||
|
|
||||||
|
Version 0.1 — SAND-WP-0006. Select backend when a profile lists multiple extensions.
|
||||||
|
|
||||||
|
## Profile route block
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
extension: ext.compose-ssh # default / explicit fallback
|
||||||
|
route:
|
||||||
|
strategy: prefer-self-hosted
|
||||||
|
extensions:
|
||||||
|
- ext.compose-ssh
|
||||||
|
- ext.saas-stub
|
||||||
|
max_cost_per_hour_usd: 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Strategies
|
||||||
|
|
||||||
|
| Strategy | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| `explicit` | Use `profile.extension` (default when no route) |
|
||||||
|
| `prefer-self-hosted` | First self-hosted candidate with resolvable host; else SaaS |
|
||||||
|
| `lowest-cost` | Self-hosted if available; else cheapest `estimate_cost` |
|
||||||
|
| `lowest-latency` | Self-hosted if available; else last candidate (v0) |
|
||||||
|
|
||||||
|
## Testing SaaS fallback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Skip self-hosted when host unavailable:
|
||||||
|
unset SANDBOXER_HOST
|
||||||
|
SANDBOXER_FORCE_SAAS=1 sandboxer create --profile profile.burst-sandbox
|
||||||
|
|
||||||
|
# Or use metered-only profile:
|
||||||
|
sandboxer create --profile profile.saas-stub
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference profiles
|
||||||
|
|
||||||
|
| Profile | Route |
|
||||||
|
|---------|-------|
|
||||||
|
| `profile.burst-sandbox` | compose-ssh → saas-stub fallback |
|
||||||
|
| `profile.saas-stub` | explicit `ext.saas-stub` |
|
||||||
|
|
||||||
|
Resolver: `sandboxer.routing.resolver.resolve_extension`.
|
||||||
@@ -46,4 +46,14 @@ Attach mode for pre-built VMs (`the-custodian/infra/build-machines/` lineage).
|
|||||||
|
|
||||||
**Profile:** `profile.vm-haskell-build` — see `docs/runbooks/profile-vm-haskell-build.md`.
|
**Profile:** `profile.vm-haskell-build` — see `docs/runbooks/profile-vm-haskell-build.md`.
|
||||||
|
|
||||||
Packer build / OVA import remains operator-driven (not triggered by `create`).
|
Packer build / OVA import remains operator-driven (not triggered by `create`).
|
||||||
|
|
||||||
|
## ext.saas-stub
|
||||||
|
|
||||||
|
Metered SaaS stub for payments and routing v0 (SAND-WP-0006). No external API.
|
||||||
|
|
||||||
|
**estimate_cost / meter_actual:** credits check on create; debit on destroy.
|
||||||
|
|
||||||
|
**Profile:** `profile.saas-stub` (explicit), `profile.burst-sandbox` (self-hosted fallback).
|
||||||
|
|
||||||
|
See `docs/payments.md` and `docs/routing.md`.
|
||||||
15
extensions/ext.saas-stub.yaml
Normal file
15
extensions/ext.saas-stub.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
id: ext.saas-stub
|
||||||
|
title: SaaS sandbox stub
|
||||||
|
description: >
|
||||||
|
Metered SaaS extension stub for payments and routing v0. No external API;
|
||||||
|
use for burst fallback testing before E2B/Modal adapters land.
|
||||||
|
handler: sandboxer.extensions.saas_stub:SaaSStubExtension
|
||||||
|
capabilities:
|
||||||
|
isolation_levels: [microvm]
|
||||||
|
regions: [us, eu]
|
||||||
|
persistence: true
|
||||||
|
pricing_model: metered
|
||||||
|
config:
|
||||||
|
provider: saas-stub
|
||||||
|
rate_usd_per_hour: 0.12
|
||||||
|
session_fee_usd: 0.01
|
||||||
40
profiles/profile.burst-sandbox.yaml
Normal file
40
profiles/profile.burst-sandbox.yaml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
id: profile.burst-sandbox
|
||||||
|
version: "1.0.0"
|
||||||
|
extension: ext.compose-ssh
|
||||||
|
route:
|
||||||
|
strategy: prefer-self-hosted
|
||||||
|
extensions:
|
||||||
|
- ext.compose-ssh
|
||||||
|
- 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: session
|
||||||
|
ttl:
|
||||||
|
default: 2h
|
||||||
|
max: 8h
|
||||||
|
idle_reap: null
|
||||||
|
resources:
|
||||||
|
cpu: null
|
||||||
|
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.
|
||||||
|
secret_refs: []
|
||||||
|
placement:
|
||||||
|
prefer: [sandboxer01]
|
||||||
|
fallback: [coulombcore]
|
||||||
|
reachability:
|
||||||
|
tunnel: ops-bridge
|
||||||
|
identity: ops-warden
|
||||||
|
metadata:
|
||||||
|
cost_class: saas-metered
|
||||||
|
latency_class: standard
|
||||||
|
observability: none
|
||||||
32
profiles/profile.saas-stub.yaml
Normal file
32
profiles/profile.saas-stub.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
id: profile.saas-stub
|
||||||
|
version: "1.0.0"
|
||||||
|
extension: ext.saas-stub
|
||||||
|
isolation:
|
||||||
|
level: microvm
|
||||||
|
network:
|
||||||
|
default: deny
|
||||||
|
egress: []
|
||||||
|
workspace:
|
||||||
|
mode: remote-canonical
|
||||||
|
access: rw
|
||||||
|
scope_default: session
|
||||||
|
ttl:
|
||||||
|
default: 1h
|
||||||
|
max: 4h
|
||||||
|
idle_reap: null
|
||||||
|
resources:
|
||||||
|
cpu: null
|
||||||
|
memory_mb: null
|
||||||
|
setup:
|
||||||
|
instructions: "Metered SaaS stub — no external provider API."
|
||||||
|
secret_refs: []
|
||||||
|
placement:
|
||||||
|
prefer: []
|
||||||
|
fallback: []
|
||||||
|
reachability:
|
||||||
|
tunnel: ops-bridge
|
||||||
|
identity: ops-warden
|
||||||
|
metadata:
|
||||||
|
cost_class: saas-metered
|
||||||
|
latency_class: standard
|
||||||
|
observability: none
|
||||||
@@ -33,7 +33,7 @@ external_evidence:
|
|||||||
- profile-based create/destroy via CLI
|
- profile-based create/destroy via CLI
|
||||||
- State Hub lifecycle events on transitions
|
- State Hub lifecycle events on transitions
|
||||||
broken_expectations:
|
broken_expectations:
|
||||||
- SaaS extensions and payments layer not yet built
|
- Real E2B/Modal adapters not yet built (saas-stub + credits v0 done)
|
||||||
- wise-validator migration not complete
|
- wise-validator migration not complete
|
||||||
out_of_scope_expectations:
|
out_of_scope_expectations:
|
||||||
- agent harness and tool orchestration (glas-harness)
|
- agent harness and tool orchestration (glas-harness)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from sandboxer import __version__
|
|||||||
from sandboxer.core.manager import SandboxManager
|
from sandboxer.core.manager import SandboxManager
|
||||||
from sandboxer.defaults import resolve_create_defaults
|
from sandboxer.defaults import resolve_create_defaults
|
||||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
|
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
|
||||||
|
from sandboxer.payments.credits import CreditsStore
|
||||||
from sandboxer.placement import resolve_host
|
from sandboxer.placement import resolve_host
|
||||||
from sandboxer.profiles.loader import load_profile
|
from sandboxer.profiles.loader import load_profile
|
||||||
from sandboxer.telemetry.export import export_telemetry
|
from sandboxer.telemetry.export import export_telemetry
|
||||||
@@ -25,6 +26,8 @@ app = typer.Typer(
|
|||||||
)
|
)
|
||||||
inspect_app = typer.Typer(help="Host introspection without provisioning.")
|
inspect_app = typer.Typer(help="Host introspection without provisioning.")
|
||||||
app.add_typer(inspect_app, name="inspect")
|
app.add_typer(inspect_app, name="inspect")
|
||||||
|
credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
|
||||||
|
app.add_typer(credits_app, name="credits")
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
@@ -216,5 +219,22 @@ def reap_stale_cmd(
|
|||||||
_print_json([r.model_dump(mode="json") for r in results])
|
_print_json([r.model_dump(mode="json") for r in results])
|
||||||
|
|
||||||
|
|
||||||
|
@credits_app.command("show")
|
||||||
|
def credits_show() -> None:
|
||||||
|
"""Show current credit balance."""
|
||||||
|
store = CreditsStore()
|
||||||
|
_print_json({"balance_usd": store.balance(), "currency": "USD"})
|
||||||
|
|
||||||
|
|
||||||
|
@credits_app.command("add")
|
||||||
|
def credits_add(
|
||||||
|
amount: Annotated[float, typer.Argument(help="USD amount to add")],
|
||||||
|
) -> None:
|
||||||
|
"""Add credits to the workspace balance."""
|
||||||
|
store = CreditsStore()
|
||||||
|
new_balance = store.add(amount)
|
||||||
|
typer.echo(f"Balance: {new_balance:.4f} USD")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
@@ -6,13 +6,17 @@ from sandboxer.extensions.registry import load_extension, resolve_backend
|
|||||||
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
|
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
|
||||||
from sandboxer.lifecycle.store import SandboxStore, utcnow
|
from sandboxer.lifecycle.store import SandboxStore, utcnow
|
||||||
from sandboxer.models import (
|
from sandboxer.models import (
|
||||||
|
MeterRecord,
|
||||||
Reachability,
|
Reachability,
|
||||||
SandboxCreateRequest,
|
SandboxCreateRequest,
|
||||||
SandboxState,
|
SandboxState,
|
||||||
SandboxStatus,
|
SandboxStatus,
|
||||||
)
|
)
|
||||||
|
from sandboxer.payments.credits import CreditsStore
|
||||||
|
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||||
from sandboxer.placement import resolve_host
|
from sandboxer.placement import resolve_host
|
||||||
from sandboxer.profiles.loader import load_profile
|
from sandboxer.profiles.loader import load_profile
|
||||||
|
from sandboxer.routing.resolver import resolve_extension
|
||||||
from sandboxer.telemetry.export import export_telemetry
|
from sandboxer.telemetry.export import export_telemetry
|
||||||
from sandboxer.telemetry.introspection import (
|
from sandboxer.telemetry.introspection import (
|
||||||
build_introspection_report,
|
build_introspection_report,
|
||||||
@@ -22,17 +26,40 @@ from sandboxer.telemetry.introspection import (
|
|||||||
|
|
||||||
|
|
||||||
class SandboxManager:
|
class SandboxManager:
|
||||||
def __init__(self, store: SandboxStore | None = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: SandboxStore | None = None,
|
||||||
|
credits: CreditsStore | None = None,
|
||||||
|
) -> None:
|
||||||
self.store = store or SandboxStore()
|
self.store = store or SandboxStore()
|
||||||
|
self.credits = credits or CreditsStore()
|
||||||
|
|
||||||
|
def _resolved_host(self, profile, extension, host_override: str | None) -> str:
|
||||||
|
if extension.capabilities.pricing_model == "metered":
|
||||||
|
return extension.config.get("provider", "saas")
|
||||||
|
return resolve_host(profile, override=host_override)
|
||||||
|
|
||||||
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
|
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
|
||||||
profile = load_profile(request.profile)
|
profile = load_profile(request.profile)
|
||||||
extension = load_extension(profile.extension)
|
extension = resolve_extension(profile, request.inputs, host_override=host)
|
||||||
backend = resolve_backend(extension)
|
backend = resolve_backend(extension)
|
||||||
resolved_host = resolve_host(profile, override=host)
|
resolved_host = self._resolved_host(profile, extension, host)
|
||||||
wants_telemetry = profile_wants_telemetry(profile)
|
wants_telemetry = profile_wants_telemetry(profile)
|
||||||
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
||||||
|
|
||||||
|
quote = estimate_cost(extension, profile, request.inputs)
|
||||||
|
meter_record: MeterRecord | None = None
|
||||||
|
if quote:
|
||||||
|
if not self.credits.can_afford(quote.estimated_usd):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Insufficient credits: need {quote.estimated_usd:.4f} USD, "
|
||||||
|
f"balance {self.credits.balance():.4f} USD"
|
||||||
|
)
|
||||||
|
meter_record = MeterRecord(
|
||||||
|
pricing_model="metered",
|
||||||
|
estimate_usd=quote.estimated_usd,
|
||||||
|
)
|
||||||
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
status = SandboxStatus(
|
status = SandboxStatus(
|
||||||
sandbox_id="pending",
|
sandbox_id="pending",
|
||||||
@@ -42,6 +69,7 @@ class SandboxManager:
|
|||||||
consumer=request.consumer,
|
consumer=request.consumer,
|
||||||
host=resolved_host,
|
host=resolved_host,
|
||||||
inputs=dict(request.inputs),
|
inputs=dict(request.inputs),
|
||||||
|
meter=meter_record,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -52,7 +80,7 @@ class SandboxManager:
|
|||||||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||||
|
|
||||||
provision_before = None
|
provision_before = None
|
||||||
if wants_telemetry:
|
if wants_telemetry and extension.capabilities.pricing_model != "metered":
|
||||||
provision_before = collect_host_snapshot(resolved_host)
|
provision_before = collect_host_snapshot(resolved_host)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -64,6 +92,7 @@ class SandboxManager:
|
|||||||
status.inputs["ssh_port"] = handle.get("ssh_port", "")
|
status.inputs["ssh_port"] = handle.get("ssh_port", "")
|
||||||
status.inputs["vm_target"] = handle.get("vm_target", "")
|
status.inputs["vm_target"] = handle.get("vm_target", "")
|
||||||
status.inputs["vm_host"] = handle.get("vm_host", "")
|
status.inputs["vm_host"] = handle.get("vm_host", "")
|
||||||
|
status.inputs["endpoint"] = handle.get("endpoint", "")
|
||||||
reach = backend.wait_ready(handle)
|
reach = backend.wait_ready(handle)
|
||||||
status.reachability = Reachability(**reach)
|
status.reachability = Reachability(**reach)
|
||||||
status.state = SandboxState.READY
|
status.state = SandboxState.READY
|
||||||
@@ -114,13 +143,13 @@ class SandboxManager:
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
profile = load_profile(status.profile_id)
|
profile = load_profile(status.profile_id)
|
||||||
extension = load_extension(profile.extension)
|
extension = load_extension(status.extension_id)
|
||||||
backend = resolve_backend(extension)
|
backend = resolve_backend(extension)
|
||||||
wants_telemetry = profile_wants_telemetry(profile)
|
wants_telemetry = profile_wants_telemetry(profile)
|
||||||
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
||||||
|
|
||||||
destroy_before = None
|
destroy_before = None
|
||||||
if wants_telemetry and status.host:
|
if wants_telemetry and status.host and extension.capabilities.pricing_model != "metered":
|
||||||
destroy_before = collect_host_snapshot(status.host)
|
destroy_before = collect_host_snapshot(status.host)
|
||||||
|
|
||||||
status.state = SandboxState.DESTROYING
|
status.state = SandboxState.DESTROYING
|
||||||
@@ -139,6 +168,7 @@ class SandboxManager:
|
|||||||
"ssh_port": status.inputs.get("ssh_port", ""),
|
"ssh_port": status.inputs.get("ssh_port", ""),
|
||||||
"vm_target": status.inputs.get("vm_target", ""),
|
"vm_target": status.inputs.get("vm_target", ""),
|
||||||
"vm_host": status.inputs.get("vm_host", ""),
|
"vm_host": status.inputs.get("vm_host", ""),
|
||||||
|
"endpoint": status.inputs.get("endpoint", ""),
|
||||||
}
|
}
|
||||||
backend.teardown(handle)
|
backend.teardown(handle)
|
||||||
|
|
||||||
@@ -146,6 +176,19 @@ class SandboxManager:
|
|||||||
status.destroyed_at = utcnow()
|
status.destroyed_at = utcnow()
|
||||||
status.updated_at = status.destroyed_at
|
status.updated_at = status.destroyed_at
|
||||||
|
|
||||||
|
settled = settle_usage(status, extension, handle, destroyed_at=status.destroyed_at)
|
||||||
|
if settled and settled.pricing_model == "metered" and settled.actual_usd:
|
||||||
|
self.credits.debit(settled.actual_usd)
|
||||||
|
status.meter = settled
|
||||||
|
emit_lifecycle_event(
|
||||||
|
status,
|
||||||
|
summary=(
|
||||||
|
f"Sandbox metered: {settled.actual_usd:.4f} USD "
|
||||||
|
f"({settled.duration_s:.0f}s, ext={extension.id})"
|
||||||
|
),
|
||||||
|
event_type="note",
|
||||||
|
)
|
||||||
|
|
||||||
if wants_telemetry and destroy_before and status.host:
|
if wants_telemetry and destroy_before and status.host:
|
||||||
destroy_after = collect_host_snapshot(status.host)
|
destroy_after = collect_host_snapshot(status.host)
|
||||||
report = build_introspection_report(
|
report = build_introspection_report(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import uuid
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sandboxer.models import Profile
|
from sandboxer.models import MeterQuote, Profile
|
||||||
|
|
||||||
|
|
||||||
class SandboxExtension(ABC):
|
class SandboxExtension(ABC):
|
||||||
@@ -31,4 +31,18 @@ class SandboxExtension(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||||
"""Release sandbox resources. Returns cleanup report fields."""
|
"""Release sandbox resources. Returns cleanup report fields."""
|
||||||
|
|
||||||
|
def estimate_cost(
|
||||||
|
self,
|
||||||
|
profile: Profile,
|
||||||
|
inputs: dict[str, str],
|
||||||
|
*,
|
||||||
|
duration_s: int = 3600,
|
||||||
|
) -> MeterQuote | None:
|
||||||
|
"""Optional pre-create cost quote (metered SaaS extensions)."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def meter_actual(self, handle: dict[str, str], *, duration_s: float) -> float | None:
|
||||||
|
"""Optional post-destroy actual cost in USD."""
|
||||||
|
return None
|
||||||
66
src/sandboxer/extensions/saas_stub.py
Normal file
66
src/sandboxer/extensions/saas_stub.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""ext.saas-stub — metered SaaS extension stub for routing and payments v0.
|
||||||
|
|
||||||
|
No external provider API. Exercises estimate_cost, credits debit, and routing
|
||||||
|
fallback without E2B/Modal credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sandboxer.extensions.base import SandboxExtension
|
||||||
|
from sandboxer.models import MeterQuote, Profile
|
||||||
|
|
||||||
|
|
||||||
|
class SaaSStubExtension(SandboxExtension):
|
||||||
|
"""Simulated metered SaaS sandbox backend."""
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__(config)
|
||||||
|
self.rate_usd_per_hour: float = float(self.config.get("rate_usd_per_hour", 0.12))
|
||||||
|
self.session_fee_usd: float = float(self.config.get("session_fee_usd", 0.01))
|
||||||
|
self.provider: str = self.config.get("provider", "saas-stub")
|
||||||
|
|
||||||
|
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="ext.saas-stub",
|
||||||
|
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)
|
||||||
|
|
||||||
|
def provision(
|
||||||
|
self, profile: Profile, inputs: dict[str, str], host: str
|
||||||
|
) -> dict[str, str]:
|
||||||
|
sandbox_id = self.new_sandbox_id(inputs)
|
||||||
|
endpoint = f"https://stub.sandboxer.local/{sandbox_id}"
|
||||||
|
return {
|
||||||
|
"sandbox_id": sandbox_id,
|
||||||
|
"host": self.provider,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"provider": self.provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"endpoint": handle["endpoint"],
|
||||||
|
"host": handle.get("host"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"provider_removed": "true",
|
||||||
|
"sandbox_id": handle.get("sandbox_id", ""),
|
||||||
|
}
|
||||||
@@ -86,10 +86,32 @@ class ProfileMetadata(BaseModel):
|
|||||||
observability: Literal["none", "canary"] = "none"
|
observability: Literal["none", "canary"] = "none"
|
||||||
|
|
||||||
|
|
||||||
|
class RouteSpec(BaseModel):
|
||||||
|
strategy: RouteStrategy = RouteStrategy.EXPLICIT
|
||||||
|
extensions: list[str] = Field(default_factory=list)
|
||||||
|
max_cost_per_hour_usd: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MeterQuote(BaseModel):
|
||||||
|
extension_id: str
|
||||||
|
estimated_usd: float
|
||||||
|
unit: Literal["per_hour", "per_session"] = "per_hour"
|
||||||
|
duration_s: int = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class MeterRecord(BaseModel):
|
||||||
|
pricing_model: Literal["self-hosted", "metered"] = "self-hosted"
|
||||||
|
estimate_usd: float | None = None
|
||||||
|
actual_usd: float | None = None
|
||||||
|
duration_s: float | None = None
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
|
||||||
class Profile(BaseModel):
|
class Profile(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
version: str
|
version: str
|
||||||
extension: str
|
extension: str
|
||||||
|
route: RouteSpec | None = None
|
||||||
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
|
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
|
||||||
network: NetworkSpec = Field(default_factory=NetworkSpec)
|
network: NetworkSpec = Field(default_factory=NetworkSpec)
|
||||||
workspace: WorkspaceSpec = Field(default_factory=WorkspaceSpec)
|
workspace: WorkspaceSpec = Field(default_factory=WorkspaceSpec)
|
||||||
@@ -130,6 +152,7 @@ class Reachability(BaseModel):
|
|||||||
remote_dir: str | None = None
|
remote_dir: str | None = None
|
||||||
compose_project: str | None = None
|
compose_project: str | None = None
|
||||||
host: str | None = None
|
host: str | None = None
|
||||||
|
endpoint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SandboxStatus(BaseModel):
|
class SandboxStatus(BaseModel):
|
||||||
@@ -142,6 +165,7 @@ class SandboxStatus(BaseModel):
|
|||||||
reachability: Reachability | None = None
|
reachability: Reachability | None = None
|
||||||
inputs: dict[str, str] = Field(default_factory=dict)
|
inputs: dict[str, str] = Field(default_factory=dict)
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
meter: MeterRecord | None = None
|
||||||
telemetry: dict | None = None # IntrospectionReport JSON when canary
|
telemetry: dict | None = None # IntrospectionReport JSON when canary
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
6
src/sandboxer/payments/__init__.py
Normal file
6
src/sandboxer/payments/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Payments and metering for SaaS sandbox extensions."""
|
||||||
|
|
||||||
|
from sandboxer.payments.credits import CreditsStore
|
||||||
|
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||||
|
|
||||||
|
__all__ = ["CreditsStore", "estimate_cost", "settle_usage"]
|
||||||
48
src/sandboxer/payments/credits.py
Normal file
48
src/sandboxer/payments/credits.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Org/workspace credits for metered sandbox consumption."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _default_credits_path() -> Path:
|
||||||
|
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||||
|
return base / "sandboxer" / "credits.json"
|
||||||
|
|
||||||
|
|
||||||
|
class CreditsStore:
|
||||||
|
def __init__(self, path: Path | None = None) -> None:
|
||||||
|
self.path = path or _default_credits_path()
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _read(self) -> dict:
|
||||||
|
if not self.path.exists():
|
||||||
|
default = float(os.environ.get("SANDBOXER_DEFAULT_CREDITS", "10.0"))
|
||||||
|
return {"balance_usd": default, "currency": "USD"}
|
||||||
|
return json.loads(self.path.read_text())
|
||||||
|
|
||||||
|
def _write(self, data: dict) -> None:
|
||||||
|
self.path.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
def balance(self) -> float:
|
||||||
|
return float(self._read().get("balance_usd", 0.0))
|
||||||
|
|
||||||
|
def can_afford(self, amount_usd: float) -> bool:
|
||||||
|
return self.balance() >= amount_usd
|
||||||
|
|
||||||
|
def add(self, amount_usd: float) -> float:
|
||||||
|
data = self._read()
|
||||||
|
data["balance_usd"] = round(float(data.get("balance_usd", 0.0)) + amount_usd, 4)
|
||||||
|
self._write(data)
|
||||||
|
return data["balance_usd"]
|
||||||
|
|
||||||
|
def debit(self, amount_usd: float) -> float:
|
||||||
|
data = self._read()
|
||||||
|
new_balance = round(float(data.get("balance_usd", 0.0)) - amount_usd, 4)
|
||||||
|
if new_balance < 0:
|
||||||
|
raise ValueError(f"Insufficient credits: need {amount_usd:.4f} USD")
|
||||||
|
data["balance_usd"] = new_balance
|
||||||
|
self._write(data)
|
||||||
|
return new_balance
|
||||||
66
src/sandboxer/payments/metering.py
Normal file
66
src/sandboxer/payments/metering.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Cost estimation and usage settlement for metered extensions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sandboxer.extensions.registry import resolve_backend
|
||||||
|
from sandboxer.models import Extension, MeterQuote, MeterRecord, Profile, SandboxStatus
|
||||||
|
|
||||||
|
|
||||||
|
def _duration_seconds(ready_at: datetime | None, destroyed_at: datetime) -> float:
|
||||||
|
if not ready_at:
|
||||||
|
return 0.0
|
||||||
|
return max(0.0, (destroyed_at - ready_at).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_cost(
|
||||||
|
extension: Extension,
|
||||||
|
profile: Profile,
|
||||||
|
inputs: dict[str, str],
|
||||||
|
*,
|
||||||
|
duration_s: int = 3600,
|
||||||
|
) -> MeterQuote | None:
|
||||||
|
if extension.capabilities.pricing_model != "metered":
|
||||||
|
return None
|
||||||
|
backend = resolve_backend(extension)
|
||||||
|
if not hasattr(backend, "estimate_cost"):
|
||||||
|
return None
|
||||||
|
quote = backend.estimate_cost(profile, inputs, duration_s=duration_s)
|
||||||
|
if quote is None:
|
||||||
|
return None
|
||||||
|
if isinstance(quote, MeterQuote):
|
||||||
|
return quote
|
||||||
|
if isinstance(quote, dict):
|
||||||
|
return MeterQuote.model_validate(quote)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def settle_usage(
|
||||||
|
status: SandboxStatus,
|
||||||
|
extension: Extension,
|
||||||
|
handle: dict[str, str],
|
||||||
|
*,
|
||||||
|
destroyed_at: datetime,
|
||||||
|
) -> MeterRecord | None:
|
||||||
|
if extension.capabilities.pricing_model != "metered":
|
||||||
|
return MeterRecord(pricing_model="self-hosted")
|
||||||
|
|
||||||
|
duration_s = _duration_seconds(status.ready_at, destroyed_at)
|
||||||
|
backend = resolve_backend(extension)
|
||||||
|
actual_usd: float | None = None
|
||||||
|
|
||||||
|
if hasattr(backend, "meter_actual"):
|
||||||
|
actual_usd = backend.meter_actual(handle, duration_s=duration_s)
|
||||||
|
|
||||||
|
if actual_usd is None and status.meter and status.meter.estimate_usd is not None:
|
||||||
|
hours = duration_s / 3600.0
|
||||||
|
actual_usd = round(status.meter.estimate_usd * max(hours, 1 / 3600), 4)
|
||||||
|
|
||||||
|
estimate = status.meter.estimate_usd if status.meter else None
|
||||||
|
return MeterRecord(
|
||||||
|
pricing_model="metered",
|
||||||
|
estimate_usd=estimate,
|
||||||
|
actual_usd=actual_usd,
|
||||||
|
duration_s=round(duration_s, 1),
|
||||||
|
)
|
||||||
5
src/sandboxer/routing/__init__.py
Normal file
5
src/sandboxer/routing/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Extension routing — OpenRouter-style backend selection."""
|
||||||
|
|
||||||
|
from sandboxer.routing.resolver import resolve_extension
|
||||||
|
|
||||||
|
__all__ = ["resolve_extension"]
|
||||||
97
src/sandboxer/routing/resolver.py
Normal file
97
src/sandboxer/routing/resolver.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Select extension backend from profile route policy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sandboxer.extensions.registry import load_extension
|
||||||
|
from sandboxer.models import Extension, Profile, RouteStrategy
|
||||||
|
from sandboxer.payments.metering import estimate_cost
|
||||||
|
from sandboxer.placement import resolve_host
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(profile: Profile) -> list[str]:
|
||||||
|
if profile.route and profile.route.extensions:
|
||||||
|
return profile.route.extensions
|
||||||
|
return [profile.extension]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_metered(ext: Extension) -> bool:
|
||||||
|
return ext.capabilities.pricing_model == "metered"
|
||||||
|
|
||||||
|
|
||||||
|
def _self_hosted_available(profile: Profile, ext: Extension, host_override: str | None) -> bool:
|
||||||
|
if _is_metered(ext):
|
||||||
|
return True
|
||||||
|
if os.environ.get("SANDBOXER_FORCE_SAAS") == "1":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
resolve_host(profile, override=host_override)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_cost(
|
||||||
|
ext: Extension, profile: Profile, inputs: dict[str, str], duration_s: int
|
||||||
|
) -> float | None:
|
||||||
|
quote = estimate_cost(ext, profile, inputs, duration_s=duration_s)
|
||||||
|
return quote.estimated_usd if quote else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_extension(
|
||||||
|
profile: Profile,
|
||||||
|
inputs: dict[str, str],
|
||||||
|
*,
|
||||||
|
host_override: str | None = None,
|
||||||
|
duration_s: int = 3600,
|
||||||
|
) -> Extension:
|
||||||
|
"""Pick extension per route strategy. Raises if no candidate qualifies."""
|
||||||
|
strategy = (
|
||||||
|
profile.route.strategy
|
||||||
|
if profile.route
|
||||||
|
else RouteStrategy.EXPLICIT
|
||||||
|
)
|
||||||
|
ids = _candidates(profile)
|
||||||
|
loaded = [load_extension(ext_id) for ext_id in ids]
|
||||||
|
|
||||||
|
if strategy == RouteStrategy.EXPLICIT or len(loaded) == 1:
|
||||||
|
chosen = load_extension(profile.extension)
|
||||||
|
if chosen.id not in {e.id for e in loaded}:
|
||||||
|
chosen = loaded[0]
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
if strategy == RouteStrategy.PREFER_SELF_HOSTED:
|
||||||
|
for ext in loaded:
|
||||||
|
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||||
|
return ext
|
||||||
|
for ext in loaded:
|
||||||
|
if _is_metered(ext):
|
||||||
|
return ext
|
||||||
|
return loaded[0]
|
||||||
|
|
||||||
|
if strategy == RouteStrategy.LOWEST_COST:
|
||||||
|
best: Extension | None = None
|
||||||
|
best_cost: float | None = None
|
||||||
|
for ext in loaded:
|
||||||
|
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||||
|
return ext
|
||||||
|
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
|
||||||
|
return loaded[-1]
|
||||||
|
|
||||||
|
if strategy == RouteStrategy.LOWEST_LATENCY:
|
||||||
|
for ext in loaded:
|
||||||
|
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
|
||||||
|
return ext
|
||||||
|
return loaded[-1]
|
||||||
|
|
||||||
|
return load_extension(profile.extension)
|
||||||
@@ -12,4 +12,5 @@ def test_load_vm_packer_extension() -> None:
|
|||||||
def test_load_all_includes_vm_packer() -> None:
|
def test_load_all_includes_vm_packer() -> None:
|
||||||
extensions = load_all_extensions()
|
extensions = load_all_extensions()
|
||||||
assert "ext.compose-ssh" in extensions
|
assert "ext.compose-ssh" in extensions
|
||||||
assert "ext.vm-packer" in extensions
|
assert "ext.vm-packer" in extensions
|
||||||
|
assert "ext.saas-stub" in extensions
|
||||||
@@ -53,8 +53,9 @@ def test_create_and_destroy(store: SandboxStore) -> None:
|
|||||||
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fake = FakeBackend()
|
||||||
with (
|
with (
|
||||||
patch("sandboxer.core.manager.resolve_backend", return_value=FakeBackend()),
|
patch("sandboxer.core.manager.resolve_backend", return_value=fake),
|
||||||
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||||
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
|
patch("sandboxer.core.manager.resolve_host", return_value="coulombcore"),
|
||||||
):
|
):
|
||||||
|
|||||||
98
tests/test_payments.py
Normal file
98
tests/test_payments.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Payments and metering integration tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sandboxer.core.manager import SandboxManager
|
||||||
|
from sandboxer.lifecycle.store import SandboxStore
|
||||||
|
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxState
|
||||||
|
from sandboxer.payments.credits import CreditsStore
|
||||||
|
|
||||||
|
|
||||||
|
class SaaSBackend:
|
||||||
|
def provision(self, profile, inputs, host):
|
||||||
|
return {"sandbox_id": "saas1234", "host": "saas-stub", "endpoint": "https://x"}
|
||||||
|
|
||||||
|
def wait_ready(self, handle):
|
||||||
|
return {"endpoint": handle["endpoint"], "host": handle["host"]}
|
||||||
|
|
||||||
|
def teardown(self, handle):
|
||||||
|
return {"provider_removed": "true"}
|
||||||
|
|
||||||
|
def estimate_cost(self, profile, inputs, *, duration_s=3600):
|
||||||
|
from sandboxer.models import MeterQuote
|
||||||
|
|
||||||
|
return MeterQuote(
|
||||||
|
extension_id="ext.saas-stub",
|
||||||
|
estimated_usd=0.5,
|
||||||
|
duration_s=duration_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
def meter_actual(self, handle, *, duration_s: float) -> float:
|
||||||
|
return 0.25
|
||||||
|
|
||||||
|
|
||||||
|
def _set_balance(credits: CreditsStore, amount: float) -> None:
|
||||||
|
credits._write({"balance_usd": amount, "currency": "USD"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_saas_create_destroy_debits_credits(tmp_path: Path) -> None:
|
||||||
|
store = SandboxStore(path=tmp_path / "sandboxes.json")
|
||||||
|
credits = CreditsStore(path=tmp_path / "credits.json")
|
||||||
|
_set_balance(credits, 5.0)
|
||||||
|
manager = SandboxManager(store=store, credits=credits)
|
||||||
|
|
||||||
|
request = SandboxCreateRequest(
|
||||||
|
profile="profile.saas-stub",
|
||||||
|
inputs={},
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
backend = SaaSBackend()
|
||||||
|
with (
|
||||||
|
patch("sandboxer.core.manager.resolve_extension") as resolve_ext,
|
||||||
|
patch("sandboxer.core.manager.resolve_backend", return_value=backend),
|
||||||
|
patch("sandboxer.payments.metering.resolve_backend", return_value=backend),
|
||||||
|
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||||
|
):
|
||||||
|
from sandboxer.extensions.registry import load_extension
|
||||||
|
|
||||||
|
resolve_ext.return_value = load_extension("ext.saas-stub")
|
||||||
|
status = manager.create(request)
|
||||||
|
assert status.state == SandboxState.READY
|
||||||
|
assert status.meter and status.meter.estimate_usd == 0.5
|
||||||
|
|
||||||
|
destroyed = manager.destroy(status.sandbox_id)
|
||||||
|
assert destroyed.state == SandboxState.DESTROYED
|
||||||
|
assert destroyed.meter and destroyed.meter.actual_usd == 0.25
|
||||||
|
|
||||||
|
assert credits.balance() == 4.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_credits_blocks_create(tmp_path: Path) -> None:
|
||||||
|
store = SandboxStore(path=tmp_path / "sandboxes.json")
|
||||||
|
credits = CreditsStore(path=tmp_path / "credits.json")
|
||||||
|
_set_balance(credits, 0.01)
|
||||||
|
manager = SandboxManager(store=store, credits=credits)
|
||||||
|
|
||||||
|
request = SandboxCreateRequest(
|
||||||
|
profile="profile.saas-stub",
|
||||||
|
inputs={},
|
||||||
|
consumer=Consumer(actor=ActorType.ADM, project="test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("sandboxer.core.manager.resolve_extension") as resolve_ext,
|
||||||
|
patch("sandboxer.core.manager.emit_lifecycle_event", return_value=None),
|
||||||
|
):
|
||||||
|
from sandboxer.extensions.registry import load_extension
|
||||||
|
|
||||||
|
resolve_ext.return_value = load_extension("ext.saas-stub")
|
||||||
|
with patch("sandboxer.core.manager.resolve_backend", return_value=SaaSBackend()):
|
||||||
|
try:
|
||||||
|
manager.create(request)
|
||||||
|
raise AssertionError("expected RuntimeError")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "Insufficient credits" in str(exc)
|
||||||
59
tests/test_routing.py
Normal file
59
tests/test_routing.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Extension routing tests."""
|
||||||
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sandboxer.extensions.registry import load_extension
|
||||||
|
from sandboxer.models import Profile, RouteSpec, RouteStrategy
|
||||||
|
from sandboxer.routing.resolver import resolve_extension
|
||||||
|
|
||||||
|
|
||||||
|
def _burst_profile() -> Profile:
|
||||||
|
return Profile.model_validate(
|
||||||
|
{
|
||||||
|
"id": "profile.burst-sandbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"extension": "ext.compose-ssh",
|
||||||
|
"route": {
|
||||||
|
"strategy": "prefer-self-hosted",
|
||||||
|
"extensions": ["ext.compose-ssh", "ext.saas-stub"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_uses_profile_extension() -> None:
|
||||||
|
profile = Profile.model_validate(
|
||||||
|
{
|
||||||
|
"id": "profile.compose-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"extension": "ext.compose-ssh",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ext = resolve_extension(profile, {}, host_override="coulombcore")
|
||||||
|
assert ext.id == "ext.compose-ssh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefer_self_hosted_when_host_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("SANDBOXER_HOST", "coulombcore")
|
||||||
|
ext = resolve_extension(_burst_profile(), {"repo": "/tmp/x"}, host_override=None)
|
||||||
|
assert ext.id == "ext.compose-ssh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefer_self_hosted_falls_back_to_saas(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("SANDBOXER_HOST", 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_lowest_cost_picks_metered_when_forced(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
profile = _burst_profile()
|
||||||
|
profile.route = RouteSpec(
|
||||||
|
strategy=RouteStrategy.LOWEST_COST,
|
||||||
|
extensions=["ext.compose-ssh", "ext.saas-stub"],
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("SANDBOXER_FORCE_SAAS", "1")
|
||||||
|
ext = resolve_extension(profile, {}, host_override=None)
|
||||||
|
assert load_extension(ext.id).capabilities.pricing_model == "metered"
|
||||||
30
tests/test_saas_stub.py
Normal file
30
tests/test_saas_stub.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""SaaS stub extension tests."""
|
||||||
|
|
||||||
|
from sandboxer.extensions.saas_stub import SaaSStubExtension
|
||||||
|
from sandboxer.models import Profile
|
||||||
|
|
||||||
|
|
||||||
|
def _profile() -> Profile:
|
||||||
|
return Profile.model_validate(
|
||||||
|
{"id": "profile.saas-stub", "version": "1.0.0", "extension": "ext.saas-stub"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_and_meter() -> None:
|
||||||
|
ext = SaaSStubExtension({"rate_usd_per_hour": 1.0, "session_fee_usd": 0.05})
|
||||||
|
quote = ext.estimate_cost(_profile(), {}, duration_s=3600)
|
||||||
|
assert quote is not None
|
||||||
|
assert quote.estimated_usd == 1.05
|
||||||
|
|
||||||
|
handle = ext.provision(_profile(), {}, "saas")
|
||||||
|
actual = ext.meter_actual(handle, duration_s=1800)
|
||||||
|
assert actual == 0.55
|
||||||
|
|
||||||
|
|
||||||
|
def test_provision_wait_teardown() -> None:
|
||||||
|
ext = SaaSStubExtension()
|
||||||
|
handle = ext.provision(_profile(), {}, "saas-stub")
|
||||||
|
reach = ext.wait_ready(handle)
|
||||||
|
assert reach["endpoint"].startswith("https://stub.sandboxer.local/")
|
||||||
|
report = ext.teardown(handle)
|
||||||
|
assert report["provider_removed"] == "true"
|
||||||
86
workplans/SAND-WP-0006-saas-extensions-and-payments.md
Normal file
86
workplans/SAND-WP-0006-saas-extensions-and-payments.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
id: SAND-WP-0006
|
||||||
|
type: workplan
|
||||||
|
title: "SaaS extensions and payments layer"
|
||||||
|
domain: infotech
|
||||||
|
repo: sand-boxer
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: custodian
|
||||||
|
created: "2026-06-23"
|
||||||
|
updated: "2026-06-23"
|
||||||
|
---
|
||||||
|
|
||||||
|
# SaaS extensions and payments layer
|
||||||
|
|
||||||
|
Deliver INTENT pillar 4 (payments/metering) and routing engine v0 for
|
||||||
|
self-hosted vs SaaS backend selection.
|
||||||
|
|
||||||
|
**Predecessor:** SAND-WP-0005 (extension SDK — finished)
|
||||||
|
**Follow-on:** SAND-WP-0007 (snapshots), real E2B/Modal adapters
|
||||||
|
|
||||||
|
## Payments and credits
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0006-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
`CreditsStore`, `estimate_cost` / `settle_usage`, pre-create balance check,
|
||||||
|
post-destroy debit, `SandboxStatus.meter` field. Docs: `docs/payments.md`.
|
||||||
|
CLI: `sandboxer credits show|add`.
|
||||||
|
|
||||||
|
## Routing engine
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0006-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
`RouteSpec` on profiles, `resolve_extension` with strategies
|
||||||
|
(`prefer-self-hosted`, `lowest-cost`, `lowest-latency`, `explicit`).
|
||||||
|
Docs: `docs/routing.md`.
|
||||||
|
|
||||||
|
## ext.saas-stub
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0006-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Metered SaaS stub extension — `estimate_cost`, `meter_actual`, no external API.
|
||||||
|
Profiles: `profile.saas-stub`, `profile.burst-sandbox`.
|
||||||
|
|
||||||
|
## Manager integration
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0006-T04
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
`SandboxManager` uses routing resolver; destroy loads `status.extension_id`;
|
||||||
|
meter settlement on destroy; meter events to State Hub.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0006-T05
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_routing.py`, `test_payments.py`, `test_saas_stub.py`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: SAND-WP-0006-T06
|
||||||
|
status: wait
|
||||||
|
priority: low
|
||||||
|
```
|
||||||
|
|
||||||
|
Real `ext.e2b` / `ext.modal` adapters, BYOK via OpenBao, fin-hub export.
|
||||||
Reference in New Issue
Block a user