Coverage for scripts / live_release_validation / checks / inference_runtime.py: 100.00%
419 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Readiness and HPA stability checks for live inference endpoints."""
3from __future__ import annotations
5import json
6import subprocess
7import time
8from collections.abc import Callable
9from typing import Any, cast
11from .inference_common import ManagedInferenceValidationError
13_TUNNEL_HEARTBEAT_INTERVAL_SECONDS = 240.0
15# Workload diagnostics: what the endpoint's pods are doing while a phase waits
16# on DDB or Kubernetes. A crash-looping SGLang pod once ran out the whole
17# 30-minute readiness window with the checkpoint recording nothing but the
18# timeout; the cause (eight restarts, "no kernel image is available for
19# execution on the device" on a T4 node) had to be dug out of CloudWatch by
20# hand after the cluster was gone. Snapshots are taken every
21# _DIAGNOSTICS_INTERVAL_SECONDS during a wait and once more when it fails,
22# kept as a bounded ring so a long wait cannot bloat the checkpoint.
23_DIAGNOSTICS_INTERVAL_SECONDS = 300.0
24_DIAGNOSTICS_RING_SIZE = 8
25_DIAGNOSTICS_LOG_TAIL_LINES = 40
26_DIAGNOSTICS_LOG_TAIL_BYTES = 6 * 1024
27_DIAGNOSTICS_EVENT_LIMIT = 20
28_DIAGNOSTICS_NODE_LABELS = (
29 "node.kubernetes.io/instance-type",
30 "eks.amazonaws.com/instance-family",
31 "eks.amazonaws.com/instance-gpu-name",
32 "eks.amazonaws.com/instance-gpu-count",
33 "eks.amazonaws.com/instance-gpu-memory",
34 "karpenter.sh/nodepool",
35 "eks.amazonaws.com/nodepool",
36 "karpenter.sh/capacity-type",
37 "eks.amazonaws.com/capacity-type",
38 "topology.kubernetes.io/zone",
39)
42def _dict_or_empty(value: Any) -> dict[str, Any]:
43 """A dict payload as itself, anything else as an empty dict."""
44 return value if isinstance(value, dict) else {}
47class InferenceRuntimeMixin:
48 """Mixin for DDB readiness, Kubernetes readiness, and HPA stability."""
50 settings: Any
52 def _persist(self) -> None: # pragma: no cover - implemented by lifecycle
53 raise NotImplementedError
55 def _strong_get(
56 self, record: dict[str, Any]
57 ) -> dict[str, Any] | None: # pragma: no cover - implemented by lifecycle
58 raise NotImplementedError
60 def _is_owned(
61 self, item: dict[str, Any]
62 ) -> bool: # pragma: no cover - implemented by lifecycle
63 raise NotImplementedError
65 def _verify_item_contract(
66 self, plan: Any, item: dict[str, Any], record: dict[str, Any]
67 ) -> None: # pragma: no cover - implemented by lifecycle
68 raise NotImplementedError
70 def _set_phase(
71 self, record: dict[str, Any], phase: str, **values: Any
72 ) -> None: # pragma: no cover - implemented by lifecycle
73 raise NotImplementedError
75 _kubectl_json: Callable[..., Any | None]
76 kubectl: Callable[..., tuple[int, str, str]]
78 def keep_cluster_tunnel_alive(
79 self,
80 record: dict[str, Any],
81 last_heartbeat: float,
82 *,
83 deadline: float | None = None,
84 ) -> float:
85 """Send bounded Kubernetes traffic before SSM's idle-session timeout."""
86 now = time.monotonic()
87 if now - last_heartbeat < _TUNNEL_HEARTBEAT_INTERVAL_SECONDS:
88 return last_heartbeat
89 process_timeout = 8.0
90 if deadline is not None:
91 remaining = deadline - now
92 if remaining <= 0:
93 raise ManagedInferenceValidationError(
94 "managed inference tunnel heartbeat deadline expired"
95 )
96 process_timeout = min(process_timeout, remaining)
97 observation: dict[str, Any] = {"started_at_monotonic": now}
98 try:
99 returncode, stdout, stderr = self.kubectl(
100 "--request-timeout=5s",
101 "get",
102 "--raw=/readyz",
103 timeout=process_timeout,
104 )
105 except (OSError, subprocess.TimeoutExpired) as exc:
106 observation.update(
107 {
108 "healthy": False,
109 "error": f"{type(exc).__name__}: {exc}",
110 }
111 )
112 else:
113 observation.update(
114 {
115 "healthy": returncode == 0 and stdout.strip() == "ok",
116 "returncode": returncode,
117 "stderr": stderr[-1000:],
118 }
119 )
120 history = record.setdefault("tunnel_heartbeats", [])
121 if not isinstance(history, list):
122 raise ManagedInferenceValidationError("managed tunnel heartbeat history is invalid")
123 history.append(observation)
124 self._persist()
125 if observation["healthy"] is not True:
126 raise ManagedInferenceValidationError(
127 "managed inference Kubernetes tunnel heartbeat failed"
128 )
129 return time.monotonic()
131 def _wait_for_owned_record(
132 self,
133 plan: Any,
134 record: dict[str, Any],
135 ) -> dict[str, Any]:
136 """Wait for the run-owned DDB record while keeping the tunnel active."""
137 deadline = time.monotonic() + self.settings.readiness_timeout_seconds
138 heartbeat_at = float("-inf")
139 while True:
140 if time.monotonic() >= deadline:
141 raise ManagedInferenceValidationError(
142 "managed inference endpoint ownership did not appear before timeout"
143 )
144 item = self._strong_get(record)
145 if item is not None:
146 if not self._is_owned(item):
147 raise ManagedInferenceValidationError(
148 "managed inference endpoint collision detected; refusing ownership"
149 )
150 self._verify_item_contract(plan, item, record)
151 record["owned"] = True
152 self._set_phase(record, "ownership-confirmed")
153 return item
154 heartbeat_at = self.keep_cluster_tunnel_alive(
155 record,
156 heartbeat_at,
157 deadline=deadline,
158 )
159 remaining = deadline - time.monotonic()
160 if remaining <= 0:
161 raise ManagedInferenceValidationError(
162 "managed inference endpoint ownership did not appear before timeout"
163 )
164 time.sleep(min(float(self.settings.poll_interval_seconds), remaining))
166 # ------------------------------------------------------------------
167 # Workload diagnostics
168 # ------------------------------------------------------------------
170 def _diagnostics_read(self, *arguments: str) -> tuple[Any | None, str | None]:
171 """One best-effort kubectl read for a diagnostics snapshot.
173 Returns ``(payload, error)``; JSON output is decoded, anything else is
174 returned as text. Never raises: a snapshot that cannot be taken is
175 itself evidence and must not mask the failure being diagnosed.
176 """
177 timeout = min(float(self.settings.command_timeout_seconds), 30.0)
178 try:
179 code, stdout, stderr = self.kubectl(*arguments, timeout=timeout)
180 except Exception as exc:
181 return None, f"{type(exc).__name__}: {exc}"
182 if code != 0:
183 return None, f"kubectl exited {code}: {stderr.strip()[-500:]}"
184 if "json" in arguments:
185 try:
186 return json.loads(stdout), None
187 except json.JSONDecodeError as exc:
188 return None, f"kubectl returned non-JSON output: {exc}"
189 return stdout, None
191 @staticmethod
192 def _summarize_container_state(state: Any) -> dict[str, Any] | None:
193 """Flatten a container ``state``/``lastState`` block to its one active branch."""
194 if not isinstance(state, dict):
195 return None
196 for branch in ("waiting", "running", "terminated"):
197 details = state.get(branch)
198 if isinstance(details, dict):
199 summary: dict[str, Any] = {"status": branch}
200 for key in ("reason", "exitCode", "signal", "startedAt", "finishedAt"):
201 if details.get(key) is not None:
202 summary[key] = details[key]
203 message = details.get("message")
204 if isinstance(message, str) and message:
205 summary["message"] = message[-500:]
206 return summary
207 return None
209 def _diagnose_pod(self, plan: Any, pod: dict[str, Any]) -> dict[str, Any]:
210 """Describe one pod: scheduling, per-container state, and crash output."""
211 metadata = _dict_or_empty(pod.get("metadata"))
212 spec = _dict_or_empty(pod.get("spec"))
213 status = _dict_or_empty(pod.get("status"))
214 name = str(metadata.get("name") or "")
215 diagnosis: dict[str, Any] = {
216 "name": name,
217 "phase": status.get("phase"),
218 "node": spec.get("nodeName"),
219 "start_time": status.get("startTime"),
220 "conditions": [],
221 "containers": [],
222 }
223 conditions = status.get("conditions")
224 if isinstance(conditions, list):
225 for condition in conditions:
226 if not isinstance(condition, dict) or condition.get("status") == "True":
227 continue
228 condition_entry: dict[str, Any] = {
229 "type": condition.get("type"),
230 "status": condition.get("status"),
231 }
232 if condition.get("reason"):
233 condition_entry["reason"] = condition["reason"]
234 if isinstance(condition.get("message"), str):
235 condition_entry["message"] = condition["message"][-500:]
236 diagnosis["conditions"].append(condition_entry)
237 statuses = status.get("containerStatuses")
238 if isinstance(statuses, list):
239 for container_status in statuses:
240 if not isinstance(container_status, dict):
241 continue
242 container_name = str(container_status.get("name") or "")
243 restarts = int(container_status.get("restartCount") or 0)
244 ready = container_status.get("ready") is True
245 entry: dict[str, Any] = {
246 "name": container_name,
247 "ready": ready,
248 "restart_count": restarts,
249 "state": self._summarize_container_state(container_status.get("state")),
250 "last_state": self._summarize_container_state(
251 container_status.get("lastState")
252 ),
253 }
254 if name and container_name and (restarts > 0 or not ready):
255 # The current attempt's output, and — the part that names
256 # a crash-loop's cause — the previous attempt's.
257 entry["log_tail"] = self._log_tail(name, container_name, previous=False)
258 if restarts > 0:
259 entry["previous_log_tail"] = self._log_tail(
260 name, container_name, previous=True
261 )
262 diagnosis["containers"].append(entry)
263 return diagnosis
265 def _log_tail(self, pod: str, container: str, *, previous: bool) -> str:
266 arguments = [
267 "logs",
268 pod,
269 "--namespace",
270 self.settings.namespace,
271 "--container",
272 container,
273 f"--tail={_DIAGNOSTICS_LOG_TAIL_LINES}",
274 ]
275 if previous:
276 arguments.append("--previous")
277 payload, error = self._diagnostics_read(*arguments)
278 if error is not None:
279 return f"<unavailable: {error}>"
280 return str(payload)[-_DIAGNOSTICS_LOG_TAIL_BYTES:]
282 def _diagnose_events(self, plan: Any) -> list[dict[str, Any]] | str:
283 payload, error = self._diagnostics_read(
284 "get",
285 "events",
286 "--namespace",
287 self.settings.namespace,
288 "--output",
289 "json",
290 )
291 if error is not None:
292 return f"<unavailable: {error}>"
293 items = payload.get("items") if isinstance(payload, dict) else None
294 events: list[dict[str, Any]] = []
295 for item in items if isinstance(items, list) else []:
296 if not isinstance(item, dict):
297 continue
298 involved = item.get("involvedObject")
299 involved_name = str(involved.get("name") or "") if isinstance(involved, dict) else ""
300 if not involved_name.startswith(str(plan.name)):
301 continue
302 message = item.get("message")
303 events.append(
304 {
305 "object": (
306 f"{involved.get('kind')}/{involved_name}"
307 if isinstance(involved, dict)
308 else involved_name
309 ),
310 "type": item.get("type"),
311 "reason": item.get("reason"),
312 "count": item.get("count"),
313 "last_timestamp": item.get("lastTimestamp") or item.get("eventTime"),
314 "message": message[-500:] if isinstance(message, str) else message,
315 }
316 )
317 events.sort(key=lambda event: str(event.get("last_timestamp") or ""))
318 return events[-_DIAGNOSTICS_EVENT_LIMIT:]
320 def _diagnose_node(self, node_name: str) -> dict[str, Any]:
321 payload, error = self._diagnostics_read("get", "node", node_name, "--output", "json")
322 if error is not None:
323 return {"error": error}
324 metadata = payload.get("metadata") if isinstance(payload, dict) else None
325 labels = metadata.get("labels") if isinstance(metadata, dict) else None
326 status = payload.get("status") if isinstance(payload, dict) else None
327 allocatable = status.get("allocatable") if isinstance(status, dict) else None
328 return {
329 "labels": {
330 key: labels[key]
331 for key in _DIAGNOSTICS_NODE_LABELS
332 if isinstance(labels, dict) and key in labels
333 },
334 "allocatable_gpus": (
335 allocatable.get("nvidia.com/gpu") if isinstance(allocatable, dict) else None
336 ),
337 }
339 @staticmethod
340 def _diagnostics_summary(snapshot: dict[str, Any]) -> str:
341 """One line a human can act on: pods, their worst container state, their GPU."""
342 pods = snapshot.get("pods")
343 if not isinstance(pods, list) or not pods:
344 error = snapshot.get("pods_error")
345 return f"no pods observed{f' ({error})' if error else ''}"
346 parts: list[str] = []
347 nodes = _dict_or_empty(snapshot.get("nodes"))
348 for pod in pods:
349 phase = pod.get("phase") or "?"
350 pieces = [f"{pod.get('name')}: {phase}"]
351 for condition in pod.get("conditions") or []:
352 if condition.get("type") == "PodScheduled" and condition.get("reason"):
353 pieces.append(f"unscheduled ({condition['reason']})")
354 for container in pod.get("containers") or []:
355 state = container.get("state") or {}
356 last = container.get("last_state") or {}
357 detail = state.get("reason") or state.get("status") or "?"
358 if last.get("exitCode") is not None:
359 detail += f", last exit {last['exitCode']}"
360 if last.get("reason"):
361 detail += f" {last['reason']}"
362 pieces.append(
363 f"{container.get('name')} {detail} (restarts={container.get('restart_count', 0)})"
364 )
365 node = nodes.get(pod.get("node") or "")
366 if isinstance(node, dict) and isinstance(node.get("labels"), dict):
367 labels = node["labels"]
368 placement = "/".join(
369 str(labels[key])
370 for key in (
371 "node.kubernetes.io/instance-type",
372 "eks.amazonaws.com/instance-gpu-name",
373 )
374 if key in labels
375 )
376 if placement:
377 pieces.append(f"on {placement}")
378 parts.append(", ".join(pieces))
379 return "; ".join(parts)
381 def capture_workload_diagnostics(
382 self,
383 plan: Any,
384 record: dict[str, Any],
385 *,
386 reason: str,
387 ) -> dict[str, Any]:
388 """Snapshot the endpoint's pods, crash output, events and nodes into the record.
390 Best-effort and bounded: every read has its own short timeout and a
391 failed read is recorded in place of the data. The snapshot ring keeps
392 the last ``_DIAGNOSTICS_RING_SIZE`` entries so periodic captures during
393 a long wait show the progression (pulling -> running -> crash-looping)
394 without growing the checkpoint unboundedly. Returns the snapshot; its
395 ``summary`` is what the caller appends to a timeout error.
396 """
397 snapshot: dict[str, Any] = {
398 "reason": reason,
399 "captured_at_monotonic": time.monotonic(),
400 "pods": [],
401 "nodes": {},
402 }
403 payload, error = self._diagnostics_read(
404 "get",
405 "pods",
406 "--namespace",
407 self.settings.namespace,
408 "--selector",
409 f"app={plan.name}",
410 "--output",
411 "json",
412 )
413 if error is not None:
414 snapshot["pods_error"] = error
415 else:
416 items = payload.get("items") if isinstance(payload, dict) else None
417 for pod in items if isinstance(items, list) else []:
418 if isinstance(pod, dict):
419 snapshot["pods"].append(self._diagnose_pod(plan, pod))
420 for node_name in sorted({str(pod["node"]) for pod in snapshot["pods"] if pod["node"]}):
421 snapshot["nodes"][node_name] = self._diagnose_node(node_name)
422 snapshot["events"] = self._diagnose_events(plan)
423 snapshot["summary"] = self._diagnostics_summary(snapshot)
425 ring = record.setdefault("workload_diagnostics", [])
426 if not isinstance(ring, list):
427 ring = []
428 record["workload_diagnostics"] = ring
429 ring.append(snapshot)
430 del ring[:-_DIAGNOSTICS_RING_SIZE]
431 record["last_workload_summary"] = snapshot["summary"]
432 self._persist()
433 return snapshot
435 def _diagnostics_due(
436 self, record: dict[str, Any], plan: Any, last_capture: float, reason: str
437 ) -> float:
438 """Take a periodic snapshot when the interval has elapsed; return the new mark."""
439 now = time.monotonic()
440 if now - last_capture < _DIAGNOSTICS_INTERVAL_SECONDS:
441 return last_capture
442 self.capture_workload_diagnostics(plan, record, reason=reason)
443 return now
445 def _timeout_error(
446 self, plan: Any, record: dict[str, Any], message: str, *, reason: str
447 ) -> ManagedInferenceValidationError:
448 """Build a timeout error that carries the final workload diagnosis."""
449 snapshot = self.capture_workload_diagnostics(plan, record, reason=reason)
450 return ManagedInferenceValidationError(f"{message} ({snapshot['summary']})")
452 def wait_for_ddb_running(self, plan: Any, record: dict[str, Any]) -> None:
453 """Require this run's exact DDB record and running regional observation."""
454 deadline = time.monotonic() + self.settings.readiness_timeout_seconds
455 heartbeat_at = float("-inf")
456 diagnostics_at = time.monotonic()
457 while True:
458 if time.monotonic() >= deadline:
459 raise self._timeout_error(
460 plan,
461 record,
462 "managed inference DDB running state was not observed before timeout",
463 reason="ddb-running-timeout",
464 )
465 item = self._strong_get(record)
466 if item is not None:
467 if not self._is_owned(item):
468 raise ManagedInferenceValidationError(
469 "managed inference endpoint ownership changed while waiting"
470 )
471 self._verify_item_contract(plan, item, record)
472 statuses = item.get("region_status")
473 regional = (
474 statuses.get(self.settings.selected_region)
475 if isinstance(statuses, dict)
476 else None
477 )
478 # The monitor's own view of the region — its state and any
479 # message it wrote — is the cheapest evidence there is.
480 record["last_ddb_observation"] = {
481 "desired_state": item.get("desired_state"),
482 "regional": regional if isinstance(regional, dict) else None,
483 "observed_at_monotonic": time.monotonic(),
484 }
485 if (
486 item.get("desired_state") == "running"
487 and isinstance(regional, dict)
488 and regional.get("state") == "running"
489 and self._regional_status_satisfied(plan, record, regional)
490 ):
491 self._set_phase(record, "ddb-running")
492 return
493 heartbeat_at = self.keep_cluster_tunnel_alive(
494 record,
495 heartbeat_at,
496 deadline=deadline,
497 )
498 diagnostics_at = self._diagnostics_due(record, plan, diagnostics_at, "ddb-running-wait")
499 remaining = deadline - time.monotonic()
500 if remaining <= 0:
501 raise self._timeout_error(
502 plan,
503 record,
504 "managed inference DDB running state was not observed before timeout",
505 reason="ddb-running-timeout",
506 )
507 time.sleep(min(float(self.settings.poll_interval_seconds), remaining))
509 def _regional_status_satisfied(
510 self, plan: Any, record: dict[str, Any], regional: dict[str, Any]
511 ) -> bool:
512 """Scenario-specific conditions on the monitor's regional observation.
514 A Mooncake store scenario is ready only when the shared master is
515 reported Ready and the requested cold tier is active with the
516 own-Region bucket URI: an unresolved cold tier (the monitor dropped it
517 and reported why) is not a running store for this harness, since the
518 scenario exists to prove that tier end to end.
519 """
520 if not getattr(plan, "mooncake_store", False):
521 return True
522 store = _dict_or_empty(regional.get("store"))
523 cold_tier = _dict_or_empty(store.get("cold_tier"))
524 s3_uri = cold_tier.get("s3_uri")
525 satisfied = (
526 store.get("ready") is True
527 and cold_tier.get("enabled") is True
528 and isinstance(s3_uri, str)
529 and s3_uri.startswith("s3://")
530 )
531 record["last_store_observation"] = {
532 "ready": store.get("ready"),
533 "master": store.get("master"),
534 "cold_tier": cold_tier,
535 "satisfied": satisfied,
536 }
537 if satisfied:
538 record["cold_tier_verified"] = True
539 return satisfied
541 @staticmethod
542 def _ready_condition(container_statuses: Any) -> bool:
543 return (
544 isinstance(container_statuses, list)
545 and bool(container_statuses)
546 and all(
547 isinstance(status, dict) and status.get("ready") is True
548 for status in container_statuses
549 )
550 )
552 def _deployment_ready_snapshot(
553 self,
554 plan: Any,
555 record: dict[str, Any],
556 expected_replicas: int,
557 *,
558 exact: bool,
559 deadline: float | None = None,
560 ) -> tuple[bool, dict[str, int]]:
561 deployment = self._kubectl_json(
562 record,
563 "get",
564 "deployment",
565 plan.name,
566 "--namespace",
567 self.settings.namespace,
568 "--output",
569 "json",
570 deadline=deadline,
571 )
572 if not isinstance(deployment, dict):
573 return False, {}
574 metadata = deployment.get("metadata")
575 spec = deployment.get("spec")
576 status = deployment.get("status")
577 if (
578 not isinstance(metadata, dict)
579 or not isinstance(spec, dict)
580 or not isinstance(status, dict)
581 ):
582 return False, {}
583 desired = int(spec.get("replicas") or 0)
584 ready = int(status.get("readyReplicas") or 0)
585 available = int(status.get("availableReplicas") or 0)
586 updated = int(status.get("updatedReplicas") or 0)
587 generation = int(metadata.get("generation") or 0)
588 observed = int(status.get("observedGeneration") or 0)
589 replica_match = desired == expected_replicas if exact else desired >= expected_replicas
590 deployment_ready = (
591 replica_match
592 and ready >= expected_replicas
593 and available >= expected_replicas
594 and updated >= expected_replicas
595 and observed >= generation
596 )
598 pods_payload = self._kubectl_json(
599 record,
600 "get",
601 "pods",
602 "--namespace",
603 self.settings.namespace,
604 "--selector",
605 f"app={plan.name}",
606 "--output",
607 "json",
608 deadline=deadline,
609 )
610 items = pods_payload.get("items", []) if isinstance(pods_payload, dict) else []
611 ready_pods = 0
612 if isinstance(items, list):
613 for item in items:
614 pod_status = item.get("status") if isinstance(item, dict) else None
615 if (
616 isinstance(pod_status, dict)
617 and pod_status.get("phase") == "Running"
618 and self._ready_condition(pod_status.get("containerStatuses"))
619 ):
620 ready_pods += 1
621 evidence = {
622 "desired": desired,
623 "ready": ready,
624 "available": available,
625 "updated": updated,
626 "ready_pods": ready_pods,
627 }
628 return deployment_ready and ready_pods >= expected_replicas, evidence
630 def wait_for_kubernetes_ready(self, plan: Any, record: dict[str, Any]) -> None:
631 """Require Deployment convergence and ready Running pods."""
632 deadline = time.monotonic() + self.settings.readiness_timeout_seconds
633 diagnostics_at = time.monotonic()
634 while True:
635 ready, evidence = self._deployment_ready_snapshot(
636 plan,
637 record,
638 plan.replicas,
639 exact=not plan.autoscaling,
640 deadline=deadline,
641 )
642 record["last_readiness"] = evidence
643 self._persist()
644 if ready:
645 self._set_phase(record, "kubernetes-ready")
646 return
647 if time.monotonic() >= deadline:
648 raise self._timeout_error(
649 plan,
650 record,
651 "managed inference Kubernetes readiness was not observed before timeout",
652 reason="kubernetes-ready-timeout",
653 )
654 diagnostics_at = self._diagnostics_due(
655 record, plan, diagnostics_at, "kubernetes-ready-wait"
656 )
657 time.sleep(
658 min(
659 float(self.settings.poll_interval_seconds),
660 max(0.0, deadline - time.monotonic()),
661 )
662 )
664 def verify_no_container_restarts(self, plan: Any, record: dict[str, Any]) -> None:
665 """Refuse a leg whose containers restarted on the way to serving.
667 Readiness, HPA stability and a successful invocation can all be
668 observed *between* restarts: an SGLang container whose probes timed
669 out was killed by liveness every ~3 minutes while readiness flapped
670 true often enough for the leg to pass (2026-09-18, A10G). A restart
671 means a crash or a probe kill, and either is a defect the release
672 must not carry, so the final diagnostics snapshot is audited: every
673 container of every pod must report ``restartCount`` 0. The snapshot
674 already holds the previous attempt's log tail and the pod events
675 (``Liveness probe failed``, ``Killing``), so the failure names its
676 cause. A snapshot that could not list the pods fails closed.
677 """
678 snapshot = self.capture_workload_diagnostics(plan, record, reason="restart-audit")
679 if "pods_error" in snapshot:
680 raise ManagedInferenceValidationError(
681 "managed inference restart audit could not list the endpoint's pods "
682 f"({snapshot['pods_error']})"
683 )
684 pods = snapshot["pods"]
685 if not pods:
686 raise ManagedInferenceValidationError(
687 "managed inference restart audit found no pods for a serving endpoint"
688 )
689 restarted = [
690 f"{pod.get('name')}/{container.get('name')} restarted "
691 f"{int(container.get('restart_count') or 0)}x"
692 for pod in pods
693 for container in pod.get("containers") or []
694 if int(container.get("restart_count") or 0) > 0
695 ]
696 record["restart_audit"] = {
697 "pods": [str(pod.get("name")) for pod in pods],
698 "restarted": restarted,
699 }
700 self._persist()
701 if restarted:
702 raise ManagedInferenceValidationError(
703 "managed inference containers restarted during the leg: "
704 f"{'; '.join(restarted)} ({snapshot['summary']})"
705 )
706 self._set_phase(record, "restart-audited")
708 def _hpa_matches(
709 self,
710 plan: Any,
711 record: dict[str, Any],
712 *,
713 deadline: float | None = None,
714 ) -> bool:
715 hpa = self._kubectl_json(
716 record,
717 "get",
718 "horizontalpodautoscaler.autoscaling",
719 plan.name,
720 "--namespace",
721 self.settings.namespace,
722 "--output",
723 "json",
724 deadline=deadline,
725 )
726 if not isinstance(hpa, dict):
727 return False
728 spec = hpa.get("spec")
729 if not isinstance(spec, dict):
730 return False
731 target = spec.get("scaleTargetRef")
732 if not isinstance(target, dict) or target != {
733 "apiVersion": "apps/v1",
734 "kind": "Deployment",
735 "name": plan.name,
736 }:
737 return False
738 if spec.get("minReplicas") != self.settings.hpa_min_replicas:
739 return False
740 if spec.get("maxReplicas") != self.settings.hpa_max_replicas:
741 return False
742 metrics = spec.get("metrics")
743 if not isinstance(metrics, list):
744 return False
745 return any(
746 isinstance(metric, dict)
747 and metric.get("type") == "Resource"
748 and isinstance(metric.get("resource"), dict)
749 and metric["resource"].get("name") == "cpu"
750 and isinstance(metric["resource"].get("target"), dict)
751 and metric["resource"]["target"].get("type") == "Utilization"
752 and metric["resource"]["target"].get("averageUtilization")
753 == self.settings.hpa_cpu_target
754 for metric in metrics
755 )
757 def verify_shared_proxy_autoscaling(self, state: dict[str, Any]) -> None:
758 """Prove the deployed TLS sidecar request and active ContainerResource HPA."""
759 record = state.setdefault(
760 "shared_proxy_autoscaling",
761 {
762 "namespace": "gco-system",
763 "deployment": "inference-proxy",
764 "hpa": "inference-proxy-hpa",
765 "phase": "waiting",
766 "commands": [],
767 },
768 )
769 if not isinstance(record, dict):
770 raise ManagedInferenceValidationError("shared proxy checkpoint evidence is invalid")
771 record["phase"] = "waiting"
772 self._persist()
773 deadline = time.monotonic() + self.settings.hpa_timeout_seconds
774 while True:
775 deployment = self._kubectl_json(
776 record,
777 "get",
778 "deployment",
779 "inference-proxy",
780 "--namespace",
781 "gco-system",
782 "--output",
783 "json",
784 deadline=deadline,
785 )
786 hpa = self._kubectl_json(
787 record,
788 "get",
789 "horizontalpodautoscaler.autoscaling",
790 "inference-proxy-hpa",
791 "--namespace",
792 "gco-system",
793 "--output",
794 "json",
795 deadline=deadline,
796 )
797 observed: dict[str, Any] = {}
798 if isinstance(deployment, dict):
799 deployment_spec = deployment.get("spec")
800 template = (
801 deployment_spec.get("template") if isinstance(deployment_spec, dict) else None
802 )
803 pod_spec = template.get("spec") if isinstance(template, dict) else None
804 containers = pod_spec.get("containers") if isinstance(pod_spec, dict) else None
805 tls_containers = (
806 [
807 item
808 for item in containers
809 if isinstance(item, dict) and item.get("name") == "api-tls-proxy"
810 ]
811 if isinstance(containers, list)
812 else []
813 )
814 if len(tls_containers) == 1:
815 resources = tls_containers[0].get("resources")
816 requests = resources.get("requests") if isinstance(resources, dict) else None
817 if isinstance(requests, dict):
818 observed["tls_cpu_request"] = requests.get("cpu")
819 if isinstance(hpa, dict):
820 metadata_value = hpa.get("metadata")
821 spec_value = hpa.get("spec")
822 status_value = hpa.get("status")
823 metadata = (
824 cast(dict[str, Any], metadata_value) if isinstance(metadata_value, dict) else {}
825 )
826 spec = cast(dict[str, Any], spec_value) if isinstance(spec_value, dict) else {}
827 status = (
828 cast(dict[str, Any], status_value) if isinstance(status_value, dict) else {}
829 )
830 target = spec.get("scaleTargetRef")
831 metrics = spec.get("metrics")
833 def matching_tls_metric(metric: object, *, current: bool) -> bool:
834 if not isinstance(metric, dict) or metric.get("type") != "ContainerResource":
835 return False
836 source = metric.get("containerResource")
837 if not isinstance(source, dict):
838 return False
839 value = source.get("current" if current else "target")
840 return (
841 source.get("name") == "cpu"
842 and source.get("container") == "api-tls-proxy"
843 and isinstance(value, dict)
844 and (current or value.get("type") == "Utilization")
845 )
847 tls_metrics = (
848 [metric for metric in metrics if matching_tls_metric(metric, current=False)]
849 if isinstance(metrics, list)
850 else []
851 )
852 current_metrics = status.get("currentMetrics")
853 active_metrics = (
854 [
855 metric
856 for metric in current_metrics
857 if matching_tls_metric(metric, current=True)
858 ]
859 if isinstance(current_metrics, list)
860 else []
861 )
862 conditions = status.get("conditions")
863 active_conditions = (
864 [
865 condition
866 for condition in conditions
867 if isinstance(condition, dict)
868 and condition.get("type") == "ScalingActive"
869 and condition.get("status") == "True"
870 ]
871 if isinstance(conditions, list)
872 else []
873 )
874 tls_target: object = None
875 if len(tls_metrics) == 1:
876 # matching_tls_metric admitted this metric only after proving
877 # containerResource.target is a Utilization object.
878 tls_target = tls_metrics[0]["containerResource"]["target"].get(
879 "averageUtilization"
880 )
881 observed.update(
882 {
883 "target_matches": target
884 == {
885 "apiVersion": "apps/v1",
886 "kind": "Deployment",
887 "name": "inference-proxy",
888 },
889 "tls_metric_count": len(tls_metrics),
890 "tls_cpu_target": tls_target,
891 "active_tls_metric_count": len(active_metrics),
892 "scaling_active": bool(active_conditions),
893 "scaling_active_reason": (
894 active_conditions[0].get("reason") if active_conditions else None
895 ),
896 "observed_generation_current": int(status.get("observedGeneration") or 0)
897 >= int(metadata.get("generation") or 0),
898 }
899 )
900 record["last_observed"] = observed
901 self._persist()
902 if (
903 observed.get("tls_cpu_request") == self.settings.proxy_tls_cpu_request
904 and observed.get("target_matches") is True
905 and observed.get("tls_metric_count") == 1
906 and observed.get("tls_cpu_target") == self.settings.proxy_tls_cpu_target
907 and observed.get("active_tls_metric_count") == 1
908 and observed.get("scaling_active") is True
909 and observed.get("observed_generation_current") is True
910 ):
911 record["phase"] = "verified"
912 record["expected"] = {
913 "tls_cpu_request": self.settings.proxy_tls_cpu_request,
914 "tls_cpu_target": self.settings.proxy_tls_cpu_target,
915 }
916 self._persist()
917 return
918 if time.monotonic() >= deadline:
919 raise ManagedInferenceValidationError(
920 "shared inference-proxy TLS autoscaling contract was not active before timeout"
921 )
922 time.sleep(
923 min(
924 float(self.settings.poll_interval_seconds),
925 max(0.0, deadline - time.monotonic()),
926 )
927 )
929 def verify_hpa_stability(self, plan: Any, record: dict[str, Any]) -> None:
930 """Prove HPA target/bounds and two full monitor intervals at two replicas."""
931 deadline = time.monotonic() + self.settings.hpa_timeout_seconds
932 while not self._hpa_matches(plan, record, deadline=deadline):
933 if time.monotonic() >= deadline:
934 raise ManagedInferenceValidationError(
935 "managed inference HPA contract was not observed before timeout"
936 )
937 time.sleep(
938 min(
939 float(self.settings.poll_interval_seconds),
940 max(0.0, deadline - time.monotonic()),
941 )
942 )
943 self._set_phase(record, "hpa-verified")
945 while True:
946 ready, evidence = self._deployment_ready_snapshot(
947 plan,
948 record,
949 self.settings.hpa_min_replicas,
950 exact=True,
951 deadline=deadline,
952 )
953 record["last_hpa_replica_observation"] = evidence
954 self._persist()
955 if ready:
956 break
957 if time.monotonic() >= deadline:
958 raise ManagedInferenceValidationError(
959 "managed inference HPA did not reach two ready replicas before timeout"
960 )
961 time.sleep(
962 min(
963 float(self.settings.poll_interval_seconds),
964 max(0.0, deadline - time.monotonic()),
965 )
966 )
968 observations = [record["last_hpa_replica_observation"]]
969 monitor_interval = float(self.settings.monitor_interval_seconds)
970 for _ in range(self.settings.hpa_stability_intervals):
971 if deadline - time.monotonic() < monitor_interval:
972 raise ManagedInferenceValidationError(
973 "managed inference HPA stability exceeded its phase deadline"
974 )
975 time.sleep(monitor_interval)
976 ready, evidence = self._deployment_ready_snapshot(
977 plan,
978 record,
979 self.settings.hpa_min_replicas,
980 exact=True,
981 deadline=deadline,
982 )
983 observations.append(evidence)
984 if not ready:
985 record["hpa_stability_observations"] = observations
986 self._persist()
987 raise ManagedInferenceValidationError(
988 "managed inference HPA replicas did not remain stable"
989 )
990 record["hpa_stability_observations"] = observations
991 self._set_phase(record, "hpa-stable")