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

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)