@@ -20,6 +20,8 @@ import (
2020 "context"
2121 "errors"
2222 "fmt"
23+ "strings"
24+ "time"
2325
2426 corev1 "k8s.io/api/core/v1"
2527 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -147,6 +149,16 @@ func (r *RuleReadinessController) processNodeAgainstAllRules(ctx context.Context
147149 continue
148150 }
149151
152+ // Recover a missing TaintAppliedAt/TaintObservedAt anchor before evaluating the rule.
153+ // Skip repeated recovery checks once attempts are exhausted.
154+ if rule .Spec .EnforcementMode == readinessv1alpha1 .EnforcementModeBootstrapOnly &&
155+ r .hasTaintBySpec (node , rule .Spec .Taint ) &&
156+ r .taintAnchorMissing (rule , node .Name ) &&
157+ r .shouldAttemptTaintAppliedAtRecovery (rule .Name , node .Name ) {
158+ recovered := r .recoverTaintAppliedAtFromAPI (ctx , rule , node .Name )
159+ r .recordTaintAppliedAtRecoveryOutcome (rule .Name , node .Name , recovered )
160+ }
161+
150162 log .Info ("Evaluating rule for node" ,
151163 "node" , node .Name ,
152164 "rule" , rule .Name ,
@@ -192,6 +204,12 @@ func (r *RuleReadinessController) processNodeAgainstAllRules(ctx context.Context
192204 found := false
193205 for i := range latestRule .Status .NodeEvaluations {
194206 if latestRule .Status .NodeEvaluations [i ].NodeName == node .Name {
207+ if currEval .TaintAppliedAt .IsZero () && ! latestRule .Status .NodeEvaluations [i ].TaintAppliedAt .IsZero () {
208+ currEval .TaintAppliedAt = latestRule .Status .NodeEvaluations [i ].TaintAppliedAt
209+ }
210+ if currEval .TaintObservedAt .IsZero () && ! latestRule .Status .NodeEvaluations [i ].TaintObservedAt .IsZero () {
211+ currEval .TaintObservedAt = latestRule .Status .NodeEvaluations [i ].TaintObservedAt
212+ }
195213 latestRule .Status .NodeEvaluations [i ] = currEval
196214 found = true
197215 break
@@ -250,6 +268,127 @@ func (r *RuleReadinessController) processNodeAgainstAllRules(ctx context.Context
250268 return errors .Join (errs ... )
251269}
252270
271+ const maxTaintAnchorRecoveryAttempts = 2
272+
273+ // Reports whether the cached evaluation is missing TaintAppliedAt or TaintObservedAt.
274+ func (r * RuleReadinessController ) taintAnchorMissing (rule * readinessv1alpha1.NodeReadinessRule , nodeName string ) bool {
275+ r .ruleCacheMutex .Lock ()
276+ defer r .ruleCacheMutex .Unlock ()
277+
278+ prevEval := r .getPreviousNodeEvaluation (rule , nodeName )
279+ return prevEval == nil || prevEval .TaintAppliedAt .IsZero () && prevEval .TaintObservedAt .IsZero ()
280+ }
281+
282+ // Reports whether recovery should still be attempted.
283+ func (r * RuleReadinessController ) shouldAttemptTaintAppliedAtRecovery (ruleName , nodeName string ) bool {
284+ r .taintAnchorRecoveryMutex .Lock ()
285+ defer r .taintAnchorRecoveryMutex .Unlock ()
286+
287+ return r .taintAnchorRecoveryAttempts [ruleName + "/" + nodeName ] < maxTaintAnchorRecoveryAttempts
288+ }
289+
290+ // Records the outcome of a recovery attempt.
291+ func (r * RuleReadinessController ) recordTaintAppliedAtRecoveryOutcome (ruleName , nodeName string , recovered bool ) {
292+ key := ruleName + "/" + nodeName
293+
294+ r .taintAnchorRecoveryMutex .Lock ()
295+ defer r .taintAnchorRecoveryMutex .Unlock ()
296+
297+ if recovered {
298+ delete (r .taintAnchorRecoveryAttempts , key )
299+ return
300+ }
301+ if r .taintAnchorRecoveryAttempts == nil {
302+ r .taintAnchorRecoveryAttempts = make (map [string ]int )
303+ }
304+ r .taintAnchorRecoveryAttempts [key ]++
305+ }
306+
307+ // Clears recovery tracking for a deleted rule.
308+ func (r * RuleReadinessController ) clearTaintAppliedAtRecoveryForRule (ruleName string ) {
309+ prefix := ruleName + "/"
310+
311+ r .taintAnchorRecoveryMutex .Lock ()
312+ defer r .taintAnchorRecoveryMutex .Unlock ()
313+
314+ for key := range r .taintAnchorRecoveryAttempts {
315+ if strings .HasPrefix (key , prefix ) {
316+ delete (r .taintAnchorRecoveryAttempts , key )
317+ }
318+ }
319+ }
320+
321+ // Clears recovery tracking for a rule/node pair.
322+ func (r * RuleReadinessController ) clearTaintAppliedAtRecoveryForNode (ruleName , nodeName string ) {
323+ r .taintAnchorRecoveryMutex .Lock ()
324+ defer r .taintAnchorRecoveryMutex .Unlock ()
325+
326+ delete (r .taintAnchorRecoveryAttempts , ruleName + "/" + nodeName )
327+ }
328+
329+ // Recovers a missing TaintAppliedAt/TaintObservedAt from the API and updates the cached rule.
330+ // Returns true if an existing anchor was found.
331+ func (r * RuleReadinessController ) recoverTaintAppliedAtFromAPI (ctx context.Context , rule * readinessv1alpha1.NodeReadinessRule , nodeName string ) bool {
332+ log := ctrl .LoggerFrom (ctx )
333+
334+ const (
335+ attempts = 3
336+ delay = 500 * time .Millisecond
337+ )
338+
339+ for i := range attempts {
340+ if i > 0 {
341+ select {
342+ case <- ctx .Done ():
343+ return false
344+ case <- time .After (delay ):
345+ }
346+ }
347+
348+ latestRule := & readinessv1alpha1.NodeReadinessRule {}
349+ if err := r .Get (ctx , client.ObjectKey {Name : rule .Name }, latestRule ); err != nil {
350+ log .V (4 ).Info ("Failed to refresh rule for TaintAppliedAt recovery" ,
351+ "rule" , rule .Name , "node" , nodeName , "error" , err .Error ())
352+ continue
353+ }
354+
355+ for _ , eval := range latestRule .Status .NodeEvaluations {
356+ if eval .NodeName != nodeName {
357+ continue
358+ }
359+ if eval .TaintAppliedAt .IsZero () && eval .TaintObservedAt .IsZero () {
360+ continue
361+ }
362+
363+ r .ruleCacheMutex .Lock ()
364+ nodeEval := r .getOrCreateNodeEvaluation (rule , nodeName )
365+ if nodeEval .TaintAppliedAt .IsZero () && ! eval .TaintAppliedAt .IsZero () {
366+ nodeEval .TaintAppliedAt = eval .TaintAppliedAt
367+ }
368+ if nodeEval .TaintObservedAt .IsZero () && ! eval .TaintObservedAt .IsZero () {
369+ nodeEval .TaintObservedAt = eval .TaintObservedAt
370+ }
371+
372+ if cachedRule , ok := r .ruleCache [rule .Name ]; ok {
373+ cachedNodeEval := r .getOrCreateNodeEvaluation (cachedRule , nodeName )
374+ if cachedNodeEval .TaintAppliedAt .IsZero () && ! eval .TaintAppliedAt .IsZero () {
375+ cachedNodeEval .TaintAppliedAt = eval .TaintAppliedAt
376+ }
377+ if cachedNodeEval .TaintObservedAt .IsZero () && ! eval .TaintObservedAt .IsZero () {
378+ cachedNodeEval .TaintObservedAt = eval .TaintObservedAt
379+ }
380+ }
381+ r .ruleCacheMutex .Unlock ()
382+
383+ log .V (4 ).Info ("Recovered taint anchor(s) from API into stale cache entry" ,
384+ "rule" , rule .Name , "node" , nodeName ,
385+ "taintAppliedAt" , eval .TaintAppliedAt , "taintObservedAt" , eval .TaintObservedAt )
386+ return true
387+ }
388+ }
389+ return false
390+ }
391+
253392// getConditionStatus gets the status of a condition on a node.
254393// If the condition is not present, defaultStatus is returned with found=false.
255394func (r * RuleReadinessController ) getConditionStatus (
0 commit comments