Add main.py
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user