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

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# 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 

39 

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) 

45 

46 

47class CostReportRequest(BaseModel): 

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

49 

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 ) 

60 

61 

62def _cost_monitor_base_url() -> str: 

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

64 

65 

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) 

75 

76 

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 

94 

95 

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) 

101 

102 

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) 

111 

112 

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)