Skip to content

[BUG] bootstrap-only: grandfathering marks bootstrap completed concurrently with taint addition, permanently orphaning the taint #382

Description

@patrickshan

What happened?

Rolling out the controller (v0.4.1) plus a condition-reporting DaemonSet
(node-problem-detector with custom conditions) simultaneously to a cluster of
long-lived, healthy nodes left one node with a permanently orphaned
readiness.k8s.io/newlyjoinednode:NoSchedule taint.

Two controllers raced on the same node within the same second, on the controller's
first-ever reconcile sweep:

  1. The rule controller evaluated the node before the reporter DaemonSet had
    stamped any of the rule's conditions, so every condition evaluated as
    observed: "Unknown" vs required: "False"allConditionsSatisfied: false
    → it added the taint.
  2. The node controller concurrently ran the grandfathering path for the
    pre-existing node and marked bootstrap completed
    (readiness.k8s.io/bootstrap-completed-<rule-uid> annotation written).
  3. Every subsequent reconcile of that node now logs
    Skipping bootstrap-only rule - already completed, which short-circuits the
    entire rule — including taint removal.

End state (still true hours later): node is healthy, all rule conditions read
False (satisfied), the bootstrap-completed annotation is present, and the
rule's NoSchedule taint is still on the node. Nothing will ever remove it; it
had to be removed manually.

Other nodes tainted in the same initial sweep were cleaned up correctly — they
won the race (taint removed before the completion mark landed). Only the
interleaving order differed.

Steps to Reproduce

  1. Cluster with existing Ready worker nodes that do not yet carry the
    rule's condition types (reporter not yet deployed).

  2. Deploy, at effectively the same time, a bootstrap-only NodeReadinessRule
    and the DaemonSet that reports its conditions:

    apiVersion: readiness.node.x-k8s.io/v1alpha1
    kind: NodeReadinessRule
    metadata:
      name: newly-joined-node-rule
    spec:
      enforcementMode: bootstrap-only
      conditions:
      - type: CustomCheckA
        requiredStatus: "False"
      - type: CustomCheckB
        requiredStatus: "False"
      nodeSelector:
        matchExpressions:
        - key: node-role.kubernetes.io/control-plane
          operator: DoesNotExist
      taint:
        key: readiness.k8s.io/newlyjoinednode
        value: "true"
        effect: NoSchedule
    
  3. On the controller's first sweep, nodes evaluate Unknown on all conditions
    and get tainted, while the node controller concurrently grandfathers them as
    bootstrap-completed. Depending on interleaving, some nodes end up completed
    with the taint still present, and it is never removed.

The race is timing-dependent; with ~20 matching nodes, one node hit it.

Log evidence (sanitized hostnames, otherwise verbatim)

07:43:10Z DEBUG Condition evaluation ... "node": "node-01", "conditionType": "CustomCheckA", "observed": "Unknown", "effective": "Unknown", "required": "False", "satisfied": false, "reconcileID": "4593ceed-..."
07:43:10Z INFO Evaluation result ... "node": "node-01", "allConditionsSatisfied": false, "hasTaint": false, "reconcileID": "4593ceed-..."
07:43:10Z INFO Adding taint ... "node": "node-01", "taint": "readiness.k8s.icileID": "4593ceed-..." <- rule controller
07:43:10Z INFO Marked bootstrap completed ... "controllerKind": "Node", "node": "node-01", "reconcileID": "bd456526-..." <- node controller, same second
07:43:10Z INFO Skipping bootstrap-only rule - already completed ... "node": "node-01"-..." <- all later reconciles

Node state hours later: conditions all False (healthy, heartbeats current),
readiness.k8s.io/bootstrap-completed- annotation present, taint
readiness.k8s.io/newlyjoinednode:NoSchedule still present.

Expected Behavior

Either of these invariants would prevent the orphaned taint:

  • markBootstrapCompleted removes (or verifies absence of) any taint owned by
    the rule before/atomically with writing the completion annotation, or
  • the skip-if-completed branch first checks whether the rule's taint is still
    present on the node and lifts it before skipping.

Related: #207 notes that completion-marking is intended to happen during taint
removal
. This report is the mirror image — the grandfathering path marks
completion via the node controller while the rule controller's taint from a
concurrent reconcile is still in flight, so the "completed" state and the taint
coexist.

Controller Version / Image Tag

v0.4.1

Kubernetes Version

v1.34.x

Controller Logs

No response

Additional Environment Details

No response

Metadata

Metadata

Assignees

Labels

kind/bugCategorizes issue or PR as related to a bug.priority/critical-urgentHighest priority. Must be actively worked on as someone's top priority right now.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions