fix(controllers): report total member loss instead of spinning - #343
fix(controllers): report total member loss instead of spinning#343Andrei Kvapil (kvaps) wants to merge 2 commits into
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
📝 WalkthroughWalkthroughThe controller detects a latched cluster with no EtcdMember resources, marks it terminal with ChangesMember-loss terminal handling
Sequence Diagram(s)sequenceDiagram
participant Reconcile
participant Handler
participant ClusterStatus
Reconcile->>Handler: detect latched ClusterID with zero members
Handler->>ClusterStatus: set AllMembersLost conditions and ReadyMembers=0
Handler-->>Reconcile: return without requeue
Estimated code review effort: 3 (Moderate) | ~25 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@controllers/etcdcluster_controller.go`:
- Around line 312-325: Move the empty-member recovery handled by the
active-member logic before the deadline-expiry check that invokes
handleDeadlineExceeded, or explicitly exempt that state from the gate. Ensure
expired deadlines still clear the stale ClusterID, reset progress, persist
status, and requeue; add a test covering empty members with a past deadline.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 7a01a8dd-cd35-4c90-92a0-32631c09acf0
📒 Files selected for processing (2)
controllers/etcdcluster_controller.gocontrollers/etcdcluster_controller_test.go
NOT LGTMReviewed The diagnosis in the PR body is correct and well-written: The fix is not. It makes the most destructive recovery an etcd operator can perform — abandoning the cluster's identity and starting from an empty data dir — fully automatic, unsignalled, and un-opt-outable. I wrote four throwaway probe tests against this branch; all four confirmed concrete defects. Field corroboration also exists: a custom build of this branch running on a dev cluster took exactly this path and came up on fresh empty PVCs, wiping the application's data. Blocking1. Silently re-runs the immutable, documented-as-one-time
|
| Run | Members | Added as learner | Seed | Victim | Result |
|---|---|---|---|---|---|
| this PR | p6bvx, v9kr8, vtm7h | v9kr8, vtm7h | p6bvx | p6bvx | FAIL 940 s |
chore/codeowners |
9xf86, bst2x, fph75 | bst2x, fph75 | 9xf86 | 9xf86 | FAIL 940 s |
chore/codeowners re-run |
— | — | not the victim | h7gtw | PASS 250 s |
Fixed separately in #345 (pick a non-bootstrap victim). Worth noting what the flake incidentally surfaces, since it is adjacent to this PR's subject: a seed member whose data dir is destroyed never recovers — quorum among the other two holds, but the seed crash-loops forever, excluded from self-heal, with Spec.Bootstrap never cleared. That is a narrower, well-bounded gap in the same problem space, and a better target than unattended re-bootstrap.
A cluster whose EtcdMember CRs have all been removed while status.clusterID is still latched never recovers and never says so. current(0) < desired routes into scaleUp, whose endpoints derive from the empty member set, so the client factory fails with "etcdclient: no available endpoints" and the reconcile requeues every 10s indefinitely. updateStatus is never reached on that path, so Available keeps its last pre-loss value — an empty cluster advertising a healthy quorum to anything gating on it. Park in a terminal AllMembersLost condition instead: Available=False with a message naming the event and the recovery options, Progressing=False, Degraded=True, and ReadyMembers zeroed so the scale subresource stops reporting a healthy count. Recovery is deliberately left to a human. Rebuilding automatically would mean adopting a new identity on an empty data dir, or — for a cluster created via spec.bootstrap.restore, which is immutable and documented as one-time — silently re-running that restore and rolling data back to create-time state. Nothing on this path mutates clusterID, clusterToken, authEnabled, Pods or PVCs, so surviving volumes stay recoverable by hand and the evidence of what removed the members is preserved. Scope: this fires for a converged cluster, whose ProgressDeadline has been cleared — the state the endless spin was observed in. A cluster that loses its members mid-reconcile still has its deadline armed and continues to park under the existing DeadlineExceeded arm, unchanged by this commit. Assisted-By: Claude Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
14128aa to
811dc2c
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/concepts.md (1)
420-432: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Degradedsummary just below is now incomplete.Line 434 enumerates
Degraded=Trueas onlyAvailable=True/QuorumAvailableorAvailable=False/QuorumLost, buthandleAllMembersLostalso setsDegraded=True/AllMembersLost. Worth extending that sentence so alerting readers get the full set.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/concepts.md` around lines 420 - 432, Update the Degraded summary near the Progressing table to include the AllMembersLost reason emitted by handleAllMembersLost, alongside the existing QuorumAvailable and QuorumLost cases. Keep the documented behavior and alerting guidance otherwise unchanged.
🧹 Nitpick comments (1)
controllers/etcdcluster_controller_test.go (1)
1017-1082: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider a case for members that exist but are terminating.
The suite pins dormant and surviving-member exemptions but not the
DeletionTimestamp-set window, which is the boundary flagged oncontrollers/etcdcluster_controller.goLine 240. A second-reconcile idempotency assertion (no further status write, still parked) would also be cheap here.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controllers/etcdcluster_controller_test.go` around lines 1017 - 1082, The test coverage for TestAllMembersLost_ParksTerminalWithoutDialing should include EtcdMember resources that still exist but have DeletionTimestamp set, verifying this terminating-member window follows the intended exemption rather than being treated as all members lost. Add a second Reconcile assertion for idempotency, confirming the cluster remains parked with no requeue and no additional status mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@controllers/etcdcluster_controller.go`:
- Around line 240-242: Update the AllMembersLost branch in the controller
reconcile flow to require both len(active) == 0 and the raw member list length
== 0 before calling handleAllMembersLost. Preserve the existing desired and
ClusterID conditions so clusters with terminating members follow the
deletion-wait path instead of being parked.
---
Outside diff comments:
In `@docs/concepts.md`:
- Around line 420-432: Update the Degraded summary near the Progressing table to
include the AllMembersLost reason emitted by handleAllMembersLost, alongside the
existing QuorumAvailable and QuorumLost cases. Keep the documented behavior and
alerting guidance otherwise unchanged.
---
Nitpick comments:
In `@controllers/etcdcluster_controller_test.go`:
- Around line 1017-1082: The test coverage for
TestAllMembersLost_ParksTerminalWithoutDialing should include EtcdMember
resources that still exist but have DeletionTimestamp set, verifying this
terminating-member window follows the intended exemption rather than being
treated as all members lost. Add a second Reconcile assertion for idempotency,
confirming the cluster remains parked with no requeue and no additional status
mutation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: ed960b7d-6527-4dd7-addf-e6ecf39ec86b
📒 Files selected for processing (4)
controllers/etcdcluster_controller.gocontrollers/etcdcluster_controller_test.godocs/concepts.mddocs/operations.md
A member whose deletion is in flight (finalizer still running MemberRemove) is filtered out of the active set while its CR is still present, so scaling the last member down — or a self-heal replacement — would be declared a total loss mid-operation and parked terminally. Require the raw member list to be empty as well, leaving those cases to the in-flight-deletion wait that already handles them. Covered by a test that fails without the extra condition. Also extend the Degraded summary in docs/concepts.md, which enumerated only QuorumAvailable and QuorumLost while handleAllMembersLost sets Degraded=True/AllMembersLost too. Assisted-By: Claude Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
|
Thanks — the review was right on every count, and the branch has been reworked accordingly. All automatic recovery is gone; the change now only reports. What it does: parks the cluster in a terminal Against the blockers:
On the two framing questions: What deleted the members — investigated and written up: postmortem. Short version: the volumes were reclaimed as a cascade behind Is automatic the right answer — no, and that is precisely what this revision concedes. Worth adding one piece of field data for the "loud terminal condition" case: |
Timofei Larkin (lllamnyp)
left a comment
There was a problem hiding this comment.
Changes requested — direction, not implementation.
On #347 I've proposed solving the member-deletion problem at the API boundary instead of in the controllers: a ValidatingAdmissionPolicy denying DELETE on etcdmembers to anything but the operator's ServiceAccount, the garbage collector, and the namespace controller (break-glass via an explicit annotation), plus finally documenting that deleting an EtcdMember destroys its data volume. See the review there for the full argument.
If that direction goes forward, it voids this PR: with member deletion guarded at admission, "every EtcdMember gone while status.clusterID is still latched" stops being reachable through any supported path, and this branch would be a terminal condition for a state the platform prevents. I'd rather not carry both.
To be clear about what I'm not disputing: the diagnosis is correct, the report-only rework was the right call, and the test suite is solid. If the admission-guard approach is rejected on #347, I'm happy to come back to this — but it would then need one fix first: the branch can false-positive during cmd/etcd-migrate runs. The tool prefills status.clusterID before creating the EtcdMember CRs (cmd/etcd-migrate/apply_adopt.go, step 1 vs. step 2), and the members it creates carry no owner reference to the EtcdCluster (internal/migrate/adopt.go), so a reconcile landing in that window parks a freshly-adopted, healthy cluster in AllMembersLost — and since the handler returns without a requeue and the Owns(&EtcdMember{}) watch only maps owner-referenced members, nothing wakes it until the manager's resync. Either the park needs a slow-cadence requeue instead of none, or the migrator needs to stamp the same controller owner reference the operator does.
Parking this pending the outcome on #347.
|
Agreed — parking this behind #348, which implements the admission guard you proposed. If that lands, this PR is for a state the platform prevents, and I'd rather not carry both either. Two things worth recording before it goes quiet. The migrate-window false positive is real, but narrower than it looks. You're right about the ordering — What I'd keep from this, if anything. The signal was worth something beyond the deletion accident: on the affected clusters Closing thought on sequencing: #348 is small and reversible, so it should go first regardless of what happens to this branch and #347. |
Problem
An
EtcdClusterwhoseEtcdMemberCRs have all been removed — whilestatus.clusterIDis still latched — never recovers and never says so.current(0) < desiredroutes intoscaleUp(), whose endpoints derive from the (empty) member set, so the client factory fails before anything else:The reconcile requeues every 10s indefinitely; the only member-creating path is gated on
ClusterID == "", which no longer holds.updateStatusis never reached on this path, soAvailablekeeps its last pre-loss value — an empty cluster advertising a healthy quorum to anything gating on it (alerting, GitOps health, platform readiness).Why this is not a corner case
This state is reachable straight from the project's own migration path, and the field data is unambiguous about how long it can hide.
On a cluster where
cmd/etcd-migrateran, theEtcdMemberCRs of seven namespaces were subsequently removed. From that moment the operator was wedged in the loop above.managedFieldson theEtcdClusterobjects record it precisely: one entry frommanager=etcd-migrateat migration time, and then nothing until an operator with this patch touched them twelve days later. No reconcile ever landed a status write. Throughout, every one of those clusters reportedAvailable=True/QuorumAvailable, 2/3 members ready.For most of that window the etcd pods were still running and serving — the data was there, and a snapshot would have saved it. Nobody took one, because nothing indicated a problem. The loss was only discovered when a routine node reboot removed the last running pods.
A terminal condition here would have surfaced the broken state on day one, while recovery was still possible. That is the entire value of this patch: it does not repair anything, it stops a destroyed data plane from looking healthy.
Fix
Park in a terminal
AllMembersLostcondition:Available=Falsewith a message naming the event and the recovery options,Progressing=False,Degraded=True, andReadyMemberszeroed so the scale subresource stops reporting a healthy count. Idempotent and parking, matchinghandleDeadlineExceeded.Recovery is deliberately left to a human, because every automatic option is destructive: re-bootstrapping means a new identity on an empty data dir, and for a cluster created via
spec.bootstrap.restore(immutable, documented as one-time) it would silently re-run that restore, rolling data back to create-time state and then reporting healthy.Nothing on this path mutates
clusterID,clusterToken,authEnabled, Pods or PVCs — a PVC that survived owner-ref GC stays recoverable by hand, and the evidence of what removed the members is preserved. That matters: several clusters entering this state at once points at a systemic cause worth finding, and auto-recovery would paper over it.Guards. The branch requires both the active set and the raw member list to be empty, so a member whose deletion is still in flight (finalizer running
MemberRemove) leaves the cluster to the existing in-flight-deletion wait instead of being declared lost mid-operation. A dormant member is woken as before, and a single surviving member keeps the cluster in its normal degraded path.Scope. This fires for a converged cluster, whose
ProgressDeadlinehas been cleared — the state the endless spin was observed in. A cluster that loses its members mid-reconcile still has its deadline armed and keeps parking under the existingDeadlineExceededarm; that path is untouched, so deadline behaviour does not change. Both outcomes are terminal and visible — only the specificity of the reason differs.Tests
TestAllMembersLost_ParksTerminalWithoutDialing— parks instead of requeuing,Available=False/AllMembersLost,ReadyMembers=0, and asserts nothing is mutated or created:clusterID,clusterToken,authEnabledunchanged, noEtcdMembercreated, etcd never dialed. Fails onmainwithterminal state must park, not requeue; got {RequeueAfter:10s}— the bug itself.TestAllMembersLost_ArmedDeadlineStillParks— pins the scope boundary above: with an armed+expired deadline the cluster still parks andAvailabledoes not survive asTrue.TestAllMembersLost_NotTriggeredByDormantMember— a paused cluster's dormant member owns its PVC and its data; it must be woken, not declared lost.TestAllMembersLost_NotTriggeredWhileAMemberIsTerminating— the last member being deleted must not be mistaken for a total loss. Fails without the raw-list guard.TestAllMembersLost_NotTriggeredWhileAMemberSurvives— one surviving not-Ready member means degraded, not lost.Tests that must not reach etcd use
factoryFailingOnEmptyEndpoints, which errors on an empty endpoint list exactly like the real factory, so "never dialed" is asserted against production behaviour rather than a fake that always answers.go test ./...andgo vet ./...are green.Docs
docs/concepts.md:AllMembersLostadded to theAvailableandProgressingreason tables, and to theDegradedsummary.docs/operations.md: a newAvailable=False/AllMembersLostsection — how to find out what removed the members (several clusters at once = systemic cause), and the three recovery paths depending on whether PVCs, a snapshot, or neither survived.Summary by CodeRabbit