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

1"""Cost reporting endpoints — the authenticated /api/v1/cost/* surface. 

2 

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: 

9 

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. 

13 

14When cost monitoring is disabled the cost-monitor Deployment does not exist, 

15so the proxy maps connection failures to a clear 503. 

16""" 

17 

18from __future__ import annotations 

19 

20import logging 

21import os 

22from datetime import UTC, datetime 

23from typing import Any 

24 

25import httpx 

26from fastapi import APIRouter, HTTPException, Query 

27from fastapi.responses import JSONResponse, Response 

28from pydantic import BaseModel, Field 

29 

30router = APIRouter(prefix="/api/v1/cost", tags=["Cost"]) 

31logger = logging.getLogger(__name__) 

32 

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 

36 

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) 

42 

43 

44class CostReportRequest(BaseModel): 

45 """Request body for POST /api/v1/cost/reports.""" 

46 

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 ) 

57 

58 

59def _cost_monitor_base_url() -> str: 

60 return os.getenv("COST_MONITOR_URL", _DEFAULT_COST_MONITOR_URL).rstrip("/") 

61 

62 

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) 

72 

73 

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 

91 

92 

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) 

98 

99 

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) 

108 

109 

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)