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