Broker CR reconciler#1638
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
4a67017 to
fee0947
Compare
Introduces the cluster.redpanda.com/v1alpha2 Broker custom resource: a per-broker unit of pod + storage management with an explicit decommission intent field, a persisted networkIndex, and pool-aware pod naming (<cluster>-<ordinal> for the default pool, <cluster>-<pool>-<ordinal> for named pools). PodOutdated() defines the rotation contract: a pod drifts when its config checksum or restart-requiring cluster-config version annotation no longer matches the desired pod template. Adds the Broker status conditions (Ready, PodScheduled, StorageBound, BrokerRegistered, ConfigOutdated, Quiesced, Stable) to statuses.yaml and the roll-grant lease primitives to the feature package (RFC Q4): the grant is <config-checksum>/<unix-deadline> where the checksum doubles as a generation so stale grants are rejected, and the TTL is a safety valve against controller restarts, not a pacing mechanism. The broker-deletion-policy annotation (cascade|orphan) declares what happens to pods and PVCs on cluster teardown. The Broker CRD registers under the experimental group (opt-in via `operator crd --experimental`). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the per-broker controller from the Broker CRD RFC: the "dumb executor" side of the separation of concerns. A sub-reconciler chain drives each Broker through PVC creation/adoption, unconditional pod-ensure (RFC Q5 — creation is never gated so bootstrap parallelizes and operator restarts can't strand a broker), PV-affinity remediation for dead nodes, roll-grant- gated pod rotation with leadership drain via maintenance mode, admin-API registration with node_id continuity verification (a rotated pod must rejoin under the same identity; a fresh identity is surfaced as Stuck + IdentityChanged rather than silently adopted), and decommission driven solely by the explicit spec.decommission intent, with recommission when the intent is unset mid-flight and the last-broker guard blocking both the spec and deletion paths (RFC Q2). Disruptive actions require a roll-grant issued by an owning cluster controller (not part of this change); this controller only consumes and never writes the annotation. Deleting a Broker CR without decommission intent releases its pod and PVCs while the owning cluster is alive; on cluster teardown the propagated broker-deletion-policy decides between cascade (default) and orphan, with any uncertainty resolving to orphan. Status reporting recomputes all conditions from the current pass — Ready, PodScheduled, StorageBound, BrokerRegistered, ConfigOutdated, Quiesced and the Stable roll-up — writes only on change, and classifies stuck pods (unschedulable, crash loops, image pull failures). Registered behind the --enable-broker flag (default false); no other controller is wired to it yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the full lifecycle against a real Redpanda cluster on k3d: pod adoption from an orphan-deleted StatefulSet, ungated pod creation, grant-gated rotation (and refusal to rotate without a grant), spec-driven decommission to completion with pod/PVC cleanup, decommission-on-delete with explicit intent, raw deletion releasing pod and PVCs, slot revival after clearing a completed decommission, the last-broker guard, finalizer behavior for gone/foreign pods, and dead-node PV-affinity remediation (scoped to agent nodes — deleting the k3d server would take down the API server for the whole suite). testenv exposes the underlying k3d cluster for the node-manipulation tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One end-to-end scenario against a real cluster: pods are adopted from an orphan-deleted StatefulSet without a restart (adoption and pod-ensure are not grant-gated), a targeted roll-grant rotates exactly one broker onto an updated pod template, and an explicit decommission drains the broker out of the cluster. The shared operator runs with --enable-broker; Broker CRs are created by the test itself, standing in for the owning cluster controller that arrives in a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fee0947 to
3426d2a
Compare
RafalKorepta
left a comment
There was a problem hiding this comment.
Review panel — Broker CR feature stack (4 commits)
Reviewed b7827ed4 → 3426d2ae with a four-reviewer panel per commit (staff-eng correctness/design, docs, security, and a Codex adversarial pass). Findings are deduplicated across reviewers and sorted by severity.
Framing: this is an experimental feature gated behind --enable-broker (default false) with no owning cluster controller wired up yet, so none of this is reachable in a default deployment. Severities are "before this feature ships / gets enabled," not "live production regression."
Verdicts by commit
| Commit | staff-eng | docs | security | Codex |
|---|---|---|---|---|
b7827ed4 CRD API + flags |
approve-with-nits | approve-with-nits | approve-with-nits | request-changes |
98bf2813 reconciler |
request-changes | approve-with-nits | approve-with-nits | request-changes (blocker) |
68efddcb integration tests |
approve-with-nits | approve-with-nits | approve | request-changes |
3426d2ae acceptance tests |
approve-with-nits | approve-with-nits | approve | request-changes |
🔴 Blocker
B1. Admin-API calls hardcode LocalCluster while the controller reconciles provider clusters — operator/internal/controller/redpanda/broker_controller.go:672,701,928,1032,1041
The For(&Broker{}) watch engages provider clusters (WithEngageWithProviderClusters(true), lines 76–77). K8s reads/writes correctly use r.Manager.GetCluster(req.ClusterName) (line 142), but every admin-API call goes through RedpandaAdminClient(ctx, broker), which is hardcoded to mcmanager.LocalCluster (operator/pkg/client/factory.go:340). Failure: a Broker reconciled from a provider cluster manages Pods/PVCs in that provider cluster but issues register / decommission / maintenance-mode / node-id-resolution against the local cluster's Redpanda — cross-cluster mistargeting, potentially decommissioning/rolling the wrong cluster. Fix: thread req.ClusterName and call the already-existing RedpandaAdminClientForCluster(ctx, broker, req.ClusterName).
🟠 Major
M1. Decommission ordering / nil BrokerID → broker stalls in Decommissioning forever — broker_controller.go:396-403,463-471 (flagged independently by staff-eng and Codex)
If spec.decommission=true is set while Status.BrokerID is nil, reconcileBrokerRegistration early-returns (never resolves the ID) and reconcileDecommission only sets phase, never calling the admin API. The broker keeps running as a full cluster member while the CR reports Decommissioning indefinitely. The deletion path already resolves the ID live (line 775-782); the spec path should too.
M2. PV-affinity remediation & pod rotation run before decommission and don't skip decommissioning brokers — broker_controller.go:319-345,347-386 (staff-eng + Codex)
These gate only on outdated+grant, not Spec.Decommission. A decommissioning broker that goes outdated (or has a Pending PV-affinity pod) gets its pod deleted / storage remediated mid-drain — clearing Status.BrokerID and interrupting partition movement. The sibling reconcilers (reconcilePVCs, reconcilePod, reconcileBrokerRegistration) all early-return on decommission; these two should too.
M3. BrokerDeletionPolicy.Parse accepts any string → typo silently falls to destructive cascade (data loss) — operator/pkg/feature/flags.go:100 (Codex + re-run staff-eng, independently)
Parse is the identity function; the consumer checks == "orphan" exactly (broker_controller.go:870). A user setting orphaned/Orphan/retain to preserve data instead hits the cascade branch → finalizer removed without stripping PVC ownerRefs → GC deletes the data, opposite of intent, no error. Fix: validate against {cascade,orphan} so unknown values degrade to the documented default via the existing Get→Default error path.
M4. Raw Broker deletion with pod already gone removes finalizer without releasing PVC ownerRefs — broker_controller.go:756-760,805-819 (Codex)
Violates the raw-delete self-heal contract: on raw CR deletion when the pod is missing, PVCs keep their controller ownerRef, so CR deletion cascades and GC deletes them → data loss. Release PVCs even when the pod is absent.
M5. Orphan-pod adoption overwrites live checksum annotation with the desired value — broker_controller.go:270-277, operator/api/redpanda/v1alpha2/broker_helpers.go:43-49 (Codex)
Adoption stamps desired rotation annotations onto the existing pod; PodOutdated only compares annotations, so a stale pod adopted after raw deletion is marked current and never rotated, while status reports synced. Preserve live annotations during adoption.
M6. resolveBrokerID only handles the FQDN-prefix address form — broker_controller.go:1040-1056 (Codex)
It does strings.Split(InternalRPCAddress, ".")[0] == podName, missing host:port, bare-host, and pod-IP fallback that existing controller code handles. In bare-IP / non-FQDN advertise modes, a ready broker never gets a BrokerID, so registration stays unverified and decommission stalls. Reuse the existing brokerID-for-pod logic (net.SplitHostPort, IP fallback, ambiguity handling).
M7. PodName input-validation gaps produce collisions / malformed names — broker_helpers.go:27-36 (Codex + re-run staff-eng + docs)
Two related hazards, neither validated (no webhook/CEL): (a) networkIndex is optional and nil→0, so a Broker with networkIndex:0 and one omitting it both resolve to <cluster>-0 → pod-ownership collision; (b) on the NodePool clusterRef path, a missing cluster.redpanda.com/cluster-name label yields -<pool>-<index> (leading dash) → invalid DNS name, pod create rejected, error loop. Fix: make networkIndex required+Minimum=0, and validate/resolve the cluster name instead of reading a bare label.
M8. --unbind-pvcs-after silently gates the advertised dead-node PV remediation, defaults to 0, undocumented — operator/cmd/run/run.go:204 (docs)
The changelog advertises "dead-node PV remediation" as a Broker capability, but remediatePVAffinity no-ops when UnbindPVCsAfter <= 0 (the default). An operator who sets --enable-broker but leaves --unbind-pvcs-after at 0 gets a broker stuck Stuck/requeueing forever with no hint. Flag help & chart README still describe it as PVCUnbinder-only. Document the prerequisite.
M9. Grant-gated rotation is never negatively tested — the gate could be fully broken and every test still passes — acceptance/features/broker-crd-adoption.feature:14, broker_controller_test.go:471-492,526-529 (staff-eng + Codex, across both test commits)
The acceptance scenario only makes one broker outdated and grants it, so "outdated+granted→rotates" and "gate ignored→rotates" are observationally identical — hasValidRollGrant() could return true always and stay green. setupBrokerCluster also grants every broker a 30-min roll-grant, so TestPVAffinityRemediation's "once granted" and TestPodRotationWithoutGrant don't exercise refusal either. Add a negative case: outdated without grant, assert the pod does not rotate (stable UID/checksum via Consistently) before granting.
M10. TestPVAffinityRemediation deletes a node on the shared k3d cluster and may reschedule onto a node lacking the imported image — broker_controller_test.go:574,650 (staff-eng + Codex)
Node-deletion on SharedClusterName "testenv" can strand pods/PVCs of other integration packages (pkg/k3d/k3d.go explicitly warns against this; pvcunbinder_test.go uses a dedicated cluster). Separately, CreateNode doesn't re-import localhost/redpanda-operator:dev, and soft anti-affinity prefers the empty new node → sidecar ImagePullBackOff → waitForPhase(Running) times out. Use a dedicated cluster and/or re-import images onto the new node.
M11. Acceptance adoption steps copy the live pod spec (incl. nodeName) and don't wait for StatefulSet teardown — acceptance/steps/broker.go:33,79 (Codex)
(a) createBrokerCRsForCluster uses Spec: pod.Spec → replacement pods pinned to the original nodeName; if that node is gone in CI, scheduling times out. The integration helper clears NodeName (broker_controller_test.go:724); the acceptance path doesn't. (b) pauseReconciliation+orphanDeleteStatefulSet don't wait for the StatefulSet to stay NotFound, so the Redpanda controller can recreate it and re-adopt pods, failing the broker-count assertions. Port the integration guard over.
🟡 Minor
DeadNodePVCsflips reclaim policy of PVs backing externally-managedExistingClaims—operator/internal/controller/pvcunbinder/pvcunbinder.go:1218(security). It patches PV reclaim toRetainon claims the controller then explicitly declines to delete; an admin'sreclaimPolicy=Deletelocal PV silently becomesRetain, leavingReleasedvolumes+data behind. FilterExistingClaims before the Retain patch. (Reachable only with--enable-broker+--unbind-pvcs-after>0+ grant.)reconcilePVCAdoptionre-adoptsExistingClaimsduring decommission —broker_controller.go:290(staff-eng). Re-sets ownerRef right before the completion branch deletes them; wasteful, inconsistent with siblings. Early-return on decommission.BuildPodshares the Broker's maps/slices with the returned Pod (no deep copy) —broker_helpers.go:52(re-run staff-eng). Latent: any caller mutating the returned pod corruptsbroker.Spec.PodTemplate. Not triggered today (caller only sets ownerRef+Create). DeepCopy the spec/maps.- Raw-deletion test asserts only on the pod, not PVC release —
broker_controller_test.go:364(docs). Comment + commit message claim "PVCs released (ownerRefs stripped)" but nothing Gets a PVC; a regression that stopped stripping PVC ownerRefs stays green. Add the PVC assertion or narrow the wording. - Finalizer tests don't deterministically hit the branches they document —
broker_controller_test.go:189,206(staff-eng + Codex).TestFinalizerPodNotOwned(ownerless pod gets adopted → exercises release, not the foreign-pod rollback) andTestFinalizerPodGone(pod-ensure recreates a pod first). Give the pod a foreign controller ownerRef / ensure absence before delete. - "Adopted without a restart" is not asserted —
broker-crd-adoption.feature:11(staff-eng). A delete-and-recreate regression would still report 3 brokers and pass. Capture pod UID/creationTimestampbefore adoption and assert unchanged. - Admin client leaked on every poll iteration —
acceptance/steps/broker.go:237(Codex). NewRedpandaAdminClientinsideEventually, never closed, ~60 polls over 5 min.defer admin.Close()or hoist outside the loop. - Heavy cloud/JWT dep chain pulled into the acceptance module for 3 trivial helpers —
acceptance/steps/broker.go:28(staff-eng). Importingoperator/pkg/featuredrags inlestrrat-go/jwx, secp256k1, dataplane/gatekeeper, etc. (~8 new indirect deps). RelocateRollGrantkey/format/TTL to a leaf package or inline the formatter. - Unrelated console
v3.7.0→v3.8.0golden bump bundled into the acceptance commit —charts/redpanda/testdata/template-cases.golden.txtar(staff-eng + re-run docs). Stale straggler from the console release;task generateswept it up. Harmless but muddies the diff — split out.
⚪ Nits
PodOutdated/BuildPod(the "rotation contract") have zero direct test coverage —broker_helpers_test.goonly testsPodName.- Commit messages say condition
ConfigOutdated; the actual condition isConfigSynced(Outdated is only a reason) — appears in 3 of the 4 commit messages. SkipNameValidation: truebroadened onto the pre-existing NodePool & Redpanda controllers — hides accidental double-registration; add a rationale comment.clustersRBAC grantsget;list;watchbut onlygetis used —broker_controller.go:47. Narrow toget.reconcileDelete/deletionPolicydoc comments describe self-heal & policy propagation that depend on the not-yet-implemented owning cluster controller, phrased as present-tense guarantees.allBrokerCRsRunning(and the cleanup closure) ignore theclusterNamearg and operate on the whole namespace — fine for@serial, misleading for reuse.b7827ed4commit message attributes thedecommissionfield &networkIndexto itself, but they pre-date it (#1631).
Bottom line
The feature is carefully built and well-documented internally, and it's safely dark by default. But before it's wired to an owning controller and enabled, I'd treat B1 (wrong-cluster admin ops), M1–M4 (decommission stalls + two data-loss paths), and M9 (the headline safety gate has zero real coverage) as must-fix. The strongest signal is convergence: B1, M1, M3, M9, M10 were each flagged independently by two or more reviewers.
🤖 Generated with Claude Code — 4-reviewer panel (staff-eng, docs, security, Codex adversarial) per commit.
M1: a decommission requested while Status.BrokerID is nil never started — reconcileBrokerRegistration skips decommissioning brokers, so the ID was never resolved and reconcileDecommission only relabeled the phase. Resolve the ID live in the spec path, mirroring the deletion path.
M2: reconcilePVAffinity and reconcilePodRotation gated only on outdated+grant, so a decommissioning broker could get its pod rotated or its storage remediated mid-drain, interrupting partition movement. Early return on Spec.Decommission like the sibling reconcilers.
…re flags
M3: BrokerDeletionPolicy.Parse was the identity function while the consumer
compares against "orphan" exactly, so any typo ("orphaned", "Orphan",
"retain") silently selected the destructive cascade branch. Parse now
validates against {cascade, orphan} case-insensitively; unknown values error
and Get falls back to the documented default with a logged complaint.
M4: raw CR deletion with the pod already gone removed the finalizer without releasing PVC ownerRefs, so the deletion cascaded and the GC destroyed the data — violating the raw-delete self-heal contract. The pod-absent branch now applies the same deletion-policy decision as the pod-present path; releaseBrokerResources accepts a nil pod.
M5: orphan-pod adoption unconditionally stamped the DESIRED config checksum onto the live pod, so a stale pod re-adopted after a raw CR deletion was marked current and never rotated while status reported synced. Adoption now stamps the checksum only when the pod carries none (the migration case, where preconditions verified the pod runs the desired config); an existing live checksum is preserved so PodOutdated can queue the rotation.
M6: resolveBrokerID only matched the per-pod-FQDN advertised-address form, so host:port / bare-host / pod-IP advertise modes never resolved an ID — registration stayed unverified and decommission stalled. Rewritten as resolveBroker: port-stripping, first-label / raw-host / pod-IP matching with ambiguity refusal (mirroring the v2 controller), returning the full membership entry. Identity ADOPTION additionally requires an active+alive entry: after a decommission the membership list can briefly retain the dead predecessor under the same reused pod name, and adopting that id poisons the node_id continuity check permanently.
…re flags M7: PodName had two unvalidated hazards. (a) networkIndex was optional and nil defaulted to 0, so a Broker omitting it collides with an explicit networkIndex: 0 on the same pod name — the field is now required with Minimum=0 (CRD regenerated). (b) A NodePool-referenced Broker missing the cluster-name label produced "-<pool>-<n>" — an invalid DNS name rejected on every pod create in an error loop; the reconciler now surfaces that as Stuck until the label convention gets CEL/webhook enforcement.
M8: the changelog advertises dead-node PV remediation as a Broker capability, but remediatePVAffinity silently no-ops at the default --unbind-pvcs-after=0 and the flag help described it as PVCUnbinder-only — an operator enabling --enable-broker alone gets a permanently Stuck broker with no hint. The flag help, the disabled-path log line, and the changelog entry now name the prerequisite.
M9 (integration half): the roll-grant gate had no negative coverage — setupBrokerCluster blanket-granted every broker, and TestPodRotationWithoutGrant only asserted ConfigSynced=False, which is set BEFORE a rotation completes, so a gate that always allowed rotation stayed green. Grants are now opt-in in the fixture; the rotation test asserts the ungranted outdated pod keeps its UID (require.Never), then grants and asserts the rotation completes; TestPVAffinityRemediation starts ungranted so its Stuck wait proves remediation refusal.
M9 (acceptance half): the adoption scenario granted the only outdated broker immediately, so "outdated+granted rotates" and "gate ignored, rotates anyway" were observationally identical. A new step asserts the outdated pod keeps its UID for a fixed window BEFORE the grant is issued.
M10: TestPVAffinityRemediation deleted a node on the SHARED k3d cluster — serializing within this suite is not enough because test packages run concurrently and other packages testenvs share that cluster; their pods and node-pinned PVCs would be stranded (pkg/k3d explicitly warns against this). The test now runs on a dedicated k3d cluster (env construction factored into newEnv with a cluster-name option, following the pvcunbinder pattern), which also let the replacement-node restoration cleanup be dropped. Additionally, the replacement node created mid-test now gets the operator and Redpanda images re-imported — k3d node create starts empty and soft anti-affinity prefers the new node, so the sidecar would ImagePullBackOff and the recovery wait would time out.
M11: (a) createBrokerCRsForCluster copied the live pod spec verbatim into the Broker pod template — including NodeName, which pins every replacement pod to the original node; if that node is gone, scheduling times out. The node name is now cleared, mirroring the integration helper. (b) orphanDeleteStatefulSet did not wait for the deletion to settle, so a racing pre-pause reconcile could recreate the StatefulSet and re-adopt the pods, corrupting the broker-count assertions; the step now fails fast if the STS does not stay gone.
Review minors (reconciler side): - DeadNodePVCs no longer Retain-patches PVs backing ExistingClaims the caller then declines to remediate: exclusion happens up front, so an admin-set reclaimPolicy=Delete is not silently overridden (security). - reconcilePVCAdoption early-returns on decommissioning brokers instead of re-adopting claims the completion branch is about to delete. - clusters RBAC narrowed to get (list/watch unused); roles regenerated. - SkipNameValidation rationale comment; self-heal doc comments rephrased to future tense — the owning cluster controller is not wired up yet.
…re flags Review minors (API side): - BuildPod deep-copies the pod template: its maps and PodSpec shared memory with the (often informer-cached) Broker object, so a caller mutating the built pod would corrupt the CR. Latent today, but cheap to fix. - Direct unit coverage for the rotation contract: PodOutdated (both rotation keys, missing-annotation drift, unset-desired-key semantics) and BuildPod (isolation from the template). - BrokerDeletionPolicy propagation comment rephrased to future tense.
Review minors (integration tests): - TestFinalizerRawDeletionReleases now also asserts the PVCs survive with the CR ownerRefs stripped — the test claimed the release contract but never looked at a PVC, so a regression that stopped stripping PVC ownerRefs stayed green. - TestFinalizerPodNotOwned gives the pod a FOREIGN controller ownerRef: an ownerless pod was adopted by the reconciler before the deletion, so the test exercised the release path instead of the rollback branch it documents. - TestFinalizerPodGone makes pod creation impossible (invalid container name): pod-ensure is unconditional, so the pod-gone branch was raced rather than deterministically hit.
Review minors (acceptance): - The adoption scenario now snapshots pod UIDs before the migration and asserts they are unchanged after — "adopted without a restart" was claimed but a delete-and-recreate regression would still have shown 3 Running brokers and passed. - clusterAdminAPIShouldShowBrokers closes the admin client it builds on every poll iteration (previously leaked ~60 clients over a full wait). - allBrokerCRsRunning and the createBrokerCRsForCluster cleanup are scoped to the cluster-name label instead of the whole namespace.
M6 refinement, found by the integration suite: plain ambiguity-refusal in resolveBroker wedges PV-remediation recovery forever — the dead predecessor entry lingers in the membership list under the SAME reused pod name until ghost decommissioning, so the resolver never returned a match and identity adoption never happened. At most one live process can advertise the address at a time, so a unique active+alive match among ambiguous candidates is authoritative. A lone dead entry remains un-adoptable, preserving the original aliasing fix.
TestFinalizerPodNotOwned gave the pod a fabricated foreign ownerRef UID; the garbage collector treats a reference to a non-existent owner as an orphan and deleted the pod out from under the test. The foreign owner is now a real object (ConfigMap).
Review follow-up (Paweł): resolveBroker returned (nil, nil) for the no-match case — an antipattern with a (value, error) signature, and nil is the COMMON case while a broker is joining, so a future call site that only checks err would panic in steady state rather than in tests. The signature is now (resolved, found, err) so every caller branches on found explicitly; ambiguous matches report found=false, same contract as no match.
Review follow-up (Paweł, amends M8): dead-node PV remediation no longer stays disabled when --unbind-pvcs-after is left at its zero default — SetupBrokerController honors the flag when set and otherwise falls back to a 5m default of its own. Remediation only fires for pods stuck Pending on PV node affinity whose Node object is gone from the API, and is roll-grant gated on top, so default-on is safe; a TODO notes the dedicated --broker-unbind-pvcs-after flag for the GA config surface. Flag help and changelog updated accordingly.
Signed-off-by: Paweł Bojanowski <pawel.bojanowski@redpanda.com>
|
Thanks for the thorough panel review @RafalKorepta — every finding is addressed below, one fixup commit per finding (they'll be autosquashed into the 4-commit series before merge, preserving these bodies). Verified with the unit suites, a green acceptance run of the broker features, and the full integration suite (which caught two bugs in my first M6 fix — noted inline). 🔴 B1 — admin-API calls hardcoded 🟠 M1 — decommission with nil 🟠 M2 — remediation/rotation not gated on decommission → 🟠 M3 — 🟠 M4 — raw delete with pod gone skips PVC release → 🟠 M5 — adoption overwrites live checksum → 🟠 M6 — 🟠 M7 — 🟠 M8 — 🟠 M9 — no negative roll-grant coverage → 🟠 M10 — node deletion on the shared k3d cluster → 🟠 M11 — acceptance adoption steps → 🟡 Minors → Deferred, deliberately: the Also, once you approve, I'll squash these commits. |
operator: Broker CRD and reconciler
Introduces the
Brokercustom resource and a fully functionalBrokerReconciler: per-broker pod/PVC lifecycle as a replacement for StatefulSet-managed brokers, per the Broker CRD RFC. It supersedes the log-only stub merged in #1631.This PR deliberately contains only the Broker controller side. The Cluster-controller side — roll-grant issuance, STS→Broker migration/rollback with safeguards, and central-config restart marking — is a follow-up PR, so this one can be reviewed standalone.
Everything stays opt-in: the CRD is only installed with
crd --experimental, and the controller only runs with--enable-broker(default false). No behavior changes for existing installations.What the reconciler does
Each
BrokerCR manages exactly one pod and its PVCs. The reconcile chain runs: PVC ensure → pod ensure/adoption → PVC adoption → PV-affinity remediation → pod rotation → registration verification → decommission, followed by an unconditional status sync.Roll-grants (RFC Q4) — the Broker controller is a consumer only of the
operator.redpanda.com/roll-grantannotation (<config-checksum>/<unix-deadline>). Disruptive actions (pod rotation, PV remediation) require a valid grant whose checksum matches the desired pod template; the TTL is a restart-safety valve. Grant issuance (single writer, serialized rolls) belongs to the cluster controller in the follow-up.Pod ensure is not grant-gated (RFC Q5) — creation is non-disruptive, bootstrap needs all pods in parallel, and an expired grant must never strand a broker between rotation's delete and recreate.
Rotation — a pod is outdated when its config-checksum or restart-requiring cluster-config-version annotation drifts from the desired template (the
kubectl rollout restartpattern; the controller never patches pods). With a grant, the broker is drained via maintenance mode (leadership drain polled at 10s so serialized fleet rolls don't crawl), then the pod is deleted and recreated from the template.Identity continuity (RFC rolling step 3) — registration is re-verified via the admin API on every pass with a ready pod. A pod that re-registers under a different
node_id(data dir lost) is refused: the conflict is surfaced asBrokerRegistered=False/IdentityChangedand the broker goesStuckfor an operator decision, instead of silently adopting the new identity.Decommission (RFC Q2) — only ever runs on explicit
spec.decommission: true, never on raw CR deletion. Includes a last-broker guard (blocks withStuck), recommission when the intent is unset mid-flight, a sticky terminalDecommissionedphase, and pod/PVC cleanup on completion.Deletion semantics (RFC Q2) — a finalizer distinguishes intents: deletion with
spec.decommissiondecommissions first and blocks while guarded; raw deletion while the owning cluster is alive releases the pod and PVCs (ownerRefs stripped — the broker keeps serving, accidental deletion self-heals); during cluster teardown the propagatedoperator.redpanda.com/broker-deletion-policyannotation decides cascade (default) vs orphan. On any uncertainty the answer is orphan — releasing is recoverable, destroying data is not.Dead-node PV remediation — when a pod is stuck Pending on volume node-affinity against a node that no longer exists (past
--unbind-pvcs-after, and only with a grant), the affected PVs are patched toRetain, PVCs and pod deleted, and everything recreated on a live node. The controller waits for the old PVC to fully terminate before recreating — recreating early pins the Terminating PVC viapvc-protectionand deadlocks permanently.ExistingClaimsare never remediated (externally owned; admin must handle). The PV identification logic is exported from the pvcunbinder asDeadNodePVCs.Adoption — orphaned pods (e.g. from an orphan-deleted StatefulSet) and
spec.storage.existingClaimsPVCs are adopted via controllerRef, enabling the STS→Broker migration path without restarting brokers. Pods owned by another controller put the broker in shadow mode (Pending).Status — conditions are produced by the generated
statuses.NewBroker()helpers fromstatuses.yaml:Ready,PodScheduled,StorageBound,BrokerRegistered,ConfigSynced, withQuiescedand theStableroll-up derived by the generator. A pending rotation makes the broker not-StableviaConfigSynced=False. Status writes are skipped when nothing changed (RFC Q11).status.phaseprovides the coarse view:Provisioning,Running,Stuck,Decommissioning,Decommissioned.Pod naming — pods keep StatefulSet ordinal names:
<cluster>-<ordinal>for the default pool,<cluster>-<pool>-<ordinal>for named pools. WhenclusterRefnames aNodePool(which carries the pool name, not the cluster), the cluster half comes from the requiredcluster.redpanda.com/cluster-namelabel — every Broker creator must set it.Commits
BrokerCRD types and helpers (PodName,PodOutdated,BuildPod), Broker conditions instatuses.yaml+ generated helpers, roll-grant/deletion-policy feature flags with parse/format round-trip tests, generated CRD/deepcopy/applyconfig/docs.--enable-brokerwiring,DeadNodePVCsextraction from pvcunbinder, RBAC.BrokerControllerSuiteon a real k3d cluster: bootstrap, adoption, grant-gated rotation (with and without grant), identity continuity, decommission + last-broker guard + revive-after-decommission, finalizer paths (decommission / raw-deletion release), PV-affinity remediation with a real node kill (runs serially — it deletes a shared k3d node — and always restores the node via cleanup, even on failure).broker-crd-adoption.feature: adopt pods from an orphan-deleted STS on a live cluster, verify admin-API membership, grant-gated env rotation, decommission down to 2 brokers.Verification
golangci-lint(0 issues), and chart golden tests pass; generated files are in sync withtask generate.Out of scope / follow-up
The companion branch adapts the vectorized Cluster controller to issue roll-grants (single writer), orchestrate STS→Broker migration and rollback behind verification safeguards (rollout complete, cluster healthy, all pods ready), and mark Brokers for central-config restarts via the pod-template annotation. It lands as a separate PR once this one merges.
🤖 Generated with Claude Code