Files
bootnode/api/bootnode/core/billing/cloud_billing.py
T
zeekay 9b6b6a1f72 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.
2026-02-13 15:33:54 -08:00

293 lines
9.6 KiB
Python

"""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()