257 lines
11 KiB
Python
257 lines
11 KiB
Python
"""Per-resource cost breakdown (INR rate card) and tenant report rendering.
|
|
|
|
Costs use the ConnectVM rate card (pricing.py): flavor monthly prices
|
|
prorated by billable hours in the period. The CloudKitty total is kept
|
|
alongside as the platform-metered reference.
|
|
"""
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pricing
|
|
|
|
|
|
def parse_ts(value: str | None) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
# OpenStack API timestamps are UTC but may omit the suffix
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
return dt
|
|
|
|
|
|
def hours_in_period(start: datetime | None, begin: datetime, end: datetime,
|
|
now: datetime) -> float:
|
|
start = max(start, begin) if start else begin
|
|
stop = min(now, end)
|
|
return max((stop - start).total_seconds() / 3600, 0.0)
|
|
|
|
|
|
def _prorate(monthly: float, hrs: float, total_hrs: float) -> float:
|
|
return monthly * (hrs / total_hrs) if total_hrs else 0.0
|
|
|
|
|
|
def vm_rows(servers: list[dict], begin, end, now) -> list[dict]:
|
|
total_hrs = pricing.period_hours(begin, end)
|
|
rows = []
|
|
for s in servers:
|
|
fl = s.get("flavor") or {}
|
|
vcpus = fl.get("vcpus", 0) or 0
|
|
ram_mb = fl.get("ram", 0) or 0
|
|
disk_gb = (fl.get("disk", 0) or 0) + (fl.get("ephemeral", 0) or 0)
|
|
started = parse_ts(s.get("OS-SRV-USG:launched_at")) or parse_ts(s.get("created"))
|
|
hrs = hours_in_period(started, begin, end, now)
|
|
age_days = max((now - started).total_seconds() / 86400, 0) if started else 0
|
|
monthly, source, mismatch = pricing.flavor_monthly(
|
|
fl.get("original_name", ""), vcpus, ram_mb, disk_gb
|
|
)
|
|
status = s.get("status", "?")
|
|
flags = []
|
|
if status == "SHUTOFF":
|
|
flags.append("STOPPED — still billing at full rate")
|
|
elif status == "ERROR":
|
|
flags.append("ERROR state — quota still allocated, still billing; delete to stop")
|
|
if mismatch:
|
|
flags.append("flavor spec differs from rate card — price needs review")
|
|
elif source == "computed":
|
|
flags.append("custom flavor — priced from add-on rates")
|
|
rows.append({
|
|
"name": s.get("name", "?"),
|
|
"id": s.get("id"),
|
|
"status": status,
|
|
"flavor": fl.get("original_name", "?"),
|
|
"vcpus": vcpus,
|
|
"ram_mb": ram_mb,
|
|
"disk_gb": disk_gb,
|
|
"started": started.isoformat() if started else None,
|
|
"age_days": round(age_days, 1),
|
|
"hours_in_period": round(hrs, 1),
|
|
"monthly_rate": round(monthly, 2),
|
|
"price_source": source,
|
|
"est_cost": round(_prorate(monthly, hrs, total_hrs), 2),
|
|
"flags": flags,
|
|
})
|
|
rows.sort(key=lambda r: r["est_cost"], reverse=True)
|
|
return rows
|
|
|
|
|
|
def volume_rows(volumes: list[dict], begin, end, now) -> list[dict]:
|
|
total_hrs = pricing.period_hours(begin, end)
|
|
rows = []
|
|
for v in volumes:
|
|
size = v.get("size", 0) or 0
|
|
created = parse_ts(v.get("created_at"))
|
|
hrs = hours_in_period(created, begin, end, now)
|
|
age_days = max((now - created).total_seconds() / 86400, 0) if created else 0
|
|
monthly = size * pricing.ADDON_DISK_PER_GB
|
|
attached = bool(v.get("attachments"))
|
|
flags = [] if attached else ["DETACHED — still billing"]
|
|
rows.append({
|
|
"name": v.get("name") or "(unnamed)",
|
|
"id": v.get("id"),
|
|
"status": v.get("status", "?"),
|
|
"size_gb": size,
|
|
"attached": attached,
|
|
"created": created.isoformat() if created else None,
|
|
"age_days": round(age_days, 1),
|
|
"hours_in_period": round(hrs, 1),
|
|
"monthly_rate": round(monthly, 2),
|
|
"est_cost": round(_prorate(monthly, hrs, total_hrs), 2),
|
|
"flags": flags,
|
|
})
|
|
rows.sort(key=lambda r: r["est_cost"], reverse=True)
|
|
return rows
|
|
|
|
|
|
def fip_rows(fips: list[dict], begin, end, now) -> list[dict]:
|
|
total_hrs = pricing.period_hours(begin, end)
|
|
rows = []
|
|
for f in fips:
|
|
created = parse_ts(f.get("created_at"))
|
|
hrs = hours_in_period(created, begin, end, now)
|
|
age_days = max((now - created).total_seconds() / 86400, 0) if created else 0
|
|
mapped = bool(f.get("port_id"))
|
|
flags = [] if mapped else ["UNASSIGNED — reserved, still billing"]
|
|
rows.append({
|
|
"address": f.get("floating_ip_address", "?"),
|
|
"id": f.get("id"),
|
|
"status": f.get("status", "?"),
|
|
"mapped": mapped,
|
|
"created": created.isoformat() if created else None,
|
|
"age_days": round(age_days, 1),
|
|
"hours_in_period": round(hrs, 1),
|
|
"monthly_rate": pricing.FIP_MONTHLY,
|
|
"est_cost": round(_prorate(pricing.FIP_MONTHLY, hrs, total_hrs), 2),
|
|
"flags": flags,
|
|
})
|
|
rows.sort(key=lambda r: r["est_cost"], reverse=True)
|
|
return rows
|
|
|
|
|
|
def build_report(tenant: str, begin, end, ck_total: float,
|
|
vms: list[dict], vols: list[dict], fips: list[dict]) -> dict:
|
|
all_rows = vms + vols + fips
|
|
accrued = sum(r["est_cost"] for r in all_rows)
|
|
run_rate = sum(r["monthly_rate"] for r in all_rows)
|
|
total_hrs = pricing.period_hours(begin, end)
|
|
now = datetime.now(begin.tzinfo)
|
|
remaining_hrs = max((end - now).total_seconds() / 3600, 0)
|
|
projected = accrued + run_rate * (remaining_hrs / total_hrs)
|
|
|
|
return {
|
|
"tenant": tenant,
|
|
"period": {"begin": begin.isoformat(), "end": end.isoformat()},
|
|
"generated_at": now.isoformat(),
|
|
"currency": "INR",
|
|
"bill_to_date": round(accrued, 2),
|
|
"run_rate_per_month": round(run_rate, 2),
|
|
"projected_month_end": round(projected, 2),
|
|
"cloudkitty_metered_total": round(ck_total, 4),
|
|
"counts": {
|
|
"vms": len(vms),
|
|
"volumes": len(vols),
|
|
"floating_ips": len(fips),
|
|
"stopped_vms_still_billing": sum(1 for r in vms if r["status"] == "SHUTOFF"),
|
|
"error_vms_still_billing": sum(1 for r in vms if r["status"] == "ERROR"),
|
|
"detached_volumes": sum(1 for r in vols if not r["attached"]),
|
|
"unassigned_fips": sum(1 for r in fips if not r["mapped"]),
|
|
},
|
|
"vms": vms,
|
|
"volumes": vols,
|
|
"floating_ips": fips,
|
|
"notes": [
|
|
"Billing is allocation-based: a STOPPED (SHUTOFF) or ERROR VM is "
|
|
"billed in full until shelved-offloaded or deleted.",
|
|
"Detached volumes and unassigned floating IPs keep billing.",
|
|
"VMs are charged their flavor's monthly price prorated by "
|
|
"billable hours; custom flavors use add-on rates "
|
|
"(₹480/vCPU, ₹192/GB RAM, ₹96/10 GB SSD per month).",
|
|
"Floating IPs: ₹300/month each. Snapshots, load balancers, "
|
|
"backup storage and bandwidth overage are not yet metered.",
|
|
],
|
|
}
|
|
|
|
|
|
def _table(headers: list[str], rows: list[list]) -> str:
|
|
head = "".join(f"<th>{h}</th>" for h in headers)
|
|
body = ""
|
|
for r in rows:
|
|
cells = "".join(f"<td>{c}</td>" for c in r)
|
|
body += f"<tr>{cells}</tr>"
|
|
if not body:
|
|
body = f"<tr><td colspan='{len(headers)}' class='empty'>none</td></tr>"
|
|
return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
|
|
|
|
|
|
def render_html(report: dict) -> str:
|
|
p = report["period"]
|
|
flags = lambda r: (
|
|
f"<br><span class='flag'>{' '.join(r['flags'])}</span>" if r["flags"] else ""
|
|
)
|
|
|
|
vms = _table(
|
|
["VM", "Status", "Flavor", "vCPU", "RAM", "Disk", "Age (days)",
|
|
"Billable hrs", "Rate/mo", "Cost to date"],
|
|
[[
|
|
v["name"], v["status"] + flags(v), v["flavor"], v["vcpus"],
|
|
f"{v['ram_mb'] // 1024} GB", f"{v['disk_gb']} GB", v["age_days"],
|
|
v["hours_in_period"], pricing.inr(v["monthly_rate"]),
|
|
pricing.inr(v["est_cost"]),
|
|
] for v in report["vms"]],
|
|
)
|
|
vols = _table(
|
|
["Volume", "Status", "Size", "Attached", "Age (days)", "Billable hrs",
|
|
"Rate/mo", "Cost to date"],
|
|
[[
|
|
v["name"], v["status"] + flags(v), f"{v['size_gb']} GB",
|
|
"yes" if v["attached"] else "no", v["age_days"], v["hours_in_period"],
|
|
pricing.inr(v["monthly_rate"]), pricing.inr(v["est_cost"]),
|
|
] for v in report["volumes"]],
|
|
)
|
|
fips = _table(
|
|
["Floating IP", "Status", "Mapped", "Age (days)", "Billable hrs",
|
|
"Rate/mo", "Cost to date"],
|
|
[[
|
|
f["address"], f["status"] + flags(f),
|
|
"yes" if f["mapped"] else "no", f["age_days"], f["hours_in_period"],
|
|
pricing.inr(f["monthly_rate"]), pricing.inr(f["est_cost"]),
|
|
] for f in report["floating_ips"]],
|
|
)
|
|
notes = "".join(f"<li>{n}</li>" for n in report["notes"])
|
|
c = report["counts"]
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><title>Billing report — {report['tenant']}</title>
|
|
<style>
|
|
body{{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;max-width:1080px;margin:32px auto;padding:0 20px;color:#1a1a2e}}
|
|
h1{{font-size:22px}} h2{{font-size:16px;margin-top:32px;border-bottom:2px solid #e5e7f0;padding-bottom:6px}}
|
|
.meta{{color:#666;font-size:13px}}
|
|
.total{{background:#f0f4ff;border:1px solid #d0d9f5;border-radius:10px;padding:16px 20px;margin:20px 0;font-size:15px}}
|
|
.total b{{font-size:24px}}
|
|
table{{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px}}
|
|
th{{text-align:left;background:#f7f8fc;padding:8px;border-bottom:2px solid #e0e3f0}}
|
|
td{{padding:8px;border-bottom:1px solid #eee}}
|
|
.flag{{color:#b45309;font-size:11px;font-weight:600}}
|
|
.empty{{color:#999}}
|
|
.notes{{font-size:12px;color:#555;background:#fafafa;border-radius:8px;padding:12px 18px;margin-top:28px}}
|
|
</style></head><body>
|
|
<h1>Billing report — {report['tenant']}</h1>
|
|
<div class="meta">Period: {p['begin'][:10]} → {p['end'][:10]} ·
|
|
Generated: {report['generated_at'][:19].replace('T',' ')} · Currency: INR</div>
|
|
|
|
<div class="total">
|
|
Bill to date: <b>{pricing.inr(report['bill_to_date'])}</b><br>
|
|
<span class="meta">Run-rate: {pricing.inr(report['run_rate_per_month'])}/month
|
|
· Projected month-end: {pricing.inr(report['projected_month_end'])}<br>
|
|
{c['vms']} VMs · {c['volumes']} volumes · {c['floating_ips']} floating IPs
|
|
· ⚠ {c['stopped_vms_still_billing']} stopped + {c['error_vms_still_billing']} error VMs still billing,
|
|
{c['detached_volumes']} detached volumes, {c['unassigned_fips']} unassigned FIPs</span>
|
|
</div>
|
|
|
|
<h2>Virtual machines</h2>{vms}
|
|
<h2>Volumes</h2>{vols}
|
|
<h2>Floating IPs</h2>{fips}
|
|
|
|
<div class="notes"><b>How billing works</b><ul>{notes}</ul></div>
|
|
</body></html>"""
|