Skip to content

Commit da8a63b

Browse files
committed
fix(validator): fail closed when a declared GPU driver floor cannot be measured
Skip on cordoned or busy GPU nodes is non-blocking, so a host-driver floor would otherwise PASS unevaluated. Block those paths when Deployment.gpu-driver.version is set, preserve Skip when it is not, and keep Evaluate()'s InvalidRequest code. Signed-off-by: Rohit Rajani <rorajani@nvidia.com>
1 parent 926010a commit da8a63b

5 files changed

Lines changed: 207 additions & 20 deletions

File tree

docs/contributor/recipe.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,15 @@ Host-managed driver floors (GKE COS / A4X Max and similar platforms where
351351
`check-nvidia-smi` evaluates it against the nvidia-smi banner on each
352352
verified node; when the constraint is absent the check keeps its
353353
banner-presence behavior and does not invent a floor (#1995).
354-
When the constraint is set but no parseable numeric driver version is
355-
available, the check fails closed.
354+
When the constraint is set but the host driver cannot be measured —
355+
unreadable nvidia-smi banner, no GPU nodes, all GPU nodes cordoned, or
356+
GPU nodes busy with workloads — the check fails closed rather than
357+
Skip. Skip on those paths is preserved only when no floor is
358+
configured. The value must carry a comparison operator (`>=`, `>`,
359+
`<=`, `<`) to behave as a floor; a bare version is exact string match,
360+
so a newer driver would fail. The constraint name is an exact match;
361+
a typo silently disables the floor (shared with
362+
`Deployment.gpu-operator.version`).
356363

357364
For a query `{service: eks, accelerator: gb200, intent: training}`,
358365
the resolver returns three independent maximal leaves —

docs/contributor/validator.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,11 @@ tagged with its cordon state, and:
611611
schedulable count: it is `0` on the all-cordoned and busy-skip
612612
paths (nothing was attempted yet) and the successful-node count on
613613
the failure path (a partial pass is not conflated with a full one).
614+
Exception: when the recipe declares `Deployment.gpu-driver.version`,
615+
those same all-cordoned and busy paths (and the no-GPU-nodes path)
616+
fail closed instead of Skip — a declared host-driver floor that
617+
cannot be measured must not PASS (#1995). Skip remains only when
618+
the floor constraint is absent.
614619
The `RESULT:` prefix is `pkg/validator/validator.go`'s
615620
`resultSummaryPrefix` convention: the validator runtime echoes the
616621
trailing text of any such stdout line into live CLI output via

recipes/validators/catalog.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ validators:
6262
phase: deployment
6363
description: >-
6464
Verify nvidia-smi on schedulable GPU nodes; enforce
65-
Deployment.gpu-driver.version when set (cordoned nodes disclosed)
65+
Deployment.gpu-driver.version when set (unmeasurable floor blocks)
6666
image: ghcr.io/nvidia/aicr-validators/deployment:latest
6767
timeout: 10m
6868
args: ["check-nvidia-smi"]

validators/deployment/nvidia_smi.go

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,12 @@ const (
6262
// host-managed drivers (e.g. GKE A4X Max requiring R580.95.05+) cannot be
6363
// gated by GPU Operator version alone when driver.enabled is false; this
6464
// constraint is evaluated against the nvidia-smi banner on each verified
65-
// node. Absent from the recipe, the check keeps its original banner-presence
66-
// behavior and does not invent a floor.
65+
// node. The value must carry a comparison operator (typically ">=") to
66+
// behave as a floor; a bare version is exact string match. Absent from the
67+
// recipe, the check keeps its original banner-presence behavior and does
68+
// not invent a floor. When the constraint is set but no node can be
69+
// measured (no GPU nodes, all cordoned, or busy), the check fails closed
70+
// instead of Skip — a declared gate must not PASS unenforced.
6771
gpuDriverVersionConstraint = "Deployment.gpu-driver.version"
6872
)
6973

@@ -184,6 +188,10 @@ func checkNvidiaSMI(ctx *validators.Context) error {
184188
"recipe declares gpu-operator but the cluster has no GPU nodes to verify — "+
185189
"check node provisioning, the GPU Operator rollout, or validator RBAC")
186190
}
191+
if err := failClosedIfUnmeasurableGPUDriverFloor(ctx,
192+
"no GPU nodes found in the cluster"); err != nil {
193+
return err
194+
}
187195
emitExtraOrWarn(nvidiaSMISkipExtra(skipReasonNoGPUNodes))
188196
return validators.Skip("no GPU nodes found in the cluster")
189197
}
@@ -198,9 +206,13 @@ func checkNvidiaSMI(ctx *validators.Context) error {
198206
// probe error. There is nothing schedulable to verify, so Skip with a
199207
// distinct, accurate code — never a false "no-gpu-nodes" in the signed
200208
// evidence.
209+
reason := fmt.Sprintf(
210+
"all %d GPU node(s) are cordoned; nothing to verify", len(coverage.cordoned))
211+
if err := failClosedIfUnmeasurableGPUDriverFloor(ctx, reason); err != nil {
212+
return err
213+
}
201214
emitExtraOrWarn(nvidiaSMISkipExtra(skipReasonNoSchedulableGPUNodes))
202-
return validators.Skip(fmt.Sprintf(
203-
"all %d GPU node(s) are cordoned; nothing to verify", len(coverage.cordoned)))
215+
return validators.Skip(reason)
204216
}
205217

206218
// Check if any nodes are busy.
@@ -231,11 +243,16 @@ func checkNvidiaSMI(ctx *validators.Context) error {
231243
if len(busyNodes) > 0 {
232244
printLines(coverage.coverageLine(0))
233245
if confirmedBusy {
234-
// At least one node was CONFIRMED occupied: a legitimate
235-
// scope-narrowing Skip. Sign nodes-busy so the signed evidence records
236-
// the occupancy. Probe errors on other nodes are already logged above.
246+
// At least one node was CONFIRMED occupied. Without a host-driver
247+
// floor this is a legitimate scope-narrowing Skip. With a declared
248+
// floor, occupancy is not a reason to leave the gate unevaluated
249+
// (#1995): Skip is non-blocking, so a below-floor cluster would PASS.
250+
reason := fmt.Sprintf("GPU nodes busy with existing workloads: %v", busyNodes)
251+
if err := failClosedIfUnmeasurableGPUDriverFloor(ctx, reason); err != nil {
252+
return err
253+
}
237254
emitExtraOrWarn(nvidiaSMISkipExtra(skipReasonNodesBusy))
238-
return validators.Skip(fmt.Sprintf("GPU nodes busy with existing workloads: %v", busyNodes))
255+
return validators.Skip(reason)
239256
}
240257
// #2122 fail-closed: every "busy" node was actually a busy-probe ERROR —
241258
// the probe proved occupancy on no node. An infra error (RBAC denial,
@@ -306,6 +323,21 @@ func nvidiaSMISkipExtra(reason string) map[string]string {
306323
return map[string]string{"skipReason": reason}
307324
}
308325

326+
// failClosedIfUnmeasurableGPUDriverFloor returns a blocking error when the
327+
// recipe declares Deployment.gpu-driver.version but check-nvidia-smi cannot
328+
// run per-node verification (no GPU nodes, all cordoned, or busy). Skip on
329+
// those paths is non-blocking; a declared floor that cannot be measured must
330+
// not PASS (#1995). No constraint keeps the existing Skip.
331+
func failClosedIfUnmeasurableGPUDriverFloor(ctx *validators.Context, reason string) error {
332+
expr, found := findDeploymentConstraint(ctx, gpuDriverVersionConstraint)
333+
if !found {
334+
return nil
335+
}
336+
return errors.New(errors.ErrCodeNotFound,
337+
fmt.Sprintf("%s %q is set but the host driver version could not be measured (%s)",
338+
gpuDriverVersionConstraint, expr, reason))
339+
}
340+
309341
// emitExtraOrWarn emits structured extra evidence, logging (never failing) on
310342
// error — a failed stdout write must not flip the check's verdict.
311343
func emitExtraOrWarn(extra map[string]string) {
@@ -446,12 +478,9 @@ func parseNvidiaSMIDriverVersion(podLogs string) (string, error) {
446478
return "", errors.New(errors.ErrCodeNotFound,
447479
"nvidia-smi driver version is not a three-component numeric field")
448480
}
449-
version := podLogs[loc[2]:loc[3]]
450-
if version == "" {
451-
return "", errors.New(errors.ErrCodeNotFound,
452-
"nvidia-smi output has no parseable Driver Version / KMD Version")
453-
}
454-
return version, nil
481+
// The capture is `[0-9]+(?:\.[0-9]+){0,2}` so loc[2]:loc[3] is never empty
482+
// once FindStringSubmatchIndex returned a match.
483+
return podLogs[loc[2]:loc[3]], nil
455484
}
456485

457486
// driverVersionFieldTerminated reports whether the character after a captured
@@ -476,7 +505,9 @@ func driverVersionFieldTerminated(podLogs string, end int) bool {
476505
// the driver version parsed from nvidia-smi logs (issue #1995). No constraint
477506
// in the recipe is a no-op — the check must not invent a floor. A constraint
478507
// with an unreadable banner fails closed: a host-driver floor that cannot be
479-
// measured must not PASS.
508+
// measured must not PASS. Enumeration paths that would Skip (no GPU nodes,
509+
// all cordoned, busy) use failClosedIfUnmeasurableGPUDriverFloor for the
510+
// same contract before per-node verification runs.
480511
func enforceGPUDriverVersionFloor(ctx *validators.Context, podLogs, nodeName string) error {
481512
constraintExpr, found := findDeploymentConstraint(ctx, gpuDriverVersionConstraint)
482513
if !found {
@@ -502,9 +533,9 @@ func enforceGPUDriverVersionFloor(ctx *validators.Context, podLogs, nodeName str
502533

503534
passed, err := parsed.Evaluate(version)
504535
if err != nil {
505-
return errors.Wrap(errors.ErrCodeInternal,
536+
return errors.PropagateOrWrap(err, errors.ErrCodeInternal,
506537
fmt.Sprintf("%s constraint evaluation failed on node %s",
507-
gpuDriverVersionConstraint, nodeName), err)
538+
gpuDriverVersionConstraint, nodeName))
508539
}
509540

510541
fmt.Printf(" %s: host driver %s, constraint %s → %v\n",

validators/deployment/nvidia_smi_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/NVIDIA/aicr/validators/helper"
2828
v1 "k8s.io/api/core/v1"
2929
apierrors "k8s.io/apimachinery/pkg/api/errors"
30+
"k8s.io/apimachinery/pkg/api/resource"
3031
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3132
"k8s.io/apimachinery/pkg/runtime"
3233
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -304,6 +305,11 @@ func TestEnforceGPUDriverVersionFloor(t *testing.T) {
304305
constraint: ">= 580.95.05",
305306
logs: goodLogs,
306307
},
308+
{
309+
name: "satisfies floor from KMD Version banner",
310+
constraint: ">= 580.95.05",
311+
logs: "NVIDIA-SMI\nKMD Version: 580.95.05\nCUDA UMD Version: 13.0\n" + gpuCheckSuccessMsg,
312+
},
307313
{
308314
// Lexical string compare would fail here ('.100' < '.99'); the
309315
// constraint evaluator must compare components numerically.
@@ -329,6 +335,14 @@ func TestEnforceGPUDriverVersionFloor(t *testing.T) {
329335
logs: goodLogs,
330336
wantErrSub: "invalid Deployment.gpu-driver.version constraint",
331337
},
338+
{
339+
// Parses as >= with a non-version value; Evaluate returns
340+
// ErrCodeInvalidRequest, which must not be recoded Internal.
341+
name: "unparseable floor value preserves InvalidRequest",
342+
constraint: ">= not-a-version",
343+
logs: goodLogs,
344+
wantErrSub: "[INVALID_REQUEST] cannot parse expected version",
345+
},
332346
}
333347

334348
for _, tt := range tests {
@@ -610,6 +624,136 @@ func TestCheckNvidiaSMI_CordonedKeepsSkipEvenWhenDeclared(t *testing.T) {
610624
}
611625
}
612626

627+
// TestCheckNvidiaSMI_DeclaredFloorUnmeasurableFailsClosed proves #1995: a
628+
// declared Deployment.gpu-driver.version must not ride the non-blocking Skip
629+
// paths. Busy workloads and all-cordoned nodes are supported Skip reasons
630+
// only when no floor is configured.
631+
func TestCheckNvidiaSMI_DeclaredFloorUnmeasurableFailsClosed(t *testing.T) {
632+
t.Parallel()
633+
634+
tests := []struct {
635+
name string
636+
nodes []runtime.Object
637+
busy bool
638+
wantErrSubs []string
639+
}{
640+
{
641+
name: "no GPU nodes with floor fails closed",
642+
nodes: []runtime.Object{},
643+
wantErrSubs: []string{
644+
"[NOT_FOUND]",
645+
gpuDriverVersionConstraint,
646+
"could not be measured",
647+
"no GPU nodes found in the cluster",
648+
},
649+
},
650+
{
651+
name: "all GPU nodes cordoned with floor fails closed",
652+
nodes: []runtime.Object{
653+
cordon(gpuNode("cordoned-1", 8, -1)),
654+
cordon(gpuNode("cordoned-2", 8, -1)),
655+
},
656+
wantErrSubs: []string{
657+
"[NOT_FOUND]",
658+
gpuDriverVersionConstraint,
659+
"could not be measured",
660+
"all 2 GPU node(s) are cordoned",
661+
},
662+
},
663+
{
664+
name: "busy GPU node with floor fails closed",
665+
nodes: []runtime.Object{
666+
gpuNode("gpu-1", 8, -1),
667+
},
668+
busy: true,
669+
wantErrSubs: []string{
670+
"[NOT_FOUND]",
671+
gpuDriverVersionConstraint,
672+
"could not be measured",
673+
"GPU nodes busy with existing workloads",
674+
},
675+
},
676+
}
677+
678+
for _, tt := range tests {
679+
t.Run(tt.name, func(t *testing.T) {
680+
t.Parallel()
681+
682+
ctx := newDeploymentTestContext(t, tt.nodes, nil, nil)
683+
withGPUDriverFloor(ctx, ">= 580.95.05")
684+
if tt.busy {
685+
ctx.Clientset.(*k8sfake.Clientset).PrependReactor("list", "pods",
686+
gpuBusyPodsReactor())
687+
}
688+
689+
err := checkNvidiaSMI(ctx)
690+
if validators.IsSkip(err) {
691+
t.Fatalf("checkNvidiaSMI() = skip (%v), want fail closed", err)
692+
}
693+
if err == nil {
694+
t.Fatal("checkNvidiaSMI() = nil, want a blocking error")
695+
}
696+
for _, sub := range tt.wantErrSubs {
697+
if !strings.Contains(err.Error(), sub) {
698+
t.Errorf("checkNvidiaSMI() error = %v, want it to contain %q", err, sub)
699+
}
700+
}
701+
})
702+
}
703+
}
704+
705+
// withGPUDriverFloor installs Deployment.gpu-driver.version on a test context.
706+
func withGPUDriverFloor(ctx *validators.Context, expr string) {
707+
ctx.ValidationInput.Config.Deployment = &validatorv1.ValidationPhase{
708+
Constraints: []recipe.Constraint{{
709+
Name: gpuDriverVersionConstraint,
710+
Value: expr,
711+
}},
712+
}
713+
}
714+
715+
// gpuBusyPodsReactor makes IsNodeGpuBusy report confirmed occupancy: a
716+
// running pod with a nvidia.com/gpu limit, regardless of field selector
717+
// (the fake clientset does not honor spec.nodeName).
718+
func gpuBusyPodsReactor() clienttesting.ReactionFunc {
719+
qty := resource.MustParse("1")
720+
return func(clienttesting.Action) (bool, runtime.Object, error) {
721+
return true, &v1.PodList{Items: []v1.Pod{{
722+
ObjectMeta: metav1.ObjectMeta{Name: "gpu-workload", Namespace: "default"},
723+
Spec: v1.PodSpec{
724+
Containers: []v1.Container{{
725+
Name: "work",
726+
Resources: v1.ResourceRequirements{
727+
Limits: v1.ResourceList{
728+
v1.ResourceName(helper.GpuResourceName): qty,
729+
},
730+
},
731+
}},
732+
},
733+
Status: v1.PodStatus{Phase: v1.PodRunning},
734+
}}}, nil
735+
}
736+
}
737+
738+
// TestCheckNvidiaSMI_BusyWithoutFloorStillSkips preserves the pre-#1995 Skip
739+
// when occupancy is confirmed and no host-driver floor is declared.
740+
func TestCheckNvidiaSMI_BusyWithoutFloorStillSkips(t *testing.T) {
741+
t.Parallel()
742+
743+
ctx := newDeploymentTestContext(t, []runtime.Object{
744+
gpuNode("gpu-1", 8, -1),
745+
}, nil, nil)
746+
ctx.Clientset.(*k8sfake.Clientset).PrependReactor("list", "pods", gpuBusyPodsReactor())
747+
748+
err := checkNvidiaSMI(ctx)
749+
if !validators.IsSkip(err) {
750+
t.Fatalf("checkNvidiaSMI() error = %v, want a skip when no floor is set", err)
751+
}
752+
if !strings.Contains(err.Error(), "GPU nodes busy with existing workloads") {
753+
t.Errorf("checkNvidiaSMI() error = %v, want the busy skip reason", err)
754+
}
755+
}
756+
613757
// TestCheckNvidiaSMI_BusyProbeAllErrorsFailClosed applies the #2122 contract to
614758
// the busy-probe path: when the GPU busy-probe ERRORS on every schedulable node
615759
// (nothing CONFIRMED busy), the probe proved no occupancy, so the check must

0 commit comments

Comments
 (0)