Coverage for k8s_utils.py: 62%

290 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 04:47 +0000

1""" 

2Utilities for Kubernetes. 

3""" 

4import logging 

5 

6from typing import Union 

7from typing import List 

8from typing import Dict 

9from typing import Optional 

10from typing import Any 

11 

12from kubernetes_asyncio import config as k8s_config 

13from kubernetes_asyncio import client as k8s_client 

14from kubernetes_asyncio.client import ApiClient 

15from kubernetes_asyncio.client import CoreV1Api 

16 

17 

18# URL scheme used when constructing outbound service URLs (http or https). 

19# Call set_service_scheme("https") at startup to enable HTTPS for all 

20# service URL lookups (K8sContainer.get_url, get_k8s_containers, etc.). 

21SERVICE_SCHEME: str = "http" 

22 

23# Whether to verify SSL certificates when making outbound HTTPS requests. 

24# Set to False when services use self-signed certificates (e.g. Key Vault 

25# generated certs in AKS). Call set_verify_ssl(False) at startup alongside 

26# set_service_scheme("https") to disable certificate verification. 

27VERIFY_SSL: bool = True 

28 

29 

30def set_service_scheme(scheme: str) -> None: 

31 """Set the URL scheme used for K8s service URLs.""" 

32 global SERVICE_SCHEME 

33 if scheme not in ("http", "https"): 

34 raise ValueError(f"Invalid service scheme: {scheme!r}. Must be 'http' or 'https'.") 

35 SERVICE_SCHEME = scheme 

36 

37 

38def set_verify_ssl(verify: bool) -> None: 

39 """Set whether to verify SSL certificates for outbound HTTPS requests.""" 

40 global VERIFY_SSL 

41 VERIFY_SSL = verify 

42 

43 

44class NoActiveContainerError(Exception): 

45 """Exception for no active containers.""" 

46 def __init__( 

47 self, 

48 service_name: str, 

49 containers: Optional[List['K8sContainer']] = None, 

50 ) -> None: 

51 super().__init__(f"No active container for '{service_name}'") 

52 self.service_name = service_name 

53 self.containers = containers 

54 

55 def __str__(self) -> str: 

56 return f"No active container for '{self.service_name}'" 

57 

58 

59class NoRunnableContainerError(Exception): 

60 """Exception for no runnable containers.""" 

61 def __init__(self, service_name: str) -> None: 

62 super().__init__(f"No runnable containers for '{service_name}'") 

63 self.service_name = service_name 

64 

65 def __str__(self) -> str: 

66 return f"No runnable containers for '{self.service_name}'" 

67 

68 

69class ServiceNotFoundError(Exception): 

70 """Exception for service not found.""" 

71 def __init__(self, service_name: str) -> None: 

72 super().__init__(f"Service '{service_name}' not found") 

73 self.service_name = service_name 

74 

75 def __str__(self) -> str: 

76 return f"Service '{self.service_name}' not found" 

77 

78 

79class K8sContainer: 

80 """A Kubernetes container.""" 

81 def __init__(self, name: str) -> None: 

82 self.name = name 

83 self.ip: Optional[str] = None 

84 self.port: Optional[int] = None 

85 self.resources: Dict[str, str] = {} 

86 self.gpu_model: Optional[str] = None 

87 self.num_gpus: int = -1 

88 self.status: Optional[str] = None 

89 self.busy = False 

90 

91 def set_gpu_model(self, gpu_model: Optional[str]) -> None: 

92 self.gpu_model = gpu_model 

93 

94 def set_num_gpus(self, num_gpus: int) -> None: 

95 self.num_gpus = num_gpus 

96 

97 def get_gpu_model(self) -> Optional[str]: 

98 return self.gpu_model 

99 

100 def get_num_gpus(self) -> int: 

101 if self.resources and "nvidia.com/gpu" in self.resources: 

102 return int(self.resources["nvidia.com/gpu"]) 

103 return self.num_gpus 

104 

105 def get_url(self) -> Optional[str]: 

106 if self.ip is None or self.port is None: 

107 return None 

108 return f"{SERVICE_SCHEME}://{self.ip}:{self.port}" 

109 

110 def is_active(self) -> bool: 

111 if self.status is None: 

112 return False 

113 if self.status != "ok": 

114 return False 

115 if self.get_url() is None: 

116 return False 

117 return True 

118 

119 def is_busy(self) -> bool: 

120 if self.busy is None: 

121 return False 

122 return self.busy 

123 

124 def __str__(self) -> str: 

125 return f"K8sContainer(name={self.name}, status={self.status}, busy={self.busy}, " + \ 

126 f"ip={self.ip}, port={self.port}, resources={self.resources})" 

127 

128 def __repr__(self) -> str: 

129 return self.__str__() 

130 

131 

132class K8sService: 

133 """A Kubernetes service with multiple containers.""" 

134 

135 def __init__(self, name: str) -> None: 

136 self.name = name 

137 self.containers: List[K8sContainer] = [] 

138 

139 def add_container(self, container: K8sContainer) -> None: 

140 self.containers.append(container) 

141 

142 def merge_service(self, other: 'K8sService') -> None: 

143 self.containers.extend(other.containers) 

144 

145 def get_active_containers(self) -> List[K8sContainer]: 

146 """Get the list of active containers for this service.""" 

147 if not self.containers: 

148 raise NoActiveContainerError(self.name, self.containers) 

149 active_containers = [c for c in self.containers if c.is_active()] 

150 if not active_containers: 

151 raise NoActiveContainerError(self.name, self.containers) 

152 return active_containers 

153 

154 def get_runnable_containers(self) -> List[K8sContainer]: 

155 """Get the list of runnable (not busy) containers for this service.""" 

156 active_containers = self.get_active_containers() 

157 nonbusy_containers = [c for c in active_containers if not c.is_busy()] 

158 if not nonbusy_containers: 

159 raise NoRunnableContainerError(self.name) 

160 return nonbusy_containers 

161 

162 def get_best_containers( 

163 self, 

164 excluded_containers: Optional[List[K8sContainer]] = None, 

165 exclude_busy: bool = True, 

166 ) -> Optional[List[K8sContainer]]: 

167 """Get the best available containers for this service, optionally excluding some containers.""" 

168 if exclude_busy: 

169 runnable_containers = self.get_runnable_containers() 

170 else: 

171 runnable_containers = self.get_active_containers() 

172 if not runnable_containers: 

173 return None 

174 if excluded_containers: 

175 runnable_containers = [ 

176 c for c in runnable_containers 

177 if c not in excluded_containers 

178 ] 

179 

180 sorted_containers = sorted( 

181 runnable_containers, 

182 key=lambda c: ( 

183 # Prefer Active, then GPU, then CPU, then memory 

184 # TODO check and rank for H200, H100, A100,... 

185 int(c.resources.get("nvidia.com/gpu", "0")), # GPU 

186 int(c.resources.get("cpu", "0")), # CPU 

187 parse_k8s_resource_quantity(c.resources.get("memory", "0")) # Memory 

188 ), 

189 reverse=True 

190 ) 

191 return sorted_containers 

192 

193 def get_best_container( 

194 self, 

195 excluded_containers: Optional[List[K8sContainer]] = None, 

196 exclude_busy: bool = True, 

197 ) -> Optional[K8sContainer]: 

198 best_containers = self.get_best_containers( 

199 excluded_containers=excluded_containers, 

200 exclude_busy=exclude_busy) 

201 if not best_containers: 

202 return None 

203 return best_containers[0] 

204 

205 def __str__(self) -> str: 

206 return f"K8sService(name={self.name}, containers={len(self.containers)})" 

207 

208 

209def parse_mig_resources(resources: Optional[Dict[str, str]]) -> Dict[str, int]: 

210 """Extract MIG partition resources from a Kubernetes resource dict. 

211 

212 Scans for keys matching ``nvidia.com/mig-<profile>`` and returns a mapping 

213 of profile name to count, e.g. ``{"1g.5gb": 7, "2g.10gb": 3}``. 

214 """ 

215 mig: Dict[str, int] = {} 

216 if resources is None: 

217 return mig 

218 MIG_RESOURCE_PREFIX = "nvidia.com/mig-" 

219 for key, value in resources.items(): 

220 if key.startswith(MIG_RESOURCE_PREFIX): 

221 profile = key[len(MIG_RESOURCE_PREFIX):] 

222 mig[profile] = int(value) 

223 return mig 

224 

225 

226def parse_k8s_resource_quantity(quantity: str) -> Union[int, float]: 

227 """Parse Kubernetes resource quantity strings into numeric values.""" 

228 try: 

229 if quantity.endswith("m"): 

230 return float(quantity[:-1]) / 1000.0 

231 if quantity.endswith("Ki"): 

232 return float(quantity[:-2]) * 1024 

233 if quantity.endswith("Mi"): 

234 return float(quantity[:-2]) * 1024 * 1024 

235 if quantity.endswith("Gi"): 

236 return float(quantity[:-2]) * 1024 * 1024 * 1024 

237 if "." in quantity: 

238 return float(quantity) 

239 return int(quantity) 

240 except Exception as ex: 

241 logging.info(f"Error parsing resource quantity '{quantity}': {ex}") 

242 return 0 

243 

244 

245async def load_k8s_config( 

246 context_name: Optional[str] = None 

247) -> None: 

248 """Load Kubernetes configuration.""" 

249 if context_name == "unittest": 

250 return # Skip loading config for unit tests 

251 elif context_name == "incluster": 

252 k8s_config.load_incluster_config() # Running in a pod 

253 else: 

254 await k8s_config.load_kube_config(context=context_name) 

255 

256 

257def is_k8s_node_ready(node: k8s_client.V1Node) -> bool: 

258 """Check if a Kubernetes node is ready.""" 

259 for condition in node.status.conditions: 

260 if condition.type == "Ready": 

261 return condition.status == "True" 

262 return False 

263 

264 

265async def get_k8s_nodes( 

266 context_name: Optional[str] = None 

267) -> List[Dict[str, Any]]: 

268 """Get the list of Kubernetes nodes.""" 

269 await load_k8s_config(context_name) 

270 async with ApiClient() as api_client: 

271 k8s_api = CoreV1Api(api_client) 

272 nodes = await k8s_api.list_node() 

273 ret = [] 

274 for node in nodes.items: 

275 node_name = node.metadata.name 

276 is_ready = is_k8s_node_ready(node) 

277 labels = node.metadata.labels 

278 

279 allocatable_resources = node.status.allocatable 

280 capacity_resources = node.status.capacity 

281 images = None 

282 if node.status.images is not None: 

283 images = [{ 

284 "names": image.names, 

285 "size_bytes": image.size_bytes 

286 } for image in node.status.images] 

287 addresses = [] 

288 if node.status.addresses is not None: 

289 for address in node.status.addresses: 

290 addresses.append(address.to_dict()) 

291 

292 gpu_model = "N/A" 

293 if "nvidia.com/gpu.product" in labels: 

294 gpu_model = labels.get("nvidia.com/gpu.product", "N/A") 

295 elif "beta.kubernetes.io/instance-type" in labels: 

296 instance_type = labels["beta.kubernetes.io/instance-type"] 

297 # gpu_model = get_gpu_model_from_instance_type(instance_type) 

298 gpu_model = instance_type 

299 

300 region = "N/A" 

301 if "azure/region" in labels: 

302 region = labels["azure/region"] 

303 elif "topology.kubernetes.io/region" in labels: 

304 region = labels["topology.kubernetes.io/region"] 

305 

306 resource_group = "N/A" 

307 if "network-resourcegroup" in labels: 

308 resource_group = labels.get("network-resourcegroup", "N/A") 

309 

310 mig_enabled = any(k.startswith("nvidia.com/mig-") for k in allocatable_resources) 

311 

312 # Collect per-profile MIG resource counts (capacity and allocatable). 

313 # These map directly to the Kubernetes resource names (nvidia.com/mig-<profile>) 

314 # and let operators diagnose scheduling errors such as 

315 # "Insufficient nvidia.com/mig-1g.5gb". 

316 mig_resources: Dict[str, Dict[str, int]] = {} 

317 for resource_key in sorted(capacity_resources): 

318 if resource_key.startswith("nvidia.com/mig-"): 

319 profile = resource_key[len("nvidia.com/mig-"):] 

320 mig_resources[profile] = { 

321 "capacity": int(capacity_resources.get(resource_key, 0)), 

322 "allocatable": int(allocatable_resources.get(resource_key, 0)), 

323 } 

324 

325 info = { 

326 "node_name": node_name, 

327 "region": region, 

328 "resource_group": resource_group, 

329 "addresses": addresses, 

330 "is_ready": is_ready, 

331 "capacity_resources": { 

332 "cpu": parse_k8s_resource_quantity(capacity_resources.get("cpu", "N/A")), 

333 "memory": parse_k8s_resource_quantity(capacity_resources.get("memory", "N/A")), 

334 "storage": parse_k8s_resource_quantity(capacity_resources.get("ephemeral-storage", "N/A")), 

335 "gpu": capacity_resources.get("nvidia.com/gpu", "N/A"), 

336 }, 

337 "allocatable_resources": { 

338 "cpu": parse_k8s_resource_quantity(allocatable_resources.get("cpu", "N/A")), 

339 "memory": parse_k8s_resource_quantity(allocatable_resources.get("memory", "N/A")), 

340 "storage": parse_k8s_resource_quantity(allocatable_resources.get("ephemeral-storage", "N/A")), 

341 "gpu": allocatable_resources.get("nvidia.com/gpu", "N/A"), 

342 }, 

343 "architecture": node.status.node_info.architecture, 

344 "kernel_version": node.status.node_info.kernel_version, 

345 "os_image": node.status.node_info.os_image, 

346 "creation_timestamp": node.metadata.creation_timestamp, 

347 "labels": labels, 

348 "images": images, 

349 "gpu_model": gpu_model, 

350 "mig_enabled": mig_enabled, 

351 "mig_resources": mig_resources, 

352 } 

353 ret.append(info) 

354 return ret 

355 

356 

357async def get_k8s_pods( 

358 context_name: Optional[str] = None 

359) -> List[Dict[str, Any]]: 

360 """Get the list of Kubernetes pods.""" 

361 ret = [] 

362 await load_k8s_config(context_name) 

363 async with ApiClient() as api_client: 

364 k8s_api = CoreV1Api(api_client) 

365 pods = await k8s_api.list_pod_for_all_namespaces() 

366 for pod in pods.items: 

367 pod_name = pod.metadata.name 

368 namespace = pod.metadata.namespace 

369 pod_status = pod.status.phase 

370 pod_ip = pod.status.pod_ip 

371 node_name = pod.spec.node_name 

372 url = "N/A" 

373 for container in pod.spec.containers: 

374 container_name = container.name 

375 resources = container.resources.requests 

376 cpu: float = 0.0 

377 memory: float = 0.0 

378 gpu: int = 0 

379 mig_profile: Optional[str] = None 

380 if resources is not None: 

381 cpu = parse_k8s_resource_quantity(resources.get("cpu", "0")) 

382 memory = parse_k8s_resource_quantity(resources.get("memory", "0")) 

383 gpu = int(resources.get("nvidia.com/gpu", 0)) 

384 

385 mig_resources = parse_mig_resources(resources) 

386 if mig_resources: 

387 mig_profile = next(iter(mig_resources)) 

388 gpu = mig_resources[mig_profile] 

389 

390 if container.ports: 

391 for container_port in container.ports: 

392 if pod_ip and container_port and container_port.container_port: 

393 # Assumes one open port per container 

394 url = f"{SERVICE_SCHEME}://{pod_ip}:{container_port.container_port}" 

395 ret.append({ 

396 "namespace": namespace, 

397 "pod_name": pod_name, 

398 "status": pod_status, 

399 "pod_ip": pod_ip, 

400 "container_name": container_name, 

401 "url": url, 

402 "node": node_name, 

403 "cpu": cpu, 

404 "memory": memory, 

405 "gpu": gpu, 

406 "mig_profile": mig_profile, 

407 }) 

408 return ret 

409 

410 

411async def get_k8s_load_balancers( 

412 context_name: Optional[str] = None 

413) -> List[Dict[str, Any]]: 

414 ret: List[Dict[str, Any]] = [] 

415 

416 await load_k8s_config(context_name) 

417 

418 async with ApiClient() as api_client: 

419 k8s_api = CoreV1Api(api_client) 

420 services = await k8s_api.list_service_for_all_namespaces() 

421 for service in services.items: 

422 service_spec = service.spec 

423 if service_spec.type == "LoadBalancer" and len(service_spec.ports) > 0: 

424 external_ip = service_spec.load_balancer_ip 

425 if service.status.load_balancer.ingress: 

426 external_ip = service.status.load_balancer.ingress[0].ip 

427 spec_port = service.spec.ports[0] 

428 external_url = None 

429 if external_ip and spec_port: 

430 external_url = f"{SERVICE_SCHEME}://{external_ip}:{spec_port.port}" 

431 lb_info = { 

432 "namespace": service.metadata.namespace, 

433 "svc_name": service.metadata.name, 

434 "pod_name": service_spec.selector.get("app", "N/A"), 

435 "cluster_ip": service_spec.cluster_ip, 

436 "external_ip": external_ip, 

437 "external_port": spec_port.port, 

438 "external_url": external_url, 

439 "cluster_port": spec_port.target_port, 

440 "node_port": spec_port.node_port, 

441 } 

442 ret.append(lb_info) 

443 return ret 

444 

445 

446def get_svc_name_from_container_name(container_name: str) -> str: 

447 """Get the service name from the container name.""" 

448 service_name = container_name.lower().replace("-", "_") 

449 return service_name 

450 

451 

452async def get_k8s_services_ns( 

453 context_name: Optional[str] = None, 

454 namespace: str = "rtgen" 

455) -> Dict[str, K8sService]: 

456 svc_map = {} 

457 await load_k8s_config(context_name) 

458 async with ApiClient() as api_client: 

459 k8s_api = CoreV1Api(api_client) 

460 pods = await k8s_api.list_namespaced_pod(namespace) 

461 for pod in pods.items: 

462 pod_name = pod.metadata.name 

463 pod_ip = pod.status.pod_ip 

464 logging.debug(f"Pod: {pod_name} IP: {pod_ip}.") 

465 for container in pod.spec.containers: 

466 resources = container.resources.requests 

467 

468 ret_container = K8sContainer(name=pod_name) 

469 ret_container.ip = pod_ip 

470 ret_container.resources = resources 

471 

472 if not container.ports: 

473 continue 

474 for container_port in container.ports: 

475 svc_name = get_svc_name_from_container_name(container.name) 

476 ret_container.port = container_port.container_port 

477 

478 if svc_name not in svc_map: 

479 svc_map[svc_name] = K8sService(svc_name) 

480 svc_map[svc_name].add_container(ret_container) 

481 return svc_map 

482 

483 

484async def get_k8s_services( 

485 context_name: Optional[str] = None 

486) -> Dict[str, K8sService]: 

487 svc_map = {} 

488 

489 await load_k8s_config(context_name) 

490 async with ApiClient() as api_client: 

491 k8s_api = CoreV1Api(api_client) 

492 namespaces = await k8s_api.list_namespace() 

493 for ns in namespaces.items: 

494 namespace = ns.metadata.name 

495 if namespace.startswith("rtgen"): 

496 svc_map_ns = await get_k8s_services_ns(context_name, namespace) 

497 if svc_map_ns: 

498 for service_name, svc in svc_map_ns.items(): 

499 if service_name not in svc_map: 

500 svc_map[service_name] = svc 

501 else: 

502 svc_map[service_name].merge_service(svc) 

503 return svc_map