@@ -133,13 +133,12 @@ func (v *Validator) prepareCluster(
133133 ctx context.Context ,
134134 validationInput * v1.ValidationInput ,
135135 snap * snapshotter.Snapshot ,
136- ) (* clusterState , error ) {
136+ ) (cs * clusterState , err error ) {
137137
138138 // Use PropagateOrWrap so a coded inner error (e.g. an invalid kubeconfig
139139 // classified as a deterministic config error) survives instead of being
140140 // blanket-relabeled ErrCodeInternal, which would mask it as retryable.
141141 var clientset kubernetes.Interface
142- var err error
143142 kubeconfig := strings .TrimSpace (v .Kubeconfig )
144143 switch {
145144 case kubeconfig == "" :
@@ -166,6 +165,32 @@ func (v *Validator) prepareCluster(
166165 return nil , errors .PropagateOrWrap (rbacErr , errors .ErrCodeInternal , "failed to ensure RBAC" )
167166 }
168167
168+ // Privileged RBAC (the per-run cluster-admin ClusterRoleBinding) now exists.
169+ // Register an immediate rollback so any later failure in prepareCluster
170+ // revokes it before returning, instead of leaking a privileged identity
171+ // until manual cleanup. On the success path err is nil and the binding is
172+ // retained — the caller's deferClusterCleanup owns success-path teardown, so
173+ // this defer must not double-clean.
174+ //
175+ //nolint:contextcheck // rollbackRBAC uses a fresh context: parent may be canceled
176+ defer func () {
177+ if err != nil {
178+ if rollbackErr := v .rollbackRBAC (clientset ); rollbackErr != nil {
179+ // The privileged binding could not be revoked after a
180+ // preparation failure. Fold the rollback failure into the
181+ // returned error so the operator sees BOTH the original cause
182+ // and the leaked cluster-admin binding — the prep error alone
183+ // would hide that manual cleanup is now required. Keep it a
184+ // coded StructuredError wrapping the joined causes so callers
185+ // can still match ErrCodeInternal and inspect either error.
186+ err = errors .WrapWithContext (errors .ErrCodeInternal ,
187+ "preparation failed and RBAC rollback failed; cluster-admin binding may be orphaned" ,
188+ stderrors .Join (err , rollbackErr ),
189+ map [string ]any {"runID" : v .RunID , "namespace" : v .Namespace })
190+ }
191+ }
192+ }()
193+
169194 if cmErr := v .ensureDataConfigMaps (ctx , clientset , snap , validationInput ); cmErr != nil {
170195 return nil , errors .PropagateOrWrap (cmErr , errors .ErrCodeInternal , "failed to create data ConfigMaps" )
171196 }
@@ -183,27 +208,62 @@ func (v *Validator) prepareCluster(
183208 }, nil
184209}
185210
186- // deferClusterCleanup registers deferred cleanup for RBAC and data ConfigMaps.
187- // Both cleanup steps share a single deadline so a stalled apiserver cannot
188- // extend total post-run blocking time to 2 * K8sCleanupTimeout. Cleanup
189- // failures are surfaced at structured-log level so operators see when
190- // resources may have been orphaned in the validator namespace.
191- func (v * Validator ) deferClusterCleanup (clientset kubernetes.Interface ) {
211+ // deferClusterCleanup performs success-path teardown of RBAC and data
212+ // ConfigMaps. Both cleanup steps share a single deadline so a stalled apiserver
213+ // cannot extend total post-run blocking time to 2 * K8sCleanupTimeout.
214+ //
215+ // RBAC is privileged (a per-run cluster-admin ClusterRoleBinding), so a failure
216+ // to revoke it is returned to the caller and promoted into the run's error —
217+ // fail closed, never leak cluster-admin silently. ConfigMap cleanup is not
218+ // privileged, so its failure stays warning-only and does not fail the run.
219+ func (v * Validator ) deferClusterCleanup (clientset kubernetes.Interface ) error {
192220 if ! v .Cleanup {
193- return
221+ return nil
194222 }
195223 //nolint:contextcheck // Fresh context: parent may be canceled during cleanup
196224 cleanupCtx , cancel := context .WithTimeout (context .Background (), defaults .K8sCleanupTimeout )
197225 defer cancel ()
198226
227+ var rbacErr error
199228 if cleanupErr := job .CleanupRBAC (cleanupCtx , clientset , v .Namespace , v .RunID ); cleanupErr != nil {
200- slog .Warn ("failed to cleanup RBAC; resources may be orphaned" ,
229+ slog .Error ("failed to cleanup RBAC; cluster-admin binding may be orphaned" ,
201230 "runID" , v .RunID , "namespace" , v .Namespace , "error" , cleanupErr )
231+ rbacErr = errors .PropagateOrWrap (cleanupErr , errors .ErrCodeInternal , "failed to revoke privileged RBAC" )
202232 }
203233 if cmErr := v .cleanupDataConfigMaps (cleanupCtx , clientset ); cmErr != nil {
204234 slog .Warn ("failed to cleanup ConfigMaps; resources may be orphaned" ,
205235 "runID" , v .RunID , "namespace" , v .Namespace , "error" , cmErr )
206236 }
237+ return rbacErr
238+ }
239+
240+ // rollbackRBAC revokes the per-run RBAC created earlier in prepareCluster when a
241+ // later preparation step fails. It uses a fresh bounded context because the
242+ // caller's ctx may already be canceled — the very condition that can trigger the
243+ // failure. Revoking the cluster-admin ClusterRoleBinding closes the privilege
244+ // escalation window immediately; the surrounding prepareCluster call still
245+ // returns its error, so the run fails closed regardless of this rollback's
246+ // outcome. Respects v.Cleanup for parity with the success-path teardown: a
247+ // caller that disabled cleanup has opted into managing teardown manually, so a
248+ // disabled-cleanup run performs no rollback and returns nil.
249+ //
250+ // Returns the CleanupRBAC error (still logged) so the caller can fold a failed
251+ // revocation into the run's error and surface that cluster-admin may be
252+ // orphaned; returns nil when cleanup is disabled or the revocation succeeds.
253+ func (v * Validator ) rollbackRBAC (clientset kubernetes.Interface ) error {
254+ if ! v .Cleanup {
255+ return nil
256+ }
257+ //nolint:contextcheck // Fresh context: parent may be canceled during rollback
258+ cleanupCtx , cancel := context .WithTimeout (context .Background (), defaults .K8sCleanupTimeout )
259+ defer cancel ()
260+
261+ if cleanupErr := job .CleanupRBAC (cleanupCtx , clientset , v .Namespace , v .RunID ); cleanupErr != nil {
262+ slog .Error ("failed to roll back RBAC after preparation failure; cluster-admin binding may be orphaned" ,
263+ "runID" , v .RunID , "namespace" , v .Namespace , "error" , cleanupErr )
264+ return cleanupErr
265+ }
266+ return nil
207267}
208268
209269// ValidatePhases runs the specified phases sequentially and returns one
@@ -215,7 +275,7 @@ func (v *Validator) ValidatePhases(
215275 phases []Phase ,
216276 validationInput * v1.ValidationInput ,
217277 snap * snapshotter.Snapshot ,
218- ) ([]* PhaseResult , error ) {
278+ ) (results []* PhaseResult , err error ) {
219279
220280 if len (phases ) == 0 {
221281 phases = PhaseOrder
@@ -226,15 +286,15 @@ func (v *Validator) ValidatePhases(
226286 // Lower any nccl-benchmark-runtime-ref into its inline carrier by reading the
227287 // referenced template from the --data tree. Fails fast on a bad ref before
228288 // deploying any Jobs.
229- if err := v .resolveBenchmarkRuntimeRef (ctx , validationInput ); err != nil {
230- return nil , err
289+ if refErr := v .resolveBenchmarkRuntimeRef (ctx , validationInput ); refErr != nil {
290+ return nil , refErr
231291 }
232292
233293 // Pre-flight: evaluate the top-level and readiness-phase constraints
234294 // against the snapshot. Fails fast before deploying any Jobs if
235295 // prerequisites aren't met.
236- if err := checkReadiness (validationInput , snap ); err != nil {
237- return nil , err
296+ if readyErr := checkReadiness (validationInput , snap ); readyErr != nil {
297+ return nil , readyErr
238298 }
239299
240300 cat , err := catalog .LoadWithDataProvider (ctx , v .dataProvider , v .Version , v .Commit )
@@ -260,9 +320,18 @@ func (v *Validator) ValidatePhases(
260320 return nil , err
261321 }
262322 defer close (cs .stopCh )
263- defer v .deferClusterCleanup (cs .clientset ) //nolint:contextcheck // cleanup uses fresh context
323+ // Promote a privileged (RBAC) cleanup failure into the run's error, but only
324+ // when there is no prior real error — a genuine phase failure takes
325+ // precedence over a cleanup problem.
326+ //
327+ //nolint:contextcheck // deferClusterCleanup uses a fresh context: parent may be canceled
328+ defer func () {
329+ if cleanupErr := v .deferClusterCleanup (cs .clientset ); cleanupErr != nil && err == nil {
330+ err = cleanupErr
331+ }
332+ }()
264333
265- results , err : = v .runPhases (ctx , func (phase Phase ) (* PhaseResult , error ) {
334+ results , err = v .runPhases (ctx , func (phase Phase ) (* PhaseResult , error ) {
266335 return v .runPhase (ctx , cs .clientset , cs .factory , cat , phase , validationInput )
267336 }, cat , phases )
268337 if err != nil {
@@ -324,19 +393,19 @@ func (v *Validator) ValidatePhase(
324393 phase Phase ,
325394 validationInput * v1.ValidationInput ,
326395 snap * snapshotter.Snapshot ,
327- ) (* PhaseResult , error ) {
396+ ) (result * PhaseResult , err error ) {
328397
329398 // Lower any nccl-benchmark-runtime-ref into its inline carrier before the
330399 // phase runs (or is skipped), so a bad ref fails fast even offline.
331- if err := v .resolveBenchmarkRuntimeRef (ctx , validationInput ); err != nil {
332- return nil , err
400+ if refErr := v .resolveBenchmarkRuntimeRef (ctx , validationInput ); refErr != nil {
401+ return nil , refErr
333402 }
334403
335404 // Readiness pre-flight — before the no-cluster short-circuit, matching
336405 // ValidatePhases: constraints are evaluated inline against the snapshot
337406 // even in test mode.
338- if err := checkReadiness (validationInput , snap ); err != nil {
339- return nil , err
407+ if readyErr := checkReadiness (validationInput , snap ); readyErr != nil {
408+ return nil , readyErr
340409 }
341410
342411 cat , err := catalog .LoadWithDataProvider (ctx , v .dataProvider , v .Version , v .Commit )
@@ -361,7 +430,16 @@ func (v *Validator) ValidatePhase(
361430 return nil , err
362431 }
363432 defer close (cs .stopCh )
364- defer v .deferClusterCleanup (cs .clientset ) //nolint:contextcheck // cleanup uses fresh context
433+ // Promote a privileged (RBAC) cleanup failure into the run's error, but only
434+ // when there is no prior real error — a genuine phase failure takes
435+ // precedence over a cleanup problem.
436+ //
437+ //nolint:contextcheck // deferClusterCleanup uses a fresh context: parent may be canceled
438+ defer func () {
439+ if cleanupErr := v .deferClusterCleanup (cs .clientset ); cleanupErr != nil && err == nil {
440+ err = cleanupErr
441+ }
442+ }()
365443
366444 return v .runPhase (ctx , cs .clientset , cs .factory , cat , phase , validationInput )
367445}
0 commit comments