Coverage for gco / services / cost_api.py: 100.00%

115 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""Cost Monitor HTTP service. 

2 

3Runs inside the ``cost-monitor`` Deployment (gco-system) and serves: 

4 

5- ``/healthz`` / ``/readyz`` — Kubernetes probes. 

6- ``/metrics`` — Prometheus metrics for the in-cluster scrape. 

7- ``/internal/status`` — service + OpenCost health, including the live 

8 "returning data" probe release validation gates on. 

9- ``GET /internal/reports`` — recent report objects for this region. 

10- ``POST /internal/reports`` — ad-hoc report generation. 

11 

12The service is cluster-internal (ClusterIP, default-deny ingress except the 

13manifest processor): the *authenticated* public surface is the manifest API's 

14``/api/v1/cost/*`` router, which proxies here. A background task writes the 

15scheduled interval reports. 

16 

17The report bucket is discovered from SSM (see :mod:`gco.services.cost_monitor`); 

18until the monitoring stack has published it, the report endpoints answer 503 

19and ``/internal/status`` shows ``bucket: null`` with the wait in ``last_error``. 

20""" 

21 

22from __future__ import annotations 

23 

24import asyncio 

25import logging 

26import os 

27from collections.abc import AsyncIterator 

28from contextlib import asynccontextmanager 

29from datetime import UTC, datetime, timedelta 

30from typing import Any 

31 

32from fastapi import FastAPI, HTTPException, Query 

33from pydantic import BaseModel, Field 

34 

35from gco.services import tracing 

36from gco.services.cost_monitor import ( 

37 CostMonitor, 

38 CostReportBucketUnavailableError, 

39 OpenCostUnavailableError, 

40 ReportWriteError, 

41 create_cost_monitor_from_env, 

42 preload_report_writer, 

43) 

44from gco.services.structured_logging import configure_structured_logging 

45 

46logging.basicConfig( 

47 level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 

48) 

49logger = logging.getLogger(__name__) 

50 

51#: Populated by the lifespan handler; read by the route handlers. 

52cost_monitor: CostMonitor | None = None 

53 

54_SCHEDULER_TICK_SECONDS = 60.0 

55 

56 

57class AdhocReportRequest(BaseModel): 

58 """Request body for POST /internal/reports.""" 

59 

60 window_hours: int = Field( 

61 24, 

62 ge=1, 

63 le=168, 

64 description="Trailing window the report covers, in hours", 

65 ) 

66 include_rows: bool = Field( 

67 False, 

68 description="Include the normalized allocation rows in the response", 

69 ) 

70 

71 

72def _check_monitor() -> CostMonitor: 

73 if cost_monitor is None: 

74 raise HTTPException(status_code=503, detail="Cost monitor not initialized") 

75 return cost_monitor 

76 

77 

78async def _scheduled_report_loop(monitor: CostMonitor, stop: asyncio.Event) -> None: 

79 """Write the aligned interval report; failures retry on the next tick.""" 

80 while not stop.is_set(): 

81 try: 

82 await asyncio.to_thread(monitor.run_scheduled_once) 

83 except asyncio.CancelledError: 

84 raise 

85 except Exception as exc: # the loop must survive any pass failure 

86 logger.warning("Scheduled cost report pass failed: %s", exc) 

87 try: 

88 await asyncio.wait_for(stop.wait(), timeout=_SCHEDULER_TICK_SECONDS) 

89 except TimeoutError: 

90 continue 

91 

92 

93@asynccontextmanager 

94async def lifespan(app: FastAPI) -> AsyncIterator[None]: 

95 """Initialize the monitor and run the scheduled reporter until shutdown.""" 

96 global cost_monitor 

97 

98 logger.info("Starting Cost Monitor Service") 

99 # Before the server listens: the startup probe covers this window, the 

100 # liveness probe does not cover the first scheduled pass. 

101 preload_report_writer() 

102 # The scheduled loop runs on the instance this lifespan built, whatever 

103 # the module global holds by then. It holds no upstream connections to 

104 # release: every OpenCost call builds and closes its own client. 

105 monitor = create_cost_monitor_from_env() 

106 cost_monitor = monitor 

107 configure_structured_logging( 

108 service_name="cost-monitor", 

109 cluster_id=monitor.cluster, 

110 region=monitor.region, 

111 ) 

112 stop = asyncio.Event() 

113 loop_task = asyncio.create_task( 

114 _scheduled_report_loop(monitor, stop), 

115 name="cost-monitor-scheduled-reports", 

116 ) 

117 app.state.scheduled_report_task = loop_task 

118 try: 

119 yield 

120 finally: 

121 stop.set() 

122 try: 

123 await asyncio.wait_for(loop_task, timeout=30) 

124 except TimeoutError: 

125 loop_task.cancel() 

126 # Last, so spans from the final scheduled pass are exported too. 

127 tracing.shutdown_tracing() 

128 logger.info("Shutting down Cost Monitor Service") 

129 

130 

131app = FastAPI( 

132 title="GCO Cost Monitor", 

133 description="Scheduled and on-demand OpenCost allocation reporting", 

134 version="1.0.0", 

135 lifespan=lifespan, 

136) 

137 

138# Server spans for every route except the probe and scrape paths, plus the 

139# OpenCost client spans under them; inert unless GCO_TRACING_ENABLED=true. 

140tracing.configure_tracing("cost-monitor") 

141tracing.instrument_fastapi_app(app) 

142 

143from gco.services.service_metrics import mount_metrics # noqa: E402 

144 

145mount_metrics(app, "cost-monitor") 

146 

147 

148@app.get("/healthz", tags=["Health"]) 

149async def kubernetes_health_check() -> dict[str, str]: 

150 """Kubernetes-style liveness probe.""" 

151 return {"status": "ok"} 

152 

153 

154@app.get("/readyz", tags=["Health"]) 

155async def kubernetes_readiness_check() -> dict[str, str]: 

156 """Readiness requires the monitor plus a live scheduled-report task.""" 

157 if cost_monitor is None: 

158 raise HTTPException(status_code=503, detail="Cost monitor not ready") 

159 task = getattr(app.state, "scheduled_report_task", None) 

160 if task is not None and task.done(): 

161 raise HTTPException(status_code=503, detail="Scheduled reporter stopped unexpectedly") 

162 return {"status": "ready"} 

163 

164 

165@app.get("/internal/status", tags=["Cost"]) 

166async def get_status() -> dict[str, Any]: 

167 """Service status including OpenCost health and the data-returning probe.""" 

168 monitor = _check_monitor() 

169 return await asyncio.to_thread(monitor.status) 

170 

171 

172@app.get("/internal/reports", tags=["Cost"]) 

173async def list_reports( 

174 adhoc: bool = Query(False, description="List ad-hoc instead of scheduled reports"), 

175 limit: int = Query(50, ge=1, le=1000, description="Maximum objects returned"), 

176) -> dict[str, Any]: 

177 """List this region's most recent report objects, newest first.""" 

178 monitor = _check_monitor() 

179 try: 

180 reports = await asyncio.to_thread(monitor.list_reports, adhoc=adhoc, limit=limit) 

181 except CostReportBucketUnavailableError as exc: 

182 raise HTTPException(status_code=503, detail=str(exc)) from exc 

183 except Exception as exc: # surface S3 failures as 502 

184 raise HTTPException(status_code=502, detail=f"Failed to list reports: {exc}") from exc 

185 return { 

186 "timestamp": datetime.now(UTC).isoformat(), 

187 "region": monitor.region, 

188 "bucket": monitor.bucket, 

189 "count": len(reports), 

190 "reports": reports, 

191 } 

192 

193 

194@app.post("/internal/reports", tags=["Cost"], status_code=201) 

195async def generate_adhoc_report(request: AdhocReportRequest) -> dict[str, Any]: 

196 """Generate one ad-hoc allocation report for the trailing window.""" 

197 monitor = _check_monitor() 

198 window_end = datetime.now(UTC) 

199 window_start = window_end - timedelta(hours=request.window_hours) 

200 try: 

201 result = await asyncio.to_thread( 

202 monitor.generate_report, 

203 window_start, 

204 window_end, 

205 adhoc=True, 

206 include_rows=request.include_rows, 

207 ) 

208 except (CostReportBucketUnavailableError, OpenCostUnavailableError) as exc: 

209 raise HTTPException(status_code=503, detail=str(exc)) from exc 

210 except ReportWriteError as exc: 

211 raise HTTPException(status_code=502, detail=str(exc)) from exc 

212 except ValueError as exc: 

213 raise HTTPException(status_code=422, detail=str(exc)) from exc 

214 body: dict[str, Any] = { 

215 "timestamp": datetime.now(UTC).isoformat(), 

216 "region": monitor.region, 

217 "bucket": monitor.bucket, 

218 "report": result.summary(), 

219 } 

220 if request.include_rows: 

221 body["rows"] = result.rows 

222 return body 

223 

224 

225def create_app() -> FastAPI: 

226 """Factory function to create the FastAPI app.""" 

227 return app 

228 

229 

230# The pod manifest gives the kubelet terminationGracePeriodSeconds > preStop + 

231# this budget, so Uvicorn can finish in-flight requests before SIGKILL. The 

232# same variable drives the TLS sidecar's drain (gco.services.tls_proxy). 

233DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 20 

234 

235 

236def _run_server() -> None: 

237 """Run Uvicorn with the same drain budget declared by the pod manifest.""" 

238 import uvicorn 

239 

240 from gco.services.uvicorn_runtime import describe_uvicorn_runtime 

241 

242 host = os.getenv("HOST", "0.0.0.0") # container listener 

243 port = int(os.getenv("PORT", "8080")) 

244 log_level = os.getenv("LOG_LEVEL", "info").lower() 

245 graceful_shutdown_seconds = int( 

246 os.getenv( 

247 "GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS", 

248 str(DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS), 

249 ) 

250 ) 

251 

252 loop_impl, http_impl = describe_uvicorn_runtime() 

253 logger.info( 

254 "Starting Cost Monitor API on %s:%d (uvicorn loop=%s http=%s)", 

255 host, 

256 port, 

257 loop_impl, 

258 http_impl, 

259 ) 

260 

261 uvicorn.run( 

262 "gco.services.cost_api:app", 

263 host=host, 

264 port=port, 

265 log_level=log_level, 

266 reload=False, 

267 timeout_graceful_shutdown=graceful_shutdown_seconds, 

268 ) 

269 

270 

271if __name__ == "__main__": 

272 _run_server()