diff --git a/main.py b/main.py new file mode 100644 index 0000000..d0d9a25 --- /dev/null +++ b/main.py @@ -0,0 +1,312 @@ +"""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, + }