Coverage for gco / services / api_routes / cost.py: 100.00%
60 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +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# The Service listens on the container port (8080) rather than 80: the VPC
34# CNI's network policy enforcement wants Service port == container port, and
35# the manifest processor's egress rule names 8080.
36_DEFAULT_COST_MONITOR_URL = "http://cost-monitor.gco-system.svc.cluster.local:8080"
37_PROXY_TIMEOUT_SECONDS = 30.0
38_REPORT_TIMEOUT_SECONDS = 120.0
40_DISABLED_DETAIL = (
41 "Cost monitoring is unavailable in this region. Enable cost_monitoring "
42 "(and cluster_observability) in cdk.json and redeploy, or check the "
43 "cost-monitor Deployment in gco-system."
44)
47class CostReportRequest(BaseModel):
48 """Request body for POST /api/v1/cost/reports."""
50 window_hours: int = Field(
51 24,
52 ge=1,
53 le=168,
54 description="Trailing window the ad-hoc report covers, in hours",
55 )
56 include_rows: bool = Field(
57 False,
58 description="Include the normalized allocation rows in the response",
59 )
62def _cost_monitor_base_url() -> str:
63 return os.getenv("COST_MONITOR_URL", _DEFAULT_COST_MONITOR_URL).rstrip("/")
66async def _proxy_get(path: str, params: dict[str, Any]) -> dict[str, Any]:
67 url = f"{_cost_monitor_base_url()}{path}"
68 try:
69 async with httpx.AsyncClient(timeout=_PROXY_TIMEOUT_SECONDS) as client:
70 response = await client.get(url, params=params)
71 except httpx.HTTPError as exc:
72 logger.warning("Cost monitor unreachable at %s: %s", url, exc)
73 raise HTTPException(status_code=503, detail=_DISABLED_DETAIL) from exc
74 return _relay_json(response)
77def _relay_json(response: httpx.Response) -> dict[str, Any]:
78 """Return the cost-monitor JSON body, propagating its error statuses."""
79 try:
80 payload = response.json()
81 except ValueError as exc:
82 raise HTTPException(
83 status_code=502, detail="Cost monitor returned a non-JSON body"
84 ) from exc
85 if response.status_code >= 400:
86 detail = payload.get("detail") if isinstance(payload, dict) else None
87 raise HTTPException(
88 status_code=response.status_code,
89 detail=str(detail or "Cost monitor request failed"),
90 )
91 if not isinstance(payload, dict):
92 raise HTTPException(status_code=502, detail="Cost monitor returned a non-object body")
93 return payload
96@router.get("/status")
97async def get_cost_status() -> Response:
98 """Cost monitoring status for this region, including OpenCost health."""
99 payload = await _proxy_get("/internal/status", {})
100 return JSONResponse(status_code=200, content=payload)
103@router.get("/reports")
104async def list_cost_reports(
105 adhoc: bool = Query(False, description="List ad-hoc instead of scheduled reports"),
106 limit: int = Query(50, ge=1, le=1000, description="Maximum objects returned"),
107) -> Response:
108 """List this region's most recent cost report objects in S3."""
109 payload = await _proxy_get("/internal/reports", {"adhoc": str(adhoc).lower(), "limit": limit})
110 return JSONResponse(status_code=200, content=payload)
113@router.post("/reports")
114async def generate_cost_report(request: CostReportRequest) -> Response:
115 """Generate an ad-hoc cost report for the trailing window."""
116 url = f"{_cost_monitor_base_url()}/internal/reports"
117 try:
118 async with httpx.AsyncClient(timeout=_REPORT_TIMEOUT_SECONDS) as client:
119 response = await client.post(
120 url,
121 json={
122 "window_hours": request.window_hours,
123 "include_rows": request.include_rows,
124 },
125 )
126 except httpx.HTTPError as exc:
127 logger.warning("Cost monitor unreachable at %s: %s", url, exc)
128 raise HTTPException(status_code=503, detail=_DISABLED_DETAIL) from exc
129 payload = _relay_json(response)
130 payload.setdefault("timestamp", datetime.now(UTC).isoformat())
131 return JSONResponse(status_code=201, content=payload)