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

156 statements  

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

1""" 

2Health API Service for GCO (Global Capacity Orchestrator on AWS). 

3 

4This FastAPI service exposes health status endpoints for: 

5- ALB health checks (/healthz, /readyz) 

6- Detailed health status (/api/v1/health) 

7- Resource utilization metrics (/api/v1/metrics) 

8- Service status information (/api/v1/status) 

9 

10The service runs a background task that continuously monitors cluster health 

11and caches the results for fast response times on health check endpoints. 

12 

13Endpoints: 

14 GET /healthz - Kubernetes liveness probe (always 200 if running) 

15 GET /readyz - Kubernetes readiness probe (200 if health monitor ready) 

16 GET /api/v1/health - Detailed health status (200 if healthy, 503 if not) 

17 GET /api/v1/metrics - Resource utilization metrics 

18 GET /api/v1/status - Service operational status 

19 

20Environment Variables: 

21 HOST: Bind address (default: 0.0.0.0) 

22 PORT: Listen port (default: 8080) 

23 LOG_LEVEL: Logging level (default: info) 

24 CLUSTER_NAME, REGION, *_THRESHOLD: See health_monitor.py 

25""" 

26 

27import asyncio 

28import contextlib 

29import logging 

30import os 

31from collections.abc import AsyncIterator 

32from contextlib import asynccontextmanager 

33from datetime import UTC, datetime 

34from typing import Any 

35 

36from fastapi import FastAPI, HTTPException, Request 

37from fastapi.responses import JSONResponse 

38 

39from gco.models import HealthStatus 

40from gco.services import tracing 

41from gco.services.auth_middleware import AuthenticationMiddleware 

42from gco.services.health_monitor import HealthMonitor, create_health_monitor_from_env 

43from gco.services.metrics_publisher import HealthMonitorMetrics 

44from gco.services.structured_logging import configure_structured_logging 

45from gco.services.webhook_dispatcher import ( 

46 WebhookDispatcher, 

47 create_webhook_dispatcher_from_env, 

48) 

49 

50logging.basicConfig( 

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

52) 

53logger = logging.getLogger(__name__) 

54 

55# Global health monitor instance 

56health_monitor: HealthMonitor | None = None 

57health_metrics: HealthMonitorMetrics | None = None 

58webhook_dispatcher: WebhookDispatcher | None = None 

59current_health_status: HealthStatus | None = None 

60health_check_task = None 

61 

62 

63@asynccontextmanager 

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

65 """ 

66 Application lifespan manager - starts and stops background health monitoring 

67 and webhook dispatcher. 

68 """ 

69 global health_monitor, health_metrics, health_check_task, webhook_dispatcher 

70 

71 # Startup 

72 logger.info("Starting Health API Service") 

73 try: 

74 health_monitor = create_health_monitor_from_env() 

75 

76 # Enable structured JSON logging now that we know cluster_id and region 

77 configure_structured_logging( 

78 service_name="health-api", 

79 cluster_id=health_monitor.cluster_id, 

80 region=health_monitor.region, 

81 ) 

82 # Initialize metrics publisher for CloudWatch custom metrics 

83 # Non-fatal: if credentials aren't available yet (e.g., Pod Identity agent 

84 # still starting), we skip metrics but keep serving health checks. 

85 try: 

86 health_metrics = HealthMonitorMetrics( 

87 cluster_name=health_monitor.cluster_id, 

88 region=health_monitor.region, 

89 ) 

90 except Exception as e: 

91 logger.warning(f"Failed to initialize CloudWatch metrics publisher: {e}") 

92 health_metrics = None 

93 health_task = asyncio.create_task(background_health_monitor()) 

94 health_check_task = health_task 

95 logger.info("Health monitoring started") 

96 

97 # Start webhook dispatcher for job event notifications 

98 try: 

99 webhook_dispatcher = create_webhook_dispatcher_from_env() 

100 await webhook_dispatcher.start() 

101 logger.info("Webhook dispatcher started") 

102 except Exception as e: 

103 logger.warning(f"Failed to start webhook dispatcher: {e}") 

104 # Don't fail startup if webhook dispatcher fails - it's not critical 

105 webhook_dispatcher = None 

106 

107 except Exception as e: 

108 logger.error(f"Failed to start health monitoring: {e}") 

109 raise 

110 

111 try: 

112 yield 

113 finally: 

114 # Shutdown must also run when a lifespan consumer raises. 

115 logger.info("Shutting down Health API Service") 

116 health_task.cancel() 

117 with contextlib.suppress(asyncio.CancelledError): 

118 await health_task 

119 if webhook_dispatcher: 

120 await webhook_dispatcher.stop() 

121 logger.info("Webhook dispatcher stopped") 

122 logger.info("Health monitoring stopped") 

123 tracing.shutdown_tracing() 

124 

125 

126# Create FastAPI app with lifespan management 

127app = FastAPI( 

128 title="GCO Health Monitor API", 

129 description="Health monitoring service for GCO (Global Capacity Orchestrator on AWS) EKS clusters", 

130 version="1.0.0", 

131 lifespan=lifespan, 

132) 

133 

134# Server spans for every route except the probe and scrape paths; inert unless 

135# GCO_TRACING_ENABLED=true. The webhook dispatcher in this process is never 

136# traced (its URLs carry credentials). 

137tracing.configure_tracing("health-monitor") 

138tracing.instrument_fastapi_app(app) 

139 

140# Add authentication middleware 

141app.add_middleware(AuthenticationMiddleware) 

142 

143# Expose Prometheus /metrics for the in-cluster observability scrape. The auth 

144# middleware exempts /metrics, so the cluster Prometheus reaches it over the 

145# existing service port without credentials. 

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

147 

148mount_metrics(app, "health-monitor") 

149 

150 

151async def background_health_monitor() -> None: 

152 """ 

153 Background task that continuously monitors cluster health 

154 and publishes metrics to CloudWatch 

155 """ 

156 global current_health_status 

157 

158 while True: 

159 try: 

160 if health_monitor is None: 

161 logger.warning("Health monitor not initialized, waiting...") 

162 await asyncio.sleep(10) 

163 continue 

164 current_health_status = await health_monitor.get_health_status() 

165 logger.debug(f"Health status updated: {current_health_status.status}") 

166 

167 # Periodically sync ALB hostname in SSM (self-healing) 

168 await health_monitor.sync_alb_registration() 

169 

170 # Publish metrics to CloudWatch for dashboard visibility 

171 if health_metrics and current_health_status: 

172 try: 

173 health_metrics.publish_resource_utilization( 

174 cpu_percent=current_health_status.resource_utilization.cpu, 

175 memory_percent=current_health_status.resource_utilization.memory, 

176 gpu_percent=current_health_status.resource_utilization.gpu, 

177 active_jobs=current_health_status.active_jobs, 

178 ) 

179 # Also publish health status 

180 threshold_violations = ( 

181 current_health_status.get_threshold_violations() 

182 if hasattr(current_health_status, "get_threshold_violations") 

183 else [] 

184 ) 

185 health_metrics.publish_health_status( 

186 is_healthy=(current_health_status.status == "healthy"), 

187 threshold_violations=threshold_violations, 

188 ) 

189 logger.debug("Published health metrics to CloudWatch") 

190 except Exception as e: 

191 logger.warning(f"Failed to publish health metrics to CloudWatch: {e}") 

192 

193 # Sleep for 30 seconds before next check 

194 await asyncio.sleep(30) 

195 

196 except asyncio.CancelledError: 

197 logger.info("Background health monitoring cancelled") 

198 break 

199 except Exception as e: 

200 logger.error(f"Error in background health monitoring: {e}") 

201 await asyncio.sleep(10) # Shorter sleep on error 

202 

203 

204@app.get("/") 

205async def root() -> dict[str, Any]: 

206 """Root endpoint with basic service information""" 

207 return { 

208 "service": "GCO Health Monitor API", 

209 "version": "1.0.0", 

210 "status": "running", 

211 "endpoints": { 

212 "health": "/api/v1/health", 

213 "metrics": "/api/v1/metrics", 

214 "status": "/api/v1/status", 

215 }, 

216 } 

217 

218 

219@app.get("/api/v1/health") 

220async def health_check() -> JSONResponse: 

221 """ 

222 Primary health check endpoint for ALB health checks 

223 Returns 200 if cluster is healthy, 503 if unhealthy 

224 """ 

225 global current_health_status 

226 

227 try: 

228 status = current_health_status 

229 # If we don't have a current status, get one immediately 

230 if status is None: 

231 if health_monitor is None: 

232 raise HTTPException(status_code=503, detail="Health monitor not initialized") 

233 status = await health_monitor.get_health_status() 

234 current_health_status = status 

235 

236 # Check if status is too old (more than 2 minutes) 

237 age_seconds = (datetime.now(UTC) - status.timestamp).total_seconds() 

238 if age_seconds > 120 and health_monitor is not None: # 2 minutes 

239 logger.warning(f"Health status is {age_seconds:.0f} seconds old, refreshing") 

240 status = await health_monitor.get_health_status() 

241 current_health_status = status 

242 

243 # Return appropriate HTTP status based on health 

244 if status.status == "healthy": 

245 return JSONResponse( 

246 status_code=200, 

247 content={ 

248 "status": "healthy", 

249 "timestamp": status.timestamp.isoformat(), 

250 "cluster_id": status.cluster_id, 

251 "region": status.region, 

252 }, 

253 ) 

254 return JSONResponse( 

255 status_code=503, 

256 content={ 

257 "status": "unhealthy", 

258 "timestamp": status.timestamp.isoformat(), 

259 "cluster_id": status.cluster_id, 

260 "region": status.region, 

261 "message": status.message, 

262 }, 

263 ) 

264 

265 except Exception as e: 

266 logger.error(f"Health check failed: {e}") 

267 return JSONResponse( 

268 status_code=503, 

269 content={ 

270 "status": "unhealthy", 

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

272 "error": "health monitor unavailable", 

273 }, 

274 ) 

275 

276 

277@app.get("/api/v1/metrics") 

278async def get_metrics() -> dict[str, Any]: 

279 """ 

280 Detailed metrics endpoint with resource utilization information 

281 """ 

282 global current_health_status 

283 

284 try: 

285 # Get fresh metrics if needed 

286 if current_health_status is None: 

287 if health_monitor is None: 

288 raise HTTPException(status_code=503, detail="Health monitor not initialized") 

289 current_health_status = await health_monitor.get_health_status() 

290 

291 return { 

292 "cluster_id": current_health_status.cluster_id, 

293 "region": current_health_status.region, 

294 "timestamp": current_health_status.timestamp.isoformat(), 

295 "status": current_health_status.status, 

296 "resource_utilization": { 

297 "cpu_percent": round(current_health_status.resource_utilization.cpu, 2), 

298 "memory_percent": round(current_health_status.resource_utilization.memory, 2), 

299 "gpu_percent": round(current_health_status.resource_utilization.gpu, 2), 

300 }, 

301 "thresholds": { 

302 "cpu_threshold": current_health_status.thresholds.cpu_threshold, 

303 "memory_threshold": current_health_status.thresholds.memory_threshold, 

304 "gpu_threshold": current_health_status.thresholds.gpu_threshold, 

305 }, 

306 "active_jobs": current_health_status.active_jobs, 

307 "message": current_health_status.message, 

308 "threshold_violations": ( 

309 current_health_status.get_threshold_violations() 

310 if hasattr(current_health_status, "get_threshold_violations") 

311 else [] 

312 ), 

313 } 

314 

315 except Exception as e: 

316 logger.error(f"Failed to get metrics: {e}") 

317 raise HTTPException(status_code=500, detail=f"Failed to get metrics: {e!s}") from e 

318 

319 

320@app.get("/api/v1/status") 

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

322 """ 

323 Service status endpoint with operational information 

324 """ 

325 

326 # Get webhook dispatcher metrics if available 

327 webhook_metrics = None 

328 if webhook_dispatcher: 

329 webhook_metrics = webhook_dispatcher.get_metrics() 

330 

331 service_status = { 

332 "service": "GCO Health Monitor API", 

333 "version": "1.0.0", 

334 "uptime_seconds": None, # Could be implemented with start time tracking 

335 "health_monitor_initialized": health_monitor is not None, 

336 "background_task_running": health_check_task is not None and not health_check_task.done(), 

337 "last_health_check": ( 

338 current_health_status.timestamp.isoformat() if current_health_status else None 

339 ), 

340 "webhook_dispatcher": { 

341 "enabled": webhook_dispatcher is not None, 

342 "running": webhook_metrics.get("running", False) if webhook_metrics else False, 

343 "deliveries_total": ( 

344 webhook_metrics.get("deliveries_total", 0) if webhook_metrics else 0 

345 ), 

346 "deliveries_success": ( 

347 webhook_metrics.get("deliveries_success", 0) if webhook_metrics else 0 

348 ), 

349 "deliveries_failed": ( 

350 webhook_metrics.get("deliveries_failed", 0) if webhook_metrics else 0 

351 ), 

352 "cached_jobs": webhook_metrics.get("cached_jobs", 0) if webhook_metrics else 0, 

353 }, 

354 "environment": { 

355 "cluster_name": os.getenv("CLUSTER_NAME", "unknown"), 

356 "region": os.getenv("REGION", "unknown"), 

357 "cpu_threshold": os.getenv("CPU_THRESHOLD", "80"), 

358 "memory_threshold": os.getenv("MEMORY_THRESHOLD", "85"), 

359 "gpu_threshold": os.getenv("GPU_THRESHOLD", "90"), 

360 }, 

361 } 

362 

363 return service_status 

364 

365 

366@app.get("/healthz") 

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

368 """ 

369 Kubernetes-style health check endpoint 

370 Simple endpoint that returns 200 if the service is running 

371 """ 

372 return {"status": "ok"} 

373 

374 

375@app.get("/readyz") 

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

377 """ 

378 Kubernetes-style readiness check endpoint 

379 Returns 200 if the service is ready to serve traffic 

380 """ 

381 

382 if health_monitor is None: 

383 raise HTTPException(status_code=503, detail="Health monitor not ready") 

384 

385 return {"status": "ready"} 

386 

387 

388# Error handlers 

389@app.exception_handler(Exception) 

390async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: 

391 """Global exception handler for unhandled errors""" 

392 logger.error(f"Unhandled exception: {exc}") 

393 return JSONResponse( 

394 status_code=500, 

395 content={ 

396 "error": "Internal server error", 

397 "detail": str(exc) if os.getenv("DEBUG") else "An unexpected error occurred", 

398 }, 

399 ) 

400 

401 

402def create_app() -> FastAPI: 

403 """Factory function to create the FastAPI app""" 

404 return app 

405 

406 

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

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

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

410DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 20 

411 

412 

413def _run_server() -> None: 

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

415 import uvicorn 

416 

417 from gco.services.uvicorn_runtime import describe_uvicorn_runtime 

418 

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

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

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

422 graceful_shutdown_seconds = int( 

423 os.getenv( 

424 "GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS", 

425 str(DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS), 

426 ) 

427 ) 

428 

429 loop_impl, http_impl = describe_uvicorn_runtime() 

430 logger.info( 

431 "Starting Health API on %s:%d (uvicorn loop=%s http=%s)", host, port, loop_impl, http_impl 

432 ) 

433 

434 uvicorn.run( 

435 "gco.services.health_api:app", 

436 host=host, 

437 port=port, 

438 log_level=log_level, 

439 reload=False, 

440 timeout_graceful_shutdown=graceful_shutdown_seconds, 

441 ) 

442 

443 

444if __name__ == "__main__": 

445 _run_server()