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

350 statements  

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

1""" 

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

3 

4This service monitors Kubernetes cluster resource utilization and reports 

5health status for load balancer health checks and monitoring dashboards. 

6 

7Key Features: 

8- Collects CPU, memory, and GPU utilization metrics from Kubernetes Metrics Server 

9- Compares utilization against configurable thresholds 

10- Reports health status (healthy/unhealthy) based on threshold violations 

11- Caches metrics to reduce API calls to Kubernetes 

12 

13Environment Variables: 

14 CLUSTER_NAME: Name of the EKS cluster being monitored 

15 REGION: AWS region of the cluster 

16 CPU_THRESHOLD: CPU utilization threshold percentage (default: 80, -1 to disable) 

17 MEMORY_THRESHOLD: Memory utilization threshold percentage (default: 80, -1 to disable) 

18 GPU_THRESHOLD: GPU utilization threshold percentage (default: 60, -1 to disable) 

19 

20Usage: 

21 health_monitor = create_health_monitor_from_env() 

22 status = await health_monitor.get_health_status() 

23""" 

24 

25import asyncio 

26import logging 

27import os 

28from datetime import UTC, datetime 

29from typing import Any, Literal 

30 

31import boto3 

32from botocore.config import Config 

33from botocore.exceptions import ClientError 

34from kubernetes import client, config 

35from kubernetes.client.rest import ApiException 

36 

37from gco.k8s_api_versions import METRICS_API_GROUP, METRICS_API_VERSION 

38from gco.models import HealthStatus, RequestedResources, ResourceThresholds, ResourceUtilization 

39from gco.services.leader_lease import ( 

40 LEASE_MIN_DURATION_SECONDS, 

41 LEASE_REQUEST_TIMEOUT, 

42 LeaseIdentity, 

43 try_acquire_lease, 

44) 

45from gco.services.structured_logging import configure_structured_logging 

46 

47# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

48# Generated at (UTC): 2026-10-04T13:42:40Z 

49# Generated from Git commit: c8a2c18a0d540e23acd5eda86d3aab9db69e5c41 

50# Flowchart(s) generated from this file: 

51# * ``HealthMonitor.get_health_status`` -> ``diagrams/code_diagrams/gco/services/health_monitor.HealthMonitor_get_health_status.html`` 

52# (PNG: ``diagrams/code_diagrams/gco/services/health_monitor.HealthMonitor_get_health_status.png``) 

53# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

54# <pyflowchart-code-diagram> END 

55 

56 

57logging.basicConfig( 

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

59) 

60logger = logging.getLogger(__name__) 

61 

62# Kept as module names for callers/tests that import them; the values live in 

63# gco.services.leader_lease so every election shares one floor and timeout. 

64_ALB_SYNC_LEASE_MIN_SECONDS = LEASE_MIN_DURATION_SECONDS 

65_ALB_SYNC_K8S_TIMEOUT = LEASE_REQUEST_TIMEOUT 

66_ALB_SYNC_SSM_CONFIG = Config( 

67 connect_timeout=3, 

68 read_timeout=10, 

69 retries={"total_max_attempts": 2, "mode": "standard"}, 

70) 

71 

72 

73class HealthMonitor: 

74 """ 

75 Monitors Kubernetes cluster resource utilization and determines health status 

76 

77 Every Kubernetes read goes through ``asyncio.to_thread``: the official 

78 client is synchronous, and a blocking call inside the poll task stalls the 

79 whole event loop — the ``/healthz`` and ``/readyz`` the kubelet probes, and 

80 the socket bind uvicorn performs right after the lifespan yields. With an 

81 unreachable API server the client's connect timeout and retries add up to 

82 minutes, which is exactly the startup-probe budget: the pod was killed for 

83 "not starting" while it was really waiting on the API server (seen on kind, 

84 where NetworkPolicy blocked the :6443 endpoint). Off the loop, the service 

85 keeps serving its probes and its cached status regardless of how the 

86 cluster reads are doing. 

87 """ 

88 

89 def __init__(self, cluster_id: str, region: str, thresholds: ResourceThresholds): 

90 self.cluster_id = cluster_id 

91 self.region = region 

92 self.thresholds = thresholds 

93 

94 # Initialize Kubernetes clients 

95 try: 

96 # Try to load in-cluster config first (when running in pod) 

97 config.load_incluster_config() 

98 logger.info("Loaded in-cluster Kubernetes configuration") 

99 except config.ConfigException: 

100 try: 

101 # Fall back to local kubeconfig (for development) 

102 config.load_kube_config() 

103 logger.info("Loaded local Kubernetes configuration") 

104 except config.ConfigException as e: 

105 logger.error(f"Failed to load Kubernetes configuration: {e}") 

106 raise 

107 

108 self.core_v1 = client.CoreV1Api() 

109 self.networking_v1 = client.NetworkingV1Api() 

110 self.coordination_v1 = client.CoordinationV1Api() 

111 self.metrics_v1beta1 = client.CustomObjectsApi() 

112 

113 # Timeout for Kubernetes API calls (seconds) 

114 self._k8s_timeout = int(os.environ.get("K8S_API_TIMEOUT", "30")) 

115 

116 # Cache for metrics 

117 self._last_metrics_time: datetime | None = None 

118 self._cached_metrics: dict[str, Any] | None = None 

119 self._cache_duration = 30 # seconds 

120 

121 # ALB hostname sync. Every replica keeps serving health endpoints, but 

122 # only the holder of this Kubernetes Lease may perform the mutating SSM 

123 # reconciliation. The Lease is pre-created by 02-rbac.yaml so RBAC can 

124 # grant update on one exact resource instead of create on every Lease in 

125 # gco-system. 

126 self._last_alb_sync: datetime | None = None 

127 self._alb_sync_interval = 300 # 5 minutes 

128 self._alb_sync_lease_name = os.environ.get( 

129 "ALB_SYNC_LEASE_NAME", "gco-health-monitor-alb-sync" 

130 ) 

131 self._alb_sync_lease_namespace = os.environ.get("POD_NAMESPACE", "gco-system") 

132 configured_lease_duration = int(os.environ.get("ALB_SYNC_LEASE_DURATION", "90")) 

133 self._alb_sync_lease_duration = max(configured_lease_duration, _ALB_SYNC_LEASE_MIN_SECONDS) 

134 if configured_lease_duration < _ALB_SYNC_LEASE_MIN_SECONDS: 

135 logger.warning( 

136 "ALB_SYNC_LEASE_DURATION=%s is too short; enforcing %s seconds", 

137 configured_lease_duration, 

138 _ALB_SYNC_LEASE_MIN_SECONDS, 

139 ) 

140 self._alb_sync_holder = ( 

141 os.environ.get("POD_NAME") 

142 or os.environ.get("HOSTNAME") 

143 or f"health-monitor-{os.getpid()}" 

144 ) 

145 

146 async def get_cluster_metrics(self) -> tuple[ResourceUtilization, int, int, RequestedResources]: 

147 """ 

148 Get current cluster resource utilization metrics 

149 Returns: (ResourceUtilization, active_jobs_count, pending_pods_count, pending_requested_resources) 

150 """ 

151 try: 

152 # Get node metrics from metrics server 

153 node_metrics = await self._get_node_metrics() 

154 

155 # Get pod metrics for active jobs count and pending pods 

156 active_jobs, pending_pods = await self._get_pod_counts() 

157 

158 # Calculate cluster-wide utilization (each helper lists the nodes) 

159 cpu_utilization = await asyncio.to_thread(self._calculate_cpu_utilization, node_metrics) 

160 memory_utilization = await asyncio.to_thread( 

161 self._calculate_memory_utilization, node_metrics 

162 ) 

163 gpu_utilization = await self._calculate_gpu_utilization() 

164 

165 # Calculate resources requested by pending pods 

166 pending_requested = await self._calculate_pending_requested_resources() 

167 

168 resource_utilization = ResourceUtilization( 

169 cpu=cpu_utilization, memory=memory_utilization, gpu=gpu_utilization 

170 ) 

171 

172 logger.info( 

173 f"Cluster metrics - CPU: {cpu_utilization:.1f}%, " 

174 f"Memory: {memory_utilization:.1f}%, GPU: {gpu_utilization:.1f}%, " 

175 f"Active Jobs: {active_jobs}, Pending Pods: {pending_pods}, " 

176 f"Pending Requested CPU: {pending_requested.cpu_vcpus:.1f} vCPUs, " 

177 f"Pending Requested Memory: {pending_requested.memory_gb:.1f} GB" 

178 ) 

179 

180 return resource_utilization, active_jobs, pending_pods, pending_requested 

181 

182 except Exception as e: 

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

184 # Re-raise so get_health_status returns "unhealthy" instead of 

185 # silently reporting 0% utilization (which looks healthy to GA). 

186 raise 

187 

188 async def _get_node_metrics(self) -> dict[str, Any]: 

189 """Get node metrics from Kubernetes metrics server""" 

190 try: 

191 # Check cache first 

192 now = datetime.now(UTC) 

193 if ( 

194 self._cached_metrics 

195 and self._last_metrics_time 

196 and (now - self._last_metrics_time).seconds < self._cache_duration 

197 ): 

198 return self._cached_metrics 

199 

200 # Fetch fresh metrics 

201 node_metrics: dict[str, Any] = await asyncio.to_thread( 

202 self.metrics_v1beta1.list_cluster_custom_object, 

203 group=METRICS_API_GROUP, 

204 version=METRICS_API_VERSION, 

205 plural="nodes", 

206 _request_timeout=self._k8s_timeout, 

207 ) 

208 

209 # Update cache 

210 self._cached_metrics = node_metrics 

211 self._last_metrics_time = now 

212 

213 return node_metrics 

214 

215 except ApiException as e: 

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

217 # Invalidate cache so stale data isn't used on next call 

218 self._cached_metrics = None 

219 self._last_metrics_time = None 

220 # Re-raise so get_cluster_metrics propagates the failure 

221 # to get_health_status, which returns "unhealthy" 

222 raise 

223 

224 def _calculate_cpu_utilization(self, node_metrics: dict[str, Any]) -> float: 

225 """Calculate cluster-wide CPU utilization percentage""" 

226 total_cpu_usage = 0.0 

227 total_cpu_capacity = 0.0 

228 

229 try: 

230 # Get node list for capacity information 

231 nodes = self.core_v1.list_node(_request_timeout=self._k8s_timeout) 

232 node_capacities = {} 

233 

234 for node in nodes.items: 

235 node_name = node.metadata.name 

236 cpu_capacity = node.status.allocatable.get("cpu", "0") 

237 # Convert CPU capacity to millicores 

238 if cpu_capacity.endswith("m"): 

239 cpu_capacity_millicores = int(cpu_capacity[:-1]) 

240 else: 

241 cpu_capacity_millicores = int(cpu_capacity) * 1000 

242 node_capacities[node_name] = cpu_capacity_millicores 

243 total_cpu_capacity += cpu_capacity_millicores 

244 

245 # Calculate usage from metrics 

246 for item in node_metrics.get("items", []): 

247 cpu_usage = item["usage"]["cpu"] 

248 

249 # Convert CPU usage to millicores 

250 if cpu_usage.endswith("n"): 

251 cpu_usage_millicores = int(cpu_usage[:-1]) / 1_000_000 

252 elif cpu_usage.endswith("u"): 

253 cpu_usage_millicores = int(cpu_usage[:-1]) / 1_000 

254 elif cpu_usage.endswith("m"): 

255 cpu_usage_millicores = int(cpu_usage[:-1]) 

256 else: 

257 cpu_usage_millicores = int(cpu_usage) * 1000 

258 

259 total_cpu_usage += cpu_usage_millicores 

260 

261 if total_cpu_capacity > 0: 

262 return (total_cpu_usage / total_cpu_capacity) * 100 

263 

264 except Exception as e: 

265 logger.error(f"Error calculating CPU utilization: {e}") 

266 

267 return 0.0 

268 

269 def _calculate_memory_utilization(self, node_metrics: dict[str, Any]) -> float: 

270 """Calculate cluster-wide memory utilization percentage""" 

271 total_memory_usage = 0 

272 total_memory_capacity = 0 

273 

274 try: 

275 # Get node list for capacity information 

276 nodes = self.core_v1.list_node(_request_timeout=self._k8s_timeout) 

277 

278 for node in nodes.items: 

279 memory_capacity = node.status.allocatable.get("memory", "0") 

280 # Convert memory capacity to bytes 

281 memory_capacity_bytes = self._parse_memory_string(memory_capacity) 

282 total_memory_capacity += memory_capacity_bytes 

283 

284 # Calculate usage from metrics 

285 for item in node_metrics.get("items", []): 

286 memory_usage = item["usage"]["memory"] 

287 memory_usage_bytes = self._parse_memory_string(memory_usage) 

288 total_memory_usage += memory_usage_bytes 

289 

290 if total_memory_capacity > 0: 

291 return (total_memory_usage / total_memory_capacity) * 100 

292 

293 except Exception as e: 

294 logger.error(f"Error calculating memory utilization: {e}") 

295 

296 return 0.0 

297 

298 def _parse_memory_string(self, memory_str: str) -> int: 

299 """Parse Kubernetes memory string to bytes""" 

300 if not memory_str: 

301 return 0 

302 

303 memory_str = memory_str.strip() 

304 

305 # Handle different units 

306 if memory_str.endswith("Ki"): 

307 return int(memory_str[:-2]) * 1024 

308 if memory_str.endswith("Mi"): 

309 return int(memory_str[:-2]) * 1024 * 1024 

310 if memory_str.endswith("Gi"): 

311 return int(memory_str[:-2]) * 1024 * 1024 * 1024 

312 if memory_str.endswith("Ti"): 

313 return int(memory_str[:-2]) * 1024 * 1024 * 1024 * 1024 

314 if memory_str.endswith("k"): 

315 return int(memory_str[:-1]) * 1000 

316 if memory_str.endswith("M"): 

317 return int(memory_str[:-1]) * 1000 * 1000 

318 if memory_str.endswith("G"): 

319 return int(memory_str[:-1]) * 1000 * 1000 * 1000 

320 return int(memory_str) 

321 

322 async def _calculate_gpu_utilization(self) -> float: 

323 """Calculate cluster-wide GPU utilization percentage""" 

324 try: 

325 # Get pods with GPU requests 

326 pods = await asyncio.to_thread( 

327 self.core_v1.list_pod_for_all_namespaces, 

328 _request_timeout=self._k8s_timeout, 

329 ) 

330 

331 total_gpu_requested = 0 

332 total_gpu_capacity = 0 

333 

334 # Get node GPU capacity 

335 nodes = await asyncio.to_thread( 

336 self.core_v1.list_node, _request_timeout=self._k8s_timeout 

337 ) 

338 for node in nodes.items: 

339 gpu_capacity = node.status.allocatable.get("nvidia.com/gpu", "0") 

340 total_gpu_capacity += int(gpu_capacity) 

341 

342 # Calculate GPU requests from running pods 

343 for pod in pods.items: 

344 if pod.status.phase == "Running": 

345 for container in pod.spec.containers: 

346 if container.resources and container.resources.requests: 

347 gpu_request = container.resources.requests.get("nvidia.com/gpu", "0") 

348 total_gpu_requested += int(gpu_request) 

349 

350 if total_gpu_capacity > 0: 

351 return (total_gpu_requested / total_gpu_capacity) * 100 

352 

353 except Exception as e: 

354 logger.error(f"Error calculating GPU utilization: {e}") 

355 

356 return 0.0 

357 

358 async def _get_active_jobs_count(self) -> int: 

359 """Get count of active jobs in the cluster""" 

360 try: 

361 # Count running pods (excluding system pods) 

362 pods = await asyncio.to_thread( 

363 self.core_v1.list_pod_for_all_namespaces, 

364 _request_timeout=self._k8s_timeout, 

365 ) 

366 active_jobs = 0 

367 

368 for pod in pods.items: 

369 # Skip system namespaces 

370 if pod.metadata.namespace in ["kube-system", "kube-public", "kube-node-lease"]: 

371 continue 

372 

373 # Count running pods as active jobs 

374 if pod.status.phase == "Running": 

375 active_jobs += 1 

376 

377 return active_jobs 

378 

379 except Exception as e: 

380 logger.error(f"Error getting active jobs count: {e}") 

381 return 0 

382 

383 async def _get_pod_counts(self) -> tuple[int, int]: 

384 """Get count of active jobs and pending pods in the cluster""" 

385 try: 

386 pods = await asyncio.to_thread( 

387 self.core_v1.list_pod_for_all_namespaces, 

388 _request_timeout=self._k8s_timeout, 

389 ) 

390 active_jobs = 0 

391 pending_pods = 0 

392 

393 for pod in pods.items: 

394 # Skip system namespaces 

395 if pod.metadata.namespace in ["kube-system", "kube-public", "kube-node-lease"]: 

396 continue 

397 

398 if pod.status.phase == "Running": 

399 active_jobs += 1 

400 elif pod.status.phase == "Pending": 

401 pending_pods += 1 

402 

403 return active_jobs, pending_pods 

404 

405 except Exception as e: 

406 logger.error(f"Error getting pod counts: {e}") 

407 return 0, 0 

408 

409 async def _calculate_pending_requested_resources(self) -> RequestedResources: 

410 """Calculate total resources requested by pending pods""" 

411 try: 

412 pods = await asyncio.to_thread( 

413 self.core_v1.list_pod_for_all_namespaces, 

414 _request_timeout=self._k8s_timeout, 

415 ) 

416 total_cpu_millicores = 0.0 

417 total_memory_bytes = 0 

418 total_gpus = 0 

419 

420 for pod in pods.items: 

421 # Skip system namespaces 

422 if pod.metadata.namespace in ["kube-system", "kube-public", "kube-node-lease"]: 

423 continue 

424 

425 # Only count pending pods 

426 if pod.status.phase != "Pending": 

427 continue 

428 

429 for container in pod.spec.containers: 

430 if container.resources and container.resources.requests: 

431 # CPU 

432 cpu_request = container.resources.requests.get("cpu", "0") 

433 if cpu_request.endswith("m"): 

434 total_cpu_millicores += int(cpu_request[:-1]) 

435 elif cpu_request.endswith("n"): 

436 total_cpu_millicores += int(cpu_request[:-1]) / 1_000_000 

437 else: 

438 total_cpu_millicores += float(cpu_request) * 1000 

439 

440 # Memory 

441 memory_request = container.resources.requests.get("memory", "0") 

442 total_memory_bytes += self._parse_memory_string(memory_request) 

443 

444 # GPUs 

445 gpu_request = container.resources.requests.get("nvidia.com/gpu", "0") 

446 total_gpus += int(gpu_request) 

447 

448 # Convert to vCPUs and GB 

449 cpu_vcpus = total_cpu_millicores / 1000 

450 memory_gb = total_memory_bytes / (1024 * 1024 * 1024) 

451 

452 return RequestedResources(cpu_vcpus=cpu_vcpus, memory_gb=memory_gb, gpus=total_gpus) 

453 

454 except Exception as e: 

455 logger.error(f"Error calculating pending requested resources: {e}") 

456 return RequestedResources(cpu_vcpus=0.0, memory_gb=0.0, gpus=0) 

457 

458 async def get_health_status(self) -> HealthStatus: 

459 """ 

460 Get current health status of the cluster 

461 """ 

462 try: 

463 # Get current metrics 

464 ( 

465 resource_utilization, 

466 active_jobs, 

467 pending_pods, 

468 pending_requested, 

469 ) = await self.get_cluster_metrics() 

470 

471 # Determine health status based on thresholds 

472 # A threshold of -1 means that check is disabled 

473 is_healthy = True 

474 if not self.thresholds.is_disabled("cpu_threshold"): 

475 is_healthy = ( 

476 is_healthy and resource_utilization.cpu <= self.thresholds.cpu_threshold 

477 ) 

478 if not self.thresholds.is_disabled("memory_threshold"): 

479 is_healthy = ( 

480 is_healthy and resource_utilization.memory <= self.thresholds.memory_threshold 

481 ) 

482 if not self.thresholds.is_disabled("gpu_threshold"): 

483 is_healthy = ( 

484 is_healthy and resource_utilization.gpu <= self.thresholds.gpu_threshold 

485 ) 

486 if not self.thresholds.is_disabled("pending_pods_threshold"): 

487 is_healthy = is_healthy and pending_pods <= self.thresholds.pending_pods_threshold 

488 if not self.thresholds.is_disabled("pending_requested_cpu_vcpus"): 

489 is_healthy = ( 

490 is_healthy 

491 and pending_requested.cpu_vcpus <= self.thresholds.pending_requested_cpu_vcpus 

492 ) 

493 if not self.thresholds.is_disabled("pending_requested_memory_gb"): 

494 is_healthy = ( 

495 is_healthy 

496 and pending_requested.memory_gb <= self.thresholds.pending_requested_memory_gb 

497 ) 

498 if not self.thresholds.is_disabled("pending_requested_gpus"): 

499 is_healthy = ( 

500 is_healthy and pending_requested.gpus <= self.thresholds.pending_requested_gpus 

501 ) 

502 

503 status: Literal["healthy", "unhealthy"] = "healthy" if is_healthy else "unhealthy" 

504 

505 # Generate status message 

506 message = None 

507 if not is_healthy: 

508 violations = [] 

509 if ( 

510 not self.thresholds.is_disabled("cpu_threshold") 

511 and resource_utilization.cpu > self.thresholds.cpu_threshold 

512 ): 

513 violations.append( 

514 f"CPU: {resource_utilization.cpu:.1f}% > {self.thresholds.cpu_threshold}%" 

515 ) 

516 if ( 

517 not self.thresholds.is_disabled("memory_threshold") 

518 and resource_utilization.memory > self.thresholds.memory_threshold 

519 ): 

520 violations.append( 

521 f"Memory: {resource_utilization.memory:.1f}% > {self.thresholds.memory_threshold}%" 

522 ) 

523 if ( 

524 not self.thresholds.is_disabled("gpu_threshold") 

525 and resource_utilization.gpu > self.thresholds.gpu_threshold 

526 ): 

527 violations.append( 

528 f"GPU: {resource_utilization.gpu:.1f}% > {self.thresholds.gpu_threshold}%" 

529 ) 

530 if ( 

531 not self.thresholds.is_disabled("pending_pods_threshold") 

532 and pending_pods > self.thresholds.pending_pods_threshold 

533 ): 

534 violations.append( 

535 f"Pending Pods: {pending_pods} > {self.thresholds.pending_pods_threshold}" 

536 ) 

537 if ( 

538 not self.thresholds.is_disabled("pending_requested_cpu_vcpus") 

539 and pending_requested.cpu_vcpus > self.thresholds.pending_requested_cpu_vcpus 

540 ): 

541 violations.append( 

542 f"Pending CPU: {pending_requested.cpu_vcpus:.1f} vCPUs > {self.thresholds.pending_requested_cpu_vcpus} vCPUs" 

543 ) 

544 if ( 

545 not self.thresholds.is_disabled("pending_requested_memory_gb") 

546 and pending_requested.memory_gb > self.thresholds.pending_requested_memory_gb 

547 ): 

548 violations.append( 

549 f"Pending Memory: {pending_requested.memory_gb:.1f} GB > {self.thresholds.pending_requested_memory_gb} GB" 

550 ) 

551 if ( 

552 not self.thresholds.is_disabled("pending_requested_gpus") 

553 and pending_requested.gpus > self.thresholds.pending_requested_gpus 

554 ): 

555 violations.append( 

556 f"Pending GPUs: {pending_requested.gpus} > {self.thresholds.pending_requested_gpus}" 

557 ) 

558 message = f"Threshold violations: {', '.join(violations)}" 

559 

560 health_status = HealthStatus( 

561 cluster_id=self.cluster_id, 

562 region=self.region, 

563 timestamp=datetime.now(UTC), 

564 status=status, 

565 resource_utilization=resource_utilization, 

566 thresholds=self.thresholds, 

567 active_jobs=active_jobs, 

568 pending_pods=pending_pods, 

569 pending_requested=pending_requested, 

570 message=message, 

571 ) 

572 

573 logger.info(f"Health status: {status} - {message or 'All thresholds within limits'}") 

574 return health_status 

575 

576 except Exception as e: 

577 logger.error(f"Error getting health status: {e}") 

578 # Return unhealthy status on error 

579 return HealthStatus( 

580 cluster_id=self.cluster_id, 

581 region=self.region, 

582 timestamp=datetime.now(UTC), 

583 status="unhealthy", 

584 resource_utilization=ResourceUtilization(cpu=0.0, memory=0.0, gpu=0.0), 

585 thresholds=self.thresholds, 

586 active_jobs=0, 

587 pending_pods=0, 

588 pending_requested=RequestedResources(cpu_vcpus=0.0, memory_gb=0.0, gpus=0), 

589 message=f"Health check error: {e!s}", 

590 ) 

591 

592 def _try_acquire_alb_sync_lease(self) -> bool: 

593 """Acquire or renew the single-writer Lease for ALB self-healing. 

594 

595 Shared election rules live in :mod:`gco.services.leader_lease`: 

596 optimistic ``replace`` (a racing writer gets HTTP 409), expired or 

597 timestamp-less holders may be replaced, and every API or RBAC failure 

598 returns ``False`` — losing self-healing is safer than allowing two 

599 replicas to mutate the cross-region SSM parameter. 

600 """ 

601 return try_acquire_lease( 

602 self.coordination_v1, 

603 LeaseIdentity( 

604 name=self._alb_sync_lease_name, 

605 namespace=self._alb_sync_lease_namespace, 

606 holder=self._alb_sync_holder, 

607 duration_seconds=self._alb_sync_lease_duration, 

608 ), 

609 label="ALB-sync", 

610 request_timeout=_ALB_SYNC_K8S_TIMEOUT, 

611 ) 

612 

613 async def sync_alb_registration(self) -> None: 

614 """Run ALB self-healing without blocking FastAPI's event loop.""" 

615 await asyncio.to_thread(self._sync_alb_registration) 

616 

617 def _sync_alb_registration(self) -> None: 

618 """Ensure the SSM hostname matches the platform Gateway address. 

619 

620 Every replica renews or checks the leader Lease on each health loop; 

621 only the leader performs reconciliation, at most once every 5 minutes. 

622 A second optimistic Lease renewal immediately before ``PutParameter`` 

623 prevents a stale former leader from writing. The SSM client's bounded 

624 retry/timeouts keep that write comfortably inside the Lease duration. 

625 """ 

626 if not self._try_acquire_alb_sync_lease(): 

627 return 

628 

629 now = datetime.now(UTC) 

630 if ( 

631 self._last_alb_sync 

632 and (now - self._last_alb_sync).total_seconds() < self._alb_sync_interval 

633 ): 

634 return 

635 

636 self._last_alb_sync = now 

637 

638 try: 

639 gateway = self.metrics_v1beta1.get_namespaced_custom_object( 

640 group="gateway.networking.k8s.io", 

641 version="v1", 

642 namespace="gco-system", 

643 plural="gateways", 

644 name="gco-gateway", 

645 _request_timeout=self._k8s_timeout, 

646 ) 

647 addresses = gateway.get("status", {}).get("addresses", []) 

648 current_hostname = next( 

649 ( 

650 str(address.get("value", "")).strip() 

651 for address in addresses 

652 if isinstance(address, dict) 

653 and address.get("type", "Hostname") == "Hostname" 

654 and str(address.get("value", "")).strip() 

655 ), 

656 None, 

657 ) 

658 if not current_hostname: 

659 return 

660 

661 global_region = os.environ.get("GLOBAL_REGION", "us-east-2") 

662 project_name = os.environ.get("PROJECT_NAME", "gco") 

663 param_name = f"/{project_name}/alb-hostname-{self.region}" 

664 ssm_client = boto3.client( 

665 "ssm", 

666 region_name=global_region, 

667 config=_ALB_SYNC_SSM_CONFIG, 

668 ) 

669 try: 

670 response = ssm_client.get_parameter(Name=param_name) 

671 stored_hostname = str(response["Parameter"]["Value"]) 

672 except ClientError as exc: 

673 if exc.response.get("Error", {}).get("Code") != "ParameterNotFound": 

674 raise 

675 stored_hostname = None 

676 

677 if stored_hostname != current_hostname: 

678 logger.warning( 

679 "ALB hostname mismatch: SSM=%s, Gateway=%s. Updating SSM.", 

680 stored_hostname, 

681 current_hostname, 

682 ) 

683 # Re-read and optimistically replace the Lease immediately 

684 # before the only mutating AWS call. A 409 or a new live holder 

685 # makes this replica fail closed. 

686 if not self._try_acquire_alb_sync_lease(): 

687 logger.info("Lost ALB-sync Lease before SSM update; skipping mutation") 

688 return 

689 ssm_client.put_parameter( 

690 Name=param_name, 

691 Value=current_hostname, 

692 Type="String", 

693 Overwrite=True, 

694 ) 

695 logger.info("Updated SSM parameter %s to %s", param_name, current_hostname) 

696 

697 except Exception as exc: 

698 logger.warning("Gateway ALB sync check failed (non-fatal): %s", exc) 

699 

700 

701def create_health_monitor_from_env() -> HealthMonitor: 

702 """ 

703 Create HealthMonitor instance from environment variables 

704 """ 

705 cluster_id = os.getenv("CLUSTER_NAME", "unknown-cluster") 

706 region = os.getenv("REGION", "unknown-region") 

707 

708 # Load thresholds from environment (defaults match cdk.json) 

709 cpu_threshold = int(os.getenv("CPU_THRESHOLD", "80")) 

710 memory_threshold = int(os.getenv("MEMORY_THRESHOLD", "80")) 

711 gpu_threshold = int(os.getenv("GPU_THRESHOLD", "60")) 

712 pending_pods_threshold = int(os.getenv("PENDING_PODS_THRESHOLD", "10")) 

713 pending_requested_cpu_vcpus = int(os.getenv("PENDING_REQUESTED_CPU_VCPUS", "100")) 

714 pending_requested_memory_gb = int(os.getenv("PENDING_REQUESTED_MEMORY_GB", "200")) 

715 pending_requested_gpus = int(os.getenv("PENDING_REQUESTED_GPUS", "8")) 

716 

717 thresholds = ResourceThresholds( 

718 cpu_threshold=cpu_threshold, 

719 memory_threshold=memory_threshold, 

720 gpu_threshold=gpu_threshold, 

721 pending_pods_threshold=pending_pods_threshold, 

722 pending_requested_cpu_vcpus=pending_requested_cpu_vcpus, 

723 pending_requested_memory_gb=pending_requested_memory_gb, 

724 pending_requested_gpus=pending_requested_gpus, 

725 ) 

726 

727 return HealthMonitor(cluster_id, region, thresholds) 

728 

729 

730async def main() -> None: 

731 """ 

732 Main function for running the health monitor with webhook dispatcher. 

733 

734 This runs both the health monitoring loop and the webhook dispatcher 

735 as concurrent tasks. 

736 """ 

737 from gco.services.webhook_dispatcher import create_webhook_dispatcher_from_env 

738 

739 health_monitor = create_health_monitor_from_env() 

740 

741 # Enable structured JSON logging for CloudWatch Insights 

742 configure_structured_logging( 

743 service_name="health-monitor", 

744 cluster_id=health_monitor.cluster_id, 

745 region=health_monitor.region, 

746 ) 

747 

748 webhook_dispatcher = create_webhook_dispatcher_from_env() 

749 

750 # Start webhook dispatcher 

751 await webhook_dispatcher.start() 

752 logger.info("Webhook dispatcher started") 

753 

754 try: 

755 while True: 

756 try: 

757 health_status = await health_monitor.get_health_status() 

758 print(f"Health Status: {health_status.status}") 

759 print(f"CPU: {health_status.resource_utilization.cpu:.1f}%") 

760 print(f"Memory: {health_status.resource_utilization.memory:.1f}%") 

761 print(f"GPU: {health_status.resource_utilization.gpu:.1f}%") 

762 print(f"Active Jobs: {health_status.active_jobs}") 

763 print(f"Pending Pods: {health_status.pending_pods}") 

764 if health_status.pending_requested: 

765 print( 

766 f"Pending Requested CPU: {health_status.pending_requested.cpu_vcpus:.1f} vCPUs" 

767 ) 

768 print( 

769 f"Pending Requested Memory: {health_status.pending_requested.memory_gb:.1f} GB" 

770 ) 

771 if health_status.message: 

772 print(f"Message: {health_status.message}") 

773 

774 # Print webhook dispatcher metrics 

775 webhook_metrics = webhook_dispatcher.get_metrics() 

776 print( 

777 f"Webhook Deliveries: {webhook_metrics['deliveries_total']} " 

778 f"(success={webhook_metrics['deliveries_success']}, " 

779 f"failed={webhook_metrics['deliveries_failed']})" 

780 ) 

781 print("-" * 50) 

782 

783 await asyncio.sleep(30) # Check every 30 seconds 

784 

785 except KeyboardInterrupt: 

786 raise 

787 except Exception as e: 

788 logger.error(f"Error in main loop: {e}") 

789 await asyncio.sleep(10) 

790 

791 except KeyboardInterrupt: 

792 logger.info("Health monitor stopped by user") 

793 finally: 

794 await webhook_dispatcher.stop() 

795 logger.info("Webhook dispatcher stopped") 

796 

797 

798if __name__ == "__main__": 

799 asyncio.run(main())