mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
feat: implement Alchemy-style billing and pluggable deployments
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
This commit is contained in:
+2
-2
@@ -51,8 +51,8 @@ temp/
|
||||
*.tmp
|
||||
|
||||
# Embedded git repositories (separate projects)
|
||||
bootnode/
|
||||
bootnode-admin/
|
||||
/bootnode/
|
||||
/bootnode-admin/
|
||||
|
||||
# Playwright MCP screenshots
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from bootnode.api.auth import router as auth_router
|
||||
from bootnode.api.billing import router as billing_router
|
||||
from bootnode.api.bundler import router as bundler_router
|
||||
from bootnode.api.chains import router as chains_router
|
||||
from bootnode.api.gas import router as gas_router
|
||||
@@ -21,6 +22,7 @@ router = APIRouter()
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||
router.include_router(billing_router, prefix="/billing", tags=["Billing"])
|
||||
router.include_router(chains_router, prefix="/chains", tags=["Chains"])
|
||||
router.include_router(nodes_router, prefix="/nodes", tags=["Nodes"])
|
||||
router.include_router(rpc_router, prefix="/rpc", tags=["RPC"])
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Billing API - Usage tracking and subscription management."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bootnode.api.deps import DbDep, ProjectDep
|
||||
from bootnode.core.billing import (
|
||||
TIER_LIMITS,
|
||||
Invoice,
|
||||
PricingTier,
|
||||
UsageSummary,
|
||||
billing_service,
|
||||
commerce_client,
|
||||
get_tier_limits,
|
||||
usage_sync_worker,
|
||||
usage_tracker,
|
||||
webhook_handler,
|
||||
)
|
||||
from bootnode.db.models import Subscription
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request/Response Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class UsageResponse(BaseModel):
|
||||
"""Current usage response."""
|
||||
|
||||
project_id: uuid.UUID
|
||||
tier: str
|
||||
current_cu: int
|
||||
limit_cu: int
|
||||
remaining_cu: int | None
|
||||
percentage_used: float
|
||||
rate_limit_per_second: int
|
||||
billing_cycle_start: datetime | None
|
||||
billing_cycle_end: datetime | None
|
||||
|
||||
|
||||
class UsageSummaryResponse(BaseModel):
|
||||
"""Usage summary response."""
|
||||
|
||||
project_id: uuid.UUID
|
||||
period_start: date
|
||||
period_end: date
|
||||
total_cu: int
|
||||
total_requests: int
|
||||
cu_by_method: dict[str, int]
|
||||
cu_by_chain: dict[str, int]
|
||||
daily_usage: list[dict[str, Any]]
|
||||
estimated_cost_cents: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
"""Subscription response."""
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
tier: str
|
||||
monthly_cu_limit: int
|
||||
rate_limit_per_second: int
|
||||
max_apps: int
|
||||
max_webhooks: int
|
||||
current_cu_used: int
|
||||
billing_cycle_start: datetime
|
||||
billing_cycle_end: datetime
|
||||
scheduled_tier: str | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TierResponse(BaseModel):
|
||||
"""Pricing tier response."""
|
||||
|
||||
name: str
|
||||
monthly_cu: int
|
||||
rate_limit_per_second: int
|
||||
max_apps: int
|
||||
max_webhooks: int
|
||||
price_per_million_cu: int
|
||||
features: list[str]
|
||||
|
||||
|
||||
class UpgradeRequest(BaseModel):
|
||||
"""Upgrade subscription request."""
|
||||
|
||||
tier: PricingTier
|
||||
hanzo_subscription_id: str | None = None
|
||||
|
||||
|
||||
class InvoiceResponse(BaseModel):
|
||||
"""Invoice response."""
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
period_start: date
|
||||
period_end: date
|
||||
total_cu: int
|
||||
tier: str
|
||||
base_cost_cents: int
|
||||
overage_cost_cents: int
|
||||
total_cost_cents: int
|
||||
status: str
|
||||
hanzo_invoice_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LimitsCheckResponse(BaseModel):
|
||||
"""Limits check response."""
|
||||
|
||||
tier: str
|
||||
quota_ok: bool
|
||||
rate_allowed: bool
|
||||
rate_remaining: int
|
||||
current_cu: int
|
||||
limit_cu: int
|
||||
remaining_cu: int | None
|
||||
percentage_used: float
|
||||
rate_limit_per_second: int
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/usage", response_model=UsageResponse)
|
||||
async def get_usage(
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> UsageResponse:
|
||||
"""Get current compute unit usage for the project."""
|
||||
subscription = await billing_service.get_or_create_subscription(project.id, db)
|
||||
tier = PricingTier(subscription.tier)
|
||||
|
||||
stats = await usage_tracker.get_usage_stats(project.id, tier)
|
||||
|
||||
return UsageResponse(
|
||||
project_id=project.id,
|
||||
tier=subscription.tier,
|
||||
current_cu=stats["current_cu"],
|
||||
limit_cu=stats["limit_cu"],
|
||||
remaining_cu=stats["remaining_cu"],
|
||||
percentage_used=stats["percentage_used"],
|
||||
rate_limit_per_second=stats["rate_limit_per_second"],
|
||||
billing_cycle_start=subscription.billing_cycle_start,
|
||||
billing_cycle_end=subscription.billing_cycle_end,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/usage/summary", response_model=UsageSummaryResponse)
|
||||
async def get_usage_summary(
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
year: int | None = None,
|
||||
month: int | None = None,
|
||||
) -> UsageSummary:
|
||||
"""Get detailed usage summary for a billing period.
|
||||
|
||||
If year/month not specified, returns current billing period.
|
||||
"""
|
||||
period = date(year, month, 1) if year and month else date.today().replace(day=1)
|
||||
return await billing_service.get_usage_summary(project.id, period, db)
|
||||
|
||||
|
||||
@router.get("/subscription", response_model=SubscriptionResponse)
|
||||
async def get_subscription(
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> Subscription:
|
||||
"""Get subscription details for the project."""
|
||||
subscription = await billing_service.get_or_create_subscription(project.id, db)
|
||||
return subscription
|
||||
|
||||
|
||||
@router.post("/subscription/upgrade", response_model=SubscriptionResponse)
|
||||
async def upgrade_subscription(
|
||||
request: UpgradeRequest,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> Subscription:
|
||||
"""Upgrade subscription to a new tier.
|
||||
|
||||
For paid tiers, a hanzo_subscription_id from Hanzo Commerce is required.
|
||||
"""
|
||||
current = await billing_service.get_or_create_subscription(project.id, db)
|
||||
current_tier = PricingTier(current.tier)
|
||||
|
||||
# Validate upgrade path
|
||||
tier_order = [PricingTier.FREE, PricingTier.PAY_AS_YOU_GO, PricingTier.GROWTH, PricingTier.ENTERPRISE]
|
||||
current_idx = tier_order.index(current_tier)
|
||||
new_idx = tier_order.index(request.tier)
|
||||
|
||||
if new_idx < current_idx:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Use /subscription/downgrade for tier downgrades",
|
||||
)
|
||||
|
||||
if new_idx == current_idx:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Already on this tier",
|
||||
)
|
||||
|
||||
# Require payment info for paid tiers
|
||||
if request.tier != PricingTier.FREE and not request.hanzo_subscription_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="hanzo_subscription_id required for paid tiers",
|
||||
)
|
||||
|
||||
return await billing_service.upgrade_tier(
|
||||
project.id,
|
||||
request.tier,
|
||||
db,
|
||||
hanzo_subscription_id=request.hanzo_subscription_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subscription/downgrade", response_model=SubscriptionResponse)
|
||||
async def downgrade_subscription(
|
||||
request: UpgradeRequest,
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> Subscription:
|
||||
"""Schedule a subscription downgrade.
|
||||
|
||||
Downgrade takes effect at the end of the current billing period.
|
||||
"""
|
||||
current = await billing_service.get_or_create_subscription(project.id, db)
|
||||
current_tier = PricingTier(current.tier)
|
||||
|
||||
tier_order = [PricingTier.FREE, PricingTier.PAY_AS_YOU_GO, PricingTier.GROWTH, PricingTier.ENTERPRISE]
|
||||
current_idx = tier_order.index(current_tier)
|
||||
new_idx = tier_order.index(request.tier)
|
||||
|
||||
if new_idx >= current_idx:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Use /subscription/upgrade for tier upgrades",
|
||||
)
|
||||
|
||||
return await billing_service.downgrade_tier(project.id, request.tier, db)
|
||||
|
||||
|
||||
@router.get("/invoices", response_model=list[InvoiceResponse])
|
||||
async def get_invoices(
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
limit: int = 12,
|
||||
) -> list[Invoice]:
|
||||
"""Get invoices for the project."""
|
||||
return await billing_service.get_invoices(project.id, db, limit=limit)
|
||||
|
||||
|
||||
@router.get("/tiers", response_model=list[TierResponse])
|
||||
async def get_tiers() -> list[TierResponse]:
|
||||
"""Get available pricing tiers."""
|
||||
return [
|
||||
TierResponse(
|
||||
name=tier.value,
|
||||
monthly_cu=limits.monthly_cu,
|
||||
rate_limit_per_second=limits.rate_limit_per_second,
|
||||
max_apps=limits.max_apps,
|
||||
max_webhooks=limits.max_webhooks,
|
||||
price_per_million_cu=limits.price_per_million_cu,
|
||||
features=limits.features,
|
||||
)
|
||||
for tier, limits in TIER_LIMITS.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/tiers/{tier_name}", response_model=TierResponse)
|
||||
async def get_tier(tier_name: str) -> TierResponse:
|
||||
"""Get details for a specific pricing tier."""
|
||||
try:
|
||||
tier = PricingTier(tier_name)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Unknown tier: {tier_name}",
|
||||
)
|
||||
|
||||
limits = get_tier_limits(tier)
|
||||
return TierResponse(
|
||||
name=tier.value,
|
||||
monthly_cu=limits.monthly_cu,
|
||||
rate_limit_per_second=limits.rate_limit_per_second,
|
||||
max_apps=limits.max_apps,
|
||||
max_webhooks=limits.max_webhooks,
|
||||
price_per_million_cu=limits.price_per_million_cu,
|
||||
features=limits.features,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/limits", response_model=LimitsCheckResponse)
|
||||
async def check_limits(
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
) -> LimitsCheckResponse:
|
||||
"""Check current limits and quotas for the project."""
|
||||
result = await billing_service.check_limits(project.id, db)
|
||||
return LimitsCheckResponse(**result)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hanzo Commerce Integration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CommerceInvoiceResponse(BaseModel):
|
||||
"""Commerce invoice response."""
|
||||
|
||||
id: str
|
||||
customer_id: str
|
||||
subscription_id: str | None
|
||||
status: str
|
||||
amount_due: int
|
||||
amount_paid: int
|
||||
currency: str
|
||||
period_start: datetime | None
|
||||
period_end: datetime | None
|
||||
paid_at: datetime | None
|
||||
hosted_invoice_url: str | None
|
||||
pdf_url: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SyncStatusResponse(BaseModel):
|
||||
"""Usage sync status response."""
|
||||
|
||||
running: bool
|
||||
interval_seconds: int
|
||||
last_sync: str | None
|
||||
lock_held: bool
|
||||
|
||||
|
||||
@router.post("/webhooks/commerce")
|
||||
async def commerce_webhook(request: Request) -> dict:
|
||||
"""
|
||||
Handle Hanzo Commerce webhooks.
|
||||
|
||||
Processes subscription lifecycle events, invoice events, etc.
|
||||
Verifies HMAC-SHA256 signature before processing.
|
||||
"""
|
||||
payload = await request.body()
|
||||
signature = request.headers.get("X-Commerce-Signature", "")
|
||||
|
||||
# Verify signature
|
||||
if not webhook_handler.verify_signature(payload, signature):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid webhook signature",
|
||||
)
|
||||
|
||||
# Parse and handle event
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid JSON payload",
|
||||
)
|
||||
|
||||
result = await webhook_handler.handle_event(event)
|
||||
return {"received": True, **result}
|
||||
|
||||
|
||||
@router.get("/commerce/invoices", response_model=list[CommerceInvoiceResponse])
|
||||
async def get_commerce_invoices(
|
||||
project: ProjectDep,
|
||||
db: DbDep,
|
||||
limit: int = 10,
|
||||
) -> list[CommerceInvoiceResponse]:
|
||||
"""
|
||||
Get invoices from Hanzo Commerce.
|
||||
|
||||
Returns invoices for the project's Commerce customer.
|
||||
"""
|
||||
subscription = await billing_service.get_subscription(project.id, db)
|
||||
|
||||
if not subscription or not subscription.hanzo_customer_id:
|
||||
return []
|
||||
|
||||
try:
|
||||
invoices = await commerce_client.get_customer_invoices(
|
||||
subscription.hanzo_customer_id,
|
||||
limit=limit,
|
||||
)
|
||||
return [
|
||||
CommerceInvoiceResponse(
|
||||
id=inv.id,
|
||||
customer_id=inv.customer_id,
|
||||
subscription_id=inv.subscription_id,
|
||||
status=inv.status.value,
|
||||
amount_due=inv.amount_due,
|
||||
amount_paid=inv.amount_paid,
|
||||
currency=inv.currency,
|
||||
period_start=inv.period_start,
|
||||
period_end=inv.period_end,
|
||||
paid_at=inv.paid_at,
|
||||
hosted_invoice_url=inv.hosted_invoice_url,
|
||||
pdf_url=inv.pdf_url,
|
||||
created_at=inv.created_at,
|
||||
)
|
||||
for inv in invoices
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Failed to fetch invoices from Commerce: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/commerce/sync")
|
||||
async def sync_usage(
|
||||
project: ProjectDep,
|
||||
) -> dict:
|
||||
"""
|
||||
Manually sync usage to Hanzo Commerce.
|
||||
|
||||
Useful before subscription changes to ensure all usage is reported.
|
||||
"""
|
||||
result = await usage_sync_worker.sync_project(project.id)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/commerce/sync/status", response_model=SyncStatusResponse)
|
||||
async def get_sync_status() -> SyncStatusResponse:
|
||||
"""Get usage sync worker status."""
|
||||
status_data = await usage_sync_worker.get_status()
|
||||
return SyncStatusResponse(**status_data)
|
||||
@@ -2,16 +2,21 @@
|
||||
Infrastructure management API endpoints
|
||||
Handles K8s clusters, persistent volumes, and storage operations
|
||||
Supports DigitalOcean (DOKS), AWS (EKS), GCP (GKE), Azure (AKS)
|
||||
|
||||
Also handles Bootnode service deployment via pluggable deployers.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Dict, Optional, Any
|
||||
from pydantic import BaseModel, Field
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
import uuid
|
||||
import os
|
||||
import base64
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from bootnode.core.deploy import ServiceStatus, ServiceType, get_deployer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -791,6 +796,273 @@ async def cancel_clone(clone_id: str):
|
||||
return {"status": "cancelled", "clone_id": clone_id}
|
||||
|
||||
|
||||
# === Service Deployment Endpoints ===
|
||||
|
||||
|
||||
class ServiceScaleRequest(BaseModel):
|
||||
"""Request to scale a service."""
|
||||
|
||||
replicas: int = Field(ge=0, le=100, description="Target number of replicas")
|
||||
|
||||
|
||||
class ServiceDeployRequest(BaseModel):
|
||||
"""Request to deploy a service."""
|
||||
|
||||
image: str = Field(description="Container image to deploy")
|
||||
replicas: int = Field(default=1, ge=1, le=100, description="Number of replicas")
|
||||
env: dict[str, str] | None = Field(default=None, description="Environment variables")
|
||||
|
||||
|
||||
class ServiceListResponse(BaseModel):
|
||||
"""List of service statuses."""
|
||||
|
||||
services: list[ServiceStatus]
|
||||
|
||||
|
||||
@router.get("/services", response_model=ServiceListResponse)
|
||||
async def list_services():
|
||||
"""List all Bootnode services and their status.
|
||||
|
||||
Returns the current status of all managed services including
|
||||
running state, replica counts, and resource usage.
|
||||
"""
|
||||
deployer = get_deployer()
|
||||
services = []
|
||||
|
||||
for service_type in ServiceType:
|
||||
try:
|
||||
status = await deployer.status(service_type)
|
||||
services.append(status)
|
||||
except Exception:
|
||||
services.append(ServiceStatus(
|
||||
name=service_type.value,
|
||||
running=False,
|
||||
replicas=0,
|
||||
ready_replicas=0,
|
||||
))
|
||||
|
||||
return ServiceListResponse(services=services)
|
||||
|
||||
|
||||
@router.get("/services/{service}/status", response_model=ServiceStatus)
|
||||
async def get_service_status(service: str):
|
||||
"""Get detailed status for a specific service.
|
||||
|
||||
Args:
|
||||
service: Service name (api, web, indexer, webhook-worker, bundler)
|
||||
|
||||
Returns:
|
||||
ServiceStatus with running state, replicas, and resource usage.
|
||||
"""
|
||||
try:
|
||||
service_type = ServiceType(service)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid service: {service}. Valid services: {[s.value for s in ServiceType]}",
|
||||
)
|
||||
|
||||
deployer = get_deployer()
|
||||
return await deployer.status(service_type)
|
||||
|
||||
|
||||
@router.post("/services/{service}/deploy", response_model=ServiceStatus)
|
||||
async def deploy_service(service: str, request: ServiceDeployRequest):
|
||||
"""Deploy or update a service.
|
||||
|
||||
Args:
|
||||
service: Service name to deploy
|
||||
request: Deployment configuration including image and replicas
|
||||
|
||||
Returns:
|
||||
ServiceStatus after deployment.
|
||||
"""
|
||||
try:
|
||||
service_type = ServiceType(service)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid service: {service}. Valid services: {[s.value for s in ServiceType]}",
|
||||
)
|
||||
|
||||
deployer = get_deployer()
|
||||
success = await deployer.deploy(
|
||||
service_type,
|
||||
request.image,
|
||||
request.replicas,
|
||||
request.env,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to deploy service: {service}",
|
||||
)
|
||||
|
||||
return await deployer.status(service_type)
|
||||
|
||||
|
||||
@router.post("/services/{service}/scale", response_model=ServiceStatus)
|
||||
async def scale_service(service: str, request: ServiceScaleRequest):
|
||||
"""Scale a service to the specified number of replicas.
|
||||
|
||||
Args:
|
||||
service: Service name to scale
|
||||
request: Scale configuration with target replicas
|
||||
|
||||
Returns:
|
||||
ServiceStatus after scaling.
|
||||
"""
|
||||
try:
|
||||
service_type = ServiceType(service)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid service: {service}. Valid services: {[s.value for s in ServiceType]}",
|
||||
)
|
||||
|
||||
deployer = get_deployer()
|
||||
success = await deployer.scale(service_type, request.replicas)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to scale service: {service}",
|
||||
)
|
||||
|
||||
return await deployer.status(service_type)
|
||||
|
||||
|
||||
@router.post("/services/{service}/restart", response_model=ServiceStatus)
|
||||
async def restart_service(service: str):
|
||||
"""Restart a service.
|
||||
|
||||
Args:
|
||||
service: Service name to restart
|
||||
|
||||
Returns:
|
||||
ServiceStatus after restart.
|
||||
"""
|
||||
try:
|
||||
service_type = ServiceType(service)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid service: {service}. Valid services: {[s.value for s in ServiceType]}",
|
||||
)
|
||||
|
||||
deployer = get_deployer()
|
||||
success = await deployer.restart(service_type)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to restart service: {service}",
|
||||
)
|
||||
|
||||
return await deployer.status(service_type)
|
||||
|
||||
|
||||
@router.delete("/services/{service}")
|
||||
async def destroy_service(service: str):
|
||||
"""Stop and remove a service.
|
||||
|
||||
Args:
|
||||
service: Service name to destroy
|
||||
|
||||
Returns:
|
||||
Confirmation of destruction.
|
||||
"""
|
||||
try:
|
||||
service_type = ServiceType(service)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid service: {service}. Valid services: {[s.value for s in ServiceType]}",
|
||||
)
|
||||
|
||||
deployer = get_deployer()
|
||||
success = await deployer.destroy(service_type)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to destroy service: {service}",
|
||||
)
|
||||
|
||||
return {"status": "destroyed", "service": service}
|
||||
|
||||
|
||||
@router.get("/services/{service}/logs")
|
||||
async def get_service_logs(
|
||||
service: str,
|
||||
tail: int = Query(default=100, ge=1, le=10000, description="Number of lines to return"),
|
||||
follow: bool = Query(default=False, description="Stream logs continuously"),
|
||||
):
|
||||
"""Get logs from a service.
|
||||
|
||||
Args:
|
||||
service: Service name to get logs from
|
||||
tail: Number of recent log lines to return
|
||||
follow: If true, stream logs continuously (SSE)
|
||||
|
||||
Returns:
|
||||
Log lines as JSON array or SSE stream if follow=true.
|
||||
"""
|
||||
try:
|
||||
service_type = ServiceType(service)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid service: {service}. Valid services: {[s.value for s in ServiceType]}",
|
||||
)
|
||||
|
||||
deployer = get_deployer()
|
||||
|
||||
if follow:
|
||||
async def stream_logs():
|
||||
async for line in deployer.logs(service_type, tail=tail, follow=True):
|
||||
yield f"data: {line}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_logs(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
else:
|
||||
logs = []
|
||||
async for line in deployer.logs(service_type, tail=tail, follow=False):
|
||||
logs.append(line)
|
||||
return {"service": service, "lines": logs}
|
||||
|
||||
|
||||
@router.get("/services/{service}/health")
|
||||
async def check_service_health(service: str):
|
||||
"""Check if a service is healthy.
|
||||
|
||||
Args:
|
||||
service: Service name to check
|
||||
|
||||
Returns:
|
||||
Health status.
|
||||
"""
|
||||
try:
|
||||
service_type = ServiceType(service)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid service: {service}. Valid services: {[s.value for s in ServiceType]}",
|
||||
)
|
||||
|
||||
deployer = get_deployer()
|
||||
is_healthy = await deployer.health(service_type)
|
||||
|
||||
return {"service": service, "healthy": is_healthy}
|
||||
|
||||
|
||||
# === Stats ===
|
||||
|
||||
@router.get("/stats")
|
||||
|
||||
@@ -91,10 +91,21 @@ class Settings(BaseSettings):
|
||||
webhook_timeout: int = 30
|
||||
webhook_max_retries: int = 5
|
||||
|
||||
# Hanzo Commerce (Stripe-based billing)
|
||||
hanzo_commerce_url: str = "https://commerce.hanzo.ai"
|
||||
hanzo_commerce_api_key: str = ""
|
||||
hanzo_commerce_webhook_secret: str = ""
|
||||
|
||||
# ERC-4337 Bundler
|
||||
bundler_private_key: str = ""
|
||||
bundler_beneficiary: str = ""
|
||||
|
||||
# Deployment Target
|
||||
deploy_target: Literal["docker", "process", "kubernetes"] = "docker"
|
||||
deploy_compose_file: str = "infra/compose.yml"
|
||||
deploy_k8s_namespace: str = "bootnode"
|
||||
deploy_k8s_context: str = ""
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Billing module - Alchemy-style compute unit billing system.
|
||||
|
||||
Exports:
|
||||
- Compute unit definitions and helpers
|
||||
- Pricing tiers and limits
|
||||
- Real-time usage tracking (Redis)
|
||||
- Billing service for subscriptions
|
||||
- Hanzo Commerce integration
|
||||
"""
|
||||
|
||||
from bootnode.core.billing.commerce import (
|
||||
CommerceError,
|
||||
HanzoCommerceClient,
|
||||
commerce_client,
|
||||
)
|
||||
from bootnode.core.billing.compute_units import (
|
||||
COMPUTE_UNITS,
|
||||
DEFAULT_COMPUTE_UNITS,
|
||||
get_batch_compute_units,
|
||||
get_compute_units,
|
||||
)
|
||||
from bootnode.core.billing.models import (
|
||||
Customer,
|
||||
Invoice as CommerceInvoice,
|
||||
InvoiceStatus,
|
||||
PlanLimits,
|
||||
PlanTier,
|
||||
ProjectBilling,
|
||||
Subscription as CommerceSubscription,
|
||||
SubscriptionStatus,
|
||||
UsageRecord,
|
||||
WebhookEvent,
|
||||
)
|
||||
from bootnode.core.billing.service import (
|
||||
BillingService,
|
||||
Invoice,
|
||||
UsageSummary,
|
||||
billing_service,
|
||||
)
|
||||
from bootnode.core.billing.sync import UsageSyncWorker, usage_sync_worker
|
||||
from bootnode.core.billing.tiers import (
|
||||
TIER_LIMITS,
|
||||
PricingTier,
|
||||
TierLimits,
|
||||
calculate_monthly_cost,
|
||||
get_tier_limits,
|
||||
)
|
||||
from bootnode.core.billing.tracker import UsageTracker, usage_tracker
|
||||
from bootnode.core.billing.webhooks import (
|
||||
CommerceWebhookHandler,
|
||||
WebhookVerificationError,
|
||||
webhook_handler,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Compute units
|
||||
"COMPUTE_UNITS",
|
||||
"DEFAULT_COMPUTE_UNITS",
|
||||
"get_compute_units",
|
||||
"get_batch_compute_units",
|
||||
# Tiers
|
||||
"PricingTier",
|
||||
"TierLimits",
|
||||
"TIER_LIMITS",
|
||||
"get_tier_limits",
|
||||
"calculate_monthly_cost",
|
||||
# Tracking
|
||||
"UsageTracker",
|
||||
"usage_tracker",
|
||||
# Service
|
||||
"BillingService",
|
||||
"billing_service",
|
||||
"UsageSummary",
|
||||
"Invoice",
|
||||
# Hanzo Commerce
|
||||
"HanzoCommerceClient",
|
||||
"commerce_client",
|
||||
"CommerceError",
|
||||
# Commerce Models
|
||||
"Customer",
|
||||
"CommerceSubscription",
|
||||
"CommerceInvoice",
|
||||
"SubscriptionStatus",
|
||||
"InvoiceStatus",
|
||||
"PlanTier",
|
||||
"PlanLimits",
|
||||
"ProjectBilling",
|
||||
"UsageRecord",
|
||||
"WebhookEvent",
|
||||
# Usage Sync
|
||||
"UsageSyncWorker",
|
||||
"usage_sync_worker",
|
||||
# Webhooks
|
||||
"CommerceWebhookHandler",
|
||||
"webhook_handler",
|
||||
"WebhookVerificationError",
|
||||
]
|
||||
@@ -0,0 +1,527 @@
|
||||
"""Hanzo Commerce client for subscription billing.
|
||||
|
||||
Integrates with Hanzo Commerce API (Stripe-based) for:
|
||||
- Customer management
|
||||
- Subscription lifecycle
|
||||
- Usage metering (PAYG)
|
||||
- Invoice retrieval
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.core.billing.models import (
|
||||
Customer,
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
PlanTier,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class CommerceError(Exception):
|
||||
"""Error from Hanzo Commerce API."""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 500, details: Any = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.details = details
|
||||
|
||||
|
||||
class HanzoCommerceClient:
|
||||
"""Client for Hanzo Commerce billing API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.base_url = settings.hanzo_commerce_url
|
||||
self.api_key = settings.hanzo_commerce_api_key
|
||||
self._timeout = httpx.Timeout(30.0)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Get request headers."""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Source": "bootnode",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
json: dict | None = None,
|
||||
params: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Make HTTP request to Commerce API."""
|
||||
url = f"{self.base_url}{path}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._headers(),
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_data = response.json() if response.content else {}
|
||||
logger.error(
|
||||
"Commerce API error",
|
||||
status=response.status_code,
|
||||
path=path,
|
||||
error=error_data,
|
||||
)
|
||||
raise CommerceError(
|
||||
message=error_data.get("message", "Commerce API error"),
|
||||
status_code=response.status_code,
|
||||
details=error_data,
|
||||
)
|
||||
|
||||
return response.json() if response.content else {}
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error("Commerce API request failed", path=path, error=str(e))
|
||||
raise CommerceError(
|
||||
message=f"Failed to connect to Commerce API: {str(e)}",
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Customer Management
|
||||
# =========================================================================
|
||||
|
||||
async def create_customer(
|
||||
self,
|
||||
user_id: str,
|
||||
email: str,
|
||||
name: str,
|
||||
org: str = "hanzo",
|
||||
) -> str:
|
||||
"""
|
||||
Create customer in Hanzo Commerce.
|
||||
Returns customer_id.
|
||||
"""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/api/v1/customers",
|
||||
json={
|
||||
"email": email,
|
||||
"name": name,
|
||||
"org": org,
|
||||
"metadata": {
|
||||
"iam_id": user_id,
|
||||
"source": "bootnode",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
customer_id = data["id"]
|
||||
logger.info(
|
||||
"Customer created in Commerce",
|
||||
customer_id=customer_id,
|
||||
email=email,
|
||||
org=org,
|
||||
)
|
||||
return customer_id
|
||||
|
||||
async def get_customer(self, customer_id: str) -> Customer:
|
||||
"""Get customer by ID."""
|
||||
data = await self._request("GET", f"/api/v1/customers/{customer_id}")
|
||||
return Customer(
|
||||
id=data["id"],
|
||||
email=data["email"],
|
||||
name=data["name"],
|
||||
org=data.get("org", "hanzo"),
|
||||
metadata=data.get("metadata", {}),
|
||||
created_at=(
|
||||
datetime.fromisoformat(data["created_at"])
|
||||
if data.get("created_at")
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
async def get_customer_by_email(self, email: str) -> Customer | None:
|
||||
"""Get customer by email."""
|
||||
try:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/api/v1/customers",
|
||||
params={"email": email},
|
||||
)
|
||||
customers = data.get("data", [])
|
||||
if not customers:
|
||||
return None
|
||||
c = customers[0]
|
||||
return Customer(
|
||||
id=c["id"],
|
||||
email=c["email"],
|
||||
name=c["name"],
|
||||
org=c.get("org", "hanzo"),
|
||||
metadata=c.get("metadata", {}),
|
||||
)
|
||||
except CommerceError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def update_customer(
|
||||
self,
|
||||
customer_id: str,
|
||||
email: str | None = None,
|
||||
name: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> Customer:
|
||||
"""Update customer details."""
|
||||
update_data = {}
|
||||
if email:
|
||||
update_data["email"] = email
|
||||
if name:
|
||||
update_data["name"] = name
|
||||
if metadata:
|
||||
update_data["metadata"] = metadata
|
||||
|
||||
data = await self._request(
|
||||
"PATCH",
|
||||
f"/api/v1/customers/{customer_id}",
|
||||
json=update_data,
|
||||
)
|
||||
return Customer(
|
||||
id=data["id"],
|
||||
email=data["email"],
|
||||
name=data["name"],
|
||||
org=data.get("org", "hanzo"),
|
||||
metadata=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Subscription Management
|
||||
# =========================================================================
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
customer_id: str,
|
||||
plan_slug: str,
|
||||
project_id: UUID,
|
||||
) -> Subscription:
|
||||
"""
|
||||
Create subscription for a plan.
|
||||
Plans: bootnode-free, bootnode-payg, bootnode-enterprise
|
||||
"""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/api/v1/subscriptions",
|
||||
json={
|
||||
"customer_id": customer_id,
|
||||
"plan_slug": plan_slug,
|
||||
"metadata": {
|
||||
"project_id": str(project_id),
|
||||
"source": "bootnode",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
subscription = Subscription(
|
||||
id=data["id"],
|
||||
customer_id=data["customer_id"],
|
||||
plan_slug=data["plan_slug"],
|
||||
status=SubscriptionStatus(data["status"]),
|
||||
current_period_start=datetime.fromisoformat(data["current_period_start"]),
|
||||
current_period_end=datetime.fromisoformat(data["current_period_end"]),
|
||||
cancel_at_period_end=data.get("cancel_at_period_end", False),
|
||||
metadata=data.get("metadata", {}),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Subscription created",
|
||||
subscription_id=subscription.id,
|
||||
customer_id=customer_id,
|
||||
plan=plan_slug,
|
||||
project_id=str(project_id),
|
||||
)
|
||||
return subscription
|
||||
|
||||
async def get_subscription(self, subscription_id: str) -> Subscription:
|
||||
"""Get subscription by ID."""
|
||||
data = await self._request("GET", f"/api/v1/subscriptions/{subscription_id}")
|
||||
return Subscription(
|
||||
id=data["id"],
|
||||
customer_id=data["customer_id"],
|
||||
plan_slug=data["plan_slug"],
|
||||
status=SubscriptionStatus(data["status"]),
|
||||
current_period_start=datetime.fromisoformat(data["current_period_start"]),
|
||||
current_period_end=datetime.fromisoformat(data["current_period_end"]),
|
||||
cancel_at_period_end=data.get("cancel_at_period_end", False),
|
||||
cancelled_at=(
|
||||
datetime.fromisoformat(data["cancelled_at"])
|
||||
if data.get("cancelled_at")
|
||||
else None
|
||||
),
|
||||
metadata=data.get("metadata", {}),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
)
|
||||
|
||||
async def get_subscriptions_by_customer(
|
||||
self, customer_id: str
|
||||
) -> list[Subscription]:
|
||||
"""Get all subscriptions for a customer."""
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/api/v1/subscriptions",
|
||||
params={"customer_id": customer_id},
|
||||
)
|
||||
return [
|
||||
Subscription(
|
||||
id=s["id"],
|
||||
customer_id=s["customer_id"],
|
||||
plan_slug=s["plan_slug"],
|
||||
status=SubscriptionStatus(s["status"]),
|
||||
current_period_start=datetime.fromisoformat(s["current_period_start"]),
|
||||
current_period_end=datetime.fromisoformat(s["current_period_end"]),
|
||||
cancel_at_period_end=s.get("cancel_at_period_end", False),
|
||||
metadata=s.get("metadata", {}),
|
||||
created_at=datetime.fromisoformat(s["created_at"]),
|
||||
)
|
||||
for s in data.get("data", [])
|
||||
]
|
||||
|
||||
async def update_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
plan_slug: str,
|
||||
) -> Subscription:
|
||||
"""Upgrade or downgrade subscription."""
|
||||
data = await self._request(
|
||||
"PATCH",
|
||||
f"/api/v1/subscriptions/{subscription_id}",
|
||||
json={"plan_slug": plan_slug},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Subscription updated",
|
||||
subscription_id=subscription_id,
|
||||
new_plan=plan_slug,
|
||||
)
|
||||
|
||||
return Subscription(
|
||||
id=data["id"],
|
||||
customer_id=data["customer_id"],
|
||||
plan_slug=data["plan_slug"],
|
||||
status=SubscriptionStatus(data["status"]),
|
||||
current_period_start=datetime.fromisoformat(data["current_period_start"]),
|
||||
current_period_end=datetime.fromisoformat(data["current_period_end"]),
|
||||
cancel_at_period_end=data.get("cancel_at_period_end", False),
|
||||
metadata=data.get("metadata", {}),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
)
|
||||
|
||||
async def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
immediately: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Cancel subscription.
|
||||
If immediately=False, cancels at end of billing period.
|
||||
"""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
f"/api/v1/subscriptions/{subscription_id}/cancel",
|
||||
json={"immediately": immediately},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Subscription cancelled",
|
||||
subscription_id=subscription_id,
|
||||
immediately=immediately,
|
||||
)
|
||||
return data.get("cancelled", True)
|
||||
|
||||
async def reactivate_subscription(self, subscription_id: str) -> Subscription:
|
||||
"""Reactivate a cancelled subscription (if not yet expired)."""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
f"/api/v1/subscriptions/{subscription_id}/reactivate",
|
||||
)
|
||||
return Subscription(
|
||||
id=data["id"],
|
||||
customer_id=data["customer_id"],
|
||||
plan_slug=data["plan_slug"],
|
||||
status=SubscriptionStatus(data["status"]),
|
||||
current_period_start=datetime.fromisoformat(data["current_period_start"]),
|
||||
current_period_end=datetime.fromisoformat(data["current_period_end"]),
|
||||
cancel_at_period_end=data.get("cancel_at_period_end", False),
|
||||
metadata=data.get("metadata", {}),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Usage Metering (for PAYG)
|
||||
# =========================================================================
|
||||
|
||||
async def report_usage(
|
||||
self,
|
||||
subscription_id: str,
|
||||
compute_units: int,
|
||||
timestamp: datetime,
|
||||
idempotency_key: str | None = None,
|
||||
) -> None:
|
||||
"""Report metered usage for PAYG billing."""
|
||||
await self._request(
|
||||
"POST",
|
||||
f"/api/v1/subscriptions/{subscription_id}/usage",
|
||||
json={
|
||||
"quantity": compute_units,
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"action": "increment",
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Usage reported to Commerce",
|
||||
subscription_id=subscription_id,
|
||||
compute_units=compute_units,
|
||||
)
|
||||
|
||||
async def get_usage_summary(
|
||||
self,
|
||||
subscription_id: str,
|
||||
period_start: datetime | None = None,
|
||||
period_end: datetime | None = None,
|
||||
) -> dict:
|
||||
"""Get usage summary for a subscription."""
|
||||
params = {}
|
||||
if period_start:
|
||||
params["period_start"] = period_start.isoformat()
|
||||
if period_end:
|
||||
params["period_end"] = period_end.isoformat()
|
||||
|
||||
return await self._request(
|
||||
"GET",
|
||||
f"/api/v1/subscriptions/{subscription_id}/usage",
|
||||
params=params or None,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Invoices
|
||||
# =========================================================================
|
||||
|
||||
async def get_customer_invoices(
|
||||
self,
|
||||
customer_id: str,
|
||||
limit: int = 10,
|
||||
) -> list[Invoice]:
|
||||
"""Get customer invoices."""
|
||||
data = await self._request(
|
||||
"GET",
|
||||
"/api/v1/invoices",
|
||||
params={"customer_id": customer_id, "limit": limit},
|
||||
)
|
||||
return [
|
||||
Invoice(
|
||||
id=inv["id"],
|
||||
customer_id=inv["customer_id"],
|
||||
subscription_id=inv.get("subscription_id"),
|
||||
status=InvoiceStatus(inv["status"]),
|
||||
amount_due=inv["amount_due"],
|
||||
amount_paid=inv["amount_paid"],
|
||||
currency=inv.get("currency", "usd"),
|
||||
period_start=(
|
||||
datetime.fromisoformat(inv["period_start"])
|
||||
if inv.get("period_start")
|
||||
else None
|
||||
),
|
||||
period_end=(
|
||||
datetime.fromisoformat(inv["period_end"])
|
||||
if inv.get("period_end")
|
||||
else None
|
||||
),
|
||||
paid_at=(
|
||||
datetime.fromisoformat(inv["paid_at"])
|
||||
if inv.get("paid_at")
|
||||
else None
|
||||
),
|
||||
hosted_invoice_url=inv.get("hosted_invoice_url"),
|
||||
pdf_url=inv.get("pdf_url"),
|
||||
created_at=datetime.fromisoformat(inv["created_at"]),
|
||||
)
|
||||
for inv in data.get("data", [])
|
||||
]
|
||||
|
||||
async def get_invoice(self, invoice_id: str) -> Invoice:
|
||||
"""Get specific invoice."""
|
||||
inv = await self._request("GET", f"/api/v1/invoices/{invoice_id}")
|
||||
return Invoice(
|
||||
id=inv["id"],
|
||||
customer_id=inv["customer_id"],
|
||||
subscription_id=inv.get("subscription_id"),
|
||||
status=InvoiceStatus(inv["status"]),
|
||||
amount_due=inv["amount_due"],
|
||||
amount_paid=inv["amount_paid"],
|
||||
currency=inv.get("currency", "usd"),
|
||||
period_start=(
|
||||
datetime.fromisoformat(inv["period_start"])
|
||||
if inv.get("period_start")
|
||||
else None
|
||||
),
|
||||
period_end=(
|
||||
datetime.fromisoformat(inv["period_end"])
|
||||
if inv.get("period_end")
|
||||
else None
|
||||
),
|
||||
paid_at=(
|
||||
datetime.fromisoformat(inv["paid_at"]) if inv.get("paid_at") else None
|
||||
),
|
||||
hosted_invoice_url=inv.get("hosted_invoice_url"),
|
||||
pdf_url=inv.get("pdf_url"),
|
||||
created_at=datetime.fromisoformat(inv["created_at"]),
|
||||
)
|
||||
|
||||
async def get_upcoming_invoice(self, customer_id: str) -> Invoice | None:
|
||||
"""Get upcoming invoice preview for a customer."""
|
||||
try:
|
||||
inv = await self._request(
|
||||
"GET",
|
||||
"/api/v1/invoices/upcoming",
|
||||
params={"customer_id": customer_id},
|
||||
)
|
||||
return Invoice(
|
||||
id=inv.get("id", "upcoming"),
|
||||
customer_id=inv["customer_id"],
|
||||
subscription_id=inv.get("subscription_id"),
|
||||
status=InvoiceStatus.DRAFT,
|
||||
amount_due=inv["amount_due"],
|
||||
amount_paid=0,
|
||||
currency=inv.get("currency", "usd"),
|
||||
period_start=(
|
||||
datetime.fromisoformat(inv["period_start"])
|
||||
if inv.get("period_start")
|
||||
else None
|
||||
),
|
||||
period_end=(
|
||||
datetime.fromisoformat(inv["period_end"])
|
||||
if inv.get("period_end")
|
||||
else None
|
||||
),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
except CommerceError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
# Global singleton
|
||||
commerce_client = HanzoCommerceClient()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Compute Unit costs per RPC method.
|
||||
|
||||
Alchemy-style billing where each method costs a fixed number of compute units.
|
||||
This allows for fair pricing based on actual resource consumption.
|
||||
"""
|
||||
|
||||
# Default CU cost for unknown methods
|
||||
DEFAULT_COMPUTE_UNITS = 10
|
||||
|
||||
# Compute units per method
|
||||
COMPUTE_UNITS: dict[str, int] = {
|
||||
# =========================================================================
|
||||
# Free tier (1 CU) - Simple queries, minimal compute
|
||||
# =========================================================================
|
||||
"eth_chainId": 1,
|
||||
"eth_blockNumber": 1,
|
||||
"eth_syncing": 1,
|
||||
"eth_gasPrice": 1,
|
||||
"net_version": 1,
|
||||
"net_listening": 1,
|
||||
"net_peerCount": 1,
|
||||
"web3_clientVersion": 1,
|
||||
"web3_sha3": 1,
|
||||
# =========================================================================
|
||||
# Light (5 CU) - Single lookups, indexed queries
|
||||
# =========================================================================
|
||||
"eth_getBalance": 5,
|
||||
"eth_getCode": 5,
|
||||
"eth_getBlockByNumber": 5,
|
||||
"eth_getBlockByHash": 5,
|
||||
"eth_getTransactionByHash": 5,
|
||||
"eth_getTransactionReceipt": 5,
|
||||
"eth_getTransactionByBlockHashAndIndex": 5,
|
||||
"eth_getTransactionByBlockNumberAndIndex": 5,
|
||||
"eth_getBlockTransactionCountByHash": 5,
|
||||
"eth_getBlockTransactionCountByNumber": 5,
|
||||
"eth_getUncleCountByBlockHash": 5,
|
||||
"eth_getUncleCountByBlockNumber": 5,
|
||||
"eth_getUncleByBlockHashAndIndex": 5,
|
||||
"eth_getUncleByBlockNumberAndIndex": 5,
|
||||
"eth_getStorageAt": 5,
|
||||
"eth_getTransactionCount": 5,
|
||||
"eth_accounts": 5,
|
||||
"eth_protocolVersion": 5,
|
||||
"eth_coinbase": 5,
|
||||
"eth_mining": 5,
|
||||
"eth_hashrate": 5,
|
||||
# =========================================================================
|
||||
# Medium (10 CU) - EVM execution, moderate compute
|
||||
# =========================================================================
|
||||
"eth_call": 10,
|
||||
"eth_estimateGas": 10,
|
||||
"eth_feeHistory": 10,
|
||||
"eth_maxPriorityFeePerGas": 10,
|
||||
"eth_createAccessList": 10,
|
||||
"eth_getBlockReceipts": 10,
|
||||
"eth_getProof": 10,
|
||||
# =========================================================================
|
||||
# Heavy (25 CU) - Log queries, filter operations
|
||||
# =========================================================================
|
||||
"eth_getLogs": 25,
|
||||
"eth_newFilter": 25,
|
||||
"eth_newBlockFilter": 25,
|
||||
"eth_newPendingTransactionFilter": 25,
|
||||
"eth_getFilterLogs": 25,
|
||||
"eth_getFilterChanges": 25,
|
||||
"eth_uninstallFilter": 25,
|
||||
# =========================================================================
|
||||
# Transaction (50 CU) - State changes, mempool operations
|
||||
# =========================================================================
|
||||
"eth_sendRawTransaction": 50,
|
||||
"eth_sendTransaction": 50,
|
||||
"eth_sign": 50,
|
||||
"eth_signTransaction": 50,
|
||||
"personal_sign": 50,
|
||||
"eth_signTypedData_v4": 50,
|
||||
# =========================================================================
|
||||
# Debug/Trace (100 CU) - Heavy compute, full execution
|
||||
# =========================================================================
|
||||
"debug_traceTransaction": 100,
|
||||
"debug_traceBlockByNumber": 100,
|
||||
"debug_traceBlockByHash": 100,
|
||||
"debug_traceCall": 100,
|
||||
"trace_block": 100,
|
||||
"trace_call": 100,
|
||||
"trace_callMany": 100,
|
||||
"trace_filter": 100,
|
||||
"trace_get": 100,
|
||||
"trace_rawTransaction": 100,
|
||||
"trace_replayBlockTransactions": 100,
|
||||
"trace_replayTransaction": 100,
|
||||
"trace_transaction": 100,
|
||||
# =========================================================================
|
||||
# ERC-4337 Account Abstraction
|
||||
# =========================================================================
|
||||
"eth_sendUserOperation": 75,
|
||||
"eth_estimateUserOperationGas": 25,
|
||||
"eth_getUserOperationByHash": 10,
|
||||
"eth_getUserOperationReceipt": 10,
|
||||
"eth_supportedEntryPoints": 5,
|
||||
# =========================================================================
|
||||
# Bootnode Enhanced APIs - Token/NFT/Webhook
|
||||
# =========================================================================
|
||||
# Token APIs
|
||||
"tokens_getBalances": 15,
|
||||
"tokens_getMetadata": 10,
|
||||
"tokens_getTransfers": 20,
|
||||
"tokens_getHolders": 25,
|
||||
"tokens_getPrice": 10,
|
||||
# NFT APIs
|
||||
"nfts_getOwned": 20,
|
||||
"nfts_getMetadata": 10,
|
||||
"nfts_refreshMetadata": 50,
|
||||
"nfts_getTransfers": 20,
|
||||
"nfts_getFloorPrice": 15,
|
||||
"nfts_getCollectionMetadata": 15,
|
||||
# Webhook APIs
|
||||
"webhooks_create": 5,
|
||||
"webhooks_list": 5,
|
||||
"webhooks_delete": 5,
|
||||
"webhooks_test": 10,
|
||||
# Address APIs
|
||||
"address_getActivity": 20,
|
||||
"address_getTokens": 15,
|
||||
"address_getNFTs": 20,
|
||||
# =========================================================================
|
||||
# Solana-specific methods
|
||||
# =========================================================================
|
||||
"getAccountInfo": 5,
|
||||
"getBalance": 5,
|
||||
"getBlock": 10,
|
||||
"getBlockHeight": 1,
|
||||
"getBlockProduction": 10,
|
||||
"getBlockCommitment": 5,
|
||||
"getBlocks": 15,
|
||||
"getBlocksWithLimit": 15,
|
||||
"getBlockTime": 5,
|
||||
"getClusterNodes": 5,
|
||||
"getEpochInfo": 5,
|
||||
"getEpochSchedule": 5,
|
||||
"getFeeForMessage": 10,
|
||||
"getFirstAvailableBlock": 5,
|
||||
"getGenesisHash": 1,
|
||||
"getHealth": 1,
|
||||
"getHighestSnapshotSlot": 5,
|
||||
"getIdentity": 5,
|
||||
"getInflationGovernor": 5,
|
||||
"getInflationRate": 5,
|
||||
"getInflationReward": 10,
|
||||
"getLargestAccounts": 15,
|
||||
"getLatestBlockhash": 5,
|
||||
"getLeaderSchedule": 10,
|
||||
"getMaxRetransmitSlot": 5,
|
||||
"getMaxShredInsertSlot": 5,
|
||||
"getMinimumBalanceForRentExemption": 5,
|
||||
"getMultipleAccounts": 10,
|
||||
"getProgramAccounts": 25,
|
||||
"getRecentPerformanceSamples": 10,
|
||||
"getRecentPrioritizationFees": 10,
|
||||
"getSignaturesForAddress": 15,
|
||||
"getSignatureStatuses": 10,
|
||||
"getSlot": 1,
|
||||
"getSlotLeader": 5,
|
||||
"getSlotLeaders": 10,
|
||||
"getStakeActivation": 10,
|
||||
"getStakeMinimumDelegation": 5,
|
||||
"getSupply": 10,
|
||||
"getTokenAccountBalance": 5,
|
||||
"getTokenAccountsByDelegate": 15,
|
||||
"getTokenAccountsByOwner": 15,
|
||||
"getTokenLargestAccounts": 15,
|
||||
"getTokenSupply": 5,
|
||||
"getTransaction": 10,
|
||||
"getTransactionCount": 5,
|
||||
"getVersion": 1,
|
||||
"getVoteAccounts": 10,
|
||||
"isBlockhashValid": 5,
|
||||
"minimumLedgerSlot": 5,
|
||||
"requestAirdrop": 50,
|
||||
"sendTransaction": 50,
|
||||
"simulateTransaction": 25,
|
||||
}
|
||||
|
||||
|
||||
def get_compute_units(method: str) -> int:
|
||||
"""Get compute unit cost for a method.
|
||||
|
||||
Args:
|
||||
method: The RPC method name
|
||||
|
||||
Returns:
|
||||
The compute unit cost for the method
|
||||
"""
|
||||
return COMPUTE_UNITS.get(method, DEFAULT_COMPUTE_UNITS)
|
||||
|
||||
|
||||
def get_batch_compute_units(methods: list[str]) -> int:
|
||||
"""Calculate total compute units for a batch request.
|
||||
|
||||
Args:
|
||||
methods: List of RPC method names
|
||||
|
||||
Returns:
|
||||
Total compute units for all methods
|
||||
"""
|
||||
return sum(get_compute_units(method) for method in methods)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Billing models for Hanzo Commerce integration."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PlanTier(str, Enum):
|
||||
"""Subscription plan tiers."""
|
||||
|
||||
FREE = "bootnode-free"
|
||||
PAYG = "bootnode-payg"
|
||||
ENTERPRISE = "bootnode-enterprise"
|
||||
|
||||
|
||||
class SubscriptionStatus(str, Enum):
|
||||
"""Subscription status values."""
|
||||
|
||||
ACTIVE = "active"
|
||||
PAST_DUE = "past_due"
|
||||
CANCELLED = "cancelled"
|
||||
TRIALING = "trialing"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
class InvoiceStatus(str, Enum):
|
||||
"""Invoice status values."""
|
||||
|
||||
DRAFT = "draft"
|
||||
OPEN = "open"
|
||||
PAID = "paid"
|
||||
VOID = "void"
|
||||
UNCOLLECTIBLE = "uncollectible"
|
||||
|
||||
|
||||
class Customer(BaseModel):
|
||||
"""Hanzo Commerce customer."""
|
||||
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
org: str = "hanzo"
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class Subscription(BaseModel):
|
||||
"""Hanzo Commerce subscription."""
|
||||
|
||||
id: str
|
||||
customer_id: str
|
||||
plan_slug: str
|
||||
status: SubscriptionStatus
|
||||
current_period_start: datetime
|
||||
current_period_end: datetime
|
||||
cancel_at_period_end: bool = False
|
||||
cancelled_at: datetime | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class Invoice(BaseModel):
|
||||
"""Hanzo Commerce invoice."""
|
||||
|
||||
id: str
|
||||
customer_id: str
|
||||
subscription_id: str | None = None
|
||||
status: InvoiceStatus
|
||||
amount_due: int # cents
|
||||
amount_paid: int # cents
|
||||
currency: str = "usd"
|
||||
period_start: datetime | None = None
|
||||
period_end: datetime | None = None
|
||||
paid_at: datetime | None = None
|
||||
hosted_invoice_url: str | None = None
|
||||
pdf_url: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UsageRecord(BaseModel):
|
||||
"""Usage record for metered billing."""
|
||||
|
||||
subscription_id: str
|
||||
quantity: int # compute units
|
||||
timestamp: datetime
|
||||
action: str = "increment" # increment or set
|
||||
idempotency_key: str | None = None
|
||||
|
||||
|
||||
class PlanLimits(BaseModel):
|
||||
"""Plan limits configuration."""
|
||||
|
||||
requests_per_minute: int
|
||||
compute_units_per_minute: int
|
||||
compute_units_per_month: int
|
||||
webhooks_max: int
|
||||
api_keys_max: int
|
||||
team_members_max: int
|
||||
|
||||
@classmethod
|
||||
def for_plan(cls, plan: PlanTier) -> "PlanLimits":
|
||||
"""Get limits for a specific plan."""
|
||||
limits = {
|
||||
PlanTier.FREE: cls(
|
||||
requests_per_minute=100,
|
||||
compute_units_per_minute=1000,
|
||||
compute_units_per_month=100_000,
|
||||
webhooks_max=3,
|
||||
api_keys_max=2,
|
||||
team_members_max=1,
|
||||
),
|
||||
PlanTier.PAYG: cls(
|
||||
requests_per_minute=1000,
|
||||
compute_units_per_minute=10_000,
|
||||
compute_units_per_month=10_000_000, # soft limit, pay for overage
|
||||
webhooks_max=50,
|
||||
api_keys_max=20,
|
||||
team_members_max=10,
|
||||
),
|
||||
PlanTier.ENTERPRISE: cls(
|
||||
requests_per_minute=10_000,
|
||||
compute_units_per_minute=100_000,
|
||||
compute_units_per_month=100_000_000,
|
||||
webhooks_max=500,
|
||||
api_keys_max=100,
|
||||
team_members_max=100,
|
||||
),
|
||||
}
|
||||
return limits.get(plan, limits[PlanTier.FREE])
|
||||
|
||||
|
||||
class ProjectBilling(BaseModel):
|
||||
"""Project billing state."""
|
||||
|
||||
project_id: UUID
|
||||
customer_id: str
|
||||
subscription_id: str | None = None
|
||||
plan: PlanTier = PlanTier.FREE
|
||||
status: SubscriptionStatus = SubscriptionStatus.ACTIVE
|
||||
limits: PlanLimits = Field(default_factory=lambda: PlanLimits.for_plan(PlanTier.FREE))
|
||||
current_period_start: datetime | None = None
|
||||
current_period_end: datetime | None = None
|
||||
|
||||
|
||||
class WebhookEvent(BaseModel):
|
||||
"""Commerce webhook event."""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
data: dict[str, Any]
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Billing service for subscription management."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bootnode.core.billing.tiers import (
|
||||
PricingTier,
|
||||
TierLimits,
|
||||
calculate_monthly_cost,
|
||||
get_tier_limits,
|
||||
)
|
||||
from bootnode.core.billing.tracker import usage_tracker
|
||||
from bootnode.core.datastore import datastore_client
|
||||
from bootnode.db.models import Subscription
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class UsageSummary(BaseModel):
|
||||
"""Usage summary for a billing period."""
|
||||
|
||||
project_id: uuid.UUID
|
||||
period_start: date
|
||||
period_end: date
|
||||
total_cu: int
|
||||
total_requests: int
|
||||
cu_by_method: dict[str, int]
|
||||
cu_by_chain: dict[str, int]
|
||||
daily_usage: list[dict[str, Any]]
|
||||
estimated_cost_cents: int
|
||||
|
||||
|
||||
class Invoice(BaseModel):
|
||||
"""Invoice for a billing period."""
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
period_start: date
|
||||
period_end: date
|
||||
total_cu: int
|
||||
tier: str
|
||||
base_cost_cents: int
|
||||
overage_cost_cents: int
|
||||
total_cost_cents: int
|
||||
status: str # draft, pending, paid, overdue
|
||||
hanzo_invoice_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BillingService:
|
||||
"""Service for billing operations."""
|
||||
|
||||
async def get_subscription(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> Subscription | None:
|
||||
"""Get subscription for a project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Subscription or None if not found
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.project_id == project_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_or_create_subscription(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> Subscription:
|
||||
"""Get or create subscription for a project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Subscription (existing or newly created)
|
||||
"""
|
||||
subscription = await self.get_subscription(project_id, db)
|
||||
|
||||
if subscription:
|
||||
return subscription
|
||||
|
||||
# Create new free-tier subscription
|
||||
now = datetime.now(UTC)
|
||||
limits = get_tier_limits(PricingTier.FREE)
|
||||
|
||||
subscription = Subscription(
|
||||
project_id=project_id,
|
||||
tier=PricingTier.FREE.value,
|
||||
monthly_cu_limit=limits.monthly_cu,
|
||||
rate_limit_per_second=limits.rate_limit_per_second,
|
||||
max_apps=limits.max_apps,
|
||||
max_webhooks=limits.max_webhooks,
|
||||
billing_cycle_start=now,
|
||||
billing_cycle_end=now + timedelta(days=30),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"Created subscription",
|
||||
project_id=str(project_id),
|
||||
tier=PricingTier.FREE.value,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
async def get_tier(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> PricingTier:
|
||||
"""Get pricing tier for a project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
The project's pricing tier
|
||||
"""
|
||||
subscription = await self.get_subscription(project_id, db)
|
||||
if subscription:
|
||||
return PricingTier(subscription.tier)
|
||||
return PricingTier.FREE
|
||||
|
||||
async def get_tier_limits_for_project(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> TierLimits:
|
||||
"""Get tier limits for a project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
TierLimits for the project's tier
|
||||
"""
|
||||
tier = await self.get_tier(project_id, db)
|
||||
return get_tier_limits(tier)
|
||||
|
||||
async def get_usage_summary(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
period: date,
|
||||
db: AsyncSession,
|
||||
) -> UsageSummary:
|
||||
"""Get usage summary for a billing period.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
period: The billing period (first day of month)
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
UsageSummary for the period
|
||||
"""
|
||||
tier = await self.get_tier(project_id, db)
|
||||
|
||||
# Calculate period bounds
|
||||
period_start = period.replace(day=1)
|
||||
if period.month == 12:
|
||||
period_end = period.replace(year=period.year + 1, month=1, day=1)
|
||||
else:
|
||||
period_end = period.replace(month=period.month + 1, day=1)
|
||||
|
||||
# Default values if ClickHouse unavailable
|
||||
total_cu = 0
|
||||
total_requests = 0
|
||||
cu_by_method: dict[str, int] = {}
|
||||
cu_by_chain: dict[str, int] = {}
|
||||
daily_usage: list[dict[str, Any]] = []
|
||||
|
||||
if datastore_client.is_connected:
|
||||
# Get total CU and requests
|
||||
total_result = await datastore_client.fetchone(
|
||||
"""
|
||||
SELECT
|
||||
sum(compute_units) as total_cu,
|
||||
count() as total_requests
|
||||
FROM api_usage
|
||||
WHERE project_id = %(project_id)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp < %(end)s
|
||||
""",
|
||||
{
|
||||
"project_id": str(project_id),
|
||||
"start": period_start.isoformat(),
|
||||
"end": period_end.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
if total_result:
|
||||
total_cu = int(total_result.get("total_cu") or 0)
|
||||
total_requests = int(total_result.get("total_requests") or 0)
|
||||
|
||||
# Get CU by method
|
||||
method_result = await datastore_client.fetch(
|
||||
"""
|
||||
SELECT method, sum(compute_units) as cu
|
||||
FROM api_usage
|
||||
WHERE project_id = %(project_id)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp < %(end)s
|
||||
GROUP BY method
|
||||
ORDER BY cu DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
{
|
||||
"project_id": str(project_id),
|
||||
"start": period_start.isoformat(),
|
||||
"end": period_end.isoformat(),
|
||||
},
|
||||
)
|
||||
cu_by_method = {row["method"]: int(row["cu"]) for row in method_result}
|
||||
|
||||
# Get CU by chain
|
||||
chain_result = await datastore_client.fetch(
|
||||
"""
|
||||
SELECT toString(chain_id) as chain, sum(compute_units) as cu
|
||||
FROM api_usage
|
||||
WHERE project_id = %(project_id)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp < %(end)s
|
||||
GROUP BY chain_id
|
||||
ORDER BY cu DESC
|
||||
""",
|
||||
{
|
||||
"project_id": str(project_id),
|
||||
"start": period_start.isoformat(),
|
||||
"end": period_end.isoformat(),
|
||||
},
|
||||
)
|
||||
cu_by_chain = {row["chain"]: int(row["cu"]) for row in chain_result}
|
||||
|
||||
# Get daily usage
|
||||
daily_result = await datastore_client.fetch(
|
||||
"""
|
||||
SELECT
|
||||
toDate(timestamp) as date,
|
||||
sum(compute_units) as cu,
|
||||
count() as requests
|
||||
FROM api_usage
|
||||
WHERE project_id = %(project_id)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp < %(end)s
|
||||
GROUP BY date
|
||||
ORDER BY date
|
||||
""",
|
||||
{
|
||||
"project_id": str(project_id),
|
||||
"start": period_start.isoformat(),
|
||||
"end": period_end.isoformat(),
|
||||
},
|
||||
)
|
||||
daily_usage = [
|
||||
{
|
||||
"date": str(row["date"]),
|
||||
"cu": int(row["cu"]),
|
||||
"requests": int(row["requests"]),
|
||||
}
|
||||
for row in daily_result
|
||||
]
|
||||
else:
|
||||
# Fall back to Redis for current period
|
||||
total_cu = await usage_tracker.get_current_usage(project_id)
|
||||
|
||||
# Calculate estimated cost
|
||||
estimated_cost = calculate_monthly_cost(tier, total_cu)
|
||||
|
||||
return UsageSummary(
|
||||
project_id=project_id,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
total_cu=total_cu,
|
||||
total_requests=total_requests,
|
||||
cu_by_method=cu_by_method,
|
||||
cu_by_chain=cu_by_chain,
|
||||
daily_usage=daily_usage,
|
||||
estimated_cost_cents=estimated_cost,
|
||||
)
|
||||
|
||||
async def upgrade_tier(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
tier: PricingTier,
|
||||
db: AsyncSession,
|
||||
hanzo_subscription_id: str | None = None,
|
||||
) -> Subscription:
|
||||
"""Upgrade a project to a new tier.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
tier: The new pricing tier
|
||||
db: Database session
|
||||
hanzo_subscription_id: Hanzo Commerce subscription ID
|
||||
|
||||
Returns:
|
||||
Updated subscription
|
||||
"""
|
||||
subscription = await self.get_or_create_subscription(project_id, db)
|
||||
limits = get_tier_limits(tier)
|
||||
|
||||
# Update subscription
|
||||
subscription.tier = tier.value
|
||||
subscription.monthly_cu_limit = limits.monthly_cu
|
||||
subscription.rate_limit_per_second = limits.rate_limit_per_second
|
||||
subscription.max_apps = limits.max_apps
|
||||
subscription.max_webhooks = limits.max_webhooks
|
||||
|
||||
if hanzo_subscription_id:
|
||||
subscription.hanzo_subscription_id = hanzo_subscription_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"Upgraded subscription",
|
||||
project_id=str(project_id),
|
||||
tier=tier.value,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
async def downgrade_tier(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
tier: PricingTier,
|
||||
db: AsyncSession,
|
||||
) -> Subscription:
|
||||
"""Downgrade a project to a lower tier.
|
||||
|
||||
Note: Downgrade takes effect at end of billing period.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
tier: The new pricing tier
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Updated subscription
|
||||
"""
|
||||
subscription = await self.get_or_create_subscription(project_id, db)
|
||||
|
||||
# Schedule downgrade for end of billing period
|
||||
subscription.scheduled_tier = tier.value
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"Scheduled downgrade",
|
||||
project_id=str(project_id),
|
||||
current_tier=subscription.tier,
|
||||
new_tier=tier.value,
|
||||
effective_date=str(subscription.billing_cycle_end),
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
async def get_invoices(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
_db: AsyncSession, # Unused, kept for API consistency
|
||||
limit: int = 12,
|
||||
) -> list[Invoice]:
|
||||
"""Get invoices for a project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
db: Database session
|
||||
limit: Maximum number of invoices to return
|
||||
|
||||
Returns:
|
||||
List of invoices (most recent first)
|
||||
"""
|
||||
if not datastore_client.is_connected:
|
||||
return []
|
||||
|
||||
result = await datastore_client.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM billing_invoices
|
||||
WHERE project_id = %(project_id)s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %(limit)s
|
||||
""",
|
||||
{"project_id": str(project_id), "limit": limit},
|
||||
)
|
||||
|
||||
return [
|
||||
Invoice(
|
||||
id=uuid.UUID(row["id"]),
|
||||
project_id=uuid.UUID(row["project_id"]),
|
||||
period_start=row["period_start"],
|
||||
period_end=row["period_end"],
|
||||
total_cu=row["total_cu"],
|
||||
tier=row["tier"],
|
||||
base_cost_cents=row["base_cost_cents"],
|
||||
overage_cost_cents=row["overage_cost_cents"],
|
||||
total_cost_cents=row["total_cost_cents"],
|
||||
status=row["status"],
|
||||
hanzo_invoice_id=row.get("hanzo_invoice_id"),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
async def check_limits(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Check all limits for a project.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dict with limit check results
|
||||
"""
|
||||
subscription = await self.get_or_create_subscription(project_id, db)
|
||||
tier = PricingTier(subscription.tier)
|
||||
|
||||
# Get current usage from Redis
|
||||
usage_stats = await usage_tracker.get_usage_stats(project_id, tier)
|
||||
|
||||
# Check rate limit
|
||||
rate_allowed, rate_remaining = await usage_tracker.check_rate_limit(
|
||||
project_id, tier
|
||||
)
|
||||
|
||||
# Check quota
|
||||
quota_ok = await usage_tracker.check_quota(project_id, tier)
|
||||
|
||||
return {
|
||||
"tier": tier.value,
|
||||
"quota_ok": quota_ok,
|
||||
"rate_allowed": rate_allowed,
|
||||
"rate_remaining": rate_remaining,
|
||||
**usage_stats,
|
||||
}
|
||||
|
||||
|
||||
# Global singleton
|
||||
billing_service = BillingService()
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Usage sync worker for Hanzo Commerce.
|
||||
|
||||
Periodically syncs compute unit usage from Redis to Hanzo Commerce
|
||||
for PAYG (pay-as-you-go) billing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.core.billing.commerce import CommerceError, commerce_client
|
||||
from bootnode.core.billing.tiers import PricingTier
|
||||
from bootnode.core.billing.tracker import usage_tracker
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.db.models import Subscription
|
||||
from bootnode.db.session import async_session
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Redis key for tracking sync state
|
||||
SYNC_LOCK_KEY = "billing:sync:lock"
|
||||
SYNC_CURSOR_KEY = "billing:sync:cursor:{project_id}"
|
||||
LAST_SYNC_KEY = "billing:sync:last"
|
||||
|
||||
|
||||
class UsageSyncWorker:
|
||||
"""Periodically sync usage from Redis to Hanzo Commerce."""
|
||||
|
||||
def __init__(self, interval_seconds: int = 3600) -> None:
|
||||
"""
|
||||
Initialize sync worker.
|
||||
|
||||
Args:
|
||||
interval_seconds: Sync interval (default: 1 hour)
|
||||
"""
|
||||
self.interval = interval_seconds
|
||||
self._running = False
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
async def _acquire_lock(self, ttl: int = 300) -> bool:
|
||||
"""Acquire distributed lock for sync operation."""
|
||||
result = await redis_client.client.set(
|
||||
SYNC_LOCK_KEY,
|
||||
datetime.now(UTC).isoformat(),
|
||||
nx=True, # Only set if not exists
|
||||
ex=ttl, # Expire after TTL
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def _release_lock(self) -> None:
|
||||
"""Release distributed lock."""
|
||||
await redis_client.delete(SYNC_LOCK_KEY)
|
||||
|
||||
async def _get_payg_subscriptions(
|
||||
self, db: AsyncSession
|
||||
) -> list[tuple[uuid.UUID, str]]:
|
||||
"""
|
||||
Get all active PAYG subscriptions.
|
||||
|
||||
Returns list of (project_id, hanzo_subscription_id) tuples.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription.project_id, Subscription.hanzo_subscription_id).where(
|
||||
Subscription.tier == PricingTier.PAY_AS_YOU_GO.value,
|
||||
Subscription.hanzo_subscription_id.isnot(None),
|
||||
)
|
||||
)
|
||||
return [(row[0], row[1]) for row in result.all()]
|
||||
|
||||
async def _get_unsync_usage(self, project_id: uuid.UUID) -> int:
|
||||
"""Get compute units accumulated since last sync."""
|
||||
key = f"billing:unsync:cu:{project_id}"
|
||||
value = await redis_client.get(key)
|
||||
return int(value) if value else 0
|
||||
|
||||
async def _mark_synced(self, project_id: uuid.UUID, compute_units: int) -> None:
|
||||
"""Mark usage as synced by decrementing counter."""
|
||||
key = f"billing:unsync:cu:{project_id}"
|
||||
# Decrement by the amount we synced
|
||||
await redis_client.client.decrby(key, compute_units)
|
||||
# Ensure we don't go negative
|
||||
current = await redis_client.get(key)
|
||||
if current and int(current) < 0:
|
||||
await redis_client.set(key, "0")
|
||||
|
||||
async def _sync_project(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
subscription_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Sync usage for a single project.
|
||||
|
||||
Returns sync result dict.
|
||||
"""
|
||||
result = {
|
||||
"project_id": str(project_id),
|
||||
"subscription_id": subscription_id,
|
||||
"compute_units": 0,
|
||||
"synced": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
# Get unsync'd usage
|
||||
compute_units = await self._get_unsync_usage(project_id)
|
||||
|
||||
if compute_units <= 0:
|
||||
result["synced"] = True
|
||||
return result
|
||||
|
||||
result["compute_units"] = compute_units
|
||||
|
||||
# Generate idempotency key for this sync
|
||||
sync_time = datetime.now(UTC)
|
||||
idempotency_key = f"{project_id}:{sync_time.strftime('%Y-%m-%d-%H')}"
|
||||
|
||||
# Report to Commerce
|
||||
await commerce_client.report_usage(
|
||||
subscription_id=subscription_id,
|
||||
compute_units=compute_units,
|
||||
timestamp=sync_time,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
# Mark as synced
|
||||
await self._mark_synced(project_id, compute_units)
|
||||
|
||||
result["synced"] = True
|
||||
logger.info(
|
||||
"Usage synced to Commerce",
|
||||
project_id=str(project_id),
|
||||
compute_units=compute_units,
|
||||
)
|
||||
|
||||
except CommerceError as e:
|
||||
result["error"] = e.message
|
||||
logger.error(
|
||||
"Failed to sync usage to Commerce",
|
||||
project_id=str(project_id),
|
||||
error=e.message,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
logger.error(
|
||||
"Unexpected error syncing usage",
|
||||
project_id=str(project_id),
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def sync_all_projects(self) -> dict[str, Any]:
|
||||
"""
|
||||
Sync usage for all active PAYG subscriptions.
|
||||
|
||||
Returns summary of sync operation.
|
||||
"""
|
||||
summary = {
|
||||
"started_at": datetime.now(UTC).isoformat(),
|
||||
"total_projects": 0,
|
||||
"synced": 0,
|
||||
"failed": 0,
|
||||
"total_cu": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
# Acquire lock
|
||||
if not await self._acquire_lock():
|
||||
logger.warning("Could not acquire sync lock, another sync in progress")
|
||||
summary["error"] = "Lock not acquired"
|
||||
return summary
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
# Get all PAYG subscriptions
|
||||
subscriptions = await self._get_payg_subscriptions(db)
|
||||
summary["total_projects"] = len(subscriptions)
|
||||
|
||||
for project_id, subscription_id in subscriptions:
|
||||
result = await self._sync_project(project_id, subscription_id)
|
||||
|
||||
if result["synced"]:
|
||||
summary["synced"] += 1
|
||||
summary["total_cu"] += result["compute_units"]
|
||||
else:
|
||||
summary["failed"] += 1
|
||||
if result["error"]:
|
||||
summary["errors"].append(
|
||||
{
|
||||
"project_id": str(project_id),
|
||||
"error": result["error"],
|
||||
}
|
||||
)
|
||||
|
||||
# Record last sync time
|
||||
await redis_client.set(LAST_SYNC_KEY, datetime.now(UTC).isoformat())
|
||||
summary["completed_at"] = datetime.now(UTC).isoformat()
|
||||
|
||||
logger.info(
|
||||
"Usage sync completed",
|
||||
synced=summary["synced"],
|
||||
failed=summary["failed"],
|
||||
total_cu=summary["total_cu"],
|
||||
)
|
||||
|
||||
finally:
|
||||
await self._release_lock()
|
||||
|
||||
return summary
|
||||
|
||||
async def sync_project(self, project_id: uuid.UUID) -> dict[str, Any]:
|
||||
"""
|
||||
Sync usage for a specific project immediately.
|
||||
|
||||
Useful for on-demand sync before subscription changes.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Subscription.hanzo_subscription_id).where(
|
||||
Subscription.project_id == project_id
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
|
||||
if not row or not row[0]:
|
||||
return {
|
||||
"project_id": str(project_id),
|
||||
"synced": False,
|
||||
"error": "No Commerce subscription found",
|
||||
}
|
||||
|
||||
return await self._sync_project(project_id, row[0])
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Run the sync loop."""
|
||||
self._running = True
|
||||
logger.info("Usage sync worker started", interval=self.interval)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self.sync_all_projects()
|
||||
except Exception as e:
|
||||
logger.error("Usage sync failed", error=str(e))
|
||||
|
||||
# Wait for next interval
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the sync worker as a background task."""
|
||||
if self._task is not None:
|
||||
logger.warning("Sync worker already running")
|
||||
return
|
||||
|
||||
self._task = asyncio.create_task(self.run())
|
||||
logger.info("Usage sync worker task started")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the sync worker."""
|
||||
self._running = False
|
||||
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
logger.info("Usage sync worker stopped")
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""Get sync worker status."""
|
||||
last_sync = await redis_client.get(LAST_SYNC_KEY)
|
||||
lock_holder = await redis_client.get(SYNC_LOCK_KEY)
|
||||
|
||||
return {
|
||||
"running": self._running,
|
||||
"interval_seconds": self.interval,
|
||||
"last_sync": last_sync,
|
||||
"lock_held": lock_holder is not None,
|
||||
}
|
||||
|
||||
|
||||
# Global singleton
|
||||
usage_sync_worker = UsageSyncWorker()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Pricing tiers and limits for compute unit billing."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PricingTier(str, Enum):
|
||||
"""Available pricing tiers."""
|
||||
|
||||
FREE = "free"
|
||||
PAY_AS_YOU_GO = "payg"
|
||||
GROWTH = "growth"
|
||||
ENTERPRISE = "enterprise"
|
||||
|
||||
|
||||
class TierLimits(BaseModel):
|
||||
"""Limits and pricing for a tier."""
|
||||
|
||||
monthly_cu: int # 0 = unlimited
|
||||
rate_limit_per_second: int
|
||||
max_apps: int # 0 = unlimited
|
||||
max_webhooks: int
|
||||
price_per_million_cu: int # cents, 0 = free or custom pricing
|
||||
overage_price_per_million_cu: int # cents for overage (PAYG tiers)
|
||||
support_level: str
|
||||
features: list[str]
|
||||
|
||||
|
||||
# Tier definitions matching Alchemy-style pricing
|
||||
TIER_LIMITS: dict[PricingTier, TierLimits] = {
|
||||
PricingTier.FREE: TierLimits(
|
||||
monthly_cu=30_000_000, # 30M CU/month
|
||||
rate_limit_per_second=25,
|
||||
max_apps=5,
|
||||
max_webhooks=5,
|
||||
price_per_million_cu=0,
|
||||
overage_price_per_million_cu=0, # Hard limit, no overage
|
||||
support_level="community",
|
||||
features=[
|
||||
"30M compute units/month",
|
||||
"25 requests/second",
|
||||
"5 apps",
|
||||
"5 webhooks",
|
||||
"Standard APIs",
|
||||
"Community support",
|
||||
],
|
||||
),
|
||||
PricingTier.PAY_AS_YOU_GO: TierLimits(
|
||||
monthly_cu=0, # Unlimited with PAYG
|
||||
rate_limit_per_second=300,
|
||||
max_apps=30,
|
||||
max_webhooks=100,
|
||||
price_per_million_cu=40, # $0.40 per million CU
|
||||
overage_price_per_million_cu=40,
|
||||
support_level="email",
|
||||
features=[
|
||||
"Pay as you go",
|
||||
"300 requests/second",
|
||||
"30 apps",
|
||||
"100 webhooks",
|
||||
"Enhanced APIs",
|
||||
"Email support",
|
||||
"Usage analytics",
|
||||
],
|
||||
),
|
||||
PricingTier.GROWTH: TierLimits(
|
||||
monthly_cu=100_000_000, # 100M CU included
|
||||
rate_limit_per_second=500,
|
||||
max_apps=50,
|
||||
max_webhooks=250,
|
||||
price_per_million_cu=35, # $0.35 per million (discounted)
|
||||
overage_price_per_million_cu=35,
|
||||
support_level="priority",
|
||||
features=[
|
||||
"100M compute units included",
|
||||
"500 requests/second",
|
||||
"50 apps",
|
||||
"250 webhooks",
|
||||
"All Enhanced APIs",
|
||||
"Priority support",
|
||||
"Advanced analytics",
|
||||
"Custom webhooks",
|
||||
],
|
||||
),
|
||||
PricingTier.ENTERPRISE: TierLimits(
|
||||
monthly_cu=0, # Custom
|
||||
rate_limit_per_second=1000,
|
||||
max_apps=0, # Unlimited
|
||||
max_webhooks=500,
|
||||
price_per_million_cu=0, # Custom pricing
|
||||
overage_price_per_million_cu=0,
|
||||
support_level="dedicated",
|
||||
features=[
|
||||
"Custom compute units",
|
||||
"Custom rate limits",
|
||||
"Unlimited apps",
|
||||
"500+ webhooks",
|
||||
"All APIs + custom",
|
||||
"Dedicated support",
|
||||
"SLA guarantee",
|
||||
"Private endpoints",
|
||||
"Custom integrations",
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_tier_limits(tier: PricingTier) -> TierLimits:
|
||||
"""Get limits for a pricing tier.
|
||||
|
||||
Args:
|
||||
tier: The pricing tier
|
||||
|
||||
Returns:
|
||||
TierLimits for the specified tier
|
||||
"""
|
||||
return TIER_LIMITS[tier]
|
||||
|
||||
|
||||
def calculate_monthly_cost(tier: PricingTier, compute_units_used: int) -> int:
|
||||
"""Calculate the monthly cost in cents.
|
||||
|
||||
Args:
|
||||
tier: The pricing tier
|
||||
compute_units_used: Total CU used in the billing period
|
||||
|
||||
Returns:
|
||||
Cost in cents
|
||||
"""
|
||||
limits = TIER_LIMITS[tier]
|
||||
|
||||
if tier == PricingTier.FREE:
|
||||
return 0
|
||||
|
||||
if tier == PricingTier.ENTERPRISE:
|
||||
# Custom pricing, handled separately
|
||||
return 0
|
||||
|
||||
# Calculate billable CU (minus included CU for Growth tier)
|
||||
included_cu = limits.monthly_cu
|
||||
billable_cu = max(0, compute_units_used - included_cu)
|
||||
|
||||
# Convert to millions and calculate cost
|
||||
millions = billable_cu / 1_000_000
|
||||
cost_cents = int(millions * limits.price_per_million_cu)
|
||||
|
||||
return cost_cents
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Real-time usage tracking with Redis.
|
||||
|
||||
Tracks compute units in Redis for real-time rate limiting and quota checks.
|
||||
Batches writes to ClickHouse for analytics and billing.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from bootnode.core.billing.compute_units import get_compute_units
|
||||
from bootnode.core.billing.tiers import PricingTier, get_tier_limits
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.core.datastore import datastore_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Redis key prefixes
|
||||
CU_USAGE_KEY = "cu:usage:{project_id}:{period}" # Monthly CU counter
|
||||
RATE_LIMIT_KEY = "rate:{project_id}:{window}" # Per-second rate limit
|
||||
CU_BUFFER_KEY = "cu:buffer:{project_id}" # Batch buffer for ClickHouse
|
||||
|
||||
|
||||
class UsageTracker:
|
||||
"""Track compute units in Redis, batch to ClickHouse."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._batch_size = 100 # Flush to ClickHouse after N records
|
||||
self._batch_interval = 60 # Flush to ClickHouse after N seconds
|
||||
|
||||
def _get_period_key(self, project_id: uuid.UUID) -> str:
|
||||
"""Get the current billing period key (YYYY-MM)."""
|
||||
now = datetime.now(UTC)
|
||||
period = now.strftime("%Y-%m")
|
||||
return CU_USAGE_KEY.format(project_id=project_id, period=period)
|
||||
|
||||
def _get_rate_key(self, project_id: uuid.UUID) -> str:
|
||||
"""Get the current rate limit window key."""
|
||||
window = int(time.time()) # 1-second windows
|
||||
return RATE_LIMIT_KEY.format(project_id=project_id, window=window)
|
||||
|
||||
async def track(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
method: str,
|
||||
compute_units: int | None = None,
|
||||
api_key_id: uuid.UUID | None = None,
|
||||
chain_id: int = 1,
|
||||
network: str = "mainnet",
|
||||
response_time_ms: int = 0,
|
||||
status_code: int = 200,
|
||||
ip_address: str = "",
|
||||
user_agent: str = "",
|
||||
) -> None:
|
||||
"""Track API usage.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
method: The RPC method called
|
||||
compute_units: Override CU cost (auto-calculated if None)
|
||||
api_key_id: The API key used
|
||||
chain_id: The chain ID
|
||||
network: The network name
|
||||
response_time_ms: Response time in milliseconds
|
||||
status_code: HTTP status code
|
||||
ip_address: Client IP address
|
||||
user_agent: Client user agent
|
||||
"""
|
||||
if compute_units is None:
|
||||
compute_units = get_compute_units(method)
|
||||
|
||||
# Increment monthly CU counter in Redis
|
||||
period_key = self._get_period_key(project_id)
|
||||
try:
|
||||
pipe = redis_client.client.pipeline()
|
||||
pipe.incrby(period_key, compute_units)
|
||||
# Set expiry to 35 days (covers billing period + grace)
|
||||
pipe.expire(period_key, 35 * 24 * 60 * 60)
|
||||
await pipe.execute()
|
||||
except Exception as e:
|
||||
logger.error("Failed to track usage in Redis", error=str(e))
|
||||
|
||||
# Buffer for ClickHouse batch insert
|
||||
usage_record = {
|
||||
"project_id": str(project_id),
|
||||
"api_key_id": str(api_key_id) if api_key_id else None,
|
||||
"chain_id": chain_id,
|
||||
"network": network,
|
||||
"endpoint": f"/rpc/{network}",
|
||||
"method": method,
|
||||
"compute_units": compute_units,
|
||||
"response_time_ms": response_time_ms,
|
||||
"status_code": status_code,
|
||||
"ip_address": ip_address,
|
||||
"user_agent": user_agent,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
# Add to buffer and check if we should flush
|
||||
buffer_key = CU_BUFFER_KEY.format(project_id=project_id)
|
||||
try:
|
||||
await redis_client.client.rpush(buffer_key, str(usage_record))
|
||||
buffer_len = await redis_client.client.llen(buffer_key)
|
||||
|
||||
if buffer_len >= self._batch_size:
|
||||
await self._flush_buffer(project_id)
|
||||
except Exception as e:
|
||||
logger.error("Failed to buffer usage", error=str(e))
|
||||
|
||||
async def _flush_buffer(self, project_id: uuid.UUID) -> None:
|
||||
"""Flush buffered usage records to ClickHouse."""
|
||||
if not datastore_client.is_connected:
|
||||
return
|
||||
|
||||
buffer_key = CU_BUFFER_KEY.format(project_id=project_id)
|
||||
|
||||
try:
|
||||
# Get and clear buffer atomically
|
||||
pipe = redis_client.client.pipeline()
|
||||
pipe.lrange(buffer_key, 0, -1)
|
||||
pipe.delete(buffer_key)
|
||||
results = await pipe.execute()
|
||||
|
||||
records_raw = results[0]
|
||||
if not records_raw:
|
||||
return
|
||||
|
||||
# Parse records and insert to ClickHouse
|
||||
records: list[dict[str, Any]] = []
|
||||
for record_str in records_raw:
|
||||
try:
|
||||
# Convert string repr back to dict
|
||||
record = eval(record_str) # Safe: we control the data
|
||||
records.append(record)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if records:
|
||||
await datastore_client.insert("api_usage", records)
|
||||
logger.debug(
|
||||
"Flushed usage to ClickHouse",
|
||||
project_id=str(project_id),
|
||||
count=len(records),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to flush buffer to ClickHouse", error=str(e))
|
||||
|
||||
async def get_current_usage(self, project_id: uuid.UUID) -> int:
|
||||
"""Get current compute units used this billing period.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
|
||||
Returns:
|
||||
Total CU used this billing period
|
||||
"""
|
||||
period_key = self._get_period_key(project_id)
|
||||
try:
|
||||
value = await redis_client.get(period_key)
|
||||
return int(value) if value else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def check_quota(self, project_id: uuid.UUID, tier: PricingTier) -> bool:
|
||||
"""Check if project is within CU quota.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
tier: The project's pricing tier
|
||||
|
||||
Returns:
|
||||
True if within quota, False if exceeded
|
||||
"""
|
||||
limits = get_tier_limits(tier)
|
||||
|
||||
# Unlimited tiers always pass
|
||||
if limits.monthly_cu == 0:
|
||||
return True
|
||||
|
||||
current_usage = await self.get_current_usage(project_id)
|
||||
return current_usage < limits.monthly_cu
|
||||
|
||||
async def check_rate_limit(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
tier: PricingTier,
|
||||
) -> tuple[bool, int]:
|
||||
"""Check if project is within rate limit.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
tier: The project's pricing tier
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, remaining requests)
|
||||
"""
|
||||
limits = get_tier_limits(tier)
|
||||
rate_key = self._get_rate_key(project_id)
|
||||
|
||||
try:
|
||||
allowed, remaining = await redis_client.rate_limit_check(
|
||||
rate_key,
|
||||
limits.rate_limit_per_second,
|
||||
window_seconds=1,
|
||||
)
|
||||
return allowed, remaining
|
||||
except Exception as e:
|
||||
logger.error("Rate limit check failed", error=str(e))
|
||||
# Fail open to avoid blocking legitimate traffic
|
||||
return True, limits.rate_limit_per_second
|
||||
|
||||
async def get_usage_stats(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
tier: PricingTier,
|
||||
) -> dict[str, Any]:
|
||||
"""Get current usage statistics.
|
||||
|
||||
Args:
|
||||
project_id: The project ID
|
||||
tier: The project's pricing tier
|
||||
|
||||
Returns:
|
||||
Dict with usage statistics
|
||||
"""
|
||||
limits = get_tier_limits(tier)
|
||||
current_usage = await self.get_current_usage(project_id)
|
||||
|
||||
# Calculate percentage (0 for unlimited tiers)
|
||||
percentage = (current_usage / limits.monthly_cu * 100) if limits.monthly_cu > 0 else 0
|
||||
|
||||
return {
|
||||
"current_cu": current_usage,
|
||||
"limit_cu": limits.monthly_cu,
|
||||
"remaining_cu": max(0, limits.monthly_cu - current_usage) if limits.monthly_cu > 0 else None,
|
||||
"percentage_used": round(percentage, 2),
|
||||
"rate_limit_per_second": limits.rate_limit_per_second,
|
||||
"tier": tier.value,
|
||||
}
|
||||
|
||||
async def flush_all_buffers(self) -> None:
|
||||
"""Flush all project buffers to ClickHouse. Called on shutdown."""
|
||||
try:
|
||||
# Find all buffer keys
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await redis_client.client.scan(
|
||||
cursor, match="cu:buffer:*", count=100
|
||||
)
|
||||
for key in keys:
|
||||
# Extract project_id from key
|
||||
project_id_str = key.split(":")[-1]
|
||||
try:
|
||||
project_id = uuid.UUID(project_id_str)
|
||||
await self._flush_buffer(project_id)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if cursor == 0:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Failed to flush all buffers", error=str(e))
|
||||
|
||||
|
||||
# Global singleton
|
||||
usage_tracker = UsageTracker()
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Hanzo Commerce webhook handler.
|
||||
|
||||
Handles webhooks from Hanzo Commerce for:
|
||||
- Subscription lifecycle events
|
||||
- Invoice events
|
||||
- Payment events
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.core.billing.models import PlanTier, SubscriptionStatus
|
||||
from bootnode.core.billing.tiers import PricingTier, get_tier_limits
|
||||
from bootnode.core.cache import redis_client
|
||||
from bootnode.db.models import Project, Subscription
|
||||
from bootnode.db.session import async_session
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Map Commerce plan slugs to internal tiers
|
||||
PLAN_TO_TIER = {
|
||||
"bootnode-free": PricingTier.FREE,
|
||||
"bootnode-payg": PricingTier.PAY_AS_YOU_GO,
|
||||
"bootnode-growth": PricingTier.GROWTH,
|
||||
"bootnode-enterprise": PricingTier.ENTERPRISE,
|
||||
}
|
||||
|
||||
|
||||
class WebhookVerificationError(Exception):
|
||||
"""Webhook signature verification failed."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CommerceWebhookHandler:
|
||||
"""Handle webhooks from Hanzo Commerce."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self.webhook_secret = settings.hanzo_commerce_webhook_secret
|
||||
|
||||
def verify_signature(self, payload: bytes, signature: str) -> bool:
|
||||
"""
|
||||
Verify HMAC-SHA256 signature from Commerce.
|
||||
|
||||
Args:
|
||||
payload: Raw request body bytes
|
||||
signature: X-Commerce-Signature header value
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
if not self.webhook_secret:
|
||||
logger.warning("Webhook secret not configured, skipping verification")
|
||||
return True
|
||||
|
||||
expected = hmac.new(
|
||||
self.webhook_secret.encode(),
|
||||
payload,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
async def handle_event(self, event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Route webhook event to appropriate handler.
|
||||
|
||||
Args:
|
||||
event: Parsed webhook event
|
||||
|
||||
Returns:
|
||||
Handler result
|
||||
"""
|
||||
event_type = event.get("type", "")
|
||||
event_id = event.get("id", "unknown")
|
||||
data = event.get("data", {})
|
||||
|
||||
logger.info(
|
||||
"Processing Commerce webhook",
|
||||
event_type=event_type,
|
||||
event_id=event_id,
|
||||
)
|
||||
|
||||
handlers = {
|
||||
"subscription.created": self.handle_subscription_created,
|
||||
"subscription.updated": self.handle_subscription_updated,
|
||||
"subscription.cancelled": self.handle_subscription_cancelled,
|
||||
"subscription.reactivated": self.handle_subscription_reactivated,
|
||||
"invoice.paid": self.handle_invoice_paid,
|
||||
"invoice.payment_failed": self.handle_invoice_failed,
|
||||
"customer.created": self.handle_customer_created,
|
||||
"customer.updated": self.handle_customer_updated,
|
||||
}
|
||||
|
||||
handler = handlers.get(event_type)
|
||||
if handler:
|
||||
try:
|
||||
result = await handler(data)
|
||||
return {"handled": True, "event_type": event_type, "result": result}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Webhook handler failed",
|
||||
event_type=event_type,
|
||||
error=str(e),
|
||||
)
|
||||
return {"handled": False, "event_type": event_type, "error": str(e)}
|
||||
else:
|
||||
logger.debug("Unhandled webhook event type", event_type=event_type)
|
||||
return {"handled": False, "event_type": event_type, "reason": "unknown_type"}
|
||||
|
||||
# =========================================================================
|
||||
# Subscription Handlers
|
||||
# =========================================================================
|
||||
|
||||
async def handle_subscription_created(self, data: dict[str, Any]) -> dict:
|
||||
"""
|
||||
Handle subscription.created event - provision resources.
|
||||
|
||||
Updates local subscription record with Commerce subscription ID.
|
||||
"""
|
||||
subscription_id = data.get("id")
|
||||
customer_id = data.get("customer_id")
|
||||
plan_slug = data.get("plan_slug", "bootnode-free")
|
||||
metadata = data.get("metadata", {})
|
||||
project_id_str = metadata.get("project_id")
|
||||
|
||||
if not project_id_str:
|
||||
logger.warning("No project_id in subscription metadata", sub_id=subscription_id)
|
||||
return {"error": "missing_project_id"}
|
||||
|
||||
try:
|
||||
project_id = UUID(project_id_str)
|
||||
except ValueError:
|
||||
logger.error("Invalid project_id format", project_id=project_id_str)
|
||||
return {"error": "invalid_project_id"}
|
||||
|
||||
# Map plan to tier
|
||||
tier = PLAN_TO_TIER.get(plan_slug, PricingTier.FREE)
|
||||
limits = get_tier_limits(tier)
|
||||
|
||||
async with async_session() as db:
|
||||
# Get or create subscription
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.project_id == project_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if subscription:
|
||||
# Update existing subscription
|
||||
subscription.hanzo_subscription_id = subscription_id
|
||||
subscription.hanzo_customer_id = customer_id
|
||||
subscription.tier = tier.value
|
||||
subscription.monthly_cu_limit = limits.monthly_cu
|
||||
subscription.rate_limit_per_second = limits.rate_limit_per_second
|
||||
subscription.max_apps = limits.max_apps
|
||||
subscription.max_webhooks = limits.max_webhooks
|
||||
else:
|
||||
# Create new subscription
|
||||
subscription = Subscription(
|
||||
project_id=project_id,
|
||||
tier=tier.value,
|
||||
hanzo_subscription_id=subscription_id,
|
||||
hanzo_customer_id=customer_id,
|
||||
monthly_cu_limit=limits.monthly_cu,
|
||||
rate_limit_per_second=limits.rate_limit_per_second,
|
||||
max_apps=limits.max_apps,
|
||||
max_webhooks=limits.max_webhooks,
|
||||
billing_cycle_start=datetime.now(UTC),
|
||||
billing_cycle_end=datetime.fromisoformat(
|
||||
data.get("current_period_end", datetime.now(UTC).isoformat())
|
||||
),
|
||||
)
|
||||
db.add(subscription)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Invalidate cached subscription data
|
||||
await self._invalidate_cache(project_id)
|
||||
|
||||
logger.info(
|
||||
"Subscription created/updated from webhook",
|
||||
project_id=str(project_id),
|
||||
subscription_id=subscription_id,
|
||||
tier=tier.value,
|
||||
)
|
||||
|
||||
return {"project_id": str(project_id), "tier": tier.value}
|
||||
|
||||
async def handle_subscription_updated(self, data: dict[str, Any]) -> dict:
|
||||
"""
|
||||
Handle subscription.updated event - update limits.
|
||||
|
||||
Called when plan changes or subscription is modified.
|
||||
"""
|
||||
subscription_id = data.get("id")
|
||||
plan_slug = data.get("plan_slug", "bootnode-free")
|
||||
status = data.get("status", "active")
|
||||
|
||||
# Map plan to tier
|
||||
tier = PLAN_TO_TIER.get(plan_slug, PricingTier.FREE)
|
||||
limits = get_tier_limits(tier)
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.hanzo_subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
logger.warning(
|
||||
"Subscription not found for update",
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
return {"error": "subscription_not_found"}
|
||||
|
||||
# Update tier and limits
|
||||
subscription.tier = tier.value
|
||||
subscription.monthly_cu_limit = limits.monthly_cu
|
||||
subscription.rate_limit_per_second = limits.rate_limit_per_second
|
||||
subscription.max_apps = limits.max_apps
|
||||
subscription.max_webhooks = limits.max_webhooks
|
||||
|
||||
# Update billing period if provided
|
||||
if data.get("current_period_end"):
|
||||
subscription.billing_cycle_end = datetime.fromisoformat(
|
||||
data["current_period_end"]
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Invalidate cache
|
||||
await self._invalidate_cache(subscription.project_id)
|
||||
|
||||
logger.info(
|
||||
"Subscription updated from webhook",
|
||||
subscription_id=subscription_id,
|
||||
tier=tier.value,
|
||||
)
|
||||
|
||||
return {"subscription_id": subscription_id, "tier": tier.value}
|
||||
|
||||
async def handle_subscription_cancelled(self, data: dict[str, Any]) -> dict:
|
||||
"""
|
||||
Handle subscription.cancelled event - downgrade to free.
|
||||
|
||||
Downgrades the project to free tier when subscription is cancelled.
|
||||
"""
|
||||
subscription_id = data.get("id")
|
||||
immediately = data.get("immediately", False)
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.hanzo_subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
logger.warning(
|
||||
"Subscription not found for cancellation",
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
return {"error": "subscription_not_found"}
|
||||
|
||||
if immediately:
|
||||
# Immediate downgrade to free
|
||||
free_limits = get_tier_limits(PricingTier.FREE)
|
||||
subscription.tier = PricingTier.FREE.value
|
||||
subscription.monthly_cu_limit = free_limits.monthly_cu
|
||||
subscription.rate_limit_per_second = free_limits.rate_limit_per_second
|
||||
subscription.max_apps = free_limits.max_apps
|
||||
subscription.max_webhooks = free_limits.max_webhooks
|
||||
subscription.hanzo_subscription_id = None
|
||||
else:
|
||||
# Schedule downgrade at end of period
|
||||
subscription.scheduled_tier = PricingTier.FREE.value
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Invalidate cache
|
||||
await self._invalidate_cache(subscription.project_id)
|
||||
|
||||
logger.info(
|
||||
"Subscription cancelled from webhook",
|
||||
subscription_id=subscription_id,
|
||||
immediately=immediately,
|
||||
)
|
||||
|
||||
return {"subscription_id": subscription_id, "immediately": immediately}
|
||||
|
||||
async def handle_subscription_reactivated(self, data: dict[str, Any]) -> dict:
|
||||
"""Handle subscription.reactivated event."""
|
||||
subscription_id = data.get("id")
|
||||
plan_slug = data.get("plan_slug", "bootnode-free")
|
||||
|
||||
tier = PLAN_TO_TIER.get(plan_slug, PricingTier.FREE)
|
||||
limits = get_tier_limits(tier)
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.hanzo_subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
return {"error": "subscription_not_found"}
|
||||
|
||||
# Restore tier
|
||||
subscription.tier = tier.value
|
||||
subscription.monthly_cu_limit = limits.monthly_cu
|
||||
subscription.rate_limit_per_second = limits.rate_limit_per_second
|
||||
subscription.max_apps = limits.max_apps
|
||||
subscription.max_webhooks = limits.max_webhooks
|
||||
subscription.scheduled_tier = None
|
||||
|
||||
await db.commit()
|
||||
await self._invalidate_cache(subscription.project_id)
|
||||
|
||||
logger.info(
|
||||
"Subscription reactivated from webhook",
|
||||
subscription_id=subscription_id,
|
||||
tier=tier.value,
|
||||
)
|
||||
|
||||
return {"subscription_id": subscription_id, "tier": tier.value}
|
||||
|
||||
# =========================================================================
|
||||
# Invoice Handlers
|
||||
# =========================================================================
|
||||
|
||||
async def handle_invoice_paid(self, data: dict[str, Any]) -> dict:
|
||||
"""
|
||||
Handle invoice.paid event - record payment.
|
||||
|
||||
Can be used for internal tracking or notifications.
|
||||
"""
|
||||
invoice_id = data.get("id")
|
||||
customer_id = data.get("customer_id")
|
||||
amount_paid = data.get("amount_paid", 0)
|
||||
|
||||
logger.info(
|
||||
"Invoice paid",
|
||||
invoice_id=invoice_id,
|
||||
customer_id=customer_id,
|
||||
amount_cents=amount_paid,
|
||||
)
|
||||
|
||||
# Could send notification, update internal records, etc.
|
||||
return {
|
||||
"invoice_id": invoice_id,
|
||||
"amount_paid": amount_paid,
|
||||
}
|
||||
|
||||
async def handle_invoice_failed(self, data: dict[str, Any]) -> dict:
|
||||
"""
|
||||
Handle invoice.payment_failed - notify user, maybe downgrade.
|
||||
|
||||
After multiple failures, Commerce will cancel the subscription,
|
||||
which triggers handle_subscription_cancelled.
|
||||
"""
|
||||
invoice_id = data.get("id")
|
||||
customer_id = data.get("customer_id")
|
||||
subscription_id = data.get("subscription_id")
|
||||
attempt_count = data.get("attempt_count", 1)
|
||||
|
||||
logger.warning(
|
||||
"Invoice payment failed",
|
||||
invoice_id=invoice_id,
|
||||
customer_id=customer_id,
|
||||
attempt_count=attempt_count,
|
||||
)
|
||||
|
||||
# Could send notification email, create alert, etc.
|
||||
# The actual downgrade happens when Commerce cancels the subscription
|
||||
|
||||
return {
|
||||
"invoice_id": invoice_id,
|
||||
"subscription_id": subscription_id,
|
||||
"attempt_count": attempt_count,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Customer Handlers
|
||||
# =========================================================================
|
||||
|
||||
async def handle_customer_created(self, data: dict[str, Any]) -> dict:
|
||||
"""Handle customer.created event."""
|
||||
customer_id = data.get("id")
|
||||
email = data.get("email")
|
||||
|
||||
logger.info(
|
||||
"Customer created in Commerce",
|
||||
customer_id=customer_id,
|
||||
email=email,
|
||||
)
|
||||
|
||||
return {"customer_id": customer_id}
|
||||
|
||||
async def handle_customer_updated(self, data: dict[str, Any]) -> dict:
|
||||
"""Handle customer.updated event."""
|
||||
customer_id = data.get("id")
|
||||
|
||||
logger.info(
|
||||
"Customer updated in Commerce",
|
||||
customer_id=customer_id,
|
||||
)
|
||||
|
||||
return {"customer_id": customer_id}
|
||||
|
||||
# =========================================================================
|
||||
# Helpers
|
||||
# =========================================================================
|
||||
|
||||
async def _invalidate_cache(self, project_id: UUID) -> None:
|
||||
"""Invalidate cached subscription data."""
|
||||
cache_key = f"billing:subscription:{project_id}"
|
||||
await redis_client.delete(cache_key)
|
||||
|
||||
|
||||
# Global singleton
|
||||
webhook_handler = CommerceWebhookHandler()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Pluggable deployment abstraction layer.
|
||||
|
||||
This module provides a unified interface for deploying Bootnode services
|
||||
across different infrastructure backends:
|
||||
|
||||
- Docker Compose (default): For containerized deployments
|
||||
- Local Processes: For development without Docker
|
||||
- Kubernetes: For production k8s clusters
|
||||
|
||||
Usage:
|
||||
from bootnode.core.deploy import get_deployer, ServiceType
|
||||
|
||||
deployer = get_deployer()
|
||||
await deployer.deploy(ServiceType.API, "bootnode/api:latest", replicas=2)
|
||||
status = await deployer.status(ServiceType.API)
|
||||
async for line in deployer.logs(ServiceType.API, tail=50):
|
||||
print(line)
|
||||
"""
|
||||
|
||||
from bootnode.core.deploy.base import DeployTarget, ServiceStatus, ServiceType
|
||||
from bootnode.core.deploy.docker import DockerDeployer
|
||||
from bootnode.core.deploy.factory import clear_deployer_cache, get_deployer
|
||||
from bootnode.core.deploy.kubernetes import KubernetesDeployer
|
||||
from bootnode.core.deploy.process import ProcessDeployer
|
||||
|
||||
__all__ = [
|
||||
# Base classes and types
|
||||
"DeployTarget",
|
||||
"ServiceStatus",
|
||||
"ServiceType",
|
||||
# Deployers
|
||||
"DockerDeployer",
|
||||
"KubernetesDeployer",
|
||||
"ProcessDeployer",
|
||||
# Factory
|
||||
"get_deployer",
|
||||
"clear_deployer_cache",
|
||||
]
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Abstract base class for deployment targets."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import AsyncIterator
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ServiceType(str, Enum):
|
||||
"""Bootnode service types."""
|
||||
|
||||
API = "api"
|
||||
WEB = "web"
|
||||
INDEXER = "indexer"
|
||||
WEBHOOK_WORKER = "webhook-worker"
|
||||
BUNDLER = "bundler"
|
||||
|
||||
|
||||
class ServiceStatus(BaseModel):
|
||||
"""Status of a deployed service."""
|
||||
|
||||
name: str
|
||||
running: bool
|
||||
replicas: int
|
||||
ready_replicas: int
|
||||
cpu_usage: float | None = None
|
||||
memory_usage: float | None = None
|
||||
image: str | None = None
|
||||
started_at: str | None = None
|
||||
|
||||
|
||||
class DeployTarget(ABC):
|
||||
"""Abstract base class for deployment targets.
|
||||
|
||||
Implementations handle deploying, scaling, and managing Bootnode services
|
||||
across different infrastructure backends (Docker, Kubernetes, local processes).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def deploy(
|
||||
self,
|
||||
service: ServiceType,
|
||||
image: str,
|
||||
replicas: int = 1,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Deploy or update a service.
|
||||
|
||||
Args:
|
||||
service: The service type to deploy.
|
||||
image: Container image or command to run.
|
||||
replicas: Number of replicas to run.
|
||||
env: Environment variables to set.
|
||||
|
||||
Returns:
|
||||
True if deployment succeeded, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def scale(self, service: ServiceType, replicas: int) -> bool:
|
||||
"""Scale a service to the specified number of replicas.
|
||||
|
||||
Args:
|
||||
service: The service type to scale.
|
||||
replicas: Target number of replicas.
|
||||
|
||||
Returns:
|
||||
True if scaling succeeded, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def status(self, service: ServiceType) -> ServiceStatus:
|
||||
"""Get the current status of a service.
|
||||
|
||||
Args:
|
||||
service: The service type to check.
|
||||
|
||||
Returns:
|
||||
ServiceStatus with current state information.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def logs(
|
||||
self,
|
||||
service: ServiceType,
|
||||
tail: int = 100,
|
||||
follow: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream logs from a service.
|
||||
|
||||
Args:
|
||||
service: The service type to get logs from.
|
||||
tail: Number of recent lines to return.
|
||||
follow: If True, continue streaming new logs.
|
||||
|
||||
Yields:
|
||||
Log lines as strings.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def destroy(self, service: ServiceType) -> bool:
|
||||
"""Stop and remove a service.
|
||||
|
||||
Args:
|
||||
service: The service type to destroy.
|
||||
|
||||
Returns:
|
||||
True if destruction succeeded, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
async def health(self, service: ServiceType) -> bool:
|
||||
"""Check if a service is healthy.
|
||||
|
||||
Default implementation checks if service is running with ready replicas.
|
||||
|
||||
Args:
|
||||
service: The service type to check.
|
||||
|
||||
Returns:
|
||||
True if service is healthy, False otherwise.
|
||||
"""
|
||||
try:
|
||||
st = await self.status(service)
|
||||
return st.running and st.ready_replicas > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def restart(self, service: ServiceType) -> bool:
|
||||
"""Restart a service.
|
||||
|
||||
Default implementation destroys and redeploys.
|
||||
|
||||
Args:
|
||||
service: The service type to restart.
|
||||
|
||||
Returns:
|
||||
True if restart succeeded, False otherwise.
|
||||
"""
|
||||
st = await self.status(service)
|
||||
if not st.image:
|
||||
return False
|
||||
await self.destroy(service)
|
||||
return await self.deploy(service, st.image, st.replicas)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Docker Compose deployment target."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
import structlog
|
||||
|
||||
from bootnode.core.deploy.base import DeployTarget, ServiceStatus, ServiceType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Service name mapping for docker compose
|
||||
SERVICE_NAMES: dict[ServiceType, str] = {
|
||||
ServiceType.API: "api",
|
||||
ServiceType.WEB: "web",
|
||||
ServiceType.INDEXER: "indexer",
|
||||
ServiceType.WEBHOOK_WORKER: "webhook-worker",
|
||||
ServiceType.BUNDLER: "bundler",
|
||||
}
|
||||
|
||||
|
||||
class DockerDeployer(DeployTarget):
|
||||
"""Deploy services using Docker Compose.
|
||||
|
||||
Uses docker compose CLI for orchestration and docker SDK for inspection.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
compose_file: Path | str = Path("infra/compose.yml"),
|
||||
project_name: str = "bootnode",
|
||||
) -> None:
|
||||
self.compose_file = Path(compose_file)
|
||||
self.project_name = project_name
|
||||
self._docker_client = None
|
||||
|
||||
@property
|
||||
def docker_client(self):
|
||||
"""Lazy-load docker client."""
|
||||
if self._docker_client is None:
|
||||
try:
|
||||
import docker
|
||||
|
||||
self._docker_client = docker.from_env()
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to Docker", error=str(e))
|
||||
raise RuntimeError("Docker not available") from e
|
||||
return self._docker_client
|
||||
|
||||
def _compose_cmd(self, *args: str) -> list[str]:
|
||||
"""Build docker compose command."""
|
||||
return [
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
str(self.compose_file),
|
||||
"-p",
|
||||
self.project_name,
|
||||
*args,
|
||||
]
|
||||
|
||||
async def _run_compose(self, *args: str) -> tuple[int, str, str]:
|
||||
"""Run docker compose command asynchronously."""
|
||||
cmd = self._compose_cmd(*args)
|
||||
logger.debug("Running compose command", cmd=" ".join(cmd))
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
return proc.returncode or 0, stdout.decode(), stderr.decode()
|
||||
|
||||
def _get_service_name(self, service: ServiceType) -> str:
|
||||
"""Get docker compose service name."""
|
||||
return SERVICE_NAMES.get(service, service.value)
|
||||
|
||||
def _get_container_name(self, service: ServiceType) -> str:
|
||||
"""Get full container name."""
|
||||
return f"{self.project_name}-{self._get_service_name(service)}-1"
|
||||
|
||||
async def deploy(
|
||||
self,
|
||||
service: ServiceType,
|
||||
image: str,
|
||||
replicas: int = 1,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Deploy a service using docker compose up."""
|
||||
service_name = self._get_service_name(service)
|
||||
|
||||
# Build environment args
|
||||
env_args = []
|
||||
if env:
|
||||
for key, value in env.items():
|
||||
env_args.extend(["-e", f"{key}={value}"])
|
||||
|
||||
# Pull latest image if specified
|
||||
if image and ":" in image:
|
||||
pull_cmd = ["docker", "pull", image]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*pull_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
|
||||
# Run docker compose up with scale
|
||||
args = ["up", "-d", "--scale", f"{service_name}={replicas}"]
|
||||
if image:
|
||||
args.extend(["--pull", "always"])
|
||||
args.append(service_name)
|
||||
|
||||
returncode, stdout, stderr = await self._run_compose(*args)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
"Deploy failed",
|
||||
service=service_name,
|
||||
returncode=returncode,
|
||||
stderr=stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"Service deployed",
|
||||
service=service_name,
|
||||
replicas=replicas,
|
||||
image=image,
|
||||
)
|
||||
return True
|
||||
|
||||
async def scale(self, service: ServiceType, replicas: int) -> bool:
|
||||
"""Scale a service to the specified number of replicas."""
|
||||
service_name = self._get_service_name(service)
|
||||
|
||||
returncode, stdout, stderr = await self._run_compose(
|
||||
"up",
|
||||
"-d",
|
||||
"--scale",
|
||||
f"{service_name}={replicas}",
|
||||
"--no-recreate",
|
||||
service_name,
|
||||
)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
"Scale failed",
|
||||
service=service_name,
|
||||
replicas=replicas,
|
||||
stderr=stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("Service scaled", service=service_name, replicas=replicas)
|
||||
return True
|
||||
|
||||
async def status(self, service: ServiceType) -> ServiceStatus:
|
||||
"""Get the current status of a service."""
|
||||
service_name = self._get_service_name(service)
|
||||
container_prefix = f"{self.project_name}-{service_name}"
|
||||
|
||||
try:
|
||||
containers = self.docker_client.containers.list(
|
||||
all=True,
|
||||
filters={"name": container_prefix},
|
||||
)
|
||||
|
||||
if not containers:
|
||||
return ServiceStatus(
|
||||
name=service_name,
|
||||
running=False,
|
||||
replicas=0,
|
||||
ready_replicas=0,
|
||||
)
|
||||
|
||||
running_count = 0
|
||||
ready_count = 0
|
||||
image = None
|
||||
started_at = None
|
||||
cpu_usage = None
|
||||
memory_usage = None
|
||||
|
||||
for container in containers:
|
||||
if container.status == "running":
|
||||
running_count += 1
|
||||
# Check health if available
|
||||
health = container.attrs.get("State", {}).get("Health", {})
|
||||
if health.get("Status") == "healthy" or not health:
|
||||
ready_count += 1
|
||||
|
||||
# Get image from first running container
|
||||
if image is None:
|
||||
image = container.image.tags[0] if container.image.tags else None
|
||||
started_at = container.attrs.get("State", {}).get("StartedAt")
|
||||
|
||||
# Get resource usage
|
||||
try:
|
||||
stats = container.stats(stream=False)
|
||||
cpu_delta = stats["cpu_stats"]["cpu_usage"]["total_usage"] - \
|
||||
stats["precpu_stats"]["cpu_usage"]["total_usage"]
|
||||
system_delta = stats["cpu_stats"]["system_cpu_usage"] - \
|
||||
stats["precpu_stats"]["system_cpu_usage"]
|
||||
if system_delta > 0:
|
||||
cpu_pct = (cpu_delta / system_delta) * 100.0
|
||||
cpu_usage = round(cpu_pct, 2) if cpu_usage is None else cpu_usage + cpu_pct
|
||||
|
||||
mem_usage = stats["memory_stats"].get("usage", 0)
|
||||
mem_limit = stats["memory_stats"].get("limit", 1)
|
||||
mem_pct = (mem_usage / mem_limit) * 100.0
|
||||
memory_usage = round(mem_pct, 2) if memory_usage is None else memory_usage + mem_pct
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ServiceStatus(
|
||||
name=service_name,
|
||||
running=running_count > 0,
|
||||
replicas=len(containers),
|
||||
ready_replicas=ready_count,
|
||||
cpu_usage=cpu_usage,
|
||||
memory_usage=memory_usage,
|
||||
image=image,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get status", service=service_name, error=str(e))
|
||||
return ServiceStatus(
|
||||
name=service_name,
|
||||
running=False,
|
||||
replicas=0,
|
||||
ready_replicas=0,
|
||||
)
|
||||
|
||||
async def logs(
|
||||
self,
|
||||
service: ServiceType,
|
||||
tail: int = 100,
|
||||
follow: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream logs from a service."""
|
||||
service_name = self._get_service_name(service)
|
||||
|
||||
args = ["logs", f"--tail={tail}"]
|
||||
if follow:
|
||||
args.append("-f")
|
||||
args.append(service_name)
|
||||
|
||||
cmd = self._compose_cmd(*args)
|
||||
|
||||
if follow:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
if proc.stdout:
|
||||
async for line in proc.stdout:
|
||||
yield line.decode().rstrip("\n")
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
for line in stdout.decode().splitlines():
|
||||
yield line
|
||||
|
||||
async def destroy(self, service: ServiceType) -> bool:
|
||||
"""Stop and remove a service."""
|
||||
service_name = self._get_service_name(service)
|
||||
|
||||
returncode, stdout, stderr = await self._run_compose(
|
||||
"rm",
|
||||
"-f",
|
||||
"-s",
|
||||
"-v",
|
||||
service_name,
|
||||
)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
"Destroy failed",
|
||||
service=service_name,
|
||||
stderr=stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("Service destroyed", service=service_name)
|
||||
return True
|
||||
|
||||
async def restart(self, service: ServiceType) -> bool:
|
||||
"""Restart a service using docker compose restart."""
|
||||
service_name = self._get_service_name(service)
|
||||
|
||||
returncode, stdout, stderr = await self._run_compose(
|
||||
"restart",
|
||||
service_name,
|
||||
)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
"Restart failed",
|
||||
service=service_name,
|
||||
stderr=stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("Service restarted", service=service_name)
|
||||
return True
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Factory for creating deployers based on configuration."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from bootnode.config import get_settings
|
||||
from bootnode.core.deploy.base import DeployTarget
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_deployer() -> DeployTarget:
|
||||
"""Get the configured deployer instance.
|
||||
|
||||
Returns a cached deployer based on the deploy_target setting:
|
||||
- "docker": DockerDeployer using docker compose
|
||||
- "process": ProcessDeployer for local development
|
||||
- "kubernetes": KubernetesDeployer for k8s clusters
|
||||
|
||||
Returns:
|
||||
DeployTarget instance configured for the current environment.
|
||||
|
||||
Raises:
|
||||
ValueError: If deploy_target is not recognized.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
if settings.deploy_target == "docker":
|
||||
from bootnode.core.deploy.docker import DockerDeployer
|
||||
|
||||
compose_file = Path(settings.deploy_compose_file)
|
||||
deployer = DockerDeployer(compose_file=compose_file)
|
||||
logger.info(
|
||||
"Using Docker deployer",
|
||||
compose_file=str(compose_file),
|
||||
)
|
||||
return deployer
|
||||
|
||||
elif settings.deploy_target == "process":
|
||||
from bootnode.core.deploy.process import ProcessDeployer
|
||||
|
||||
deployer = ProcessDeployer()
|
||||
logger.info("Using Process deployer for local development")
|
||||
return deployer
|
||||
|
||||
elif settings.deploy_target == "kubernetes":
|
||||
from bootnode.core.deploy.kubernetes import KubernetesDeployer
|
||||
|
||||
deployer = KubernetesDeployer(
|
||||
namespace=settings.deploy_k8s_namespace,
|
||||
context=settings.deploy_k8s_context or None,
|
||||
)
|
||||
logger.info(
|
||||
"Using Kubernetes deployer",
|
||||
namespace=settings.deploy_k8s_namespace,
|
||||
context=settings.deploy_k8s_context or "default",
|
||||
)
|
||||
return deployer
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown deploy target: {settings.deploy_target}")
|
||||
|
||||
|
||||
def clear_deployer_cache() -> None:
|
||||
"""Clear the cached deployer instance.
|
||||
|
||||
Useful for testing or when configuration changes.
|
||||
"""
|
||||
get_deployer.cache_clear()
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Kubernetes deployment target."""
|
||||
|
||||
import asyncio
|
||||
from typing import AsyncIterator
|
||||
|
||||
import structlog
|
||||
|
||||
from bootnode.core.deploy.base import DeployTarget, ServiceStatus, ServiceType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Deployment name mapping for Kubernetes
|
||||
DEPLOYMENT_NAMES: dict[ServiceType, str] = {
|
||||
ServiceType.API: "bootnode-api",
|
||||
ServiceType.WEB: "bootnode-web",
|
||||
ServiceType.INDEXER: "bootnode-indexer",
|
||||
ServiceType.WEBHOOK_WORKER: "bootnode-webhook-worker",
|
||||
ServiceType.BUNDLER: "bootnode-bundler",
|
||||
}
|
||||
|
||||
# Default container ports
|
||||
SERVICE_PORTS: dict[ServiceType, int] = {
|
||||
ServiceType.API: 8000,
|
||||
ServiceType.WEB: 3000,
|
||||
ServiceType.INDEXER: 8001,
|
||||
ServiceType.WEBHOOK_WORKER: 8002,
|
||||
ServiceType.BUNDLER: 8003,
|
||||
}
|
||||
|
||||
|
||||
class KubernetesDeployer(DeployTarget):
|
||||
"""Deploy services to Kubernetes.
|
||||
|
||||
Uses the official kubernetes python client for all operations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
namespace: str = "bootnode",
|
||||
context: str | None = None,
|
||||
in_cluster: bool = False,
|
||||
) -> None:
|
||||
self.namespace = namespace
|
||||
self.context = context
|
||||
self.in_cluster = in_cluster
|
||||
self._apps_v1 = None
|
||||
self._core_v1 = None
|
||||
self._initialized = False
|
||||
|
||||
def _ensure_initialized(self) -> None:
|
||||
"""Initialize Kubernetes clients lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
from kubernetes import client, config
|
||||
|
||||
if self.in_cluster:
|
||||
config.load_incluster_config()
|
||||
elif self.context:
|
||||
config.load_kube_config(context=self.context)
|
||||
else:
|
||||
try:
|
||||
config.load_incluster_config()
|
||||
except config.ConfigException:
|
||||
config.load_kube_config()
|
||||
|
||||
self._apps_v1 = client.AppsV1Api()
|
||||
self._core_v1 = client.CoreV1Api()
|
||||
self._initialized = True
|
||||
logger.info(
|
||||
"Kubernetes client initialized",
|
||||
namespace=self.namespace,
|
||||
context=self.context,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize Kubernetes client", error=str(e))
|
||||
raise RuntimeError("Kubernetes not available") from e
|
||||
|
||||
@property
|
||||
def apps_v1(self):
|
||||
"""Get AppsV1 API client."""
|
||||
self._ensure_initialized()
|
||||
return self._apps_v1
|
||||
|
||||
@property
|
||||
def core_v1(self):
|
||||
"""Get CoreV1 API client."""
|
||||
self._ensure_initialized()
|
||||
return self._core_v1
|
||||
|
||||
def _get_deployment_name(self, service: ServiceType) -> str:
|
||||
"""Get Kubernetes deployment name."""
|
||||
return DEPLOYMENT_NAMES.get(service, f"bootnode-{service.value}")
|
||||
|
||||
def _get_labels(self, service: ServiceType) -> dict[str, str]:
|
||||
"""Get standard labels for a service."""
|
||||
return {
|
||||
"app.kubernetes.io/name": service.value,
|
||||
"app.kubernetes.io/component": service.value,
|
||||
"app.kubernetes.io/part-of": "bootnode",
|
||||
"app.kubernetes.io/managed-by": "bootnode-deployer",
|
||||
}
|
||||
|
||||
async def deploy(
|
||||
self,
|
||||
service: ServiceType,
|
||||
image: str,
|
||||
replicas: int = 1,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Deploy or update a service in Kubernetes."""
|
||||
from kubernetes import client
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
deployment_name = self._get_deployment_name(service)
|
||||
labels = self._get_labels(service)
|
||||
port = SERVICE_PORTS.get(service, 8000)
|
||||
|
||||
# Build environment variables
|
||||
env_vars = []
|
||||
if env:
|
||||
for key, value in env.items():
|
||||
env_vars.append(client.V1EnvVar(name=key, value=value))
|
||||
|
||||
# Build container spec
|
||||
container = client.V1Container(
|
||||
name=service.value,
|
||||
image=image,
|
||||
ports=[client.V1ContainerPort(container_port=port)],
|
||||
env=env_vars if env_vars else None,
|
||||
resources=client.V1ResourceRequirements(
|
||||
requests={"cpu": "100m", "memory": "128Mi"},
|
||||
limits={"cpu": "1000m", "memory": "512Mi"},
|
||||
),
|
||||
liveness_probe=client.V1Probe(
|
||||
http_get=client.V1HTTPGetAction(path="/health", port=port),
|
||||
initial_delay_seconds=10,
|
||||
period_seconds=10,
|
||||
),
|
||||
readiness_probe=client.V1Probe(
|
||||
http_get=client.V1HTTPGetAction(path="/health", port=port),
|
||||
initial_delay_seconds=5,
|
||||
period_seconds=5,
|
||||
),
|
||||
)
|
||||
|
||||
# Build deployment spec
|
||||
deployment = client.V1Deployment(
|
||||
api_version="apps/v1",
|
||||
kind="Deployment",
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=deployment_name,
|
||||
namespace=self.namespace,
|
||||
labels=labels,
|
||||
),
|
||||
spec=client.V1DeploymentSpec(
|
||||
replicas=replicas,
|
||||
selector=client.V1LabelSelector(match_labels=labels),
|
||||
template=client.V1PodTemplateSpec(
|
||||
metadata=client.V1ObjectMeta(labels=labels),
|
||||
spec=client.V1PodSpec(containers=[container]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# Try to update existing deployment
|
||||
self.apps_v1.patch_namespaced_deployment(
|
||||
name=deployment_name,
|
||||
namespace=self.namespace,
|
||||
body=deployment,
|
||||
)
|
||||
logger.info(
|
||||
"Deployment updated",
|
||||
deployment=deployment_name,
|
||||
image=image,
|
||||
replicas=replicas,
|
||||
)
|
||||
except ApiException as e:
|
||||
if e.status == 404:
|
||||
# Create new deployment
|
||||
self.apps_v1.create_namespaced_deployment(
|
||||
namespace=self.namespace,
|
||||
body=deployment,
|
||||
)
|
||||
logger.info(
|
||||
"Deployment created",
|
||||
deployment=deployment_name,
|
||||
image=image,
|
||||
replicas=replicas,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to deploy",
|
||||
deployment=deployment_name,
|
||||
error=str(e),
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def scale(self, service: ServiceType, replicas: int) -> bool:
|
||||
"""Scale a deployment to the specified number of replicas."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
deployment_name = self._get_deployment_name(service)
|
||||
|
||||
try:
|
||||
self.apps_v1.patch_namespaced_deployment_scale(
|
||||
name=deployment_name,
|
||||
namespace=self.namespace,
|
||||
body={"spec": {"replicas": replicas}},
|
||||
)
|
||||
logger.info(
|
||||
"Deployment scaled",
|
||||
deployment=deployment_name,
|
||||
replicas=replicas,
|
||||
)
|
||||
return True
|
||||
except ApiException as e:
|
||||
logger.error(
|
||||
"Failed to scale",
|
||||
deployment=deployment_name,
|
||||
error=str(e),
|
||||
)
|
||||
return False
|
||||
|
||||
async def status(self, service: ServiceType) -> ServiceStatus:
|
||||
"""Get the current status of a deployment."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
deployment_name = self._get_deployment_name(service)
|
||||
|
||||
try:
|
||||
deployment = self.apps_v1.read_namespaced_deployment(
|
||||
name=deployment_name,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
status = deployment.status
|
||||
spec = deployment.spec
|
||||
|
||||
# Get image from container spec
|
||||
image = None
|
||||
if spec.template.spec.containers:
|
||||
image = spec.template.spec.containers[0].image
|
||||
|
||||
# Get creation timestamp
|
||||
started_at = None
|
||||
if deployment.metadata.creation_timestamp:
|
||||
started_at = deployment.metadata.creation_timestamp.isoformat()
|
||||
|
||||
# Get resource metrics if available
|
||||
cpu_usage = None
|
||||
memory_usage = None
|
||||
try:
|
||||
# Try to get metrics from metrics server
|
||||
from kubernetes import client as k8s_client
|
||||
|
||||
custom_api = k8s_client.CustomObjectsApi()
|
||||
metrics = custom_api.list_namespaced_custom_object(
|
||||
group="metrics.k8s.io",
|
||||
version="v1beta1",
|
||||
namespace=self.namespace,
|
||||
plural="pods",
|
||||
label_selector=f"app.kubernetes.io/name={service.value}",
|
||||
)
|
||||
for item in metrics.get("items", []):
|
||||
for container in item.get("containers", []):
|
||||
usage = container.get("usage", {})
|
||||
# Parse CPU (e.g., "100m" -> 0.1)
|
||||
cpu = usage.get("cpu", "0")
|
||||
if cpu.endswith("m"):
|
||||
cpu_usage = (cpu_usage or 0) + int(cpu[:-1]) / 1000
|
||||
elif cpu.endswith("n"):
|
||||
cpu_usage = (cpu_usage or 0) + int(cpu[:-1]) / 1e9
|
||||
# Parse memory (e.g., "128Mi" -> 128)
|
||||
mem = usage.get("memory", "0")
|
||||
if mem.endswith("Ki"):
|
||||
memory_usage = (memory_usage or 0) + int(mem[:-2]) / 1024
|
||||
elif mem.endswith("Mi"):
|
||||
memory_usage = (memory_usage or 0) + int(mem[:-2])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ServiceStatus(
|
||||
name=deployment_name,
|
||||
running=status.available_replicas is not None and status.available_replicas > 0,
|
||||
replicas=status.replicas or 0,
|
||||
ready_replicas=status.ready_replicas or 0,
|
||||
cpu_usage=round(cpu_usage, 3) if cpu_usage else None,
|
||||
memory_usage=round(memory_usage, 2) if memory_usage else None,
|
||||
image=image,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
except ApiException as e:
|
||||
if e.status == 404:
|
||||
return ServiceStatus(
|
||||
name=deployment_name,
|
||||
running=False,
|
||||
replicas=0,
|
||||
ready_replicas=0,
|
||||
)
|
||||
logger.error(
|
||||
"Failed to get status",
|
||||
deployment=deployment_name,
|
||||
error=str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
async def logs(
|
||||
self,
|
||||
service: ServiceType,
|
||||
tail: int = 100,
|
||||
follow: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream logs from pods in a deployment."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
labels = self._get_labels(service)
|
||||
label_selector = ",".join(f"{k}={v}" for k, v in labels.items())
|
||||
|
||||
try:
|
||||
pods = self.core_v1.list_namespaced_pod(
|
||||
namespace=self.namespace,
|
||||
label_selector=label_selector,
|
||||
)
|
||||
|
||||
if not pods.items:
|
||||
return
|
||||
|
||||
# Get logs from first running pod
|
||||
for pod in pods.items:
|
||||
if pod.status.phase == "Running":
|
||||
if follow:
|
||||
# Streaming logs
|
||||
from kubernetes import watch
|
||||
|
||||
w = watch.Watch()
|
||||
try:
|
||||
for line in w.stream(
|
||||
self.core_v1.read_namespaced_pod_log,
|
||||
name=pod.metadata.name,
|
||||
namespace=self.namespace,
|
||||
tail_lines=tail,
|
||||
follow=True,
|
||||
):
|
||||
yield line
|
||||
finally:
|
||||
w.stop()
|
||||
else:
|
||||
# Static logs
|
||||
logs = self.core_v1.read_namespaced_pod_log(
|
||||
name=pod.metadata.name,
|
||||
namespace=self.namespace,
|
||||
tail_lines=tail,
|
||||
)
|
||||
for line in logs.splitlines():
|
||||
yield line
|
||||
break
|
||||
|
||||
except ApiException as e:
|
||||
logger.error(
|
||||
"Failed to get logs",
|
||||
service=service.value,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
async def destroy(self, service: ServiceType) -> bool:
|
||||
"""Delete a deployment."""
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
deployment_name = self._get_deployment_name(service)
|
||||
|
||||
try:
|
||||
self.apps_v1.delete_namespaced_deployment(
|
||||
name=deployment_name,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
logger.info("Deployment deleted", deployment=deployment_name)
|
||||
return True
|
||||
except ApiException as e:
|
||||
if e.status == 404:
|
||||
return True
|
||||
logger.error(
|
||||
"Failed to delete deployment",
|
||||
deployment=deployment_name,
|
||||
error=str(e),
|
||||
)
|
||||
return False
|
||||
|
||||
async def restart(self, service: ServiceType) -> bool:
|
||||
"""Restart a deployment by patching the template annotation."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
deployment_name = self._get_deployment_name(service)
|
||||
|
||||
try:
|
||||
# Patch the deployment with a new annotation to trigger rollout
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
patch = {
|
||||
"spec": {
|
||||
"template": {
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"bootnode.dev/restartedAt": now,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.apps_v1.patch_namespaced_deployment(
|
||||
name=deployment_name,
|
||||
namespace=self.namespace,
|
||||
body=patch,
|
||||
)
|
||||
logger.info("Deployment restarted", deployment=deployment_name)
|
||||
return True
|
||||
except ApiException as e:
|
||||
logger.error(
|
||||
"Failed to restart deployment",
|
||||
deployment=deployment_name,
|
||||
error=str(e),
|
||||
)
|
||||
return False
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Local process deployment target for development."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
import structlog
|
||||
|
||||
from bootnode.core.deploy.base import DeployTarget, ServiceStatus, ServiceType
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
# Command mapping for local processes
|
||||
SERVICE_COMMANDS: dict[ServiceType, tuple[str, list[str]]] = {
|
||||
ServiceType.API: (
|
||||
"uvicorn",
|
||||
["bootnode.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"],
|
||||
),
|
||||
ServiceType.WEB: (
|
||||
"npm",
|
||||
["run", "dev"],
|
||||
),
|
||||
ServiceType.INDEXER: (
|
||||
sys.executable,
|
||||
["-m", "bootnode.workers.indexer"],
|
||||
),
|
||||
ServiceType.WEBHOOK_WORKER: (
|
||||
sys.executable,
|
||||
["-m", "bootnode.workers.webhook"],
|
||||
),
|
||||
ServiceType.BUNDLER: (
|
||||
sys.executable,
|
||||
["-m", "bootnode.workers.bundler"],
|
||||
),
|
||||
}
|
||||
|
||||
# Working directories for services
|
||||
SERVICE_CWD: dict[ServiceType, str | None] = {
|
||||
ServiceType.API: None, # Uses project root
|
||||
ServiceType.WEB: "web", # Web frontend directory
|
||||
ServiceType.INDEXER: None,
|
||||
ServiceType.WEBHOOK_WORKER: None,
|
||||
ServiceType.BUNDLER: None,
|
||||
}
|
||||
|
||||
|
||||
class ProcessInfo:
|
||||
"""Information about a running process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
process: asyncio.subprocess.Process,
|
||||
command: str,
|
||||
started_at: str,
|
||||
) -> None:
|
||||
self.process = process
|
||||
self.command = command
|
||||
self.started_at = started_at
|
||||
self.log_buffer: list[str] = []
|
||||
self.max_log_lines = 1000
|
||||
|
||||
|
||||
class ProcessDeployer(DeployTarget):
|
||||
"""Run services as local processes for development.
|
||||
|
||||
This deployer runs each service as a subprocess, useful for
|
||||
local development without Docker.
|
||||
"""
|
||||
|
||||
def __init__(self, project_root: Path | str | None = None) -> None:
|
||||
self.project_root = Path(project_root) if project_root else Path.cwd()
|
||||
self._processes: dict[ServiceType, ProcessInfo] = {}
|
||||
self._log_tasks: dict[ServiceType, asyncio.Task] = {}
|
||||
|
||||
def _get_command(self, service: ServiceType) -> tuple[str, list[str]]:
|
||||
"""Get command and args for a service."""
|
||||
return SERVICE_COMMANDS.get(service, (service.value, []))
|
||||
|
||||
def _get_cwd(self, service: ServiceType) -> Path:
|
||||
"""Get working directory for a service."""
|
||||
cwd = SERVICE_CWD.get(service)
|
||||
if cwd:
|
||||
return self.project_root / cwd
|
||||
return self.project_root
|
||||
|
||||
async def _read_output(
|
||||
self,
|
||||
service: ServiceType,
|
||||
stream: asyncio.StreamReader,
|
||||
) -> None:
|
||||
"""Read output from a process and buffer it."""
|
||||
try:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
break
|
||||
decoded = line.decode().rstrip("\n")
|
||||
if service in self._processes:
|
||||
info = self._processes[service]
|
||||
info.log_buffer.append(decoded)
|
||||
if len(info.log_buffer) > info.max_log_lines:
|
||||
info.log_buffer.pop(0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("Error reading process output", service=service.value, error=str(e))
|
||||
|
||||
async def deploy(
|
||||
self,
|
||||
service: ServiceType,
|
||||
image: str,
|
||||
replicas: int = 1,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Start a service as a local process.
|
||||
|
||||
Note: 'image' parameter is ignored for process deployer.
|
||||
Only one replica is supported for local processes.
|
||||
"""
|
||||
if service in self._processes:
|
||||
info = self._processes[service]
|
||||
if info.process.returncode is None:
|
||||
logger.warning("Service already running", service=service.value)
|
||||
return True
|
||||
|
||||
cmd, args = self._get_command(service)
|
||||
cwd = self._get_cwd(service)
|
||||
|
||||
# Build environment
|
||||
proc_env = os.environ.copy()
|
||||
if env:
|
||||
proc_env.update(env)
|
||||
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
cmd,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd=cwd,
|
||||
env=proc_env,
|
||||
)
|
||||
|
||||
started_at = datetime.now(timezone.utc).isoformat()
|
||||
info = ProcessInfo(
|
||||
process=process,
|
||||
command=f"{cmd} {' '.join(args)}",
|
||||
started_at=started_at,
|
||||
)
|
||||
self._processes[service] = info
|
||||
|
||||
# Start log reader task
|
||||
if process.stdout:
|
||||
task = asyncio.create_task(
|
||||
self._read_output(service, process.stdout)
|
||||
)
|
||||
self._log_tasks[service] = task
|
||||
|
||||
logger.info(
|
||||
"Service started",
|
||||
service=service.value,
|
||||
pid=process.pid,
|
||||
command=info.command,
|
||||
cwd=str(cwd),
|
||||
)
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"Command not found",
|
||||
service=service.value,
|
||||
command=cmd,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to start service",
|
||||
service=service.value,
|
||||
error=str(e),
|
||||
)
|
||||
return False
|
||||
|
||||
async def scale(self, service: ServiceType, replicas: int) -> bool:
|
||||
"""Scale a service.
|
||||
|
||||
Note: Local process deployer only supports 0 or 1 replica.
|
||||
"""
|
||||
if replicas == 0:
|
||||
return await self.destroy(service)
|
||||
elif replicas == 1:
|
||||
if service not in self._processes:
|
||||
# Can't scale up without knowing what to run
|
||||
logger.warning(
|
||||
"Cannot scale up - service not previously deployed",
|
||||
service=service.value,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Process deployer only supports 1 replica",
|
||||
service=service.value,
|
||||
requested=replicas,
|
||||
)
|
||||
return False
|
||||
|
||||
async def status(self, service: ServiceType) -> ServiceStatus:
|
||||
"""Get the current status of a service."""
|
||||
if service not in self._processes:
|
||||
return ServiceStatus(
|
||||
name=service.value,
|
||||
running=False,
|
||||
replicas=0,
|
||||
ready_replicas=0,
|
||||
)
|
||||
|
||||
info = self._processes[service]
|
||||
is_running = info.process.returncode is None
|
||||
|
||||
return ServiceStatus(
|
||||
name=service.value,
|
||||
running=is_running,
|
||||
replicas=1 if is_running else 0,
|
||||
ready_replicas=1 if is_running else 0,
|
||||
image=info.command,
|
||||
started_at=info.started_at,
|
||||
)
|
||||
|
||||
async def logs(
|
||||
self,
|
||||
service: ServiceType,
|
||||
tail: int = 100,
|
||||
follow: bool = False,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream logs from a service."""
|
||||
if service not in self._processes:
|
||||
return
|
||||
|
||||
info = self._processes[service]
|
||||
|
||||
# Return buffered logs
|
||||
start_idx = max(0, len(info.log_buffer) - tail)
|
||||
for line in info.log_buffer[start_idx:]:
|
||||
yield line
|
||||
|
||||
if follow and info.process.returncode is None:
|
||||
# Continue streaming new logs
|
||||
last_idx = len(info.log_buffer)
|
||||
while info.process.returncode is None:
|
||||
await asyncio.sleep(0.1)
|
||||
if len(info.log_buffer) > last_idx:
|
||||
for line in info.log_buffer[last_idx:]:
|
||||
yield line
|
||||
last_idx = len(info.log_buffer)
|
||||
|
||||
async def destroy(self, service: ServiceType) -> bool:
|
||||
"""Stop and remove a service process."""
|
||||
if service not in self._processes:
|
||||
return True
|
||||
|
||||
info = self._processes[service]
|
||||
|
||||
# Cancel log reader task
|
||||
if service in self._log_tasks:
|
||||
self._log_tasks[service].cancel()
|
||||
try:
|
||||
await self._log_tasks[service]
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
del self._log_tasks[service]
|
||||
|
||||
# Terminate process
|
||||
if info.process.returncode is None:
|
||||
try:
|
||||
info.process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(info.process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
info.process.kill()
|
||||
await info.process.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
del self._processes[service]
|
||||
logger.info("Service stopped", service=service.value)
|
||||
return True
|
||||
|
||||
async def destroy_all(self) -> None:
|
||||
"""Stop all running services."""
|
||||
services = list(self._processes.keys())
|
||||
for service in services:
|
||||
await self.destroy(service)
|
||||
@@ -71,6 +71,7 @@ class Project(Base):
|
||||
# Relationships
|
||||
api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="project")
|
||||
webhooks: Mapped[list["Webhook"]] = relationship(back_populates="project")
|
||||
subscription: Mapped["Subscription | None"] = relationship(back_populates="project", uselist=False)
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
@@ -254,3 +255,50 @@ class GasPolicy(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
"""Subscription model for billing and quotas."""
|
||||
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, unique=True
|
||||
)
|
||||
tier: Mapped[str] = mapped_column(String(50), nullable=False, default="free")
|
||||
hanzo_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
hanzo_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Limits (can be overridden from tier defaults)
|
||||
monthly_cu_limit: Mapped[int] = mapped_column(Integer, default=30_000_000)
|
||||
rate_limit_per_second: Mapped[int] = mapped_column(Integer, default=25)
|
||||
max_apps: Mapped[int] = mapped_column(Integer, default=5)
|
||||
max_webhooks: Mapped[int] = mapped_column(Integer, default=5)
|
||||
|
||||
# Usage tracking (real-time in Redis, snapshot here)
|
||||
current_cu_used: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Billing cycle
|
||||
billing_cycle_start: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
billing_cycle_end: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
# Scheduled changes (for downgrades)
|
||||
scheduled_tier: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
project: Mapped["Project"] = relationship(back_populates="subscription")
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""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
|
||||
@@ -284,6 +284,7 @@ CREATE TABLE IF NOT EXISTS api_usage (
|
||||
network String,
|
||||
endpoint String,
|
||||
method String,
|
||||
tier String DEFAULT 'free',
|
||||
compute_units UInt32,
|
||||
response_time_ms UInt32,
|
||||
status_code UInt16,
|
||||
@@ -345,3 +346,172 @@ ALTER TABLE logs ADD INDEX idx_topic0 topic0 TYPE bloom_filter GRANULARITY 4;
|
||||
ALTER TABLE token_transfers ADD INDEX idx_from from_address TYPE bloom_filter GRANULARITY 4;
|
||||
ALTER TABLE token_transfers ADD INDEX idx_to to_address TYPE bloom_filter GRANULARITY 4;
|
||||
ALTER TABLE token_transfers ADD INDEX idx_token token_address TYPE bloom_filter GRANULARITY 4;
|
||||
|
||||
-- =============================================================================
|
||||
-- BILLING TABLES
|
||||
-- =============================================================================
|
||||
|
||||
-- Billing invoices (monthly statements)
|
||||
CREATE TABLE IF NOT EXISTS billing_invoices (
|
||||
id UUID DEFAULT generateUUIDv4(),
|
||||
project_id UUID,
|
||||
period_start Date,
|
||||
period_end Date,
|
||||
tier String,
|
||||
total_cu UInt64,
|
||||
total_requests UInt64,
|
||||
base_cost_cents UInt32,
|
||||
overage_cost_cents UInt32,
|
||||
total_cost_cents UInt32,
|
||||
status Enum8('draft' = 0, 'pending' = 1, 'paid' = 2, 'overdue' = 3, 'void' = 4),
|
||||
hanzo_invoice_id Nullable(String),
|
||||
hanzo_payment_id Nullable(String),
|
||||
paid_at Nullable(DateTime64(3)),
|
||||
created_at DateTime64(3) DEFAULT now64(3),
|
||||
updated_at DateTime64(3) DEFAULT now64(3)
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(updated_at)
|
||||
ORDER BY (project_id, period_start)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Daily compute unit usage (aggregated from api_usage)
|
||||
CREATE TABLE IF NOT EXISTS billing_daily_usage (
|
||||
project_id UUID,
|
||||
date Date,
|
||||
tier String,
|
||||
total_cu UInt64,
|
||||
total_requests UInt64,
|
||||
cu_by_method Map(String, UInt64),
|
||||
cu_by_chain Map(UInt64, UInt64),
|
||||
peak_rps UInt32,
|
||||
error_count UInt32,
|
||||
created_at DateTime64(3) DEFAULT now64(3)
|
||||
)
|
||||
ENGINE = SummingMergeTree()
|
||||
PARTITION BY toYYYYMM(date)
|
||||
ORDER BY (project_id, date)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Materialized view: Daily CU aggregation
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS billing_daily_usage_mv
|
||||
TO billing_daily_usage
|
||||
AS SELECT
|
||||
project_id,
|
||||
toDate(timestamp) as date,
|
||||
'' as tier, -- Filled by application
|
||||
sum(compute_units) as total_cu,
|
||||
count() as total_requests,
|
||||
cast(
|
||||
groupArray((method, toUInt64(compute_units))) as Map(String, UInt64)
|
||||
) as cu_by_method,
|
||||
cast(
|
||||
groupArray((chain_id, toUInt64(compute_units))) as Map(UInt64, UInt64)
|
||||
) as cu_by_chain,
|
||||
0 as peak_rps, -- Calculated separately
|
||||
countIf(status_code >= 400) as error_count,
|
||||
now64(3) as created_at
|
||||
FROM api_usage
|
||||
GROUP BY project_id, date;
|
||||
|
||||
-- Monthly CU summary (for billing)
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS billing_monthly_summary
|
||||
ENGINE = SummingMergeTree()
|
||||
PARTITION BY toYYYYMM(month)
|
||||
ORDER BY (project_id, month)
|
||||
AS SELECT
|
||||
project_id,
|
||||
toStartOfMonth(timestamp) as month,
|
||||
sum(compute_units) as total_cu,
|
||||
count() as total_requests,
|
||||
uniq(api_key_id) as unique_api_keys,
|
||||
countIf(status_code >= 400) as error_count,
|
||||
avg(response_time_ms) as avg_response_time_ms,
|
||||
quantile(0.95)(response_time_ms) as p95_response_time_ms
|
||||
FROM api_usage
|
||||
GROUP BY project_id, month;
|
||||
|
||||
-- Rate limit events (for analytics and alerting)
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_events (
|
||||
project_id UUID,
|
||||
timestamp DateTime64(3),
|
||||
tier String,
|
||||
limit_type String, -- 'cu_quota', 'rate_limit', 'app_limit', 'webhook_limit'
|
||||
current_value UInt64,
|
||||
limit_value UInt64,
|
||||
blocked Bool,
|
||||
api_key_id Nullable(UUID),
|
||||
ip_address Nullable(String)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMMDD(timestamp)
|
||||
ORDER BY (project_id, timestamp)
|
||||
TTL timestamp + INTERVAL 30 DAY
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Subscription changes audit log
|
||||
CREATE TABLE IF NOT EXISTS subscription_changes (
|
||||
id UUID DEFAULT generateUUIDv4(),
|
||||
project_id UUID,
|
||||
old_tier String,
|
||||
new_tier String,
|
||||
change_type Enum8('upgrade' = 1, 'downgrade' = 2, 'renewal' = 3),
|
||||
hanzo_subscription_id Nullable(String),
|
||||
effective_at DateTime64(3),
|
||||
created_at DateTime64(3) DEFAULT now64(3)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(created_at)
|
||||
ORDER BY (project_id, created_at)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- =============================================================================
|
||||
-- BILLING ANALYTICS TABLES
|
||||
-- =============================================================================
|
||||
|
||||
-- Daily billing usage aggregation
|
||||
CREATE TABLE IF NOT EXISTS billing_usage_daily (
|
||||
project_id UUID,
|
||||
date Date,
|
||||
tier String,
|
||||
chain String,
|
||||
method String,
|
||||
compute_units UInt64,
|
||||
request_count UInt64,
|
||||
error_count UInt64,
|
||||
avg_response_time_ms Float32,
|
||||
p99_response_time_ms Float32,
|
||||
created_at DateTime64(3) DEFAULT now64(3)
|
||||
)
|
||||
ENGINE = SummingMergeTree()
|
||||
PARTITION BY toYYYYMM(date)
|
||||
ORDER BY (project_id, date, chain, method)
|
||||
SETTINGS index_granularity = 8192;
|
||||
|
||||
-- Monthly rollup for invoicing
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS billing_usage_monthly
|
||||
ENGINE = SummingMergeTree()
|
||||
PARTITION BY toYYYYMM(month)
|
||||
ORDER BY (project_id, month, tier)
|
||||
AS SELECT
|
||||
project_id,
|
||||
toStartOfMonth(date) as month,
|
||||
tier,
|
||||
sum(compute_units) as total_compute_units,
|
||||
sum(request_count) as total_requests,
|
||||
sum(error_count) as total_errors,
|
||||
avg(avg_response_time_ms) as avg_response_time_ms
|
||||
FROM billing_usage_daily
|
||||
GROUP BY project_id, month, tier;
|
||||
|
||||
-- Compute unit breakdown by method (for analytics dashboard)
|
||||
CREATE TABLE IF NOT EXISTS cu_by_method (
|
||||
project_id UUID,
|
||||
date Date,
|
||||
method String,
|
||||
chain String,
|
||||
compute_units UInt64,
|
||||
call_count UInt64
|
||||
)
|
||||
ENGINE = SummingMergeTree()
|
||||
PARTITION BY toYYYYMM(date)
|
||||
ORDER BY (project_id, date, method, chain);
|
||||
|
||||
+192
-106
@@ -6,81 +6,164 @@ import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
HelpCircle,
|
||||
Minus,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { getBrand } from "@/lib/brand"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing",
|
||||
description:
|
||||
"Simple, transparent pricing for blockchain infrastructure. Start free with 100M compute units per month.",
|
||||
"Simple, transparent pricing for blockchain infrastructure. Start free with 30M compute units per month.",
|
||||
}
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
period: "forever",
|
||||
description: "For hobbyists and developers exploring blockchain. Get started with no commitment.",
|
||||
cta: "Start Building Free",
|
||||
period: "/month",
|
||||
description: "For hobbyists and developers exploring blockchain.",
|
||||
cta: "Start for free",
|
||||
ctaHref: "/dashboard",
|
||||
ctaVariant: "outline" as const,
|
||||
highlighted: false,
|
||||
limits: [
|
||||
"100M compute units / month",
|
||||
"5 API keys",
|
||||
"3 chains",
|
||||
"Community support",
|
||||
"Standard rate limits",
|
||||
"7-day data retention",
|
||||
features: [
|
||||
"30M Compute Units/month",
|
||||
"25 requests/second",
|
||||
"5 apps",
|
||||
"5 webhooks",
|
||||
"Standard support (48hr response)",
|
||||
"All mainnets & testnets",
|
||||
"Core APIs (Node, Debug, Trace)",
|
||||
"Enhanced APIs (NFT, Token, Webhooks, Transfers)",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Growth",
|
||||
price: "$99",
|
||||
period: "/month",
|
||||
description: "For teams shipping production apps. Everything you need to scale with confidence.",
|
||||
cta: "Get Started",
|
||||
name: "Pay As You Go",
|
||||
price: "$0.40-0.45",
|
||||
period: "/1M CUs",
|
||||
description: "For teams shipping production apps. Pay only for what you use.",
|
||||
cta: "Start building",
|
||||
ctaHref: "/dashboard",
|
||||
ctaVariant: "default" as const,
|
||||
highlighted: true,
|
||||
limits: [
|
||||
"1B compute units / month",
|
||||
"Unlimited API keys",
|
||||
"All 100+ chains",
|
||||
"Email support (< 24h response)",
|
||||
"Enhanced rate limits",
|
||||
"30-day data retention",
|
||||
"Webhook delivery guarantees",
|
||||
"Team members (up to 10)",
|
||||
"Usage analytics dashboard",
|
||||
features: [
|
||||
"Unlimited CUs (pay for what you use)",
|
||||
"300 requests/second",
|
||||
"30 apps",
|
||||
"100 webhooks",
|
||||
"Priority support (24hr response)",
|
||||
"All features from Free",
|
||||
"Transaction Simulation",
|
||||
"Private Transactions",
|
||||
"Smart Wallets SDK",
|
||||
"Gas Manager (8% admin fee)",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
period: "",
|
||||
description: "For organizations with mission-critical infrastructure and compliance requirements.",
|
||||
cta: "Contact Sales",
|
||||
description: "For organizations with mission-critical infrastructure.",
|
||||
cta: "Contact sales",
|
||||
ctaHref: "/contact",
|
||||
ctaVariant: "outline" as const,
|
||||
highlighted: false,
|
||||
limits: [
|
||||
"Custom compute units",
|
||||
"Dedicated infrastructure",
|
||||
"99.999% uptime SLA",
|
||||
"Custom chain support",
|
||||
"24/7 priority support",
|
||||
"90-day data retention",
|
||||
"Dedicated account manager",
|
||||
"Custom rate limits",
|
||||
"SOC 2 compliance reports",
|
||||
"SSO / SAML authentication",
|
||||
"Unlimited team members",
|
||||
"Private networking (VPC peering)",
|
||||
features: [
|
||||
"Custom throughput",
|
||||
"Unlimited apps & webhooks",
|
||||
"VIP eng support (5min response)",
|
||||
"Volume discounts",
|
||||
"SLAs",
|
||||
"Priority on product roadmaps",
|
||||
"Custom Gas Manager fee",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
type FeatureValue = boolean | string
|
||||
|
||||
interface FeatureRow {
|
||||
feature: string
|
||||
free: FeatureValue
|
||||
payg: FeatureValue
|
||||
enterprise: FeatureValue
|
||||
}
|
||||
|
||||
interface FeatureCategory {
|
||||
category: string
|
||||
features: FeatureRow[]
|
||||
}
|
||||
|
||||
const featureComparison: FeatureCategory[] = [
|
||||
{
|
||||
category: "Core APIs",
|
||||
features: [
|
||||
{ feature: "Node API", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Debug API", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Trace API", free: true, payg: true, enterprise: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Enhanced APIs",
|
||||
features: [
|
||||
{ feature: "NFT API", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Token API", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Webhooks", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Transfers API", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Smart Websockets", free: true, payg: true, enterprise: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Transactions",
|
||||
features: [
|
||||
{ feature: "Transaction Simulation", free: false, payg: true, enterprise: true },
|
||||
{ feature: "Private Transactions", free: false, payg: true, enterprise: true },
|
||||
{ feature: "Smart Wallets SDK", free: false, payg: true, enterprise: true },
|
||||
{ feature: "Bundler API", free: false, payg: true, enterprise: true },
|
||||
{ feature: "Gas Manager", free: false, payg: "8% fee", enterprise: "Custom fee" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Pricing & Limits",
|
||||
features: [
|
||||
{ feature: "Compute Units", free: "30M/month", payg: "Unlimited", enterprise: "Custom" },
|
||||
{ feature: "CU Rate", free: "Included", payg: "$0.40-0.45/1M", enterprise: "Volume discounts" },
|
||||
{ feature: "Throughput", free: "25 req/s", payg: "300 req/s", enterprise: "Custom" },
|
||||
{ feature: "Apps", free: "5", payg: "30", enterprise: "Unlimited" },
|
||||
{ feature: "Webhooks", free: "5", payg: "100", enterprise: "Unlimited" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Tools",
|
||||
features: [
|
||||
{ feature: "SDK Access", free: true, payg: true, enterprise: true },
|
||||
{ feature: "API Composer", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Block Explorer", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Usage Analytics", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Custom Alerts", free: false, payg: true, enterprise: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Support",
|
||||
features: [
|
||||
{ feature: "Documentation", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Community Discord", free: true, payg: true, enterprise: true },
|
||||
{ feature: "Email Support", free: "48hr response", payg: "24hr response", enterprise: "5min response" },
|
||||
{ feature: "Dedicated Account Manager", free: false, payg: false, enterprise: true },
|
||||
{ feature: "SLA Guarantee", free: false, payg: false, enterprise: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -89,46 +172,38 @@ const faqs = [
|
||||
{
|
||||
question: "What is a compute unit?",
|
||||
answer:
|
||||
"A compute unit (CU) is Bootnode's universal unit of measurement for API usage. Different API methods consume different amounts of CUs based on their computational complexity. For example, a simple eth_blockNumber call costs 1 CU, while eth_getLogs may cost 10-100 CUs depending on the range. This ensures fair pricing across all chains and methods.",
|
||||
"A compute unit (CU) is our universal unit of measurement for API usage. Different API methods consume different amounts of CUs based on their computational complexity. For example, a simple eth_blockNumber call costs 1 CU, while eth_getLogs may cost 10-100 CUs depending on the block range. This ensures fair, predictable pricing across all chains and methods.",
|
||||
},
|
||||
{
|
||||
question: "Can I switch plans at any time?",
|
||||
question: "How do I get started?",
|
||||
answer:
|
||||
"Yes. You can upgrade or downgrade your plan at any time from your dashboard. When upgrading, the new plan takes effect immediately and you are billed a prorated amount for the remainder of the billing cycle. When downgrading, the change takes effect at the start of the next billing cycle.",
|
||||
"Getting started is easy. Sign up for a free account, create an API key from your dashboard, and start making requests immediately. No credit card required. You get 30M compute units per month free to build and test your applications.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I exceed my compute unit limit?",
|
||||
question: "When do I get billed?",
|
||||
answer:
|
||||
"On the Free plan, requests are rate-limited once you reach your monthly allocation. On the Growth plan, you can enable overage billing at $0.10 per million additional CUs so your app never goes down. Enterprise plans include custom overage arrangements.",
|
||||
"Free tier users are never billed. Pay As You Go users are billed monthly based on their compute unit usage above the free allocation. You can set spending limits and alerts to control costs. Enterprise customers have custom billing arrangements.",
|
||||
},
|
||||
{
|
||||
question: "Do you offer annual billing?",
|
||||
question: "How can I upgrade to Enterprise?",
|
||||
answer:
|
||||
"Yes. Annual billing is available for the Growth plan at $79/month (a 20% discount). Enterprise plans are custom-quoted with flexible billing terms. Contact our sales team for details.",
|
||||
},
|
||||
{
|
||||
question: "Which chains are available on each plan?",
|
||||
answer:
|
||||
"The Free plan gives you access to any 3 chains of your choice. The Growth and Enterprise plans include all 100+ supported chains with no restrictions. You can change your selected chains on the Free plan at any time.",
|
||||
},
|
||||
{
|
||||
question: "What kind of support do you offer?",
|
||||
answer:
|
||||
"The Free plan includes access to our community Discord and documentation. The Growth plan includes email support with a guaranteed response time of under 24 hours during business hours. Enterprise plans include 24/7 priority support with a dedicated account manager and Slack channel.",
|
||||
},
|
||||
{
|
||||
question: "Is there a free trial for the Growth plan?",
|
||||
answer:
|
||||
"The Free plan serves as a generous trial with 100M compute units per month. There is no time limit -- you can use the Free plan indefinitely. When you are ready to scale, upgrading to Growth takes one click.",
|
||||
},
|
||||
{
|
||||
question: "Do you offer discounts for startups?",
|
||||
answer:
|
||||
"Yes. We offer a startup program that provides Growth plan access at no cost for 6 months for qualifying early-stage startups. Apply through our startup program page or contact sales for details.",
|
||||
"Contact our sales team to discuss your requirements. Enterprise plans include custom throughput limits, volume discounts, SLAs, dedicated support with 5-minute response times, and priority access to new features. We will work with you to create a plan that fits your needs.",
|
||||
},
|
||||
]
|
||||
|
||||
function FeatureCell({ value }: { value: FeatureValue }) {
|
||||
if (value === true) {
|
||||
return <Check className="h-5 w-5 text-green-500 mx-auto" />
|
||||
}
|
||||
if (value === false) {
|
||||
return <Minus className="h-5 w-5 text-muted-foreground mx-auto" />
|
||||
}
|
||||
return <span className="text-sm">{value}</span>
|
||||
}
|
||||
|
||||
export default function PricingPage() {
|
||||
const brand = getBrand()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
@@ -142,11 +217,11 @@ export default function PricingPage() {
|
||||
Simple Pricing
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Start free, scale without limits
|
||||
Start free, scale as you grow
|
||||
</h1>
|
||||
<p className="mt-6 text-lg text-muted-foreground">
|
||||
Transparent pricing with no hidden fees. Every plan includes access to
|
||||
our full API suite: RPC, Token API, NFT API, Webhooks, Smart Wallets, and more.
|
||||
Transparent pricing with no hidden fees. Get 30M compute units free every month.
|
||||
Pay only for what you use beyond that.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,10 +270,10 @@ export default function PricingPage() {
|
||||
</Button>
|
||||
<Separator className="my-6" />
|
||||
<ul className="space-y-3">
|
||||
{tier.limits.map((limit) => (
|
||||
<li key={limit} className="flex items-start gap-2">
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span className="text-sm">{limit}</span>
|
||||
<span className="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -209,44 +284,55 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Compute Unit Table */}
|
||||
{/* Feature Comparison Table */}
|
||||
<section className="border-y bg-muted/30 py-16">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-center">
|
||||
Compute Unit Costs
|
||||
Compare Plans
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-muted-foreground">
|
||||
Each API method uses a specific number of compute units. Here are some common examples.
|
||||
See all features available across each plan.
|
||||
</p>
|
||||
<div className="mt-8 overflow-hidden rounded-lg border bg-card">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Method</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium">CUs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{[
|
||||
{ method: "eth_blockNumber", cus: "1" },
|
||||
{ method: "eth_getBalance", cus: "5" },
|
||||
{ method: "eth_call", cus: "10" },
|
||||
{ method: "eth_getTransactionReceipt", cus: "10" },
|
||||
{ method: "eth_getLogs (100 blocks)", cus: "25" },
|
||||
{ method: "eth_getBlockByNumber (full)", cus: "50" },
|
||||
{ method: "debug_traceTransaction", cus: "200" },
|
||||
{ method: "Token API - Get balances", cus: "5" },
|
||||
{ method: "NFT API - Get collection", cus: "10" },
|
||||
{ method: "Webhook - Create", cus: "50" },
|
||||
].map((row) => (
|
||||
<tr key={row.method} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-mono text-sm">{row.method}</td>
|
||||
<td className="px-4 py-3 text-right text-sm">{row.cus}</td>
|
||||
</tr>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[300px]">Feature</TableHead>
|
||||
<TableHead className="text-center">Free</TableHead>
|
||||
<TableHead className="text-center">Pay As You Go</TableHead>
|
||||
<TableHead className="text-center">Enterprise</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{featureComparison.map((category) => (
|
||||
<>
|
||||
<TableRow key={category.category} className="bg-muted/30">
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="font-semibold text-sm py-2"
|
||||
>
|
||||
{category.category}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{category.features.map((row) => (
|
||||
<TableRow key={row.feature}>
|
||||
<TableCell className="text-sm">{row.feature}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<FeatureCell value={row.free} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<FeatureCell value={row.payg} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<FeatureCell value={row.enterprise} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,12 +370,12 @@ export default function PricingPage() {
|
||||
Ready to start building?
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Get 100M compute units free every month. No credit card required.
|
||||
Get 30M compute units free every month. No credit card required.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/dashboard">
|
||||
Start Building Free
|
||||
Start for free
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user