Coverage for gco/services/api_routes/cost.py: 100.00%
60 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""Cost reporting endpoints — the authenticated /api/v1/cost/* surface.
3The manifest API is the cluster's authenticated ingress (HMAC middleware +
4IAM-authorized API Gateway in front), so cost reporting is exposed here and
5proxied to the internal ``cost-monitor`` ClusterIP service, which owns the
6OpenCost queries and the S3 report pipeline. Keeping the cost-monitor
7unexposed preserves its single-writer isolation while giving operators one
8API host for every control-plane call:
10- ``GET /api/v1/cost/status`` — service + OpenCost health for this region.
11- ``GET /api/v1/cost/reports`` — recent scheduled/ad-hoc report objects.
12- ``POST /api/v1/cost/reports`` — generate an ad-hoc report now.
14When cost monitoring is disabled the cost-monitor Deployment does not exist,
15so the proxy maps connection failures to a clear 503.
16"""
18from __future__ import annotations
20import logging
21import os
22from datetime import UTC, datetime
23from typing import Any
25import httpx
26from fastapi import APIRouter, HTTPException, Query
27from fastapi.responses import JSONResponse, Response
28from pydantic import BaseModel, Field
30router = APIRouter(prefix="/api/v1/cost", tags=["Cost"])
31logger = logging.getLogger(__name__)
33_DEFAULT_COST_MONITOR_URL = "http://cost-monitor.gco-system.svc.cluster.local"
34_PROXY_TIMEOUT_SECONDS = 30.0
35_REPORT_TIMEOUT_SECONDS = 120.0
37_DISABLED_DETAIL = (
38 "Cost monitoring is unavailable in this region. Enable cost_monitoring "
39 "(and cluster_observability) in cdk.json and redeploy, or check the "
40 "cost-monitor Deployment in gco-system."
41)
44class CostReportRequest(BaseModel):
45 """Request body for POST /api/v1/cost/reports."""
47 window_hours: int = Field(
48 24,
49 ge=1,
50 le=168,
51 description="Trailing window the ad-hoc report covers, in hours",
52 )
53 include_rows: bool = Field(
54 False,
55 description="Include the normalized allocation rows in the response",
56 )
59def _cost_monitor_base_url() -> str:
60 return os.getenv("COST_MONITOR_URL", _DEFAULT_COST_MONITOR_URL).rstrip("/")
63async def _proxy_get(path: str, params: dict[str, Any]) -> dict[str, Any]:
64 url = f"{_cost_monitor_base_url()}{path}"
65 try:
66 async with httpx.AsyncClient(timeout=_PROXY_TIMEOUT_SECONDS) as client:
67 response = await client.get(url, params=params)
68 except httpx.HTTPError as exc:
69 logger.warning("Cost monitor unreachable at %s: %s", url, exc)
70 raise HTTPException(status_code=503, detail=_DISABLED_DETAIL) from exc
71 return _relay_json(response)
74def _relay_json(response: httpx.Response) -> dict[str, Any]:
75 """Return the cost-monitor JSON body, propagating its error statuses."""
76 try:
77 payload = response.json()
78 except ValueError as exc:
79 raise HTTPException(
80 status_code=502, detail="Cost monitor returned a non-JSON body"
81 ) from exc
82 if response.status_code >= 400:
83 detail = payload.get("detail") if isinstance(payload, dict) else None
84 raise HTTPException(
85 status_code=response.status_code,
86 detail=str(detail or "Cost monitor request failed"),
87 )
88 if not isinstance(payload, dict):
89 raise HTTPException(status_code=502, detail="Cost monitor returned a non-object body")
90 return payload
93@router.get("/status")
94async def get_cost_status() -> Response:
95 """Cost monitoring status for this region, including OpenCost health."""
96 payload = await _proxy_get("/internal/status", {})
97 return JSONResponse(status_code=200, content=payload)
100@router.get("/reports")
101async def list_cost_reports(
102 adhoc: bool = Query(False, description="List ad-hoc instead of scheduled reports"),
103 limit: int = Query(50, ge=1, le=1000, description="Maximum objects returned"),
104) -> Response:
105 """List this region's most recent cost report objects in S3."""
106 payload = await _proxy_get("/internal/reports", {"adhoc": str(adhoc).lower(), "limit": limit})
107 return JSONResponse(status_code=200, content=payload)
110@router.post("/reports")
111async def generate_cost_report(request: CostReportRequest) -> Response:
112 """Generate an ad-hoc cost report for the trailing window."""
113 url = f"{_cost_monitor_base_url()}/internal/reports"
114 try:
115 async with httpx.AsyncClient(timeout=_REPORT_TIMEOUT_SECONDS) as client:
116 response = await client.post(
117 url,
118 json={
119 "window_hours": request.window_hours,
120 "include_rows": request.include_rows,
121 },
122 )
123 except httpx.HTTPError as exc:
124 logger.warning("Cost monitor unreachable at %s: %s", url, exc)
125 raise HTTPException(status_code=503, detail=_DISABLED_DETAIL) from exc
126 payload = _relay_json(response)
127 payload.setdefault("timestamp", datetime.now(UTC).isoformat())
128 return JSONResponse(status_code=201, content=payload)