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:
@@ -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.store import SandboxStore, utcnow
|
||||
from sandboxer.models import (
|
||||
MeterRecord,
|
||||
Reachability,
|
||||
SandboxCreateRequest,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
)
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
from sandboxer.routing.resolver import resolve_extension
|
||||
from sandboxer.telemetry.export import export_telemetry
|
||||
from sandboxer.telemetry.introspection import (
|
||||
build_introspection_report,
|
||||
@@ -22,17 +26,40 @@ from sandboxer.telemetry.introspection import (
|
||||
|
||||
|
||||
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.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:
|
||||
profile = load_profile(request.profile)
|
||||
extension = load_extension(profile.extension)
|
||||
extension = resolve_extension(profile, request.inputs, host_override=host)
|
||||
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)
|
||||
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()
|
||||
status = SandboxStatus(
|
||||
sandbox_id="pending",
|
||||
@@ -42,6 +69,7 @@ class SandboxManager:
|
||||
consumer=request.consumer,
|
||||
host=resolved_host,
|
||||
inputs=dict(request.inputs),
|
||||
meter=meter_record,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -52,7 +80,7 @@ class SandboxManager:
|
||||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||
|
||||
provision_before = None
|
||||
if wants_telemetry:
|
||||
if wants_telemetry and extension.capabilities.pricing_model != "metered":
|
||||
provision_before = collect_host_snapshot(resolved_host)
|
||||
|
||||
try:
|
||||
@@ -64,6 +92,7 @@ class SandboxManager:
|
||||
status.inputs["ssh_port"] = handle.get("ssh_port", "")
|
||||
status.inputs["vm_target"] = handle.get("vm_target", "")
|
||||
status.inputs["vm_host"] = handle.get("vm_host", "")
|
||||
status.inputs["endpoint"] = handle.get("endpoint", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
@@ -114,13 +143,13 @@ class SandboxManager:
|
||||
return status
|
||||
|
||||
profile = load_profile(status.profile_id)
|
||||
extension = load_extension(profile.extension)
|
||||
extension = load_extension(status.extension_id)
|
||||
backend = resolve_backend(extension)
|
||||
wants_telemetry = profile_wants_telemetry(profile)
|
||||
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
||||
|
||||
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)
|
||||
|
||||
status.state = SandboxState.DESTROYING
|
||||
@@ -139,6 +168,7 @@ class SandboxManager:
|
||||
"ssh_port": status.inputs.get("ssh_port", ""),
|
||||
"vm_target": status.inputs.get("vm_target", ""),
|
||||
"vm_host": status.inputs.get("vm_host", ""),
|
||||
"endpoint": status.inputs.get("endpoint", ""),
|
||||
}
|
||||
backend.teardown(handle)
|
||||
|
||||
@@ -146,6 +176,19 @@ class SandboxManager:
|
||||
status.destroyed_at = utcnow()
|
||||
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:
|
||||
destroy_after = collect_host_snapshot(status.host)
|
||||
report = build_introspection_report(
|
||||
|
||||
Reference in New Issue
Block a user