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

View file

@ -6,7 +6,7 @@ import uuid
from abc import ABC, abstractmethod
from typing import Any
from sandboxer.models import Profile
from sandboxer.models import MeterQuote, Profile
class SandboxExtension(ABC):
@ -31,4 +31,18 @@ class SandboxExtension(ABC):
@abstractmethod
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

View 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", ""),
}