Skip to content

Commit 189b173

Browse files
authored
fix(cli): make cluster-agent validate-deployment check the real Pod, not a mirrored CR status string (#1514)
1 parent 5d8856f commit 189b173

2 files changed

Lines changed: 450 additions & 7 deletions

File tree

‎src/clis/nvcf-cli/internal/clusteragent/k8s_validator.go‎

Lines changed: 162 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
corev1 "k8s.io/api/core/v1"
3232
apierrors "k8s.io/apimachinery/pkg/api/errors"
3333
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
34+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3435
"k8s.io/client-go/dynamic"
3536
"k8s.io/client-go/kubernetes"
3637
)
@@ -381,6 +382,7 @@ func (v *k8sValidator) ValidateDeployment(ctx context.Context, functionID, versi
381382

382383
var match map[string]interface{}
383384
var matchVerID string
385+
var matchNamespace string
384386
for i := range items {
385387
fid, vid := functionIdentity(items[i].Object)
386388
if fid != functionID {
@@ -391,6 +393,7 @@ func (v *k8sValidator) ValidateDeployment(ctx context.Context, functionID, versi
391393
}
392394
match = items[i].Object
393395
matchVerID = vid
396+
matchNamespace = items[i].GetNamespace()
394397
break
395398
}
396399
if match == nil {
@@ -406,34 +409,187 @@ func (v *k8sValidator) ValidateDeployment(ctx context.Context, functionID, versi
406409
}
407410
instances := extractInstances(match)
408411
out.Checks = append(out.Checks,
409-
checkPodReadiness(instances),
412+
checkPodReadiness(ctx, v.cs, v.dc, matchNamespace, instances),
410413
checkQueueHealth(match, instances),
411414
checkGPUUtilization(cc.backend),
412415
)
413416
return out, nil
414417
}
415418

416-
// checkPodReadiness fails when no instances are running or any instance reports
417-
// an error/failed status.
418-
func checkPodReadiness(instances []Instance) CheckResult {
419+
// podReadinessRestartThreshold matches NVCA's own RestartCountToFailInstance
420+
// (internal/util/k8sutil/pod.go): the restart count past which a container's
421+
// last crash, not just its current state, is the relevant signal.
422+
const podReadinessRestartThreshold = int32(3)
423+
424+
// utilsPodName is the fixed name NVCA gives the sidecar Pod it uses as the
425+
// health signal for an entire MiniService (Helm) release: NVCA's own status
426+
// reconciler reduces "is this MiniService healthy" to this Pod's readiness
427+
// the same way checkPodReadiness does for a plain container function's Pod.
428+
const utilsPodName = "utils"
429+
430+
// checkPodReadiness reads each instance's real Pod (PodReady condition,
431+
// container state) instead of the ICMSRequest CR's mirrored status string,
432+
// which can lag or omit a crash NVCA hasn't reconciled forward yet. A
433+
// container function's Pod is named after the instance ID; a MiniService
434+
// (Helm) instance resolves to the "utils" Pod in its own namespace.
435+
func checkPodReadiness(ctx context.Context, cs kubernetes.Interface, dc dynamic.Interface, namespace string, instances []Instance) CheckResult {
419436
res := CheckResult{Name: "pod-readiness"}
420437
if len(instances) == 0 {
421438
res.Status = CheckFailed
422439
res.Message = "no instances running for this function version"
423440
return res
424441
}
442+
var warnMsg string
425443
for _, in := range instances {
426-
if instanceUnhealthy(in) {
444+
var pod *corev1.Pod
445+
var err error
446+
switch {
447+
case isPodBackedInstance(in):
448+
pod, err = cs.CoreV1().Pods(namespace).Get(ctx, in.ID, metav1.GetOptions{})
449+
case in.Type == "MiniService":
450+
pod, err = getMiniServiceUtilsPod(ctx, dc, cs, in.ID)
451+
default:
452+
// Unrecognized type: don't guess the resource kind, fall back
453+
// to the CR-reported status instead of silently skipping.
454+
if instanceUnhealthy(in) {
455+
res.Status = CheckFailed
456+
res.Message = fmt.Sprintf("instance %s is unhealthy (status=%s lastReported=%s)", in.ID, orUnknown(in.Status), orUnknown(in.LastReportedStatus))
457+
return res
458+
}
459+
continue
460+
}
461+
if err != nil {
462+
res.Status = CheckFailed
463+
if apierrors.IsNotFound(err) {
464+
res.Message = fmt.Sprintf("instance %s: pod not found", in.ID)
465+
} else {
466+
res.Message = fmt.Sprintf("instance %s: failed to read pod: %v", in.ID, err)
467+
}
468+
return res
469+
}
470+
reason, severity := podReadinessSeverity(pod)
471+
switch severity {
472+
case podSeverityFailed:
427473
res.Status = CheckFailed
428-
res.Message = fmt.Sprintf("instance %s is unhealthy (status=%s lastReported=%s)", in.ID, orUnknown(in.Status), orUnknown(in.LastReportedStatus))
474+
res.Message = fmt.Sprintf("instance %s is unhealthy: %s", in.ID, reason)
429475
return res
476+
case podSeverityWarning:
477+
// Keep checking the rest instead of failing fast: a later
478+
// instance could still be a genuine failure.
479+
if warnMsg == "" {
480+
warnMsg = fmt.Sprintf("instance %s not yet ready: %s", in.ID, reason)
481+
}
430482
}
431483
}
484+
if warnMsg != "" {
485+
res.Status = CheckWarning
486+
res.Message = warnMsg
487+
return res
488+
}
432489
res.Status = CheckPassed
433490
res.Message = fmt.Sprintf("%s healthy", pluralize(len(instances), "instance"))
434491
return res
435492
}
436493

494+
// isPodBackedInstance reports whether an instance is backed by a single Pod
495+
// (the empty type defaults to Pod, matching the legacy field the read-only
496+
// inspector already tolerates) as opposed to a MiniService (Helm) release.
497+
func isPodBackedInstance(in Instance) bool {
498+
switch in.Type {
499+
case "", "Pod":
500+
return true
501+
default:
502+
return false
503+
}
504+
}
505+
506+
// getMiniServiceUtilsPod resolves a MiniService instance to its utils Pod:
507+
// the MiniService CR (cluster-scoped, same lookup evictInstances uses to
508+
// delete it) names the release's dedicated namespace, where the utils Pod
509+
// lives under the fixed name NVCA itself relies on.
510+
func getMiniServiceUtilsPod(ctx context.Context, dc dynamic.Interface, cs kubernetes.Interface, instanceID string) (*corev1.Pod, error) {
511+
ms, err := dc.Resource(miniServiceGVR).Get(ctx, instanceID, metav1.GetOptions{})
512+
if err != nil {
513+
return nil, fmt.Errorf("reading MiniService %s: %w", instanceID, err)
514+
}
515+
msNamespace, found, err := unstructured.NestedString(ms.Object, "spec", "namespace")
516+
if err != nil {
517+
return nil, fmt.Errorf("MiniService %s spec.namespace: %w", instanceID, err)
518+
}
519+
if !found || msNamespace == "" {
520+
return nil, fmt.Errorf("MiniService %s has no spec.namespace", instanceID)
521+
}
522+
return cs.CoreV1().Pods(msNamespace).Get(ctx, utilsPodName, metav1.GetOptions{})
523+
}
524+
525+
// podReadinessSeverityLevel classifies a not-ready Pod: ordinary startup is
526+
// a warning, and only a positively-identified problem (image pull failure,
527+
// a crash, a restart loop) is a failure.
528+
type podReadinessSeverityLevel int
529+
530+
const (
531+
podSeverityHealthy podReadinessSeverityLevel = iota
532+
podSeverityWarning
533+
podSeverityFailed
534+
)
535+
536+
// podReadinessSeverity reports why a Pod isn't ready and how severe that is:
537+
// image pull problems, then a container terminated with a non-zero exit
538+
// code, then a crash loop (restarts past the threshold, judged by the last
539+
// crash since the container may be cycling through Waiting again by now).
540+
// Mirrors NVCA's own IsPodReady/IsPodStuckInitializing (internal/util/k8sutil/pod.go)
541+
// without its time-threshold gating, since this is a point-in-time snapshot.
542+
// Ready=False/Unknown with none of the above (Pending, ContainerCreating,
543+
// an early probe not yet passing) is ordinary rollout, so it's a warning,
544+
// not a failure -- consistent with queue-health's DEPLOYING handling.
545+
func podReadinessSeverity(pod *corev1.Pod) (string, podReadinessSeverityLevel) {
546+
if isPodReadyConditionTrue(pod.Status) {
547+
return "", podSeverityHealthy
548+
}
549+
550+
allContainers := make([]corev1.ContainerStatus, 0, len(pod.Status.InitContainerStatuses)+len(pod.Status.ContainerStatuses))
551+
allContainers = append(allContainers, pod.Status.InitContainerStatuses...)
552+
allContainers = append(allContainers, pod.Status.ContainerStatuses...)
553+
for _, cs := range allContainers {
554+
if w := cs.State.Waiting; w != nil && (w.Reason == "ErrImagePull" || w.Reason == "ImagePullBackOff") {
555+
return fmt.Sprintf("container %s: %s (%s)", cs.Name, w.Reason, w.Message), podSeverityFailed
556+
}
557+
}
558+
559+
for _, cs := range allContainers {
560+
if t := cs.State.Terminated; t != nil && t.ExitCode != 0 {
561+
return fmt.Sprintf("container %s terminated: %s (exit code %d)", cs.Name, t.Reason, t.ExitCode), podSeverityFailed
562+
}
563+
}
564+
565+
for _, cs := range allContainers {
566+
if cs.RestartCount < podReadinessRestartThreshold {
567+
continue
568+
}
569+
if t := cs.LastTerminationState.Terminated; t != nil {
570+
return fmt.Sprintf("container %s restarted %d times, last exit: %s (exit code %d)", cs.Name, cs.RestartCount, t.Reason, t.ExitCode), podSeverityFailed
571+
}
572+
if w := cs.LastTerminationState.Waiting; w != nil {
573+
return fmt.Sprintf("container %s restarted %d times, last state: %s", cs.Name, cs.RestartCount, w.Reason), podSeverityFailed
574+
}
575+
}
576+
577+
return fmt.Sprintf("pod condition Ready=%s", podReadyConditionStatus(pod.Status)), podSeverityWarning
578+
}
579+
580+
func isPodReadyConditionTrue(status corev1.PodStatus) bool {
581+
return podReadyConditionStatus(status) == corev1.ConditionTrue
582+
}
583+
584+
func podReadyConditionStatus(status corev1.PodStatus) corev1.ConditionStatus {
585+
for _, c := range status.Conditions {
586+
if c.Type == corev1.PodReady {
587+
return c.Status
588+
}
589+
}
590+
return corev1.ConditionUnknown
591+
}
592+
437593
// checkQueueHealth maps the collapsed request phase to a verdict: ACTIVE passes,
438594
// DEPLOYING/DRAINING warn (transient), FAILED fails.
439595
func checkQueueHealth(obj map[string]interface{}, instances []Instance) CheckResult {

0 commit comments

Comments
 (0)