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:
2026-06-24 07:52:20 +02:00
parent eee336149e
commit 1415e17230
29 changed files with 878 additions and 18 deletions

View File

@@ -12,4 +12,5 @@ def test_load_vm_packer_extension() -> None:
def test_load_all_includes_vm_packer() -> None:
extensions = load_all_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

View File

@@ -53,8 +53,9 @@ def test_create_and_destroy(store: SandboxStore) -> None:
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
)
fake = FakeBackend()
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.resolve_host", return_value="coulombcore"),
):

98
tests/test_payments.py Normal file
View 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
View 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
View 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"