Skip to content

Commit f889aee

Browse files
committed
extend histogram buckets
1 parent e259c3f commit f889aee

10 files changed

Lines changed: 921 additions & 26 deletions

api/v1alpha1/nodereadinessrule_types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,16 @@ type NodeEvaluation struct {
275275
//
276276
// +required
277277
LastEvaluationTime metav1.Time `json:"lastEvaluationTime,omitempty,omitzero"`
278+
279+
// taintAppliedAt is the timestamp when the controller applied the readiness taint to this Node.
280+
//
281+
// +optional
282+
TaintAppliedAt metav1.Time `json:"taintAppliedAt,omitempty,omitzero"`
283+
284+
// taintObservedAt is the timestamp when the readiness taint was first observed on this Node.
285+
//
286+
// +optional
287+
TaintObservedAt metav1.Time `json:"taintObservedAt,omitempty,omitzero"`
278288
}
279289

280290
// ConditionEvaluationResult provides a detailed report of the comparison between

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/readiness.node.x-k8s.io_nodereadinessrules.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,16 @@ spec:
440440
minLength: 1
441441
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
442442
type: string
443+
taintAppliedAt:
444+
description: taintAppliedAt is the timestamp when the controller
445+
applied the readiness taint to this Node.
446+
format: date-time
447+
type: string
448+
taintObservedAt:
449+
description: taintObservedAt is the timestamp when the readiness
450+
taint was first observed on this Node.
451+
format: date-time
452+
type: string
443453
taintStatus:
444454
description: taintStatus represents the taint status on the
445455
Node, one of Present, Absent.

docs/book/src/operations/monitoring.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ Total number of failure events recorded by the controller.
6969
| `rule` | `NodeReadinessRule` name | Any rule name |
7070
| `reason` | Failure label recorded by the controller | `EvaluationError`, `AddTaintError`, `RemoveTaintError` |
7171

72+
### `node_readiness_bootstrap_hold_duration_seconds`
73+
74+
Time from readiness taint application or observation to bootstrap completion.
75+
76+
| Property | Value |
77+
| --- | --- |
78+
| Type | `histogram` |
79+
| Labels | `rule`, `taint_origin` |
80+
| Buckets | `1, 5, 10, 30, 60, 120, 300, 600, 1200, 1800, 3600` |
81+
| Recorded when | The controller marks bootstrap as completed for a node under a bootstrap-only rule. |
82+
83+
#### Labels
84+
85+
| Label | Description | Values |
86+
| --- | --- | --- |
87+
| `rule` | `NodeReadinessRule` name | Any rule name |
88+
| `taint_origin` | Origin of the readiness taint's anchor timestamp | `controller`, `adopted` |
89+
90+
7291
### `node_readiness_build_info`
7392

7493
*Available starting from the v0.6.0 release.*

internal/controller/helper.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,21 @@ import (
2525
readinessv1alpha1 "sigs.k8s.io/node-readiness-controller/api/v1alpha1"
2626
)
2727

28-
//nolint:godot
2928
const (
3029
// bootstrapAnnotationPrefix is the common prefix for all bootstrap completion
3130
// annotations on a Node. The suffix is the rule's metadata.uid (RFC 4122 UUID,
3231
// ~36 chars), which is immutable for the object's lifetime and globally unique.
3332
//
3433
// Full key format: readiness.k8s.io/bootstrap-completed-<ruleUID>
35-
// Value format: {"rule-name":"<ruleName>"} (for human readability)
34+
// Value format: {"rule-name":"<ruleName>"} (for human readability).
3635
bootstrapAnnotationPrefix = "readiness.k8s.io/bootstrap-completed-"
3736
)
3837

38+
// bootstrapAnnotationPayload is the JSON value stored in a bootstrap-completion annotation.
39+
type bootstrapAnnotationPayload struct {
40+
RuleName string `json:"rule-name"`
41+
}
42+
3943
// bootstrapAnnotationKey returns the annotation key for a rule's bootstrap
4044
// completion state, using the rule's UID as the suffix.
4145
func bootstrapAnnotationKey(uid types.UID) string {
@@ -45,9 +49,7 @@ func bootstrapAnnotationKey(uid types.UID) string {
4549
// bootstrapAnnotationValue returns the JSON-encoded value to store in the
4650
// bootstrap annotation. It includes the rule name for human readability.
4751
func bootstrapAnnotationValue(ruleName string) string {
48-
v := struct {
49-
RuleName string `json:"rule-name"`
50-
}{RuleName: ruleName}
52+
v := bootstrapAnnotationPayload{RuleName: ruleName}
5153
b, err := json.Marshal(v)
5254
if err != nil {
5355
return `{"rule-name":""}` // should never happen

internal/controller/node_controller.go

Lines changed: 139 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/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.
255394
func (r *RuleReadinessController) getConditionStatus(

internal/controller/node_controller_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,97 @@ var _ = Describe("Node Controller", func() {
375375
return false
376376
}, time.Second*2).Should(BeFalse())
377377
})
378+
379+
It("should not retry recovery for an adopted taint", func() {
380+
// The node already has the taint, so the first reconcile takes the adopt path.
381+
// TaintObservedAt is set while TaintAppliedAt stays zero.
382+
_, err := nodeReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: namespacedName})
383+
Expect(err).NotTo(HaveOccurred())
384+
385+
updatedRule := &nodereadinessiov1alpha1.NodeReadinessRule{}
386+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: ruleName}, updatedRule)).To(Succeed())
387+
388+
var eval *nodereadinessiov1alpha1.NodeEvaluation
389+
for i := range updatedRule.Status.NodeEvaluations {
390+
if updatedRule.Status.NodeEvaluations[i].NodeName == nodeName {
391+
eval = &updatedRule.Status.NodeEvaluations[i]
392+
}
393+
}
394+
Expect(eval).NotTo(BeNil())
395+
Expect(eval.TaintAppliedAt.IsZero()).To(BeTrue(), "TaintAppliedAt must stay zero for an adopted taint")
396+
Expect(eval.TaintObservedAt.IsZero()).To(BeFalse(), "TaintObservedAt must be stamped for an adopted taint")
397+
Expect(readinessController.taintAnchorMissing(updatedRule, nodeName)).To(BeFalse())
398+
399+
// Refresh the cache and record the recovery attempts so far.
400+
readinessController.ruleCache[ruleName] = updatedRule
401+
readinessController.taintAnchorRecoveryMutex.Lock()
402+
attemptsBefore := readinessController.taintAnchorRecoveryAttempts[ruleName+"/"+nodeName]
403+
readinessController.taintAnchorRecoveryMutex.Unlock()
404+
405+
_, err = nodeReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: namespacedName})
406+
Expect(err).NotTo(HaveOccurred())
407+
408+
readinessController.taintAnchorRecoveryMutex.Lock()
409+
attemptsAfter := readinessController.taintAnchorRecoveryAttempts[ruleName+"/"+nodeName]
410+
readinessController.taintAnchorRecoveryMutex.Unlock()
411+
Expect(attemptsAfter).To(Equal(attemptsBefore),
412+
"no additional recovery attempt should have been made once TaintObservedAt is known")
413+
})
414+
415+
It("should self-heal the rule cache after recovering the taint anchor", func() {
416+
// Reconcile #1: adopt the existing taint and persist TaintObservedAt to the API.
417+
_, err := nodeReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: namespacedName})
418+
Expect(err).NotTo(HaveOccurred())
419+
420+
readCachedEval := func() *nodereadinessiov1alpha1.NodeEvaluation {
421+
readinessController.ruleCacheMutex.RLock()
422+
defer readinessController.ruleCacheMutex.RUnlock()
423+
cachedRule, ok := readinessController.ruleCache[ruleName]
424+
if !ok {
425+
return nil
426+
}
427+
for i := range cachedRule.Status.NodeEvaluations {
428+
if cachedRule.Status.NodeEvaluations[i].NodeName == nodeName {
429+
return &cachedRule.Status.NodeEvaluations[i]
430+
}
431+
}
432+
return nil
433+
}
434+
435+
Expect(readCachedEval()).To(BeNil(),
436+
"the persistent cache should still be stale immediately after the adopt reconcile")
437+
438+
// Reconcile #2: recover TaintObservedAt from the API and update the cache.
439+
_, err = nodeReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: namespacedName})
440+
Expect(err).NotTo(HaveOccurred())
441+
442+
cachedEval := readCachedEval()
443+
Expect(cachedEval).NotTo(BeNil(),
444+
"the persistent cache should now contain a NodeEvaluation for this node")
445+
Expect(cachedEval.TaintObservedAt.IsZero()).To(BeFalse(),
446+
"the recovered TaintObservedAt should have been written into r.ruleCache directly")
447+
Expect(cachedEval.TaintAppliedAt.IsZero()).To(BeTrue())
448+
449+
readinessController.ruleCacheMutex.RLock()
450+
cachedRule := readinessController.ruleCache[ruleName]
451+
readinessController.ruleCacheMutex.RUnlock()
452+
Expect(readinessController.taintAnchorMissing(cachedRule, nodeName)).To(BeFalse(),
453+
"the self-healed cache entry must no longer report the anchor as missing")
454+
455+
readinessController.taintAnchorRecoveryMutex.Lock()
456+
attemptsBefore := readinessController.taintAnchorRecoveryAttempts[ruleName+"/"+nodeName]
457+
readinessController.taintAnchorRecoveryMutex.Unlock()
458+
459+
// Reconcile #3: the cache is already healed, so no further recovery is needed.
460+
_, err = nodeReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: namespacedName})
461+
Expect(err).NotTo(HaveOccurred())
462+
463+
readinessController.taintAnchorRecoveryMutex.Lock()
464+
attemptsAfter := readinessController.taintAnchorRecoveryAttempts[ruleName+"/"+nodeName]
465+
readinessController.taintAnchorRecoveryMutex.Unlock()
466+
Expect(attemptsAfter).To(Equal(attemptsBefore),
467+
"no further recovery attempt should be needed once the cache has self-healed")
468+
})
378469
})
379470

380471
When("in continuous mode", func() {

0 commit comments

Comments
 (0)