mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
Billing System: - Add compute unit costs for 100+ RPC methods (1-100 CU) - Implement pricing tiers: Free (30M CU), PAYG ($0.40/M), Enterprise - Real-time usage tracking with Redis + ClickHouse analytics - Subscription model with billing cycles - Billing API endpoints: /usage, /subscription, /invoices, /tiers Hanzo Commerce Integration: - HanzoCommerceClient for subscriptions and invoices - Usage sync worker for PAYG metered billing - Webhook handler for subscription lifecycle events - HMAC signature verification for webhooks Pluggable Deployment Targets: - Abstract DeployTarget interface - DockerDeployer - Docker Compose management - ProcessDeployer - Local dev with subprocesses - KubernetesDeployer - K8s deployments via python client - Factory pattern with caching - Infrastructure API: /services, /scale, /logs Pricing Page: - Updated to match Alchemy pricing structure - Three tiers with feature comparison table - FAQ section with compute unit explanation ClickHouse Schema: - billing_usage_daily with SummingMergeTree - billing_usage_monthly materialized view - cu_by_method for analytics dashboard - rate_limit_events tracking
196 lines
6.4 KiB
Python
196 lines
6.4 KiB
Python
"""Tests for the deployment abstraction layer."""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from bootnode.core.deploy import (
|
|
DeployTarget,
|
|
DockerDeployer,
|
|
KubernetesDeployer,
|
|
ProcessDeployer,
|
|
ServiceStatus,
|
|
ServiceType,
|
|
clear_deployer_cache,
|
|
get_deployer,
|
|
)
|
|
|
|
|
|
class TestServiceType:
|
|
"""Tests for ServiceType enum."""
|
|
|
|
def test_service_types_exist(self):
|
|
"""All expected service types should be defined."""
|
|
assert ServiceType.API.value == "api"
|
|
assert ServiceType.WEB.value == "web"
|
|
assert ServiceType.INDEXER.value == "indexer"
|
|
assert ServiceType.WEBHOOK_WORKER.value == "webhook-worker"
|
|
assert ServiceType.BUNDLER.value == "bundler"
|
|
|
|
def test_service_type_from_string(self):
|
|
"""ServiceType should be constructable from string."""
|
|
assert ServiceType("api") == ServiceType.API
|
|
assert ServiceType("web") == ServiceType.WEB
|
|
|
|
|
|
class TestServiceStatus:
|
|
"""Tests for ServiceStatus model."""
|
|
|
|
def test_service_status_basic(self):
|
|
"""ServiceStatus should work with minimal fields."""
|
|
status = ServiceStatus(
|
|
name="test",
|
|
running=True,
|
|
replicas=2,
|
|
ready_replicas=2,
|
|
)
|
|
assert status.name == "test"
|
|
assert status.running is True
|
|
assert status.replicas == 2
|
|
assert status.ready_replicas == 2
|
|
assert status.cpu_usage is None
|
|
assert status.memory_usage is None
|
|
|
|
def test_service_status_full(self):
|
|
"""ServiceStatus should work with all fields."""
|
|
status = ServiceStatus(
|
|
name="api",
|
|
running=True,
|
|
replicas=3,
|
|
ready_replicas=2,
|
|
cpu_usage=25.5,
|
|
memory_usage=512.0,
|
|
image="bootnode/api:latest",
|
|
started_at="2024-01-15T10:00:00Z",
|
|
)
|
|
assert status.cpu_usage == 25.5
|
|
assert status.memory_usage == 512.0
|
|
assert status.image == "bootnode/api:latest"
|
|
|
|
|
|
class TestDeployTarget:
|
|
"""Tests for DeployTarget ABC."""
|
|
|
|
def test_is_abstract(self):
|
|
"""DeployTarget should not be instantiable directly."""
|
|
with pytest.raises(TypeError):
|
|
DeployTarget()
|
|
|
|
|
|
class TestProcessDeployer:
|
|
"""Tests for ProcessDeployer."""
|
|
|
|
def test_init(self):
|
|
"""ProcessDeployer should initialize correctly."""
|
|
deployer = ProcessDeployer()
|
|
assert deployer.project_root is not None
|
|
assert len(deployer._processes) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_not_running(self):
|
|
"""Status should show not running for unstarted service."""
|
|
deployer = ProcessDeployer()
|
|
status = await deployer.status(ServiceType.API)
|
|
assert status.running is False
|
|
assert status.replicas == 0
|
|
assert status.ready_replicas == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_destroy_not_running(self):
|
|
"""Destroy should succeed for service not running."""
|
|
deployer = ProcessDeployer()
|
|
result = await deployer.destroy(ServiceType.API)
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scale_zero(self):
|
|
"""Scale to zero should destroy service."""
|
|
deployer = ProcessDeployer()
|
|
result = await deployer.scale(ServiceType.API, 0)
|
|
assert result is True
|
|
|
|
|
|
class TestDockerDeployer:
|
|
"""Tests for DockerDeployer."""
|
|
|
|
def test_init_default(self):
|
|
"""DockerDeployer should initialize with defaults."""
|
|
deployer = DockerDeployer()
|
|
assert deployer.compose_file.name == "compose.yml"
|
|
assert deployer.project_name == "bootnode"
|
|
|
|
def test_init_custom(self):
|
|
"""DockerDeployer should accept custom compose file."""
|
|
deployer = DockerDeployer(
|
|
compose_file="/custom/docker-compose.yml",
|
|
project_name="custom",
|
|
)
|
|
assert str(deployer.compose_file) == "/custom/docker-compose.yml"
|
|
assert deployer.project_name == "custom"
|
|
|
|
def test_get_service_name(self):
|
|
"""Service name mapping should work correctly."""
|
|
deployer = DockerDeployer()
|
|
assert deployer._get_service_name(ServiceType.API) == "api"
|
|
assert deployer._get_service_name(ServiceType.WEBHOOK_WORKER) == "webhook-worker"
|
|
|
|
|
|
class TestKubernetesDeployer:
|
|
"""Tests for KubernetesDeployer."""
|
|
|
|
def test_init_default(self):
|
|
"""KubernetesDeployer should initialize with defaults."""
|
|
deployer = KubernetesDeployer()
|
|
assert deployer.namespace == "bootnode"
|
|
assert deployer.context is None
|
|
assert deployer._initialized is False
|
|
|
|
def test_init_custom(self):
|
|
"""KubernetesDeployer should accept custom namespace and context."""
|
|
deployer = KubernetesDeployer(
|
|
namespace="custom-ns",
|
|
context="my-context",
|
|
)
|
|
assert deployer.namespace == "custom-ns"
|
|
assert deployer.context == "my-context"
|
|
|
|
def test_get_deployment_name(self):
|
|
"""Deployment name mapping should work correctly."""
|
|
deployer = KubernetesDeployer()
|
|
assert deployer._get_deployment_name(ServiceType.API) == "bootnode-api"
|
|
assert deployer._get_deployment_name(ServiceType.WEB) == "bootnode-web"
|
|
|
|
def test_get_labels(self):
|
|
"""Labels should be generated correctly."""
|
|
deployer = KubernetesDeployer()
|
|
labels = deployer._get_labels(ServiceType.API)
|
|
assert labels["app.kubernetes.io/name"] == "api"
|
|
assert labels["app.kubernetes.io/part-of"] == "bootnode"
|
|
|
|
|
|
class TestFactory:
|
|
"""Tests for deployer factory."""
|
|
|
|
def test_get_deployer_returns_deploytarget(self):
|
|
"""get_deployer should return a DeployTarget instance."""
|
|
clear_deployer_cache()
|
|
deployer = get_deployer()
|
|
assert isinstance(deployer, DeployTarget)
|
|
|
|
def test_get_deployer_cached(self):
|
|
"""get_deployer should return cached instance."""
|
|
clear_deployer_cache()
|
|
deployer1 = get_deployer()
|
|
deployer2 = get_deployer()
|
|
assert deployer1 is deployer2
|
|
|
|
def test_clear_cache(self):
|
|
"""clear_deployer_cache should clear the cached instance."""
|
|
clear_deployer_cache()
|
|
deployer1 = get_deployer()
|
|
clear_deployer_cache()
|
|
deployer2 = get_deployer()
|
|
# They should be equal in value but may or may not be same object
|
|
# depending on config - just verify no error
|
|
assert deployer2 is not None
|