@@ -19,6 +19,8 @@ import (
1919 stderrors "errors"
2020 "fmt"
2121 "io/fs"
22+ "log/slog"
23+ "regexp"
2224 "strings"
2325
2426 "github.com/NVIDIA/aicr/pkg/bundler/gatemanifest"
@@ -32,6 +34,32 @@ import (
3234// standalone readiness gate chart. See #904.
3335const readinessFileName = "readiness.yaml"
3436
37+ // networkOperatorComponentName is the ref name of the Helm-deployed
38+ // network-operator component whose gate asserts a NicClusterPolicy CR.
39+ // The pinned upstream chart (26.4.1) does not template NCP even with
40+ // deployCR=true, so overlays that do not attach one explicitly (kind,
41+ // Talos base) have nothing to gate on — the readiness gate must skip
42+ // itself in that case, or it will poll to --max-wait timeout on every
43+ // deploy (#2337).
44+ const networkOperatorComponentName = "network-operator"
45+
46+ // ncpKindRE / ncpAPIVersionRE match a NicClusterPolicy top-level manifest
47+ // via line-anchored regexes rather than YAML unmarshalling. Attached NCP
48+ // manifests are Helm templates ({{ .Release.Service }} etc.), which
49+ // strict YAML parsers reject; a probe just needs to detect presence.
50+ // Both patterns are pinned to column 0 so nested list items or comments
51+ // (which start with an indent or `#`) never trigger a false positive.
52+ // The apiVersion pattern intentionally uses a group prefix so a future
53+ // chart version that graduates NCP past v1alpha1 still matches without
54+ // a code change. TestNCPRegexNearMiss pins these invariants so a future
55+ // edit that drops (?m), the ^ anchor, or the AND between the two patterns
56+ // fails a test rather than silently re-introducing the #2337 false-emit
57+ // regression.
58+ var (
59+ ncpKindRE = regexp .MustCompile (`(?m)^kind:\s*NicClusterPolicy\s*$` )
60+ ncpAPIVersionRE = regexp .MustCompile (`(?m)^apiVersion:\s*mellanox\.com/` )
61+ )
62+
3563// defaultGateImageRepo is the image (without tag) that runs the readiness
3664// gate Job. It carries only the gate CLI — assertions run in-process through
3765// pkg/chainsaw; the embedded `chainsaw` binary this image used to ship was
@@ -68,6 +96,27 @@ func (b *DefaultBundler) gateImage() string {
6896 return defaultGateImageRepo + ":" + tag
6997}
7098
99+ // wrapCtxErr renders ctx.Err() as a StructuredError whose ErrorCode and
100+ // message distinguish explicit cancellation from deadline expiration
101+ // while preserving the underlying context sentinel through Unwrap so
102+ // upstream callers can still branch on stderrors.Is(err, context.Canceled)
103+ // and stderrors.Is(err, context.DeadlineExceeded). Cancellation maps to
104+ // ErrCodeCanceled (operator abort), deadline expiration to ErrCodeTimeout
105+ // (time budget exhausted); the two are distinct exit paths in
106+ // pkg/errors/exitcode.go. Callers should have already confirmed
107+ // ctx.Err() != nil.
108+ func wrapCtxErr (ctx context.Context , activity string ) error {
109+ cause := ctx .Err ()
110+ code := errors .ErrCodeCanceled
111+ verb := "cancelled"
112+ if stderrors .Is (cause , context .DeadlineExceeded ) {
113+ code = errors .ErrCodeTimeout
114+ verb = "deadline exceeded"
115+ }
116+ return errors .Wrap (code ,
117+ fmt .Sprintf ("context %s while %s" , verb , activity ), cause )
118+ }
119+
71120// collectComponentReadiness gathers per-component readiness gate manifests,
72121// keyed by component name then manifest path (mirroring the pre/post manifest
73122// collectors so the localformat writer can treat readiness as another
@@ -92,10 +141,37 @@ func (b *DefaultBundler) collectComponentReadiness(
92141 provider := recipeResult .DataProvider ()
93142 image := b .gateImage ()
94143
144+ // The RDMA-fabric probe is only run when a network-operator ref is
145+ // encountered; keep the result cached so multi-component recipes
146+ // don't reload every ref's ManifestFiles per iteration.
147+ var (
148+ fabricProbed bool
149+ fabricPresent bool
150+ )
151+
95152 for _ , ref := range recipeResult .ComponentRefs {
96- if err := ctx .Err (); err != nil {
97- return nil , errors .Wrap (errors .ErrCodeTimeout ,
98- "context cancelled while collecting component readiness gates" , err )
153+ if ctx .Err () != nil {
154+ return nil , wrapCtxErr (ctx , "collecting component readiness gates" )
155+ }
156+
157+ // The network-operator gate asserts a NicClusterPolicy the
158+ // pinned Helm chart does not create; skip when no ref attaches
159+ // one. Otherwise the gate polls to timeout on kind + Talos-base
160+ // recipes that include network-operator without an NCP (#2337).
161+ if ref .Name == networkOperatorComponentName {
162+ if ! fabricProbed {
163+ present , err := recipeAttachesNicClusterPolicy (ctx , provider , recipeResult .ComponentRefs )
164+ if err != nil {
165+ return nil , err
166+ }
167+ fabricProbed = true
168+ fabricPresent = present
169+ }
170+ if ! fabricPresent {
171+ slog .Info ("skipping readiness gate for network-operator: recipe attaches no NicClusterPolicy CR" ,
172+ "component" , ref .Name )
173+ continue
174+ }
99175 }
100176
101177 path := fmt .Sprintf ("components/%s/%s" , ref .Name , readinessFileName )
@@ -122,6 +198,69 @@ func (b *DefaultBundler) collectComponentReadiness(
122198 return result , nil
123199}
124200
201+ // recipeAttachesNicClusterPolicy reports whether any component in the
202+ // resolved recipe attaches a NicClusterPolicy CR via PreManifestFiles or
203+ // ManifestFiles. The Helm network-operator gate depends on that CR: the
204+ // pinned upstream chart does not template it, so a recipe that includes
205+ // network-operator without attaching an NCP has nothing for the gate to
206+ // assert on and the gate would poll to --max-wait timeout (#2337).
207+ //
208+ // Sibling predicate: validators/deployment/expected_resources.go's
209+ // recipeDeclaresRDMAFabric encodes the same "does this recipe stand up
210+ // an NCP?" question for the RDMA-fabric-readiness check, but decides via
211+ // a filename substring (nicClusterPolicyManifestMarker) over a single
212+ // ref's ManifestFiles rather than by scanning manifest content across
213+ // every ref. Package layering blocks direct reuse, so the two functions
214+ // have deliberately different names and must be kept in sync when a
215+ // future overlay changes how an NCP is attached (a new marker filename,
216+ // an attachment via PreManifestFiles, a differently-scoped ref). Update
217+ // both — and their cross-reference comments — together.
218+ //
219+ // Iterates every ref because the NCP is usually attached by an
220+ // overlay-owned ref (e.g., aks.yaml attaches
221+ // components/network-operator/manifests/nic-cluster-policy-aks.yaml
222+ // under the network-operator ref), not by a base component definition.
223+ // Splits on the canonical "\n---\n" separator that loadManifestFiles
224+ // emits; each doc is regex-scanned, so unrelated manifest content is
225+ // cheap. Same-doc match is best-effort: the split does not normalize
226+ // CRLF, a leading `---` at byte 0, or a separator with trailing
227+ // whitespace, in which case the two (?m) patterns fall back to
228+ // whole-file matching. That is safe today because every embedded NCP
229+ // manifest is LF-terminated and no other embedded manifest ships an
230+ // `apiVersion: mellanox.com/` line, but the split alone is not a
231+ // same-doc guarantee — the (?m)^ anchors carry that load.
232+ func recipeAttachesNicClusterPolicy (ctx context.Context , provider recipe.DataProvider , refs []recipe.ComponentRef ) (bool , error ) {
233+ for _ , ref := range refs {
234+ paths := make ([]string , 0 , len (ref .PreManifestFiles )+ len (ref .ManifestFiles ))
235+ paths = append (paths , ref .PreManifestFiles ... )
236+ paths = append (paths , ref .ManifestFiles ... )
237+ for _ , path := range paths {
238+ if ctx .Err () != nil {
239+ return false , wrapCtxErr (ctx , "probing NicClusterPolicy manifests" )
240+ }
241+ content , err := recipe .GetManifestContentWithContext (ctx , provider , path )
242+ if err != nil {
243+ return false , errors .PropagateOrWrap (err , errors .ErrCodeInternal ,
244+ fmt .Sprintf ("failed to read manifest %s while probing NicClusterPolicy" , path ))
245+ }
246+ // Per-doc ctx check so a large multi-doc manifest cannot
247+ // outlive a mid-scan cancellation before the next path
248+ // iteration observes it. The strings.Split is O(bytes),
249+ // the regex matches are O(bytes-per-doc), so this branch
250+ // is dominated by memory access rather than CPU.
251+ for _ , doc := range strings .Split (string (content ), "\n ---\n " ) {
252+ if ctx .Err () != nil {
253+ return false , wrapCtxErr (ctx , fmt .Sprintf ("scanning docs in manifest %s" , path ))
254+ }
255+ if ncpKindRE .MatchString (doc ) && ncpAPIVersionRE .MatchString (doc ) {
256+ return true , nil
257+ }
258+ }
259+ }
260+ }
261+ return false , nil
262+ }
263+
125264// validateReadinessTestYAML fails fast when readiness.yaml is not a chainsaw Test.
126265func validateReadinessTestYAML (componentName string , testYAML []byte ) error {
127266 var head struct {
0 commit comments