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:
@@ -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
|
||||
@@ -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
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"
|
||||
Reference in New Issue
Block a user