Skip to content

Commit 6f60849

Browse files
committed
Fix bootstrap NRC duration metric
1 parent 59e4f78 commit 6f60849

3 files changed

Lines changed: 235 additions & 4 deletions

File tree

internal/controller/node_controller.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 before evaluating the rule.
153+
// Skip repeated recovery checks for adopted taints.
154+
if rule.Spec.EnforcementMode == readinessv1alpha1.EnforcementModeBootstrapOnly &&
155+
r.hasTaintBySpec(node, rule.Spec.Taint) &&
156+
r.taintAppliedAtMissing(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,
@@ -188,6 +200,9 @@ func (r *RuleReadinessController) processNodeAgainstAllRules(ctx context.Context
188200
found := false
189201
for i := range latestRule.Status.NodeEvaluations {
190202
if latestRule.Status.NodeEvaluations[i].NodeName == node.Name {
203+
if currEval.TaintAppliedAt.IsZero() && !latestRule.Status.NodeEvaluations[i].TaintAppliedAt.IsZero() {
204+
currEval.TaintAppliedAt = latestRule.Status.NodeEvaluations[i].TaintAppliedAt
205+
}
191206
latestRule.Status.NodeEvaluations[i] = currEval
192207
found = true
193208
break
@@ -246,6 +261,106 @@ func (r *RuleReadinessController) processNodeAgainstAllRules(ctx context.Context
246261
return errors.Join(errs...)
247262
}
248263

264+
const maxTaintAnchorRecoveryAttempts = 2
265+
266+
// Reports whether the cached evaluation is missing TaintAppliedAt.
267+
func (r *RuleReadinessController) taintAppliedAtMissing(rule *readinessv1alpha1.NodeReadinessRule, nodeName string) bool {
268+
r.ruleCacheMutex.Lock()
269+
defer r.ruleCacheMutex.Unlock()
270+
271+
prevEval := r.getPreviousNodeEvaluation(rule, nodeName)
272+
return prevEval == nil || prevEval.TaintAppliedAt.IsZero()
273+
}
274+
275+
// Reports whether recovery should still be attempted.
276+
func (r *RuleReadinessController) shouldAttemptTaintAppliedAtRecovery(ruleName, nodeName string) bool {
277+
r.taintAnchorRecoveryMutex.Lock()
278+
defer r.taintAnchorRecoveryMutex.Unlock()
279+
280+
return r.taintAnchorRecoveryAttempts[ruleName+"/"+nodeName] < maxTaintAnchorRecoveryAttempts
281+
}
282+
283+
// Records the outcome of a recovery attempt.
284+
func (r *RuleReadinessController) recordTaintAppliedAtRecoveryOutcome(ruleName, nodeName string, recovered bool) {
285+
key := ruleName + "/" + nodeName
286+
287+
r.taintAnchorRecoveryMutex.Lock()
288+
defer r.taintAnchorRecoveryMutex.Unlock()
289+
290+
if recovered {
291+
delete(r.taintAnchorRecoveryAttempts, key)
292+
return
293+
}
294+
if r.taintAnchorRecoveryAttempts == nil {
295+
r.taintAnchorRecoveryAttempts = make(map[string]int)
296+
}
297+
r.taintAnchorRecoveryAttempts[key]++
298+
}
299+
300+
// Clears recovery tracking for a deleted rule.
301+
func (r *RuleReadinessController) clearTaintAppliedAtRecoveryForRule(ruleName string) {
302+
prefix := ruleName + "/"
303+
304+
r.taintAnchorRecoveryMutex.Lock()
305+
defer r.taintAnchorRecoveryMutex.Unlock()
306+
307+
for key := range r.taintAnchorRecoveryAttempts {
308+
if strings.HasPrefix(key, prefix) {
309+
delete(r.taintAnchorRecoveryAttempts, key)
310+
}
311+
}
312+
}
313+
314+
// Clears recovery tracking for a rule/node pair.
315+
func (r *RuleReadinessController) clearTaintAppliedAtRecoveryForNode(ruleName, nodeName string) {
316+
r.taintAnchorRecoveryMutex.Lock()
317+
defer r.taintAnchorRecoveryMutex.Unlock()
318+
319+
delete(r.taintAnchorRecoveryAttempts, ruleName+"/"+nodeName)
320+
}
321+
322+
// Recovers a missing TaintAppliedAt from the API and updates the cached rule.
323+
// Returns true if an existing anchor was found.
324+
func (r *RuleReadinessController) recoverTaintAppliedAtFromAPI(ctx context.Context, rule *readinessv1alpha1.NodeReadinessRule, nodeName string) bool {
325+
log := ctrl.LoggerFrom(ctx)
326+
327+
const (
328+
attempts = 3
329+
delay = 500 * time.Millisecond
330+
)
331+
332+
for i := range attempts {
333+
if i > 0 {
334+
select {
335+
case <-ctx.Done():
336+
return false
337+
case <-time.After(delay):
338+
}
339+
}
340+
341+
latestRule := &readinessv1alpha1.NodeReadinessRule{}
342+
if err := r.Get(ctx, client.ObjectKey{Name: rule.Name}, latestRule); err != nil {
343+
log.V(4).Info("Failed to refresh rule for TaintAppliedAt recovery",
344+
"rule", rule.Name, "node", nodeName, "error", err.Error())
345+
continue
346+
}
347+
348+
for _, eval := range latestRule.Status.NodeEvaluations {
349+
if eval.NodeName == nodeName && !eval.TaintAppliedAt.IsZero() {
350+
r.ruleCacheMutex.Lock()
351+
nodeEval := r.getOrCreateNodeEvaluation(rule, nodeName)
352+
nodeEval.TaintAppliedAt = eval.TaintAppliedAt
353+
r.ruleCacheMutex.Unlock()
354+
355+
log.V(4).Info("Recovered TaintAppliedAt anchor from API into stale cache entry",
356+
"rule", rule.Name, "node", nodeName, "taintAppliedAt", eval.TaintAppliedAt)
357+
return true
358+
}
359+
}
360+
}
361+
return false
362+
}
363+
249364
// getConditionStatus gets the status of a condition on a node.
250365
func (r *RuleReadinessController) getConditionStatus(
251366
node *corev1.Node,

internal/controller/nodereadinessrule_controller.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ type RuleReadinessController struct {
5959
// Cache for efficient rule lookup
6060
ruleCacheMutex sync.RWMutex
6161
ruleCache map[string]*readinessv1alpha1.NodeReadinessRule // ruleName -> rule
62+
63+
// taintAnchorRecoveryMutex guards taintAnchorRecoveryAttempts.
64+
taintAnchorRecoveryMutex sync.Mutex
65+
taintAnchorRecoveryAttempts map[string]int
6266
}
6367

6468
// RuleReconciler handles NodeReadinessRule reconciliation.
@@ -237,6 +241,8 @@ func (r *RuleReadinessController) cleanupDeletedNodes(ctx context.Context, rule
237241
for _, evaluation := range rule.Status.NodeEvaluations {
238242
if existingNodes[evaluation.NodeName] {
239243
newNodeEvaluations = append(newNodeEvaluations, evaluation)
244+
} else {
245+
r.clearTaintAppliedAtRecoveryForNode(rule.Name, evaluation.NodeName)
240246
}
241247
}
242248

@@ -416,9 +422,12 @@ func (r *RuleReadinessController) evaluateRuleForNode(ctx context.Context, rule
416422

417423
// Observe NRC-attributable hold time only on the first bootstrap completion.
418424
// Skip repeated completions and adopted taints.
419-
if !wasAlreadyCompleted {
425+
//
426+
// Match BootstrapDuration's guard conditions.
427+
if !wasAlreadyCompleted &&
428+
!node.CreationTimestamp.Time.Before(rule.CreationTimestamp.Time) && !latestTransition.IsZero() {
420429
if prevEval := r.getPreviousNodeEvaluation(rule, node.Name); prevEval != nil && !prevEval.TaintAppliedAt.IsZero() {
421-
duration := time.Since(prevEval.TaintAppliedAt.Time).Seconds()
430+
duration := latestTransition.Time.Sub(prevEval.TaintAppliedAt.Time).Seconds()
422431

423432
if duration < 0 {
424433
log.Info("Skipping bootstrap NRC duration metric due to negative duration",
@@ -581,6 +590,8 @@ func (r *RuleReadinessController) removeRuleFromCache(ctx context.Context, ruleN
581590
delete(r.ruleCache, ruleName)
582591
metrics.RulesTotal.Set(float64(len(r.ruleCache)))
583592
log.Info("Removed rule from cache", "rule", ruleName, "totalRules", len(r.ruleCache))
593+
594+
r.clearTaintAppliedAtRecoveryForRule(ruleName)
584595
}
585596

586597
// updateRuleStatus updates the status of a NodeReadinessRule.

internal/controller/nodereadinessrule_controller_test.go

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ func histogramSampleCount(histogram interface{ Write(*dto.Metric) error }) uint6
5555
return metric.GetHistogram().GetSampleCount()
5656
}
5757

58+
func histogramSampleSum(histogram interface{ Write(*dto.Metric) error }) float64 {
59+
metric := &dto.Metric{}
60+
Expect(histogram.Write(metric)).To(Succeed())
61+
return metric.GetHistogram().GetSampleSum()
62+
}
63+
5864
// errorInjectingClient forces Patch to fail for selected nodes.
5965
type errorInjectingClient struct {
6066
client.Client
@@ -2327,7 +2333,7 @@ var _ = Describe("NodeReadinessRule Controller", func() {
23272333

23282334
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node)).To(Succeed())
23292335
node.Status.Conditions = []corev1.NodeCondition{
2330-
{Type: "Ready", Status: corev1.ConditionTrue},
2336+
{Type: "Ready", Status: corev1.ConditionTrue, LastTransitionTime: metav1.NewTime(time.Now().Add(2 * time.Second))},
23312337
}
23322338
Expect(k8sClient.Status().Update(ctx, node)).To(Succeed())
23332339
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node)).To(Succeed())
@@ -2369,7 +2375,7 @@ var _ = Describe("NodeReadinessRule Controller", func() {
23692375

23702376
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node)).To(Succeed())
23712377
node.Status.Conditions = []corev1.NodeCondition{
2372-
{Type: "Ready", Status: corev1.ConditionTrue},
2378+
{Type: "Ready", Status: corev1.ConditionTrue, LastTransitionTime: metav1.NewTime(time.Now().Add(2 * time.Second))},
23732379
}
23742380
Expect(k8sClient.Status().Update(ctx, node)).To(Succeed())
23752381
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node)).To(Succeed())
@@ -2484,6 +2490,105 @@ var _ = Describe("NodeReadinessRule Controller", func() {
24842490
Expect(taintAppliedAtIsZero(rule, nodeName)).To(BeTrue())
24852491
})
24862492

2493+
It("should use LastTransitionTime to calculate BootstrapNRCDuration", func() {
2494+
ruleName := "nrc-dur-clock-anchor-rule"
2495+
nodeName := "nrc-dur-clock-anchor-node"
2496+
labelKey := "nrc-dur-clock-anchor"
2497+
2498+
rule := newBootstrapOnlyRule(ruleName, labelKey)
2499+
Expect(k8sClient.Create(ctx, rule)).To(Succeed())
2500+
defer func() { _ = k8sClient.Delete(ctx, rule) }()
2501+
2502+
// Node without taint, condition NOT satisfied.
2503+
node := &corev1.Node{
2504+
ObjectMeta: metav1.ObjectMeta{
2505+
Name: nodeName,
2506+
Labels: map[string]string{labelKey: "true"},
2507+
},
2508+
Status: corev1.NodeStatus{
2509+
Conditions: []corev1.NodeCondition{
2510+
{Type: "Ready", Status: corev1.ConditionFalse},
2511+
},
2512+
},
2513+
}
2514+
Expect(k8sClient.Create(ctx, node)).To(Succeed())
2515+
defer func() { _ = k8sClient.Delete(ctx, node) }()
2516+
2517+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: ruleName}, rule)).To(Succeed())
2518+
Expect(readinessController.evaluateRuleForNode(ctx, rule, node)).To(Succeed())
2519+
Expect(taintAppliedAtIsZero(rule, nodeName)).To(BeFalse())
2520+
2521+
// Backdate TaintAppliedAt to verify the duration uses LastTransitionTime
2522+
backdated := metav1.NewTime(time.Now().Add(-1 * time.Hour))
2523+
for i := range rule.Status.NodeEvaluations {
2524+
if rule.Status.NodeEvaluations[i].NodeName == nodeName {
2525+
rule.Status.NodeEvaluations[i].TaintAppliedAt = backdated
2526+
}
2527+
}
2528+
2529+
transitionTime := metav1.NewTime(backdated.Add(2 * time.Second))
2530+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node)).To(Succeed())
2531+
node.Status.Conditions = []corev1.NodeCondition{
2532+
{Type: "Ready", Status: corev1.ConditionTrue, LastTransitionTime: transitionTime},
2533+
}
2534+
Expect(k8sClient.Status().Update(ctx, node)).To(Succeed())
2535+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node)).To(Succeed())
2536+
2537+
histogram := metrics.BootstrapNRCDuration.WithLabelValues(ruleName).(prometheus.Histogram)
2538+
sumBefore := histogramSampleSum(histogram)
2539+
countBefore := histogramSampleCount(histogram)
2540+
2541+
Expect(readinessController.evaluateRuleForNode(ctx, rule, node)).To(Succeed())
2542+
2543+
Expect(histogramSampleCount(histogram)).To(Equal(countBefore + 1))
2544+
observed := histogramSampleSum(histogram) - sumBefore
2545+
2546+
Expect(observed).To(BeNumerically(">=", 0))
2547+
Expect(observed).To(BeNumerically("<", 10))
2548+
})
2549+
2550+
It("should skip BootstrapNRCDuration when LastTransitionTime is unset", func() {
2551+
ruleName := "nrc-dur-zero-transition-rule"
2552+
nodeName := "nrc-dur-zero-transition-node"
2553+
labelKey := "nrc-dur-zero-transition"
2554+
2555+
rule := newBootstrapOnlyRule(ruleName, labelKey)
2556+
Expect(k8sClient.Create(ctx, rule)).To(Succeed())
2557+
defer func() { _ = k8sClient.Delete(ctx, rule) }()
2558+
2559+
node := &corev1.Node{
2560+
ObjectMeta: metav1.ObjectMeta{
2561+
Name: nodeName,
2562+
Labels: map[string]string{labelKey: "true"},
2563+
},
2564+
Status: corev1.NodeStatus{
2565+
Conditions: []corev1.NodeCondition{
2566+
{Type: "Ready", Status: corev1.ConditionFalse},
2567+
},
2568+
},
2569+
}
2570+
Expect(k8sClient.Create(ctx, node)).To(Succeed())
2571+
defer func() { _ = k8sClient.Delete(ctx, node) }()
2572+
2573+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: ruleName}, rule)).To(Succeed())
2574+
Expect(readinessController.evaluateRuleForNode(ctx, rule, node)).To(Succeed())
2575+
Expect(taintAppliedAtIsZero(rule, nodeName)).To(BeFalse())
2576+
2577+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node)).To(Succeed())
2578+
node.Status.Conditions = []corev1.NodeCondition{
2579+
{Type: "Ready", Status: corev1.ConditionTrue},
2580+
}
2581+
Expect(k8sClient.Status().Update(ctx, node)).To(Succeed())
2582+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, node)).To(Succeed())
2583+
2584+
histogram := metrics.BootstrapNRCDuration.WithLabelValues(ruleName).(prometheus.Histogram)
2585+
before := histogramSampleCount(histogram)
2586+
2587+
Expect(readinessController.evaluateRuleForNode(ctx, rule, node)).To(Succeed())
2588+
2589+
Expect(histogramSampleCount(histogram)).To(Equal(before))
2590+
})
2591+
24872592
It("should clean up BootstrapNRCDuration label values on rule deletion", func() {
24882593
ruleName := "nrc-dur-del-rule"
24892594
labelKey := "nrc-dur-del"

0 commit comments

Comments
 (0)