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
RotationPolicyspec (desired configuration an operator authors) - State = annotations on
NodeClaim/NodePool+ transient Node/placeholder markers (§5.3) - The CRD never carries authoritative runtime state — its
statusis observational only
Startup preflight
Before reconciling, the controller fails fast if:
- The cluster does not serve
karpenter.sh/v1withnodeclaims/nodepoolsresources - 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
PodreachingRunning - Surge host
NodereachingReady
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 opennot frozen(np)— no freeze annotationsince_last_rotation(np) >= cooldownAfter— gate A: post-success settlesince_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 stateempty (fresh) orfailedpast the escalated backoff (retryBackoff · 2^(retry-count − 1), capped 8×), clamped to the maintenance-window occurrence the failure happened in (§3.2)pending/drainingnever re-selected;expiredis 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:
drainingpresent → success (cooldown consumed)drainingabsent → expired (alert, no cooldown)
Force-expiry detection
Caught on two paths:
- Early:
deletionTimestampappearing while stillpending— checked first, before everything else - Late: old NodeClaim disappearing with no
drainingmirror
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
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:
pendingre-asserts freeze, cordon, placeholder existence on every passdrainingre-issues idempotentdeleteifdeletionTimestampis 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 thefailed→pendingretry. 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
pendinghandler makes (pending→draining,pending→failed) follow its own guarded entry. The forceful fallback is started directly from candidate selection, never through that handler, so what protects itsdrainingwrite 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
pendingview from undoing a rollback — restoringpending, re-stampingstarted-atand so restarting thereadyTimeoutdeadline whileretry-countkeeps 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(abortPendingExpiryandadvanceFailed's deletion branch, whose conditional write accepts only the dispatching handler's own pre-state — matchingadvanceExpired, which never re-announces a claim already terminal); thefailurerollback (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 theWindowMissedEvent follow the write that cleared thewindow-opened-atstamp, 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
| Key | Target | Value | Purpose |
|---|---|---|---|
active-rotation | NodePool | NodeClaim name | Durable anchor + serial gate |
active-rotation-state | NodePool | draining | Phase mirror for completion outcome |
draining-at | NodePool | RFC3339 | Drain-duration anchor (§4.2) |
surge-wait | NodePool | Go duration | Surge-phase duration for completion log |
surge-path | NodePool | provisioned/absorbed | Which §3.3 path reserved the capacity |
rotation-mode | NodePool | forceful-fallback | Surge-less path marker |
window-opened-at | NodePool | RFC3339 | Observed window occurrence (§4.2) |
state | Old NodeClaim | pending/draining/failed/expired | Progress state |
started-at | Old NodeClaim | RFC3339 | readyTimeout deadline |
failed-at | Old NodeClaim | RFC3339 | Backoff anchor |
retry-count | Old NodeClaim | integer | Escalates backoff |
surge-claim | Old NodeClaim | NodeClaim name | Induced surge identification |
surge-for | Pod + frozen nodes | NodeClaim name | Rotation pairing |
do-not-disrupt | Old + surge nodes | true | Block voluntary disruption |
do-not-disrupt-owned | Old + surge nodes | true | Controller ownership marker |
cordoned | Old node | true | Controller's cordon marker |
last-failure-at | NodePool | RFC3339 | Inter-attempt pause anchor |
freeze | NodePool | RFC3339 | Suppresses rotation until time |
last-rotation-at | NodePool | RFC3339 | cooldownAfter 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 formaxUnavailable = 1active-rotation-state: written immediately beforedelete(cand). Absence = rotation never leftpending. Read by completion handler after old NodeClaim is gonedraining-at: write-once atpending → draining. The old NodeClaim'sdeletionTimestampis gone by completion — needs this anchorsurge-wait: write-once atpending → draining. The old NodeClaim (started-atcarrier) is deleted at that transitionsurge-path: write-once atpending → draining, in the same update assurge-waitand for the same reason — the predicate that derives it needsstarted-at. It qualifiessurge-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'sstatewrite 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 carryrotation-mode: stamped on anchor at forceful-fallback start. Absent = default surge. Cleared with anchor on every end pathwindow-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
WindowDeferbecause 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 earlierwindow-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
- 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
state:expiredis terminal — blocks re-selection while the claim finalizes under the forceful drainstarted-at: write-once per attempt. Cleared by the failed write (single update withstate=failed). Re-stamped on retryfailed-at: the next-attempt instant reported in therotation attempt failedlog line and theRotationFailedEvent is a snapshot under the schedule as it stands, not a lower bound: the clamp is evaluated on each read (§3.2), so extending amaintenanceWindowsentry mid-occurrence can reopen the claim earlier than the instant already reported. It is derived from thefailed-atthe failing write persisted, truncated to the second the RFC3339 annotation stores, so it names the same instant the re-selection predicate parses backsurge-claim: persisted as soon as placeholder's bind target (spec.nodeName) is observable. Cleared with the failed writesurge-for: on frozen nodes, attributes freeze to this rotation. On the Pod, pairs it for discoverydo-not-disrupt-owned: set only when the controller actually appliesdo-not-disrupt. An operator's pre-existing annotation (no marker) is never touchedcordoned: set only when the controller flipsspec.unschedulable. An operator's cordon (no marker) is never adoptedlast-failure-at:maxsemantics on crash-recovery branch prevents voiding the pause
State transitions
Transition side effects — click to expand
| From | Event | To | Side effects |
|---|---|---|---|
| (none) | selected in window | pending | write anchor (first); freeze old node; cordon old node; create placeholder |
| (none) | forceful fallback | draining | write anchor + rotation-mode + draining-at; write state=draining; delete old NodeClaim (surge-less) |
pending | each reconcile | pending | claim state=pending from none/pending (conditional, before anything else); re-assert freeze + cordon; persist surge-claim; recreate placeholder if missing (held during freeze) |
pending | surge_ready | draining | freeze surge target; write draining-at + surge-wait + surge-path; delete old NodeClaim |
pending | readyTimeout | failed | reap 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 |
pending | force-expiring | expired | claim state=expired from pending (conditional, before cleanup); emit expired once; delete placeholder; unfreeze; clear anchor |
draining | no deletionTimestamp | draining | re-issue delete (crash recovery) |
draining | drain > tGP + buffer | draining | stuck-drain gauge; gate held |
draining | NodeClaim gone | (success) | unfreeze; write last-rotation-at; emit success; clear anchor |
failed | backoff + gates pass | pending | claim state=pending from failed (conditional); started-at re-stamped by the new attempt |
failed | deletionTimestamp | expired | claim state=expired from failed (conditional); emit expired once; clear anchor |
expired | still anchored | expired | idempotent 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-forclaim is absent/not-anchored → deleted - Node markers (
surge-for, controller'sdo-not-disruptby owned marker) → removed cordonedmarker with no anchored rotation → uncordon and remove
Rules:
- An anchored NodePool is not stale — step 1 resumes it normally
failed/expiredclaims keep their annotations (backoff re-entry / terminal marker)- A
pending/drainingclaim with no anchor (impossible from any crash point) → claimstate=failedfrompending/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-statewithout 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)
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: AcceptedStatus subresource
matchedNodePools: pools this policy wins by selector specificityrotatingNodePools: of those, count with an in-flight rotationReadycondition:Accepted— valid and uncontestedInvalid— failed reconcile-time validationConflict— equal-specificity tie (§below)
Invalidtakes precedence overConflict- 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
| Rule | Behavior |
|---|---|
| Most-specific wins | Specificity = label-key constraint count |
| Equal-specificity tie | Hard error — refuses to rotate that NodePool |
| No policy matches | Not rotated (safe no-op) |
- Specificity:
matchLabelsentries +matchExpressionsentries. Empty (catch-all) selector scores 0 — loses to any keyed selector - Tie: emits
PolicyConflictWarning Event + setsnoderotation_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:
- Deletes placeholder
- Unfreezes nodes (preserving operator's own protections)
- Clears the anchor — only while it still names this rotation
- Emits
GovernanceLostWarning 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).