Skip to content

Commit b0719a4

Browse files
njhensleymchmarny
andauthored
fix(validator): fail closed on unmatched declared checks (#2129)
Signed-off-by: Nathan Hensley <nhensley@nvidia.com> Co-authored-by: Mark Chmarny <mchmarny@users.noreply.github.com>
1 parent 1ce6fcb commit b0719a4

6 files changed

Lines changed: 366 additions & 22 deletions

File tree

docs/contributor/validator.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ spec:
4444
value: ">= 450" # GB/s
4545
```
4646
47+
**Declared checks are resolved fail-closed.** Before the cluster is
48+
prepared or any Job runs, `Validator.preflightDeclaredChecks`
49+
(`pkg/validator/validator.go`) rejects the run with `ErrCodeInvalidRequest`
50+
if any declared check name does not resolve to exactly one catalog validator
51+
in its declared phase — a name matching nothing (typo or a check missing from
52+
the loaded, possibly `--data`, catalog), a name that exists only under a
53+
different phase (`nccl-all-reduce-bw` declared under `deployment`), or a name
54+
declared more than once in one phase's `checks` list. All offenders across
55+
every requested phase are aggregated into a single error. This closes the
56+
fail-open path where an all-unmatched phase silently filtered to zero tests →
57+
`skipped` → nonblocking, letting `aicr validate --fail-on-error` exit `0` on a
58+
recipe whose required gate the catalog cannot supply
59+
([#2121](https://github.com/NVIDIA/aicr/issues/2121)). The gate runs in
60+
`--no-cluster` mode too, so typos are caught in offline recipe validation.
61+
There is no opt-out: a declared name that resolves nowhere is always an
62+
authoring error. A check that is *legitimately* not applicable at runtime
63+
reports its own `skip` sentinel from inside the container — it is still
64+
declared and still resolves to a catalog entry.
65+
4766
Top-level `constraints` — and any declared under
4867
`validation.readiness.constraints` — are evaluated as a **pre-flight
4968
gate** before phase checks run; other phases' `constraints` are

docs/user/cli-reference.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,8 @@ Validation can be run in different phases to validate different aspects of the d
10031003

10041004
> **Note:** Readiness constraints (K8s version, OS, kernel) are always evaluated implicitly before any phase runs. If readiness fails, validation stops before deploying any Jobs and exits 2 (`INVALID_REQUEST`). This gate always fails closed — `--fail-on-error=false` scopes to phase check results and does not downgrade a readiness failure.
10051005
>
1006+
> **Declared-check pre-flight:** Every check named under a phase's `checks` list must resolve to exactly one catalog validator in that phase. Before any Job is deployed, `validate` fails closed with exit 2 (`INVALID_REQUEST`) if a declared check matches no validator (a typo, or a check missing from the loaded `--data` catalog), exists only under a different phase, or is declared more than once — reporting every offender at once. This runs in `--no-cluster` mode too, and like readiness it is independent of `--fail-on-error`. It replaces the previous warn-and-continue behavior, which let a phase with only unresolved checks report `skipped` and exit `0`.
1007+
>
10061008
> **Version skew:** Snapshots and recipes record the `aicr` version that produced them. When the recipe, the snapshot, and the running binary report different release versions, `validate` logs a single advisory warning (`version skew detected across validate inputs`) naming all three. This is a debugging breadcrumb — mixing artifacts from different versions can surface as confusing failures — and does **not** fail the command. Dev (`dev`) and pre-release (`-next`) builds are ignored to avoid noise.
10071009
>
10081010
> **apiVersion gate:** Snapshots and catalog artifacts use `aicr.run/v1alpha2`;
@@ -1301,7 +1303,7 @@ Results are output in CTRF (Common Test Report Format) — an industry-standard
13011303
| Code | Description |
13021304
|------|-------------|
13031305
| `0` | All phases passed or were skipped (also returned under `--fail-on-error=false` even when phases report `failed`/`other`) |
1304-
| `2` | Invalid input (bad flags, missing recipe), or a readiness pre-flight constraint not met — the readiness gate always fails closed here regardless of `--fail-on-error` |
1306+
| `2` | Invalid input (bad flags, missing recipe), a readiness pre-flight constraint not met, or a declared check that does not resolve to exactly one catalog validator in its phase (unmatched, cross-phase, or duplicate) — these pre-flight gates always fail closed here regardless of `--fail-on-error` |
13051307
| `5` | Timeout (validator section or context deadline exceeded) |
13061308
| `8` | One or more phase checks reported `failed` or `other` (crash/OOM/deadline) — when `--fail-on-error` is set |
13071309

pkg/validator/v1/catalog.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,34 @@ func (c *ValidatorCatalog) UnmatchedChecks(phase Phase, validationInput *Validat
262262

263263
return unmatched
264264
}
265+
266+
// DuplicateChecks returns each check name declared more than once in phase's
267+
// checks list, in order of first appearance and reported once regardless of
268+
// how many times it repeats. UnmatchedChecks dedups declared names before
269+
// comparing against the catalog, so it cannot see duplicates; the preflight
270+
// needs this complement to reject a checks list that names the same gate twice
271+
// (a copy-paste error that inflates the apparent test count without adding
272+
// coverage). Pure function — no catalog is consulted. Returns nil when every
273+
// declared name for the phase is unique.
274+
func DuplicateChecks(phase Phase, validationInput *ValidationInput) []string {
275+
phaseChecks := checksForPhase(phase, validationInput)
276+
if len(phaseChecks) == 0 {
277+
return nil
278+
}
279+
280+
counts := make(map[string]int, len(phaseChecks))
281+
for _, name := range phaseChecks {
282+
counts[name]++
283+
}
284+
285+
var dupes []string
286+
reported := make(map[string]bool)
287+
for _, name := range phaseChecks {
288+
if counts[name] > 1 && !reported[name] {
289+
reported[name] = true
290+
dupes = append(dupes, name)
291+
}
292+
}
293+
294+
return dupes
295+
}

pkg/validator/v1/catalog_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,76 @@ func TestUnmatchedChecks(t *testing.T) {
293293
}
294294
}
295295

296+
func TestDuplicateChecks(t *testing.T) {
297+
tests := []struct {
298+
name string
299+
phase Phase
300+
checks []string
301+
want []string
302+
}{
303+
{
304+
name: "no duplicates",
305+
phase: PhaseDeployment,
306+
checks: []string{"operator-health", "expected-resources"},
307+
want: nil,
308+
},
309+
{
310+
name: "single duplicate reported once",
311+
phase: PhaseDeployment,
312+
checks: []string{"operator-health", "operator-health"},
313+
want: []string{"operator-health"},
314+
},
315+
{
316+
name: "triple occurrence reported once",
317+
phase: PhasePerformance,
318+
checks: []string{"nccl", "nccl", "nccl"},
319+
want: []string{"nccl"},
320+
},
321+
{
322+
name: "multiple distinct duplicates in declaration order",
323+
phase: PhaseConformance,
324+
checks: []string{"a", "b", "a", "c", "b"},
325+
want: []string{"a", "b"},
326+
},
327+
{
328+
name: "empty checks",
329+
phase: PhaseDeployment,
330+
checks: nil,
331+
want: nil,
332+
},
333+
}
334+
335+
for _, tt := range tests {
336+
t.Run(tt.name, func(t *testing.T) {
337+
vi := &ValidationInput{}
338+
switch tt.phase {
339+
case PhaseDeployment:
340+
vi.Config.Deployment = &ValidationPhase{Checks: tt.checks}
341+
case PhasePerformance:
342+
vi.Config.Performance = &ValidationPhase{Checks: tt.checks}
343+
case PhaseConformance:
344+
vi.Config.Conformance = &ValidationPhase{Checks: tt.checks}
345+
}
346+
347+
got := DuplicateChecks(tt.phase, vi)
348+
if len(got) != len(tt.want) {
349+
t.Fatalf("DuplicateChecks() = %v, want %v", got, tt.want)
350+
}
351+
for i := range got {
352+
if got[i] != tt.want[i] {
353+
t.Errorf("DuplicateChecks()[%d] = %q, want %q", i, got[i], tt.want[i])
354+
}
355+
}
356+
})
357+
}
358+
}
359+
360+
func TestDuplicateChecks_NilValidationInput(t *testing.T) {
361+
if got := DuplicateChecks(PhaseDeployment, nil); got != nil {
362+
t.Errorf("DuplicateChecks(nil) = %v, want nil", got)
363+
}
364+
}
365+
296366
func TestUnmatchedChecks_NilReceiverAndNoChecks(t *testing.T) {
297367
var nilCat *ValidatorCatalog
298368
if got := nilCat.UnmatchedChecks(PhaseDeployment, &ValidationInput{}); got != nil {

pkg/validator/validator.go

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ func (v *Validator) ValidatePhases(
242242
return nil, errors.PropagateOrWrap(err, errors.ErrCodeInternal, "failed to load validator catalog")
243243
}
244244

245+
// Fail closed on unmatched, cross-phase, or duplicate declared checks
246+
// before preparing the cluster or running any Job. Runs for the normalized
247+
// phase set and in --no-cluster mode too, so an unresolved required gate
248+
// cannot masquerade as a skipped (spuriously passing) phase (issue #2121).
249+
if err = v.preflightDeclaredChecks(cat, phases, validationInput); err != nil {
250+
return nil, err
251+
}
252+
245253
// --no-cluster: report all as skipped, no K8s calls
246254
if v.NoCluster {
247255
return v.phasesSkipped(cat, phases, "skipped - no-cluster mode"), nil
@@ -336,10 +344,15 @@ func (v *Validator) ValidatePhase(
336344
return nil, errors.PropagateOrWrap(err, errors.ErrCodeInternal, "failed to load validator catalog")
337345
}
338346

347+
// Fail closed on unmatched, cross-phase, or duplicate declared checks
348+
// before the no-cluster short-circuit and before any cluster preparation,
349+
// matching ValidatePhases: a per-phase caller must not be able to skip an
350+
// unresolved required gate into a spuriously passing run (issue #2121).
351+
if err = v.preflightDeclaredChecks(cat, []Phase{phase}, validationInput); err != nil {
352+
return nil, err
353+
}
354+
339355
if v.NoCluster {
340-
// Warn on unmatched check names even in no-cluster mode so typos are
341-
// caught during offline recipe validation, not just live runs.
342-
warnUnmatchedChecks(cat, phase, validationInput)
343356
return v.phaseSkipped(cat, phase, "skipped - no-cluster mode"), nil
344357
}
345358

@@ -353,21 +366,56 @@ func (v *Validator) ValidatePhase(
353366
return v.runPhase(ctx, cs.clientset, cs.factory, cat, phase, validationInput)
354367
}
355368

356-
// warnUnmatchedChecks emits a structured warning for every declared check name
357-
// that matched no catalog entry in its phase. A name that exists under a
358-
// different phase is called out as a likely misplacement; anything else is a
359-
// probable typo or a check missing from the loaded (possibly --data) catalog.
360-
// Advisory only — it never fails the run.
361-
func warnUnmatchedChecks(cat *catalog.ValidatorCatalog, phase Phase, validationInput *v1.ValidationInput) {
362-
for _, u := range cat.UnmatchedChecks(phase, validationInput) {
363-
if u.OtherPhase != "" {
364-
slog.Warn("declared check matches no validator in this phase; it exists under a different phase",
365-
"check", u.Name, "phase", u.Phase, "foundInPhase", u.OtherPhase)
366-
continue
369+
// preflightDeclaredChecks fails closed when any declared check name does not
370+
// resolve to exactly one catalog entry in its declared phase. It aggregates
371+
// three defects across every requested phase into a single error so mixed
372+
// valid/invalid lists surface every problem in one pass:
373+
//
374+
// - unmatched: a name matching no validator in the catalog at all (typo, a
375+
// check missing from an incomplete external --data catalog, or a missing
376+
// embedded validator);
377+
// - cross-phase: a name that exists but under a different phase (a
378+
// misplacement, e.g. a performance check declared under deployment);
379+
// - duplicate: a name declared more than once in one phase's checks list.
380+
//
381+
// This runs BEFORE the cluster is prepared or any Job is deployed, in both
382+
// live and --no-cluster modes. Without it an all-unmatched phase silently
383+
// filters down to zero tests → StatusSkipped → nonblocking, so
384+
// `aicr validate --fail-on-error` exits 0 on a recipe that names a required
385+
// gate the catalog cannot supply (issue #2121). Returns nil when every
386+
// declared check for every requested phase resolves exactly once.
387+
func (v *Validator) preflightDeclaredChecks(
388+
cat *catalog.ValidatorCatalog,
389+
phases []Phase,
390+
validationInput *v1.ValidationInput,
391+
) error {
392+
393+
var problems []string
394+
for _, phase := range phases {
395+
for _, u := range cat.UnmatchedChecks(phase, validationInput) {
396+
if u.OtherPhase != "" {
397+
problems = append(problems, fmt.Sprintf(
398+
"declared check %q in phase %s matches no validator in that phase (found under phase: %s)",
399+
u.Name, u.Phase, u.OtherPhase))
400+
continue
401+
}
402+
problems = append(problems, fmt.Sprintf(
403+
"declared check %q in phase %s matches no validator in the catalog",
404+
u.Name, u.Phase))
405+
}
406+
for _, name := range v1.DuplicateChecks(phase, validationInput) {
407+
problems = append(problems, fmt.Sprintf(
408+
"declared check %q is declared more than once in phase %s", name, phase))
367409
}
368-
slog.Warn("declared check matches no validator in the catalog; it will not run",
369-
"check", u.Name, "phase", u.Phase)
370410
}
411+
412+
if len(problems) == 0 {
413+
return nil
414+
}
415+
416+
return errors.New(errors.ErrCodeInvalidRequest,
417+
"validation declares checks that do not match the validator catalog:\n - "+
418+
strings.Join(problems, "\n - "))
371419
}
372420

373421
// runPhase executes all validators for a single phase sequentially.
@@ -391,11 +439,10 @@ func (v *Validator) runPhase(
391439
slog.Info("running validation phase", "phase", phase,
392440
"catalog", len(allEntries), "selected", len(entries))
393441

394-
// Surface declared check names that matched no catalog entry for this
395-
// phase. Silently dropping them lets a typo'd or misplaced check name
396-
// (e.g. a performance check declared under deployment) produce an empty,
397-
// spuriously-passing phase — the fail-open direction for a gate.
398-
warnUnmatchedChecks(cat, phase, validationInput)
442+
// Note: unmatched, cross-phase, and duplicate declared checks are rejected
443+
// up front by preflightDeclaredChecks (in ValidatePhase/ValidatePhases)
444+
// before this phase ever runs, so by here every declared check for the
445+
// phase resolves to exactly one catalog entry.
399446

400447
builder := ctrf.NewBuilder("aicr", v.Version, string(phase))
401448

0 commit comments

Comments
 (0)