fix(controllers): derive --initial-cluster-state from phase, not from the seed - #355
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
📝 WalkthroughWalkthroughChangesSeed recovery state
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant EtcdMemberController
participant EtcdCluster
participant buildPod
participant EtcdPod
participant SelfHealTest
EtcdMemberController->>EtcdCluster: read cluster status
EtcdMemberController->>buildPod: pass clusterFormed
buildPod->>EtcdPod: set initial cluster state
SelfHealTest->>EtcdPod: wipe seed data directory
EtcdPod-->>SelfHealTest: report readiness and recovery
Possibly related PRs
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 |
fd81844 to
da5d6d9
Compare
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
Andrey Kolkov (androndo)
left a comment
There was a problem hiding this comment.
LGTM. The fix correctly reframes --initial-cluster-state as a phase question (Bootstrap && MemberID == "" && !clusterFormed) rather than a frozen identity property, so a seed that loses its data dir no longer re-bootstraps a fresh cluster.
Two safety claims verified:
MemberIDis never cleared (only assigned once at the latch), so a formed seed always forces=existingindependently.- The swallowed
clusterForerror is safe: a transient failure collapsesclusterFormedtofalse, which can only yield=newwhenMemberID == ""too — the early-bootstrap window where=newis already correct.
Coverage is complete: TestBuildPod_InitialClusterState (6-row table over all signal combinations), TestEnsurePod_FormedClusterGivesSeedExistingState, and the TestSeedDataDirLossDoesNotRebootstrap e2e. Docs updated in lockstep. build/vet/unit tests green.
Branch review — LGTMReviewed What the change doesReplaces No blocking findingsBoth safety claims the fix rests on hold:
Coverage
Docs ( Verification
Nothing found outside the diff. |
da5d6d9 to
9b6fe17
Compare
Pull Request is not mergeable
Pull Request is not mergeable
9b6fe17 to
2f82f25
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 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/etcdmember_controller.go`:
- Around line 628-647: Update the explanatory comment above clusterState in
buildPod to state that --initial-cluster-state=new is gated first by
member.Spec.Bootstrap, then by the two phase signals: empty
member.Status.MemberID and !clusterFormed. Preserve the existing code and
explicitly retain the scale-up behavior where non-bootstrap members use
"existing".
In `@docs/concepts.md`:
- Line 93: Update the derivation documentation for --initial-cluster-state,
including the “new” case at the referenced conditions section, to state that
spec.bootstrap=true is required in addition to the empty status.memberID and
absent status.clusterID conditions. Preserve the explanation that spec.bootstrap
identifies the bootstrap member while the status fields describe cluster phase.
- Line 181: Update the self-heal reference in the paragraph around “Either
signal being set...” to use the existing heading anchor `#crash-loop-self-heal`,
matching the “Crash-loop self-heal” section and other reference in the document.
In `@test/e2e/seed_rebootstrap_test.go`:
- Around line 187-226: Update the wipe-data command in wipeMemberDataDir so a
failed or incomplete rm operation causes the ephemeral container to exit
nonzero; do not let sync or ls mask the removal status. Preserve the existing
completion and exit-code validation in the waitFor callback, and ensure the
command explicitly verifies that /var/lib/etcd is empty before succeeding.
🪄 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: 27a45e03-0f47-4e86-9808-fe729b908f19
📒 Files selected for processing (6)
controllers/etcdmember_controller.gocontrollers/etcdmember_controller_test.gocontrollers/restore_initcontainer_test.godocs/concepts.mdtest/e2e/member_selfheal_test.gotest/e2e/seed_rebootstrap_test.go
2f82f25 to
335f91f
Compare
335f91f to
5b2059e
Compare
… the seed --initial-cluster-state was rendered from `spec.bootstrap`, a field set once at seed creation and never cleared. The seed's Pod was therefore built with `=new` for the entire life of the cluster rather than only for its first boot, while its --initial-cluster stayed frozen at the bootstrap value naming only itself. etcd honours the flag only on an empty data dir, so this was inert on ordinary restarts and inert on a corrupt data dir — that fails to boot either way, which is the crash-loop path self-heal already covers. It was not inert when the seed's data dir came back *empty* with the PVC binding intact: a re-provisioned volume, a blank PV restore, node-local storage lost on reimage. `=new` against a self-only --initial-cluster is a complete, internally consistent bootstrap instruction, so etcd did not error. It formed a fresh one-member cluster on the empty dir and reported healthy. That failure is quieter than the crash-loop it resembles. The Pod goes Ready, so neither self-heal trigger can see it — one needs a lost Pod, the other a not-ready container — and the <cluster>-client Service selects every member Pod with no role filter, so a share of client traffic reaches a member serving an empty keyspace while writes routed there stay invisible to the real cluster. Since etcd derives cluster and member IDs from the initial peer-URL set plus the cluster token, none of which change here, such a member returns under the same cluster ID rather than being rejected on a mismatch. Emit `=new` only while nothing yet says the cluster exists: the member has never appeared in etcd's member list (status.memberID empty) and the cluster has not latched a status.clusterID. Requiring both is strictly safer than either alone and cannot misfire — either signal being set proves the cluster formed, which makes an empty data dir data loss rather than a pending bootstrap, and `=existing` then fails loudly into the self-heal path instead of silently forking the cluster. spec.bootstrap keeps its identity role: it still anchors seed discovery before clusterID is latched, and now records which member bootstrapped without steering any ongoing behaviour. No spec writes and no Pod restarts — ensurePod never re-templates, so running seeds keep their current argv and converge to `=existing` whenever their Pod is next recreated. The restore path is unaffected: the agent's snapshot.Restore writes a complete data dir with a WAL, so etcd ignores the flag there entirely. Adds unit coverage for a flag that previously had none, and an e2e that wipes (rather than corrupts) the seed's data dir and asserts it never returns serving an empty keyspace, that it is replaced, and that data written before the wipe survives on every member. Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
5b2059e to
57d21b5
Compare
Second layer of a stack. Depends on #354, which makes the seed eligible for
crash-loop self-heal; this one makes the seed fail loudly in the case that
self-heal cannot otherwise see.
The bug
buildPodrendered etcd's--initial-cluster-statestraight offspec.bootstrap:spec.bootstrapis set once at seed creation and never cleared, so the seed'sPod was built with
=newfor the entire life of the cluster, not just forits first boot. Its
--initial-clusteris likewise frozen at the bootstrapvalue, which names only itself.
Why that is usually invisible, and occasionally very bad
etcd honours
--initial-cluster-stateonly when the data dir is empty. So it isinert on ordinary restarts, and inert on a corrupt data dir — etcd fails to
boot either way, the Pod crash-loops, and self-heal replaces the member (that is
the path #336 added and #354 extends to the seed).
It is not inert when the seed's data dir comes back empty with the PVC
binding intact: a re-provisioned volume, a PV restored blank, node-local storage
lost on a node reimage. There the two cases diverge sharply:
=existingagainst a stale--initial-cluster, failsloudly (
member count is unequal), crash-loops, and is self-healed;=newagainst an--initial-clusternaming only itself. That isa complete, internally consistent bootstrap instruction, so etcd does not
error. It forms a fresh one-member cluster on the empty dir and reports
healthy.
The second outcome is worse than the crash-loop it resembles, because it is
quiet. Two things are certain here, and one is not — worth separating.
Certain: the member goes Ready on an empty data dir. Its readiness probe is
/health, which a healthy one-member cluster answers 200.<cluster>-clientselects every member Pod with no role filter, so it immediately takes a share of
client traffic — serving reads from an empty keyspace and accepting writes the
rest of the cluster knows nothing about. Neither self-heal trigger sees any of
it: the
Status.PodUIDcheck needs a lost Pod,etcdContainerStuckneeds anot-ready container.
Certain: etcd's own guard against strangers does not fire.
EtcdServer.Processrejects a raft message whosem.Tois not the local memberID (
cannot process message to mismatch member) — the check that would normallyfence off a member from a different cluster. Member IDs are derived
deterministically (
computeMemberID= sha1 over sorted peer URLs + clustertoken;
NewClusterFromURLsMappasses a nil timestamp, so bootstrap IDs carry noentropy), and nothing about this member changed, so its ID is identical to the
one the survivors still have on file. Their messages address it correctly and go
straight into raft. Same story one level up for the cluster ID and the peer
transport's
X-Etcd-Cluster-IDcheck. The collision is precisely what removesthe loud failure.
Not established: how long the divergence lasts. Once those messages reach
raft, the surviving leader's higher term and mismatched log should pull the
member back into line, most likely via
InstallSnapshot. If so the window isshort. That is expected behaviour rather than something observed here, and it is
deliberately not what this PR rests on — a brief window is still wrong reads
served to clients, and a write acknowledged in that window is silently discarded
when the snapshot lands, which is an acknowledged-write loss rather than a stale
read.
The case for
=existingdoes not depend on the divergence being durable. Itrests on not issuing a bootstrap instruction to a member that is not
bootstrapping:
=existingfails immediately and deterministically into adesigned, tested recovery path, instead of relying on raft to repair a state the
operator should never have created.
One likely objection: the operator already randomises the cluster token, so
shouldn't the IDs differ?
deriveClusterToken(helpers.go:521) is<namespace>-<cluster>-<uid>, and its entropy is theEtcdCluster's UID — so itvaries across incarnations, which is what stops a stale PVC from a previous
same-named cluster rejoining. A member rebooting inside one incarnation has the
same object, hence the same UID, hence the same token;
spec.clusterTokeniscopied from
status.clusterTokenat creation and never rewritten afterwards (noassignment to it exists outside the two creation-time struct literals). The token
defends the boundary between clusters, not between boots.
The fix
Ask a phase question instead of an identity question. Emit
=newonly whilenothing yet says this cluster exists — two independent signals, both of which
must agree:
clusterFormediscluster.Status.ClusterID != "", read inensurePodvia theexisting
clusterForhelper and passed in, which keepsbuildPoda purefunction of its arguments.
Requiring both is strictly safer than either alone and cannot misfire:
either signal being set proves the cluster formed, which makes an empty data dir
data loss rather than a pending bootstrap — and
=existingfailing loudly isprecisely the outcome we want, because #354 then replaces the member.
spec.bootstrapkeeps its identity role. It still anchors seed discovery beforeclusterIDis latched, and it remains a permanent record of which memberbootstrapped; it simply no longer steers ongoing behaviour. Clearing the field
was the obvious alternative and was rejected: it records phase in spec (where
status.clusterIDalready answers the question with no write at all), it iscorrect only if the clearing write is ordered right — a seed cleared before its
Pod ever starts would boot
=existingagainst a self-only--initial-clusterand never form the cluster — and it destroys the only record of which member was
the seed, which still explains that member's otherwise-anomalous
--initial-cluster.Why it is safe to roll out
ensurePodnever re-templates anexisting Pod, so running seeds keep their current argv and converge to
=existingwhenever their Pod is next recreated. Nothing moves on upgrade.internal/agent/restore.go:140callssnapshot.RestorewithInitialCluster/InitialClusterToken, producing acomplete data dir with a WAL — etcd ignores
--initial-cluster-stateentirelyon that path.
it returns with
=new, so a PVC that lost data during the pause wouldsilently re-bootstrap.
=existingmakes that loud.Residual, deliberately not closed: a seed that formed the cluster while
both
memberIDandclusterIDare still unset — a seconds-wide window — andloses its data dir inside it would still re-bootstrap. That is today's behaviour
rather than a regression; closing it fully needs a persisted "has booted once"
marker, which is not worth the field.
Tests
TestBuildPod_InitialClusterState— table over the six meaningful states.Note that nothing asserted this flag before, so this closes a live coverage
gap rather than adjusting existing expectations. The three seed-with-a-formed-
cluster rows fail against the old derivation.
TestEnsurePod_FormedClusterGivesSeedExistingState— the integration half:proves
ensurePodactually reads the parent cluster'sclusterIDrather thanjust accepting a bool. Also fails against the old derivation.
TestSeedDataDirLossDoesNotRebootstrap(e2e, new file) — wipes rather thancorrupts the seed's data dir, because empty-vs-corrupt is exactly where the two
paths diverge and corruption only reaches the already-covered crash-loop. It
writes a sentinel key through the client Service first, then asserts that the
wiped seed is replaced and that the sentinel survives on every ready
member afterwards — checking all of them, so a single member answering out of an
empty store is caught rather than averaged away by whichever endpoint the Service
happens to pick.
Those two are the load-bearing assertions, and both are deterministic: a seed
that re-bootstrapped stays Ready and is therefore never replaced, so the wait
times out regardless of what raft does afterwards. The test additionally probes
the seed for the sentinel while waiting; catching it serving without the sentinel
fast-fails with the precise diagnosis instead of burning the full 15-minute
budget. That probe is opportunistic — missing it proves nothing, since the
surviving leader may already have snapshotted the member back into consistency —
but it cannot produce a false failure, as it fires only on a successful read
returning something other than the value we wrote.
The rest of the diff is mechanical:
buildPodgained a parameter, so its ~24existing test call sites pass
false. Two namespace-bound e2e helpers weregeneralised (
readyMembersIsIn,seedMemberIn) so the new suite can use its ownnamespace; the existing wrappers are unchanged for their callers.
Docs
concepts.mdreplaces the "Known gap: a seed that re-bootstraps" section addedin #354 with "Bootstrap state is a phase, not a member", documenting the derivation
and keeping the failure mode on record as the reason for it.
Verification
go build ./...,go vet -tags e2e ./test/e2e/andgo test ./controllers/are green. Both new unit tests were confirmed to fail against the previous
derivation before the fix was applied.
The e2e has now run on CI's kind cluster and passes:
--- PASS: TestSeedDataDirLossDoesNotRebootstrap (246.70s), in a suitefinishing in 1018s against a 45m budget. The
kamaji-datastorejob runs thewhole
test/e2epackage unfiltered despite its name, so the new test iscovered there.
The wipe helper's verification step has since been tightened: the ephemeral
container's exit code now reflects whether the data dir is actually empty
afterwards. Previously the final
ls -Aalways exited 0, so a partial wipewould have been reported as success and surfaced much later as a seed that
simply starts fine. The check also fails closed when the data dir cannot be
read at all, which an emptiness test alone treats as "empty".