@@ -21,41 +21,74 @@ import (
2121 "testing"
2222)
2323
24- // numNodesKeyRE matches an indented mapping key. Anchored per line so a key
25- // inside a comment or a quoted error string cannot satisfy it.
26- var numNodesKeyRE = regexp .MustCompile (`(?m)^[ \t]+numNodes[ \t]*:` )
24+ // specKeyRE matches the document's top-level `spec:` mapping key.
25+ var specKeyRE = regexp .MustCompile (`^(\s*)spec\s*:\s*$` )
2726
28- // computeDomainDocsMissingNumNodes returns the 0-based indexes of YAML
29- // documents that declare kind: ComputeDomain without a numNodes key.
27+ // numNodesChildRE matches `numNodes:` at a given exact indentation.
28+ func numNodesChildRE (indent string ) * regexp.Regexp {
29+ return regexp .MustCompile (`^` + regexp .QuoteMeta (indent ) + `numNodes\s*:` )
30+ }
31+
32+ // specHasNumNodes reports whether a single YAML document declares numNodes as a
33+ // DIRECT CHILD of spec.
3034//
31- // Scoped per document rather than per file. A multi-document manifest where one
32- // ComputeDomain sets numNodes and a second omits it would satisfy a whole-file
33- // scan while still failing admission, and so would an unrelated resource that
34- // happens to carry a numNodes key. No such manifest exists in the catalog
35- // today; the guard is document-scoped so that adding one cannot silently
36- // bypass it.
35+ // Path-aware on purpose. An earlier version matched `numNodes:` anywhere in the
36+ // document, which accepted `metadata.numNodes` — a key Kubernetes ignores, while
37+ // the required `spec.numNodes` stays absent and admission still fails. Matching
38+ // the key without its parent is not a weaker check, it is the wrong check.
3739//
3840// Comment lines are stripped first: these manifests legitimately discuss
39- // "spec.numNodes: Required value" in prose, and a naive substring scan matches
40- // that instead of the real key, passing even when the key is deleted.
41+ // "spec.numNodes: Required value" in prose, and a scan that does not strip them
42+ // matches that instead of the real key, passing even when the key is deleted.
43+ //
44+ // A full YAML parse is unavailable — the manifests are Helm templates containing
45+ // {{ }} expressions that no YAML parser accepts — so this walks indentation.
46+ func specHasNumNodes (doc string ) bool {
47+ var lines []string
48+ for _ , line := range strings .Split (doc , "\n " ) {
49+ if strings .HasPrefix (strings .TrimSpace (line ), "#" ) || strings .TrimSpace (line ) == "" {
50+ continue
51+ }
52+ lines = append (lines , line )
53+ }
54+ for i , line := range lines {
55+ m := specKeyRE .FindStringSubmatch (line )
56+ if m == nil {
57+ continue
58+ }
59+ specIndent := m [1 ]
60+ var childRE * regexp.Regexp
61+ for _ , sub := range lines [i + 1 :] {
62+ subIndent := sub [:len (sub )- len (strings .TrimLeft (sub , " \t " ))]
63+ // Dedent to spec's level or shallower ends the spec mapping.
64+ if len (subIndent ) <= len (specIndent ) {
65+ break
66+ }
67+ if childRE == nil {
68+ childRE = numNodesChildRE (subIndent )
69+ }
70+ if childRE .MatchString (sub ) {
71+ return true
72+ }
73+ }
74+ }
75+ return false
76+ }
77+
78+ // computeDomainDocsMissingNumNodes returns the 0-based indexes of YAML
79+ // documents that declare kind: ComputeDomain without spec.numNodes.
4180//
42- // A full YAML parse is unavailable — the manifests are Helm templates and
43- // contain {{ }} expressions that no YAML parser accepts.
81+ // Scoped per document: a multi-document manifest where one ComputeDomain sets
82+ // the key and a second omits it would satisfy a whole-file scan while still
83+ // failing admission. No such manifest exists in the catalog today; the guard is
84+ // document-scoped so adding one cannot silently bypass it.
4485func computeDomainDocsMissingNumNodes (content string ) []int {
4586 var missing []int
4687 for i , doc := range strings .Split (content , "\n ---" ) {
4788 if ! strings .Contains (doc , "kind: ComputeDomain" ) {
4889 continue
4990 }
50- var b strings.Builder
51- for _ , line := range strings .Split (doc , "\n " ) {
52- if strings .HasPrefix (strings .TrimSpace (line ), "#" ) {
53- continue
54- }
55- b .WriteString (line )
56- b .WriteString ("\n " )
57- }
58- if ! numNodesKeyRE .MatchString (b .String ()) {
91+ if ! specHasNumNodes (doc ) {
5992 missing = append (missing , i )
6093 }
6194 }
@@ -137,3 +170,79 @@ func TestComputeDomainManifestsSetNumNodes(t *testing.T) {
137170 }
138171 t .Logf ("verified %d ComputeDomain manifest(s) set spec.numNodes" , checked )
139172}
173+
174+ // TestComputeDomainScannerCases pins the scanner's behavior directly, so the
175+ // catalog guard above cannot quietly stop discriminating if the catalog changes.
176+ // Each case is a shape that has either fooled a previous version of this
177+ // scanner or must keep working.
178+ func TestComputeDomainScannerCases (t * testing.T ) {
179+ t .Parallel ()
180+
181+ const header = "apiVersion: resource.nvidia.com/v1beta1\n kind: ComputeDomain\n "
182+
183+ tests := []struct {
184+ name string
185+ doc string
186+ wantMissing bool
187+ }{
188+ {
189+ name : "spec.numNodes present" ,
190+ doc : header + "metadata:\n name: cd\n spec:\n numNodes: 0\n channel:\n allocationMode: All\n " ,
191+ },
192+ {
193+ name : "spec.numNodes absent" ,
194+ doc : header + "metadata:\n name: cd\n spec:\n channel:\n allocationMode: All\n " ,
195+ wantMissing : true ,
196+ },
197+ {
198+ // Regression: an earlier scanner matched numNodes anywhere in the
199+ // document, so this passed while admission would still fail.
200+ name : "numNodes under metadata, not spec" ,
201+ doc : header + "metadata:\n name: cd\n numNodes: 0\n spec:\n channel:\n allocationMode: All\n " ,
202+ wantMissing : true ,
203+ },
204+ {
205+ // Regression: an earlier scanner did not strip comments, so the
206+ // prose in the real manifest satisfied it even with the key gone.
207+ name : "numNodes only mentioned in a comment" ,
208+ doc : header + "metadata:\n name: cd\n spec:\n # numNodes: Required value\n channel:\n allocationMode: All\n " ,
209+ wantMissing : true ,
210+ },
211+ {
212+ name : "nested numNodes does not satisfy the direct-child rule" ,
213+ doc : header + "metadata:\n name: cd\n spec:\n channel:\n numNodes: 0\n " ,
214+ // numNodes exists but under spec.channel, not spec.
215+ wantMissing : true ,
216+ },
217+ {
218+ name : "templated value is acceptable" ,
219+ doc : header + "metadata:\n name: cd\n spec:\n numNodes: {{ .Values.numNodes }}\n " ,
220+ },
221+ }
222+
223+ for _ , tt := range tests {
224+ t .Run (tt .name , func (t * testing.T ) {
225+ t .Parallel ()
226+ got := ! specHasNumNodes (tt .doc )
227+ if got != tt .wantMissing {
228+ t .Errorf ("specHasNumNodes reported missing=%v, want %v\n doc:\n %s" ,
229+ got , tt .wantMissing , tt .doc )
230+ }
231+ })
232+ }
233+ }
234+
235+ // TestComputeDomainMultiDocument covers the per-document scoping: a file where
236+ // one ComputeDomain is valid and a second is not must report only the second.
237+ func TestComputeDomainMultiDocument (t * testing.T ) {
238+ t .Parallel ()
239+
240+ content := "apiVersion: v1\n kind: ConfigMap\n metadata:\n name: unrelated\n " +
241+ "\n ---\n apiVersion: resource.nvidia.com/v1beta1\n kind: ComputeDomain\n metadata:\n name: ok\n spec:\n numNodes: 0\n " +
242+ "\n ---\n apiVersion: resource.nvidia.com/v1beta1\n kind: ComputeDomain\n metadata:\n name: bad\n spec:\n channel:\n allocationMode: All\n "
243+
244+ missing := computeDomainDocsMissingNumNodes (content )
245+ if len (missing ) != 1 || missing [0 ] != 2 {
246+ t .Errorf ("missing documents = %v, want [2] (only the third document lacks spec.numNodes)" , missing )
247+ }
248+ }
0 commit comments