feat: cloud adapters E2B/Modal and billing export (SAND-WP-0010)

Add credentialed E2B and Modal extensions, burst routing fallback,
fin-hub meter export hook, BYOK docs, and 77 tests.
This commit is contained in:
2026-06-24 12:50:19 +02:00
parent 6d0a1a8b1e
commit 15f031fd65
26 changed files with 859 additions and 75 deletions

View File

@@ -0,0 +1,50 @@
"""fin-hub billing export tests."""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock, patch
from sandboxer.models import ActorType, Consumer, MeterRecord, SandboxState, SandboxStatus
from sandboxer.payments.billing_export import export_meter_usage
def test_export_skipped_when_url_unset() -> None:
now = datetime.now(UTC)
status = SandboxStatus(
sandbox_id="s1",
profile_id="profile.e2b-burst",
extension_id="ext.e2b",
state=SandboxState.DESTROYED,
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
created_at=now,
updated_at=now,
)
meter = MeterRecord(pricing_model="metered", actual_usd=0.5, duration_s=100.0)
assert export_meter_usage(status, extension_id="ext.e2b", meter=meter) is None
def test_export_posts_when_configured(monkeypatch) -> None:
monkeypatch.setenv("SANDBOXER_FIN_HUB_URL", "http://fin-hub.test")
now = datetime.now(UTC)
status = SandboxStatus(
sandbox_id="s1",
profile_id="profile.e2b-burst",
extension_id="ext.e2b",
state=SandboxState.DESTROYED,
consumer=Consumer(actor=ActorType.ADM, project="sand-boxer"),
created_at=now,
updated_at=now,
)
meter = MeterRecord(pricing_model="metered", actual_usd=0.5, duration_s=100.0)
mock_response = MagicMock()
mock_response.json.return_value = {"ok": True}
with patch("sandboxer.payments.billing_export.httpx.post", return_value=mock_response) as post:
result = export_meter_usage(status, extension_id="ext.e2b", meter=meter)
assert result == {"ok": True}
post.assert_called_once()
payload = post.call_args.kwargs["json"]
assert payload["sandbox_id"] == "s1"
assert payload["actual_usd"] == 0.5

72
tests/test_e2b.py Normal file
View File

@@ -0,0 +1,72 @@
"""E2B cloud adapter tests."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from sandboxer.extensions.credentials import credentials_available, resolve_api_key
from sandboxer.extensions.e2b import E2BExtension
from sandboxer.models import Profile
def _profile() -> Profile:
return Profile.model_validate(
{
"id": "profile.e2b-burst",
"version": "1.0.0",
"extension": "ext.e2b",
}
)
def test_credentials_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("E2B_API_KEY", "test-key")
config = {"api_key_env": "E2B_API_KEY", "secret_ref": "e2b-api-key"}
assert resolve_api_key(config, extension_id="ext.e2b") == "test-key"
assert credentials_available("ext.e2b", config)
def test_provision_and_teardown_with_mock_client(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("E2B_API_KEY", "test-key")
ext = E2BExtension(
{
"api_base": "https://api.e2b.dev",
"api_key_env": "E2B_API_KEY",
"provider": "e2b",
"template_id": "base",
}
)
mock_client = MagicMock()
create_resp = MagicMock()
create_resp.status_code = 200
create_resp.json.return_value = {
"sandboxID": "e2b-prov-1",
"sandboxURL": "https://e2b-prov-1.e2b.dev",
}
ready_resp = MagicMock()
ready_resp.status_code = 200
delete_resp = MagicMock()
delete_resp.status_code = 204
mock_client.post.return_value = create_resp
mock_client.get.return_value = ready_resp
mock_client.delete.return_value = delete_resp
mock_client.__enter__.return_value = mock_client
mock_client.__exit__.return_value = None
with patch.object(ext, "_client_factory", return_value=mock_client):
handle = ext.provision(_profile(), {}, "e2b")
assert handle["provider_sandbox_id"] == "e2b-prov-1"
reach = ext.wait_ready(handle)
assert "e2b-prov-1" in reach["endpoint"]
report = ext.teardown(handle)
assert report["provider_removed"] == "true"
def test_estimate_cost() -> None:
ext = E2BExtension({"rate_usd_per_hour": 0.15, "session_fee_usd": 0.02})
quote = ext.estimate_cost(_profile(), {}, duration_s=3600)
assert quote.extension_id == "ext.e2b"
assert quote.estimated_usd > 0

63
tests/test_modal.py Normal file
View File

@@ -0,0 +1,63 @@
"""Modal cloud adapter tests."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from sandboxer.extensions.modal import ModalExtension
from sandboxer.models import Profile
def _profile() -> Profile:
return Profile.model_validate(
{
"id": "profile.modal-gpu",
"version": "1.0.0",
"extension": "ext.modal",
}
)
def test_provision_and_teardown_with_mock_client(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("MODAL_TOKEN_ID", "modal-token")
ext = ModalExtension(
{
"api_base": "https://api.modal.com",
"api_key_env": "MODAL_TOKEN_ID",
"provider": "modal",
}
)
mock_client = MagicMock()
create_resp = MagicMock()
create_resp.status_code = 200
create_resp.json.return_value = {
"sandbox_id": "modal-prov-1",
"url": "https://modal.run/sandbox/modal-prov-1",
"status": "ready",
}
ready_resp = MagicMock()
ready_resp.status_code = 200
ready_resp.json.return_value = {"status": "ready"}
delete_resp = MagicMock()
delete_resp.status_code = 200
mock_client.post.return_value = create_resp
mock_client.get.return_value = ready_resp
mock_client.delete.return_value = delete_resp
mock_client.__enter__.return_value = mock_client
mock_client.__exit__.return_value = None
with patch.object(ext, "_client_factory", return_value=mock_client):
handle = ext.provision(_profile(), {}, "modal")
assert handle["provider_sandbox_id"] == "modal-prov-1"
ext.wait_ready(handle)
report = ext.teardown(handle)
assert report["provider_removed"] == "true"
def test_provision_without_credentials_raises() -> None:
ext = ModalExtension({"api_key_env": "MODAL_TOKEN_ID"})
with pytest.raises(RuntimeError, match="API key"):
ext.provision(_profile(), {}, "modal")

View File

@@ -16,7 +16,12 @@ def _burst_profile() -> Profile:
"extension": "ext.compose-ssh",
"route": {
"strategy": "prefer-self-hosted",
"extensions": ["ext.compose-ssh", "ext.saas-stub"],
"extensions": [
"ext.compose-ssh",
"ext.e2b",
"ext.modal",
"ext.saas-stub",
],
},
}
)
@@ -42,12 +47,23 @@ def test_prefer_self_hosted_when_host_set(monkeypatch: pytest.MonkeyPatch) -> No
def test_prefer_self_hosted_falls_back_to_saas(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SANDBOXER_HOST", raising=False)
monkeypatch.delenv("E2B_API_KEY", raising=False)
monkeypatch.delenv("MODAL_TOKEN_ID", 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_prefer_self_hosted_falls_back_to_e2b_when_credentialed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SANDBOXER_FORCE_SAAS", "1")
monkeypatch.setenv("E2B_API_KEY", "test-key")
ext = resolve_extension(_burst_profile(), {}, host_override=None)
assert ext.id == "ext.e2b"
def test_lowest_cost_picks_metered_when_forced(monkeypatch: pytest.MonkeyPatch) -> None:
profile = _burst_profile()
profile.route = RouteSpec(