Skip to content

Commit 05b75b7

Browse files
framsouzamchmarny
andauthored
feat(bundler): add readiness gate for the Helm network-operator (#2337)
Signed-off-by: framsouza <fram.souza14@gmail.com> Co-authored-by: Mark Chmarny <mchmarny@users.noreply.github.com>
1 parent 564c598 commit 05b75b7

6 files changed

Lines changed: 652 additions & 5 deletions

File tree

‎pkg/bundler/gatemanifest/manifest.go‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ rules:
8080
- apiGroups: ["apiextensions.k8s.io"]
8181
resources: ["customresourcedefinitions"]
8282
verbs: ["get", "list", "watch"]
83-
---
83+
%[12]s---
8484
apiVersion: rbac.authorization.k8s.io/v1
8585
kind: ClusterRoleBinding
8686
metadata:
@@ -140,11 +140,32 @@ spec:
140140
defaults.ReadinessGateStabilityWindow.String(),
141141
defaults.ReadinessGateMaxWait.String(),
142142
jobAnnotations,
143-
defaults.ReadinessGateBackoffLimit)
143+
defaults.ReadinessGateBackoffLimit,
144+
componentClusterRoleRules(componentName))
144145

145146
return []byte(sb.String()), nil
146147
}
147148

149+
// componentClusterRoleRules returns any component-specific ClusterRole rules
150+
// appended to the uniform base rules above. Emitting a rule only for the
151+
// component whose readiness Test actually reads its API group keeps unused
152+
// permissions off gate ServiceAccounts of unrelated components (PR #2337
153+
// review). Kept as a switch rather than a data-driven scan of testYAML so
154+
// the emitter's RBAC surface stays statically auditable — the trade-off is
155+
// that a new readiness gate for a new API group must be registered here.
156+
func componentClusterRoleRules(componentName string) string {
157+
switch componentName { //nolint:gocritic // single case today; kept as a switch per the doc-comment rationale above (future readiness gates register a new case here).
158+
case "network-operator":
159+
// See recipes/components/network-operator/readiness.yaml — the
160+
// gate asserts on mellanox.com/v1alpha1 NicClusterPolicy.
161+
return ` - apiGroups: ["mellanox.com"]
162+
resources: ["nicclusterpolicies"]
163+
verbs: ["get", "list", "watch"]
164+
`
165+
}
166+
return ""
167+
}
168+
148169
func jobMetadataAnnotations(deployer config.DeployerType) string {
149170
switch deployer {
150171
case config.DeployerHelm:

‎pkg/bundler/gatemanifest/manifest_test.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ func TestRender(t *testing.T) {
5454
if strings.Contains(got, "secrets") {
5555
t.Error("manifest must not grant secrets read")
5656
}
57+
// The mellanox.com rule is component-specific — it must NOT leak
58+
// into gpu-operator's gate SA (PR #2337 review). It appears only
59+
// for the network-operator component; see TestRender_NetworkOperator.
60+
if strings.Contains(got, "mellanox.com") {
61+
t.Errorf("gpu-operator manifest must not grant mellanox.com read; got:\n%s", got)
62+
}
5763
for _, want := range []string{
5864
"--timeout=" + defaults.ReadinessGateExecTimeout.String(),
5965
"--max-wait=" + defaults.ReadinessGateMaxWait.String(),
@@ -110,3 +116,24 @@ func TestRender_EmptyComponentName(t *testing.T) {
110116
t.Fatal("expected error for empty component name")
111117
}
112118
}
119+
120+
// TestRender_NetworkOperator pins the component-specific mellanox.com
121+
// rule that componentClusterRoleRules injects only for the
122+
// network-operator gate (PR #2337 review).
123+
func TestRender_NetworkOperator(t *testing.T) {
124+
got, err := Render("network-operator", "img:tag", []byte(validReadinessTestYAML), config.DeployerArgoCD)
125+
if err != nil {
126+
t.Fatalf("Render: %v", err)
127+
}
128+
s := string(got)
129+
130+
const mellanoxRule = ` - apiGroups: ["mellanox.com"]
131+
resources: ["nicclusterpolicies"]
132+
verbs: ["get", "list", "watch"]`
133+
if !strings.Contains(s, mellanoxRule) {
134+
t.Errorf("network-operator manifest missing mellanox.com rule:\n%s", s)
135+
}
136+
if strings.Count(s, `apiGroups: ["mellanox.com"]`) != 1 {
137+
t.Errorf("mellanox.com rule must appear exactly once in the ClusterRole; got:\n%s", s)
138+
}
139+
}

‎pkg/bundler/readiness.go‎

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
3335
const 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.
126265
func validateReadinessTestYAML(componentName string, testYAML []byte) error {
127266
var head struct {

0 commit comments

Comments
 (0)