feat: add cloud compute billing tiers and authorization API

Cloud compute quotas per tier (Free: 1 Linux/20hr, PAYG: 5L+2W+1M,
Growth: 20L+5W+3M, Enterprise: custom). Adds billing endpoints for
control-plane authorization, quota checks, and usage reporting.
This commit is contained in:
2026-02-13 15:33:54 -08:00
parent 98c31d7ee6
commit 9b6b6a1f72
4 changed files with 473 additions and 0 deletions
+111
View File
@@ -600,3 +600,114 @@ async def sync_iam_to_commerce(
unified = get_unified_billing_client()
result = await unified.sync_iam_to_commerce(user)
return {"synced": True, "result": result}
# =============================================================================
# Cloud Compute Billing (called by agents control-plane)
# =============================================================================
from bootnode.core.billing.cloud_billing import (
CloudAuthResponse,
CloudBillingService,
CloudQuotaResponse,
CloudUsageReport,
cloud_billing_service,
)
from bootnode.core.billing.tiers import (
CLOUD_COMPUTE_LIMITS,
CloudComputeLimits,
get_cloud_compute_limits,
)
class CloudAuthRequest(BaseModel):
"""Request from control-plane to authorize provisioning."""
team_id: str
platform: str
instance_type: str = ""
@router.post("/cloud/authorize", response_model=CloudAuthResponse)
async def authorize_cloud_provisioning(
request: CloudAuthRequest,
db: DbDep,
) -> CloudAuthResponse:
"""Authorize cloud instance provisioning for a team.
Called by the agents control-plane before creating an instance.
Checks tier limits, quotas, and budget.
"""
return await cloud_billing_service.authorize_provisioning(
team_id=request.team_id,
platform=request.platform,
instance_type=request.instance_type,
db=db,
)
@router.get("/cloud/quota", response_model=CloudQuotaResponse)
async def get_cloud_quota(
team_id: str,
db: DbDep,
) -> CloudQuotaResponse:
"""Get cloud compute quota and usage for a team.
Returns tier limits and current usage counts.
"""
return await cloud_billing_service.get_team_quota(
team_id=team_id,
db=db,
)
@router.post("/cloud/usage")
async def report_cloud_usage(
report: CloudUsageReport,
db: DbDep,
) -> dict:
"""Report cloud compute usage from the control-plane.
Called periodically by the monitor for each running instance.
Records compute hours and cost for billing.
"""
return await cloud_billing_service.report_usage(
report=report,
db=db,
)
class CloudTierLimitsResponse(BaseModel):
"""Cloud compute limits for a tier."""
tier: str
max_linux_instances: int
max_windows_instances: int
max_macos_instances: int
max_compute_hours_monthly: int
monthly_budget_cents: int
linux_hourly_cents: int
windows_hourly_cents: int
macos_hourly_cents: int
@router.get("/cloud/tiers", response_model=list[CloudTierLimitsResponse])
async def get_cloud_tiers() -> list[CloudTierLimitsResponse]:
"""Get cloud compute limits for all pricing tiers.
Shows what cloud resources each tier provides.
"""
return [
CloudTierLimitsResponse(
tier=tier.value,
max_linux_instances=limits.max_linux_instances,
max_windows_instances=limits.max_windows_instances,
max_macos_instances=limits.max_macos_instances,
max_compute_hours_monthly=limits.max_compute_hours_monthly,
monthly_budget_cents=limits.monthly_budget_cents,
linux_hourly_cents=limits.linux_hourly_cents,
windows_hourly_cents=limits.windows_hourly_cents,
macos_hourly_cents=limits.macos_hourly_cents,
)
for tier, limits in CLOUD_COMPUTE_LIMITS.items()
]
+7
View File
@@ -57,6 +57,10 @@ from bootnode.core.billing.unified import (
UnifiedUser,
get_unified_billing_client,
)
from bootnode.core.billing.cloud_billing import (
CloudBillingService,
cloud_billing_service,
)
__all__ = [
# Compute units
@@ -104,4 +108,7 @@ __all__ = [
"UnifiedBillingClient",
"UnifiedUser",
"get_unified_billing_client",
# Cloud compute billing
"CloudBillingService",
"cloud_billing_service",
]
+292
View File
@@ -0,0 +1,292 @@
"""Cloud compute billing service.
Handles authorization, quota checks, and usage metering for
cloud instances provisioned by the agents control-plane.
"""
from datetime import UTC, datetime
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 (
CLOUD_COMPUTE_LIMITS,
CloudComputeLimits,
PricingTier,
get_cloud_compute_limits,
)
from bootnode.core.billing.tracker import usage_tracker
from bootnode.db.models import Subscription
logger = structlog.get_logger()
class CloudAuthResponse(BaseModel):
"""Response for cloud provisioning authorization."""
authorized: bool
tier: str
hourly_rate_cents: int
reason: str = ""
billing_account_id: str = ""
class CloudQuotaResponse(BaseModel):
"""Cloud compute quota for a team."""
tier: str
max_linux_instances: int
max_windows_instances: int
max_macos_instances: int
max_compute_hours_monthly: int
used_linux: int
used_windows: int
used_macos: int
used_compute_hours: float
monthly_budget_cents: int
used_budget_cents: int
class CloudUsageReport(BaseModel):
"""Usage report from the control-plane."""
instance_id: str
platform: str
compute_hours: float
hourly_rate_cents: int
class CloudBillingService:
"""Service for cloud compute billing operations."""
async def authorize_provisioning(
self,
team_id: str,
platform: str,
instance_type: str,
db: AsyncSession,
) -> CloudAuthResponse:
"""Check if a team can provision a cloud instance.
Args:
team_id: The team/project ID
platform: linux, windows, or macos
instance_type: Instance type requested
db: Database session
Returns:
Authorization response with tier and hourly rate
"""
# Get the team's subscription tier.
subscription = await self._get_team_subscription(team_id, db)
tier = PricingTier(subscription.tier) if subscription else PricingTier.FREE
limits = get_cloud_compute_limits(tier)
# Check platform-specific instance limit.
max_for_platform = self._max_instances_for_platform(limits, platform)
if max_for_platform == 0:
return CloudAuthResponse(
authorized=False,
tier=tier.value,
hourly_rate_cents=0,
reason=f"{platform} instances not available on {tier.value} tier",
)
# Check current usage against quota.
current_count = await self._count_active_instances(team_id, platform, db)
if current_count >= max_for_platform:
return CloudAuthResponse(
authorized=False,
tier=tier.value,
hourly_rate_cents=0,
reason=f"max {platform} instances ({max_for_platform}) reached for {tier.value} tier",
)
# Check monthly budget (if set).
if limits.monthly_budget_cents > 0:
used = await self._get_monthly_cloud_spend(team_id, db)
if used >= limits.monthly_budget_cents:
return CloudAuthResponse(
authorized=False,
tier=tier.value,
hourly_rate_cents=0,
reason=f"monthly cloud budget (${limits.monthly_budget_cents / 100:.0f}) exceeded",
)
# Get hourly rate for platform.
hourly = self._hourly_rate_for_platform(limits, platform)
# Check free tier compute hours.
if tier == PricingTier.FREE and limits.max_compute_hours_monthly > 0:
used_hours = await self._get_monthly_compute_hours(team_id, db)
if used_hours >= limits.max_compute_hours_monthly:
return CloudAuthResponse(
authorized=False,
tier=tier.value,
hourly_rate_cents=hourly,
reason=f"free tier compute hours ({limits.max_compute_hours_monthly}h) exhausted",
)
customer_id = subscription.hanzo_customer_id if subscription else ""
logger.info(
"cloud provisioning authorized",
team_id=team_id,
platform=platform,
tier=tier.value,
hourly_cents=hourly,
)
return CloudAuthResponse(
authorized=True,
tier=tier.value,
hourly_rate_cents=hourly,
billing_account_id=customer_id or "",
)
async def get_team_quota(
self,
team_id: str,
db: AsyncSession,
) -> CloudQuotaResponse:
"""Get cloud compute quota and usage for a team."""
subscription = await self._get_team_subscription(team_id, db)
tier = PricingTier(subscription.tier) if subscription else PricingTier.FREE
limits = get_cloud_compute_limits(tier)
used_linux = await self._count_active_instances(team_id, "linux", db)
used_windows = await self._count_active_instances(team_id, "windows", db)
used_macos = await self._count_active_instances(team_id, "macos", db)
used_hours = await self._get_monthly_compute_hours(team_id, db)
used_budget = await self._get_monthly_cloud_spend(team_id, db)
return CloudQuotaResponse(
tier=tier.value,
max_linux_instances=limits.max_linux_instances,
max_windows_instances=limits.max_windows_instances,
max_macos_instances=limits.max_macos_instances,
max_compute_hours_monthly=limits.max_compute_hours_monthly,
used_linux=used_linux,
used_windows=used_windows,
used_macos=used_macos,
used_compute_hours=used_hours,
monthly_budget_cents=limits.monthly_budget_cents,
used_budget_cents=used_budget,
)
async def report_usage(
self,
report: CloudUsageReport,
db: AsyncSession,
) -> dict[str, Any]:
"""Record cloud compute usage for billing.
Called by the control-plane monitor on each tick for running instances.
"""
cost_cents = int(report.compute_hours * report.hourly_rate_cents)
# Track in Redis for real-time aggregation.
key = f"cloud:usage:{report.instance_id}"
month_key = f"cloud:monthly:{datetime.now(UTC).strftime('%Y-%m')}"
await usage_tracker.redis.incrbyfloat(key, report.compute_hours)
await usage_tracker.redis.incrby(f"{month_key}:cost_cents", cost_cents)
logger.debug(
"cloud usage recorded",
instance_id=report.instance_id,
platform=report.platform,
hours=report.compute_hours,
cost_cents=cost_cents,
)
return {"recorded": True, "cost_cents": cost_cents}
# --- Internal helpers ---
async def _get_team_subscription(
self, team_id: str, db: AsyncSession
) -> Subscription | None:
"""Get subscription for a team/project."""
try:
from uuid import UUID
project_id = UUID(team_id)
result = await db.execute(
select(Subscription).where(Subscription.project_id == project_id)
)
return result.scalar_one_or_none()
except (ValueError, Exception):
return None
async def _count_active_instances(
self, team_id: str, platform: str, db: AsyncSession
) -> int:
"""Count active (non-terminated) instances for a team+platform.
Uses Redis counter set by the control-plane, or returns 0 if unavailable.
"""
key = f"cloud:active:{team_id}:{platform}"
try:
val = await usage_tracker.redis.get(key)
return int(val) if val else 0
except Exception:
return 0
async def _get_monthly_compute_hours(
self, team_id: str, db: AsyncSession
) -> float:
"""Get total compute hours used this month."""
month = datetime.now(UTC).strftime("%Y-%m")
key = f"cloud:hours:{team_id}:{month}"
try:
val = await usage_tracker.redis.get(key)
return float(val) if val else 0.0
except Exception:
return 0.0
async def _get_monthly_cloud_spend(
self, team_id: str, db: AsyncSession
) -> int:
"""Get total cloud spend in cents this month."""
month = datetime.now(UTC).strftime("%Y-%m")
key = f"cloud:spend:{team_id}:{month}"
try:
val = await usage_tracker.redis.get(key)
return int(val) if val else 0
except Exception:
return 0
def _max_instances_for_platform(
self, limits: CloudComputeLimits, platform: str
) -> int:
"""Get max instances for a platform from tier limits."""
match platform:
case "linux":
return limits.max_linux_instances
case "windows":
return limits.max_windows_instances
case "macos":
return limits.max_macos_instances
case _:
return 0
def _hourly_rate_for_platform(
self, limits: CloudComputeLimits, platform: str
) -> int:
"""Get hourly rate in cents for a platform."""
match platform:
case "linux":
return limits.linux_hourly_cents
case "windows":
return limits.windows_hourly_cents
case "macos":
return limits.macos_hourly_cents
case _:
return 10
# Global singleton
cloud_billing_service = CloudBillingService()
+63
View File
@@ -118,6 +118,69 @@ def get_tier_limits(tier: PricingTier) -> TierLimits:
return TIER_LIMITS[tier]
class CloudComputeLimits(BaseModel):
"""Cloud compute limits per tier."""
max_linux_instances: int # 0 = not allowed
max_windows_instances: int
max_macos_instances: int
max_compute_hours_monthly: int # 0 = unlimited
monthly_budget_cents: int # 0 = unlimited
linux_hourly_cents: int # per-hour cost
windows_hourly_cents: int
macos_hourly_cents: int
# Cloud compute quotas per tier
CLOUD_COMPUTE_LIMITS: dict[PricingTier, CloudComputeLimits] = {
PricingTier.FREE: CloudComputeLimits(
max_linux_instances=1,
max_windows_instances=0,
max_macos_instances=0,
max_compute_hours_monthly=20, # 20 hours free
monthly_budget_cents=0,
linux_hourly_cents=1, # $0.01/hr (free tier absorbed)
windows_hourly_cents=10,
macos_hourly_cents=120,
),
PricingTier.PAY_AS_YOU_GO: CloudComputeLimits(
max_linux_instances=5,
max_windows_instances=2,
max_macos_instances=1,
max_compute_hours_monthly=0, # unlimited, pay per use
monthly_budget_cents=50000, # $500 soft cap
linux_hourly_cents=1, # $0.01/hr
windows_hourly_cents=10, # $0.10/hr
macos_hourly_cents=120, # $1.20/hr
),
PricingTier.GROWTH: CloudComputeLimits(
max_linux_instances=20,
max_windows_instances=5,
max_macos_instances=3,
max_compute_hours_monthly=0, # unlimited
monthly_budget_cents=200000, # $2000 soft cap
linux_hourly_cents=1,
windows_hourly_cents=8, # discounted
macos_hourly_cents=100, # discounted
),
PricingTier.ENTERPRISE: CloudComputeLimits(
max_linux_instances=100,
max_windows_instances=20,
max_macos_instances=10,
max_compute_hours_monthly=0, # unlimited
monthly_budget_cents=0, # custom
linux_hourly_cents=1,
windows_hourly_cents=6, # volume discount
macos_hourly_cents=80, # volume discount
),
}
def get_cloud_compute_limits(tier: PricingTier) -> CloudComputeLimits:
"""Get cloud compute limits for a pricing tier."""
return CLOUD_COMPUTE_LIMITS[tier]
def calculate_monthly_cost(tier: PricingTier, compute_units_used: int) -> int:
"""Calculate the monthly cost in cents.