Files
billing/main.py
T
2026-08-21 01:55:13 +00:00

313 lines
11 KiB
Python

"""ConnectVM Billing API — aggregates CloudKitty rating data.
Billing model: ALLOCATION-BASED. CloudKitty collects openstack_nova_limits_*
/ cinder_limits_* metrics, which track quota allocation, not power state.
A stopped (SHUTOFF) VM is therefore billed in full until it is shelved
(offloaded) or deleted. See /api/v1/billing/policy.
"""
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel
import aggregator
import mailer
import pricing
import reporting
from cloudkitty_client import CloudKittyClient
from config import get_settings
from openstack_client import OpenStackClient
settings = get_settings()
ck: CloudKittyClient | None = None
osx: OpenStackClient | None = None
STATIC_DIR = Path(__file__).parent / "static"
BILLING_POLICY = {
"model": "allocation-based (quota), hourly collection windows",
"source_metrics": [
"openstack_nova_limits_vcpus_used",
"openstack_nova_limits_memory_used",
"openstack_nova_limits_instances_used",
"openstack_nova_server_local_gb",
"openstack_cinder_limits_volume_used_gb",
"openstack_glance_image_bytes",
"openstack_neutron_floating_ip",
],
"vm_state_rules": [
{
"state": "ACTIVE",
"billed": True,
"detail": "Full rate: vCPU, RAM, disk, volumes, floating IPs.",
},
{
"state": "SHUTOFF (stopped) — e.g. VM down 5 days",
"billed": True,
"detail": (
"STILL FULLY BILLED. Nova keeps the vCPU/RAM/disk quota "
"allocated (capacity reserved), so openstack_nova_limits_* "
"metrics do not change on power-off. Billing only stops when "
"the allocation is released."
),
},
{
"state": "PAUSED / SUSPENDED",
"billed": True,
"detail": "RAM stays reserved on the host; quota unchanged.",
},
{
"state": "SHELVED_OFFLOADED",
"billed": "partial",
"detail": (
"vCPU/RAM freed from quota -> compute not billed. Root/data "
"volumes in Cinder keep billing; image resumes on unshelve."
),
},
{
"state": "DELETED",
"billed": "until deletion",
"detail": (
"CloudKitty rates hourly windows, so a VM deleted mid-month "
"is billed only for the hours it existed."
),
},
],
"how_to_stop_billing_a_vm": (
"Shelve it (openstack server shelve, then it offloads) or delete it. "
"Stopping/shutting off the VM does NOT stop billing."
),
}
@asynccontextmanager
async def lifespan(app: FastAPI):
global ck, osx
ck = CloudKittyClient(settings)
osx = OpenStackClient(settings)
yield
await ck.close()
await osx.close()
app = FastAPI(
title="ConnectVM Billing API",
version="1.0.0",
description="Aggregated tenant billing on top of CloudKitty.",
lifespan=lifespan,
)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/admin", response_class=HTMLResponse)
async def admin_ui():
"""Admin billing dashboard."""
return FileResponse(STATIC_DIR / "admin.html")
async def _tenant_resources(tenant: str, year: int | None, month: int | None):
"""Shared loader: period, inventory, INR cost rows, report data."""
begin, end = aggregator.month_range(year, month, settings.billing_tz)
now = datetime.now(begin.tzinfo)
projects = await osx.projects()
project_id = projects.get(tenant)
if not project_id:
raise HTTPException(404, f"keystone project '{tenant}' not found")
total_rows, servers, volumes, fips = await asyncio.gather(
ck.summary(begin=begin, end=end, groupby=("tenant",)),
osx.servers(project_id),
osx.volumes(project_id),
osx.floatingips(project_id),
)
mine = [r for r in total_rows if (r.get("tenant") or "") == tenant]
ck_total = (mine[0].get("rate", 0) or 0) if mine else 0.0
vms = reporting.vm_rows(servers, begin, end, now)
vols = reporting.volume_rows(volumes, begin, end, now)
fip_list = reporting.fip_rows(fips, begin, end, now)
report = reporting.build_report(tenant, begin, end, ck_total, vms, vols, fip_list)
report["pricing"] = pricing.RATE_CARD_INFO
return report
@app.get("/api/v1/billing/tenants/{tenant}/resources")
async def tenant_resources(tenant: str, year: int | None = None, month: int | None = None):
"""Admin drill-down: every VM / volume / FIP with hours and INR cost."""
return await _tenant_resources(tenant, year, month)
@app.get("/api/v1/billing/summary-inr")
async def summary_inr(year: int | None = None, month: int | None = None):
"""All tenants: INR bill-to-date and monthly run-rate (rate card)."""
begin, end = aggregator.month_range(year, month, settings.billing_tz)
now = datetime.now(begin.tzinfo)
total_rows, projects = await asyncio.gather(
ck.summary(begin=begin, end=end, groupby=("tenant",)),
osx.projects(),
)
sem = asyncio.Semaphore(6)
async def one_tenant(tname: str):
pid = projects.get(tname)
base = {"tenant": tname}
if not pid:
return {**base, "error": "no keystone project"}
async with sem:
servers, volumes, fips = await asyncio.gather(
osx.servers(pid), osx.volumes(pid), osx.floatingips(pid)
)
vms = reporting.vm_rows(servers, begin, end, now)
vols = reporting.volume_rows(volumes, begin, end, now)
fip_rows = reporting.fip_rows(fips, begin, end, now)
all_rows = vms + vols + fip_rows
return {
**base,
"bill_to_date": round(sum(r["est_cost"] for r in all_rows), 2),
"run_rate_per_month": round(sum(r["monthly_rate"] for r in all_rows), 2),
"vms": len(vms),
"volumes": len(vols),
"floating_ips": len(fip_rows),
"stopped_vms": sum(1 for r in vms if r["status"] in ("SHUTOFF", "ERROR")),
}
names = [(r.get("tenant") or "") for r in total_rows]
tenants = await asyncio.gather(*(one_tenant(n) for n in names if n))
tenants.sort(key=lambda t: t.get("bill_to_date") or 0, reverse=True)
return {
"period": {"begin": begin.isoformat(), "end": end.isoformat()},
"currency": "INR",
"total_bill_to_date": round(
sum(t.get("bill_to_date") or 0 for t in tenants), 2
),
"tenants": tenants,
}
@app.get("/api/v1/billing/tenants/{tenant}/report")
async def tenant_report_json(tenant: str, year: int | None = None, month: int | None = None):
return await _tenant_resources(tenant, year, month)
@app.get("/api/v1/billing/tenants/{tenant}/report.html", response_class=HTMLResponse)
async def tenant_report_html(tenant: str, year: int | None = None, month: int | None = None):
"""User-facing report page (this is what gets emailed to the tenant)."""
report = await _tenant_resources(tenant, year, month)
return HTMLResponse(reporting.render_html(report))
class SendRequest(BaseModel):
to_email: str
@app.post("/api/v1/billing/tenants/{tenant}/send")
async def tenant_report_send(tenant: str, req: SendRequest,
year: int | None = None, month: int | None = None):
"""Email the report to the tenant (after admin review in /admin)."""
report = await _tenant_resources(tenant, year, month)
html = reporting.render_html(report)
subject = (
f"ConnectVM billing report — {tenant} "
f"({report['period']['begin'][:10]} to {report['period']['end'][:10]})"
)
try:
await mailer.send_report(settings, req.to_email, subject, html)
except mailer.MailNotConfigured as e:
raise HTTPException(
400,
f"{e}. Review/download instead: "
f"/api/v1/billing/tenants/{tenant}/report.html",
)
return {"sent": True, "to": req.to_email, "subject": subject}
@app.get("/api/v1/billing/policy")
async def policy():
"""How power states affect billing (the 'VM down 5 days' question)."""
return BILLING_POLICY
@app.get("/api/v1/billing/summary")
async def summary(
year: int | None = Query(None),
month: int | None = Query(None),
groupby: str = Query("tenant", description="comma-separated, e.g. tenant,type"),
):
"""All-tenant totals for a month (defaults to current month)."""
begin, end = aggregator.month_range(year, month, settings.billing_tz)
rows = await ck.summary(begin=begin, end=end, groupby=tuple(groupby.split(",")))
tenants = aggregator.tenant_costs(rows) if "tenant" in groupby else rows
return {
"period": {"begin": begin.isoformat(), "end": end.isoformat()},
"currency_note": "rate unit is whatever CloudKitty hashmap prices define",
"total_rate": round(sum(t.get("rate", 0) for t in tenants), 2)
if tenants
else 0,
"tenants": tenants,
}
@app.get("/api/v1/billing/tenants/{tenant}")
async def tenant_detail(tenant: str, year: int | None = None, month: int | None = None):
"""One tenant: month total, per-metric breakdown, month-end projection."""
begin, end = aggregator.month_range(year, month, settings.billing_tz)
total_rows, metric_rows = await asyncio.gather(
ck.summary(begin=begin, end=end, groupby=("tenant",)),
ck.summary(begin=begin, end=end, groupby=("tenant", "type")),
)
mine = [r for r in total_rows if (r.get("tenant") or "") == tenant]
if not mine:
raise HTTPException(404, f"no billing data for tenant '{tenant}'")
rate = mine[0].get("rate", 0) or 0
return {
"tenant": tenant,
"period": {"begin": begin.isoformat(), "end": end.isoformat()},
"rate_so_far": round(rate, 6),
"qty_so_far": mine[0].get("qty", 0),
"metrics": aggregator.per_metric(metric_rows, tenant),
"projection": aggregator.projection(rate, begin, end, settings.billing_tz),
}
@app.get("/api/v1/billing/daily")
async def daily(
tenant: str | None = Query(None, description="filter to one tenant"),
year: int | None = None,
month: int | None = None,
):
"""Day-by-day cost rollup, month-to-date."""
begin, end = aggregator.month_range(year, month, settings.billing_tz)
days = list(aggregator.days_of_month(begin, end))
sem = asyncio.Semaphore(4)
async def one_day(d_begin, d_end):
async with sem:
rows = await ck.summary(begin=d_begin, end=d_end, groupby=("tenant",))
if tenant:
rows = [r for r in rows if (r.get("tenant") or "") == tenant]
return {
"date": d_begin.date().isoformat(),
"rate": round(sum(r.get("rate", 0) or 0 for r in rows), 6),
"tenants": aggregator.tenant_costs(rows) if not tenant else None,
}
results = await asyncio.gather(*(one_day(b, e) for b, e in days))
for r in results:
if r["tenants"] is None:
del r["tenants"]
return {
"period": {"begin": begin.isoformat(), "end": end.isoformat()},
"tenant": tenant or "ALL",
"days": results,
}