Skip to content

5. Implementation

5.1 Architecture

What this section defines

The controller is a controller-runtime manager (Deployment, replicas=2, leader election) that reconciles per NodePool, resolving each pool's governing RotationPolicy on every pass.

Policy vs state separation

  • Policy = the RotationPolicy spec (desired configuration an operator authors)
  • State = annotations on NodeClaim/NodePool + transient Node/placeholder markers (§5.3)
  • The CRD never carries authoritative runtime state — its status is observational only

Startup preflight

Before reconciling, the controller fails fast if:

  • The cluster does not serve karpenter.sh/v1 with nodeclaims/nodepools resources
  • RBAC cannot read them

The compatibility contract is the karpenter.sh/v1 group/version, independent of the managed Karpenter minor (EKS Auto Mode does not expose it). A successful decode of the v1 types confirms wire-compatible schema. Per-field CRD introspection is not attempted.

5.2 Reconcile Loop

What this section defines

Each Reconcile call performs exactly one non-blocking step and returns a Requeue. No blocking waits — all state is read from annotations and survives restarts.

The reconciler is keyed on NodePool and watches:

  • NodeClaim (mapped to owning NodePool)
  • Placeholder Pod reaching Running
  • Surge host Node reaching Ready

A periodic self-requeue remains the backstop for window edges, freeze releases, drain progress, and force-expiry.

Decision flow

Static capacity gate (step 1a)

A NodePool with spec.replicas set can never complete a surge (§3.3), so no rotation is started for it: the pass warns once (StaticNodePool, §4.3) and requeues.

The gate sits after the in-flight advance() and before every start gate, so an anchor written before the gate existed (by an earlier controller version — Karpenter itself rejects adding spec.replicas to a running NodePool) still drives that rotation to completion instead of stranding a cordoned node and a placeholder. advance()'s failed-retry branch is a new attempt and is closed on a static pool separately, so it releases the anchor rather than retrying the doomed attempt once per escalated backoff.

Start gates (step 2)

All of the following must pass before a new rotation can begin:

  • in_window(now) — maintenance window open
  • not frozen(np) — no freeze annotation
  • since_last_rotation(np) >= cooldownAfter — gate A: post-success settle
  • since_last_failure(np) >= failurePause — gate B: post-failure pause (§4.4, ADR-0004)

Candidate selection (step 3)

pick_earliest_deadline_eligible selects claims with:

  • No deletionTimestamp
  • state empty (fresh) or failed past the escalated backoff (retryBackoff · 2^(retry-count − 1), capped 8×), clamped to the maintenance-window occurrence the failure happened in (§3.2)
  • pending/draining never re-selected; expired is terminal

Anchor semantics

The active-rotation anchor is:

  • Written before any other side effect at start
  • Cleared last at completion/failure
  • Conflict-checked, only-if-absent write (optimistic concurrency)
  • Ticks and NodeClaim events can race on the same NodePool — the precondition makes the race harmless

Completion outcome

Decided by the NodePool-side active-rotation-state mirror:

  • draining present → success (cooldown consumed)
  • draining absent → expired (alert, no cooldown)

Force-expiry detection

Caught on two paths:

  • Early: deletionTimestamp appearing while still pending — checked first, before everything else
  • Late: old NodeClaim disappearing with no draining mirror

The early path also writes state=expired before releasing the anchor (prevents livelock under Auto Mode's tGP = 24h).

Stuck drain

A drain exceeding tGP + buffer raises noderotation_drain_stuck but keeps the serial gate held — a rotation in draining cannot be rolled back (the delete already happened), and releasing the gate would violate maxUnavailable = 1.

Cooldown anchor

last-rotation-at lives on the NodePool (not the deleted old NodeClaim). The pause is durable across the completion boundary and leader changes.

Full pseudocode — click to expand
text
Reconcile(req):
  if req is Tick:
      for np in in_scope_nodepools():
          reconcile_nodepool(np)
      return Requeue(1m)
  return reconcile_nodepool(nodepool(req.obj))

reconcile_nodepool(np):
  # ── 0. Window-close evaluation (§4.2). Above every gate in this function: it
  #        states what happened to the window, not why the controller did not
  #        act. Reconcile's own governance gates (policy conflict, no governing
  #        policy) already returned before reconcile_nodepool was ever called.
  #        Claim-then-announce (§5.2), with the verdict itself as the condition:
  #        inside the write loop it is re-run against the AUTHORITATIVE
  #        annotations (same census, same now) and must still yield the same
  #        action on the same stamp, in either direction.
  match window_edge(np, census(np), in_window(now)):
    case stamp:    annotate(np, window-opened-at=now)        # only-if still `stamp`
    case defer:    pass                                      # a rotation may still succeed
    case settled:  clear(np, window-opened-at)               # only-if still `settled`
    case missed:   won := clear(np, window-opened-at)        # only-if still `missed`
                   if won: emit_metrics(window_missed); event(WindowMissed)

  # ── 1. Drive in-flight rotation first (serial: at most one per NodePool)
  if name := np[active-rotation]:
      return advance(np, name)

  # ── 1a. Static capacity gate (§3.3): surge cannot serve a fixed-replica pool.
  #        After advance(), so an in-flight rotation still completes.
  if np.spec.replicas is set:
      warn_once(np, StaticNodePool)
      return Requeue(1m)

  # ── 2. Start gates
  start_gates(np) :=
      in_window(now) and not frozen(np)
      and since_last_rotation(np) >= cooldownAfter   # gate A
      and since_last_failure(np)  >= failurePause    # gate B
  if not start_gates(np): return Requeue(1m)

  # ── 3. Pick candidate, check headroom, anchor
  cand := pick_earliest_deadline_eligible(np)
  if cand == nil: return Requeue(1m)
  surgeless := forceful_fallback(np, cand)
  if not surgeless and not surge_headroom(np, cand):
      warn(InsufficientHeadroom, resource, want, remaining, limit)  # deduped
      return Requeue(1m)
  annotate(np, active-rotation=cand.name)    # conflict-checked, only-if-absent
  if surgeless:
      annotate(np, rotation-mode=forceful-fallback,
               active-rotation-state=draining, draining-at=now)
      annotate(cand, state=draining)
      emit_metrics(forceful_fallback); event
      delete(cand)
      return Requeue(30s)
  return advance(np, cand.name)

advance(np, name):
  cand := nodeclaim(name)
  if cand == nil:                            # old NodeClaim finalized
      delete(placeholder(name))
      for node in nodes_with(surge-for=name):
          unfreeze(node)
      # ONE conflict-checked write, only-if active-rotation == name (§5.2). It
      # reads the outcome from the same fresh copy it is validated against,
      # stamps last-rotation-at when that copy says draining, clears the anchor,
      # and reports whether THIS pass released it.
      won, rotated := release_anchor(np, name)
      if not won:                            # an earlier pass already completed it
          return Requeue(1m)
      if rotated:
          emit_metrics(success, duration)
      else:
          emit_metrics(expired); alert
      return Requeue(1m)

  switch cand.state:
  case (none) | pending:
      if cand.deletionTimestamp != nil:      # force-expiry caught
          # ONE conflict-checked write, only-if the claim still holds THIS
          # handler's pre-state (§5.2). It runs BEFORE the cleanup: a pass that
          # does not own the transition must not unfreeze the surge node a live
          # drain still depends on.
          out := mark_expired(cand, from=[none, pending],
                              clear=[started-at, surge-claim])
          if out in {gone, raced}:           # nothing written; this pass owns nothing
              return Requeue(30s)            # gone ⇒ release_anchor counts the abort
          # announce BEFORE the fallible cleanup (§5.2)
          if out == claimed: emit_metrics(expired); alert
          delete(placeholder(name))
          for node in nodes_with(surge-for=name): unfreeze(node)
          clear(np, anchor)
          return Requeue(1m)
      # only from the states advance() dispatches here on (§5.2): a `pending`
      # view of a claim whose durable state has moved past it must not undo the
      # rollback — re-stamping started-at would restart the readyTimeout deadline
      wrote := annotate_if(cand, from=[none, pending],
                           state=pending, once(started-at=now))
      if not wrote: return Requeue(30s)   # this pass owns nothing
      if elapsed(cand.started-at) > readyTimeout:
          reap_surge_claim(cand[surge-claim])
          delete(placeholder(name))
          for node in nodes_with(surge-for=name): unfreeze(node)
          wrote := annotate(cand, state=failed, failed-at=now, retry-count+=1,
                            clear=[started-at, surge-claim])
          if not wrote:                      # the claim finalized away mid-rollback
              return Requeue(30s)            # a force-expiry, not a failed attempt
          # the alert reports the retry-count this write produced, never the
          # caller's cached copy of it
          emit_metrics(failure); alert
          annotate(np, last-failure-at=now, clear=anchor)
          return Requeue(1m)
      freeze(cand.node, surge-for=name)
      cordon(cand.node)
      if c := induced_claim(name):
          annotate(cand, surge-claim=c.name)
      if frozen(np): return Requeue(1m)      # hold escalation
      if placeholder(name) is missing:
          create_placeholder(np, cand)
          return Requeue(30s)
      if surge_ready(cand):
          host := placeholder_node(name)
          freeze(host, surge-for=name)
          path := surge_path(host, cand.started-at)   # provisioned | absorbed | unknown
          annotate(np, active-rotation-state=draining, draining-at=now,
                   surge-wait=now − cand.started-at,
                   surge-path=path if known)
          annotate(cand, state=draining)
          delete(cand)
          return Requeue(30s)
      return Requeue(30s)

  case draining:
      annotate(np, active-rotation-state=draining)
      if cand.deletionTimestamp == nil:      # crash recovery
          delete(cand)
          return Requeue(30s)
      if elapsed(cand.deletionTimestamp) > drain_bound(np):
          alert(stuck_drain)
      return Requeue(30s)

  case failed:
      if cand.deletionTimestamp != nil:
          out := mark_expired(cand, from=[failed])   # same conditional write
          if out in {gone, raced}: return Requeue(30s)
          if out == claimed: emit_metrics(expired); alert
          clear(np, anchor)
          return Requeue(1m)
      # A retry is a NEW attempt: it must also clear the step-1a static gate and
      # the step-1b fatal feasibility gate, which this path sits above (the
      # anchor entered advance() first).
      if start_gates(np) and np.spec.replicas is unset
         and no fatal feasibility finding                          # step 1b, re-asserted: this path sits above it
         and elapsed(cand.failed-at) >= effective_backoff(cand)   # escalated, clamped to the occurrence (§3.2)
         and surge_headroom(np, cand):
          # only from failed (§5.2). The same guard bounds the re-entry below:
          # advance() re-reads through the cache, and a read still lagging this
          # write would dispatch straight back here with every gate open
          wrote := annotate_if(cand, from=[failed], state=pending)
          if not wrote: return Requeue(30s)
          return advance(np, name)
      annotate(np, last-failure-at=max(np[last-failure-at], cand.failed-at),
               clear=anchor)
      return Requeue(1m)

  case expired:                              # terminal cleanup
      delete(placeholder(name))
      for node in nodes_with(surge-for=name): unfreeze(node)
      clear(np, anchor)
      return Requeue(1m)

Claim-then-announce

Every write a cache-lagged pass can reach is conditional: it accepts only the pre-state its handler is dispatched on, and its outcome is produced by the write loop itself and reset per attempt — so a first attempt that conflicts and a retry that finds the object finalized away report gone, not success. The pass whose write lands owns the transition, and only it announces: the metric, the log line and the Event follow the write, never the attempt.

Three properties follow, and hold at every site that uses this ordering:

  • At-most-once. A controller that dies between the write and the emission drops the signal rather than inventing one, and nothing re-announces a transition it did not make.
  • The emission sits immediately after the write, ahead of the cleanup. The cleanup is fallible, and an error there hands the next reconcile to a handler that repairs it and deliberately never emits — so an emission placed behind the cleanup would be dropped by an ordinary transient API error rather than retried.
  • A pass that owns nothing does nothing. It writes nothing at all, touches none of the rotation's runtime objects, and leaves the transition to the handler that owns it — beyond the idempotent cleanup, where that is its job.

The §5.3 startup sweep and the §5.4 governance-loss reap use the same ordering, each conditioned on what selected the object rather than on a handler's pre-state.

Idempotent recovery

Each state handler re-asserts its phase's desired state rather than performing one-shot actions:

  • pending re-asserts freeze, cordon, placeholder existence on every pass
  • draining re-issues idempotent delete if deletionTimestamp is missing (crash between state write and delete)
  • completion re-runs its cleanup but claims the rotation with a conditional write: the anchor's release and the success/expired outcome are both decided from the fresh read that write is validated against
  • the four writes a cache-lagged dispatch can reach claim their transition — the two entries into expired, pending's entry assertion, and the failedpending retry. A pass arriving on a cached claim already written terminal still cleans up and releases the anchor
  • the reconcile's remaining claim-state writes stay unconditional, and are safe structurally rather than by veto — though not all by the same structure. The two the pending handler makes (pendingdraining, pendingfailed) follow its own guarded entry. The forceful fallback is started directly from candidate selection, never through that handler, so what protects its draining write is the only-if-absent NodePool anchor it has just won. All three record work the owning pass performed, and all three move the claim forward. The §5.3 startup sweep's write sits outside this dispatch altogether; it is conditional too, but on the predicate that selected the claim rather than on a handler's pre-state
  • that guard is what stops a lagging pending view from undoing a rollback — restoring pending, re-stamping started-at and so restarting the readyTimeout deadline while retry-count keeps the value the escalation was based on — and what bounds the retry branch's re-entry into the dispatcher, whose own cached read can still lag the write it has just made

Observability skews (accepted in v1)

  • Mirror-to-delete gap: a crash there followed by force-expiry records success (surge was reserved — practical outcome matches)
  • Where claim-then-announce applies. Four emission sites, each with its own artifacts: completion (the anchor-releasing write — the counter, the histogram, the completion line and the Event fire once per released anchor); both transitions into expired (abortPendingExpiry and advanceFailed's deletion branch, whose conditional write accepts only the dispatching handler's own pre-state — matching advanceExpired, which never re-announces a claim already terminal); the failure rollback (which announces an attempt and stamps the failure pause only when the write that records it landed, and reports the retry count that write produced); and the window close (the lost-window counter and the WindowMissed Event follow the write that cleared the window-opened-at stamp, at most once per occurrence — a stop between the counter and the Event can leave one without the other)
  • A claim that vanishes before a terminal write is left anchored, and its outcome falls to completion (expired, no cooldown)

5.3 State Model

What this section defines

All state lives on Kubernetes objects — no external datastore. The NodePool's active-rotation anchor records which rotation is in flight; the old NodeClaim's state records where it is.

Annotation reference

KeyTargetValuePurpose
active-rotationNodePoolNodeClaim nameDurable anchor + serial gate
active-rotation-stateNodePooldrainingPhase mirror for completion outcome
draining-atNodePoolRFC3339Drain-duration anchor (§4.2)
surge-waitNodePoolGo durationSurge-phase duration for completion log
surge-pathNodePoolprovisioned/absorbedWhich §3.3 path reserved the capacity
rotation-modeNodePoolforceful-fallbackSurge-less path marker
window-opened-atNodePoolRFC3339Observed window occurrence (§4.2)
stateOld NodeClaimpending/draining/failed/expiredProgress state
started-atOld NodeClaimRFC3339readyTimeout deadline
failed-atOld NodeClaimRFC3339Backoff anchor
retry-countOld NodeClaimintegerEscalates backoff
surge-claimOld NodeClaimNodeClaim nameInduced surge identification
surge-forPod + frozen nodesNodeClaim nameRotation pairing
do-not-disruptOld + surge nodestrueBlock voluntary disruption
do-not-disrupt-ownedOld + surge nodestrueController ownership marker
cordonedOld nodetrueController's cordon marker
last-failure-atNodePoolRFC3339Inter-attempt pause anchor
freezeNodePoolRFC3339Suppresses rotation until time
last-rotation-atNodePoolRFC3339cooldownAfter gate anchor

All keys use the noderotation.io/ prefix except karpenter.sh/do-not-disrupt.

Annotation details — click to expand
  • active-rotation: written before any side effect, cleared last. Outlives the old NodeClaim (which is deleted on success). Also the serial gate for maxUnavailable = 1
  • active-rotation-state: written immediately before delete(cand). Absence = rotation never left pending. Read by completion handler after old NodeClaim is gone
  • draining-at: write-once at pending → draining. The old NodeClaim's deletionTimestamp is gone by completion — needs this anchor
  • surge-wait: write-once at pending → draining. The old NodeClaim (started-at carrier) is deleted at that transition
  • surge-path: write-once at pending → draining, in the same update as surge-wait and for the same reason — the predicate that derives it needs started-at. It qualifies surge-wait: on the absorb path the reservation is aggregate capacity on a host already running other Pods, so the duration does not bound the time until the evicted Pods are running (§3.3). Absent when no path was established: the surge-less fallback has no surge phase, and a surge host whose NodeClaim cannot be resolved yields no value rather than a guessed one. What the lines report is what this field holds, never a later re-resolution — a retried transition (the claim's state write failing after the pool write landed) resolves the path again on a later pass, and announcing that value would name a path completion does not carry
  • rotation-mode: stamped on anchor at forceful-fallback start. Absent = default surge. Cleared with anchor on every end path
  • window-opened-at: stamped on the first in-window reconcile that finds it absent, cleared on the first out-of-window reconcile that finds it present. Its presence is the occurrence's identity, so no occurrence start is derived from the schedule — the weekly projection pins DST to an anchor week and would put a recovered start up to an hour out. An in-flight rotation defers the clear; an unreadable value is re-stamped in-window and cleared silently out of it. Known limits of identifying an occurrence by observation, accepted in v1:
    • Two occurrences collapse into one report in either of two ways: (a) no reconcile ever observes the out-of-window gap between them — a gap shorter than the reconcile interval, a controller down only across the gap, or API errors that persist through it — or (b) the gap is observed on every pass, but every one of those passes returns WindowDefer because a rotation is still in flight; a drain stuck across the gap and into the next occurrence collapses the pair this way. Either path leaves the first occurrence's stamp in place through the second, and the pair is judged and reported once, under the earlier window-opened-at. A later success then settles against that earlier stamp — against the merged span, not the occurrence it actually belonged to. The narrower, already-implied case is a whole window shorter than the 1-minute self-requeue: never observed, so never stamped and never reported
    • A schedule edit that puts the current time out of window while a stamp is held is an immediate close. The occurrence is judged there and then, against the census as it stands at the edit — the controller has no record of the schedule the stamp was written under
  • state: expired is terminal — blocks re-selection while the claim finalizes under the forceful drain
  • started-at: write-once per attempt. Cleared by the failed write (single update with state=failed). Re-stamped on retry
  • failed-at: the next-attempt instant reported in the rotation attempt failed log line and the RotationFailed Event is a snapshot under the schedule as it stands, not a lower bound: the clamp is evaluated on each read (§3.2), so extending a maintenanceWindows entry mid-occurrence can reopen the claim earlier than the instant already reported. It is derived from the failed-at the failing write persisted, truncated to the second the RFC3339 annotation stores, so it names the same instant the re-selection predicate parses back
  • surge-claim: persisted as soon as placeholder's bind target (spec.nodeName) is observable. Cleared with the failed write
  • surge-for: on frozen nodes, attributes freeze to this rotation. On the Pod, pairs it for discovery
  • do-not-disrupt-owned: set only when the controller actually applies do-not-disrupt. An operator's pre-existing annotation (no marker) is never touched
  • cordoned: set only when the controller flips spec.unschedulable. An operator's cordon (no marker) is never adopted
  • last-failure-at: max semantics on crash-recovery branch prevents voiding the pause

State transitions

Transition side effects — click to expand
FromEventToSide effects
(none)selected in windowpendingwrite anchor (first); freeze old node; cordon old node; create placeholder
(none)forceful fallbackdrainingwrite anchor + rotation-mode + draining-at; write state=draining; delete old NodeClaim (surge-less)
pendingeach reconcilependingclaim state=pending from none/pending (conditional, before anything else); re-assert freeze + cordon; persist surge-claim; recreate placeholder if missing (held during freeze)
pendingsurge_readydrainingfreeze surge target; write draining-at + surge-wait + surge-path; delete old NodeClaim
pendingreadyTimeoutfailedreap surge claim; delete placeholder; unfreeze; write state=failed + last-failure-at; clear anchor. A claim that vanished mid-rollback writes nothing: no attempt is announced, no pause stamped, and the anchor is left for completion to record a force-expiry
pendingforce-expiringexpiredclaim state=expired from pending (conditional, before cleanup); emit expired once; delete placeholder; unfreeze; clear anchor
drainingno deletionTimestampdrainingre-issue delete (crash recovery)
drainingdrain > tGP + bufferdrainingstuck-drain gauge; gate held
drainingNodeClaim gone(success)unfreeze; write last-rotation-at; emit success; clear anchor
failedbackoff + gates passpendingclaim state=pending from failed (conditional); started-at re-stamped by the new attempt
faileddeletionTimestampexpiredclaim state=expired from failed (conditional); emit expired once; clear anchor
expiredstill anchoredexpiredidempotent cleanup; clear anchor (metric not re-emitted)

Clearing the anchor

clear(np, anchor) is a single update removing the whole rotation-scoped set:

  • active-rotation, active-rotation-state, draining-at, surge-wait, surge-path, rotation-mode

No companion field can outlive the rotation. The failure path additionally writes last-failure-at in the same update.

Startup sweep

Runs once, gated before the first reconcile. Cleans only markers that no anchor references:

  • Placeholder Pods whose surge-for claim is absent/not-anchored → deleted
  • Node markers (surge-for, controller's do-not-disrupt by owned marker) → removed
  • cordoned marker with no anchored rotation → uncordon and remove

Rules:

  • An anchored NodePool is not stale — step 1 resumes it normally
  • failed/expired claims keep their annotations (backoff re-entry / terminal marker)
  • A pending/draining claim with no anchor (impossible from any crash point) → claim state=failed from pending/draining (conditional) + alert. Claim-then-announce (§5.2) applies, conditioned on the predicate that selected the claim rather than on a handler's pre-state — unlike the reconcile paths there is no anchor to hand the outcome to: having none is what selected it. The sweep selects from a List and writes later, so a claim finalized away in that window, or one whose durable state has already left those two, is repaired by nothing here
  • The node leg re-applies its selection predicate the same way, to the read its write is validated against and to the anchor set captured when the sweep started: a node whose markers that read shows belong to an anchored rotation carries current markers, not orphaned ones, and is left to the rotation that owns them. What was reversed is decided from that same read, and the line names it — unfroze for a surge-frozen node, uncordoned for a cordon-only one, which was never frozen and belongs to no claim
  • An orphaned active-rotation-state without anchor → simply removed
  • Best-effort: per-item errors logged, never fatal

5.4 Configuration Schema

What this section defines

The RotationPolicy CRD (cluster-scoped, v1alpha1) carries per-NodePool rotation configuration. The controller resolves each NodePool's governing policy by selector specificity.

RotationPolicy CRD (noderotation.io/v1alpha1)

yaml
apiVersion: noderotation.io/v1alpha1
kind: RotationPolicy
metadata:
  name: api                       # cluster-scoped; one per NodePool policy
spec:
  nodePoolSelector:               # selects governed NodePools
    matchLabels:
      workload: api
  ageThreshold: auto              # "auto" (derived, §3.2) or Go duration override
  minRotationChances: 2           # K; floor 1
  maintenanceWindows:             # per-policy; union semantics (§3.1)
    - timezone: Asia/Tokyo
      days: [Wed, Sat]
      start: "02:00"
      end:   "06:00"
  surge:
    maxUnavailable: 1             # v1 fixed at 1 (OpenAPI rejects other)
    readyTimeout: 15m             # must be > 0
    cooldownAfter: 10m            # gate A; may be 0
    # failurePause: 10m           # gate B; unset → max(10m, cooldownAfter)
    # drainEstimate: 10m          # layer-2 only; unset → min(tGP, 10m)
    # provisioningEstimate: 5m    # layer-2 only; unset → min(readyTimeout, 5m)
    retryBackoff: 30m             # must be > 0
    matchNodeRequirements:        # placeholder requirement replication (§3.7)
      required:
        - topology.kubernetes.io/zone
        - kubernetes.io/arch
        - karpenter.sh/capacity-type
      preferred: []
    forcefulFallback:             # opt-in surge-less fallback (§3.6)
      enabled: false
    wholeNodeReservation:         # opt-in whole-node reservation (§3.3, ADR-0005)
      enabled: false
  prePull:                        # v2 (disabled in v1)
    enabled: false
status:
  observedGeneration: 3
  matchedNodePools: 2
  rotatingNodePools: 1
  conditions:
    - type: Ready
      status: "True"
      reason: Accepted

Status subresource

  • matchedNodePools: pools this policy wins by selector specificity
  • rotatingNodePools: of those, count with an in-flight rotation
  • Ready condition:
    • Accepted — valid and uncontested
    • Invalid — failed reconcile-time validation
    • Conflict — equal-specificity tie (§below)
  • Invalid takes precedence over Conflict
  • Status is observational only — never authoritative for rotation decisions

A dedicated RotationPolicyStatusReconciler populates this view. Optimistic-concurrency conflicts are treated as silent requeues.

Targeting and conflict resolution

RuleBehavior
Most-specific winsSpecificity = label-key constraint count
Equal-specificity tieHard error — refuses to rotate that NodePool
No policy matchesNot rotated (safe no-op)
  • Specificity: matchLabels entries + matchExpressions entries. Empty (catch-all) selector scores 0 — loses to any keyed selector
  • Tie: emits PolicyConflict Warning Event + sets noderotation_policy_conflict{nodepool} = 1
  • Unmatched: no implicit default; operator writes a catch-all if blanket coverage is desired

Leaving governance mid-rotation

When a pool ceases to be governed while a rotation is anchored, the controller rolls it back, in this order:

  1. Deletes placeholder
  2. Unfreezes nodes (preserving operator's own protections)
  3. Clears the anchor — only while it still names this rotation
  4. Emits GovernanceLost Warning Event

This prevents orphaned placeholders and stale do-not-disrupt markers from silently blocking Karpenter's voluntary operations indefinitely.

The order is normative, for two reasons:

  • The rollback precedes the clear. The anchor is the only thing that brings a later reconcile back to this cleanup — the reap returns immediately on a pool without one, and no policy governs the pool any longer. Clearing it ahead of a step that then fails would orphan the artifacts permanently.
  • The conditional clear elects the announcer. The reap is entered from the anchor its caller was handed, which is a cache read that still shows an anchor an earlier pass already cleared. The write that clears it is therefore what identifies the pass that reaped the rotation — claim-then-announce (§5.2), with the same at-most-once semantics: one Event per reaped rotation, and none when the controller dies between the write and the emission.

Policy change propagation

A create/update/delete of any RotationPolicy re-enqueues every NodePool for re-resolution (one change can alter which policy wins for any pool).

Per-NodePool maintenance windows

maintenanceWindows lives on each policy, so the window is per-NodePool. The union semantics (§3.1) apply within one policy's list. This is why noderotation_window_active and noderotation_window_period_seconds carry a load-bearing nodepool label (§4.2).