Skip to content

test(e2e): pick a non-seed victim in the self-heal test - #345

Closed
Andrey Kolkov (androndo) wants to merge 1 commit into
mainfrom
fix/selfheal-e2e-victim-must-not-be-seed
Closed

test(e2e): pick a non-seed victim in the self-heal test#345
Andrey Kolkov (androndo) wants to merge 1 commit into
mainfrom
fix/selfheal-e2e-victim-must-not-be-seed

Conversation

@androndo

@androndo Andrey Kolkov (androndo) commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

TestPVCMemberCrashLoopSelfHeal fails roughly one run in three, for a reason
built into the test rather than into the operator. It picks its victim by index
and can land on the bootstrap seed, which is permanently excluded from the
self-heal path — so the test waits out its full 15-minute timeout for a deletion
that is by design never coming. This makes the victim selection explicit.

Root cause

The test chose original[0]:

original := selfHealMembers(ctx, t)   // kube.List → items sorted by name
victim := original[0]

List returns items name-sorted, and member names are apiserver-assigned random
suffixes (etcd-p6bvx, etcd-v9kr8, …). So original[0] is simply whichever
member's random suffix sorts first — the seed in about a third of runs.

The seed can never be self-healed. etcdContainerStuck's caller gates on it:

// controllers/etcdmember_controller.go:1058
if !member.Spec.Bootstrap &&
    member.Spec.Storage.Medium != lll.StorageMediumMemory &&
    etcdContainerStuck(pod) &&
    r.clusterHasQuorumWithout(ctx, member) {

and Spec.Bootstrap is set once, at member creation
(controllers/etcdcluster_controller.go:463), and never cleared. When the victim
is the seed the member is therefore never deleted, and the test burns its whole
15m budget before failing.

Observed across three consecutive E2E runs — the seed is the member that was
not added as a learner:

Run Members Added as learner Seed Victim Result
30297109488 (fix/rebootstrap-when-every-member-lost) p6bvx, v9kr8, vtm7h v9kr8, vtm7h p6bvx p6bvx FAIL 940 s
30302593237 (chore/codeowners) 9xf86, bst2x, fph75 bst2x, fph75 9xf86 9xf86 FAIL 940 s
30342866014 (chore/codeowners, re-run) not the victim h7gtw PASS 250 s

Note the second row: chore/codeowners is an 8-line change to
.github/CODEOWNERS with no Go code at all, and it failed identically. The
940.9 s duration in both failures is the 15m wait plus setup — the test never
had a chance, it just had to time out first.

What changed

selfHealReplaceableMember picks a member with Spec.Bootstrap == false, so the
victim is always one the self-heal path can actually act on. It also asserts the
single-seed invariant (the cluster controller finds the seed by Spec.Bootstrap
and assumes exactly one) rather than silently selecting from a wrongly-shaped
cluster.

The timeout message is fixed too. It claimed:

victim "etcd-p6bvx" still present (crash-loop not yet past threshold)

In both failures the victim was well past the threshold — 7 restarts against
dataLossRestartThreshold = 5 — and held back by the bootstrap gate instead. The
message sent the one real failure this test has produced looking in the wrong
place. It now reports the actual restart count and readiness:

victim "etcd-p6bvx" still present; etcd container restarts=7 ready=false

Test coverage

No new test: this is the test, and the fix is in how it selects its subject.
The two behaviours involved are each covered where they belong —

  • seed is excluded from self-heal (the contract that made the old selection
    flaky): TestUpdateStatus_KeepsStuckBootstrapMember, unit, already on main.
  • a replaceable member is self-healed (the contract this E2E exists for):
    TestPVCMemberCrashLoopSelfHeal, which now reliably exercises it instead of
    covering it two runs out of three.

Verified locally: go vet ./..., go vet -tags e2e ./test/e2e/..., gofmt -l
clean, and go test ./controllers/... green. The E2E itself needs kind +
cert-manager + Kamaji and runs in CI here. Worth stating plainly: a single green
E2E run cannot by itself prove a 1-in-3 flake is gone — the argument is that the
victim is now selected by the very predicate the self-heal path gates on, so the
failing case is unreachable by construction rather than unlikely.

Follow-up (not in this PR)

The flake incidentally documents a real gap: a seed member whose data dir is
destroyed never recovers.
Quorum among the remaining members holds, but the
seed crash-loops forever — excluded from self-heal, with Spec.Bootstrap never
cleared and no condition saying so. Two candidate fixes, both narrow:

  • clear Spec.Bootstrap once status.clusterID has latched — docs/concepts.md:91
    already asserts that after latching "the seed is, from that point on, just a
    regular member", which is not currently true of this field; or
  • allow self-heal for the seed when clusterHasQuorumWithout holds, which is the
    condition that makes replacement safe in the first place.

Happy to open that separately once this lands.

NONE

Summary by CodeRabbit

  • Bug Fixes
    • Improved self-healing validation by selecting a replaceable member instead of the bootstrap member.
    • Enhanced self-healing failure messages with the affected member’s etcd restart count and readiness diagnostics per attempt to simplify troubleshooting.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f4e51ec5-ce7b-485c-abe6-ea05cffe4df4

📥 Commits

Reviewing files that changed from the base of the PR and between ff888ee and 1ec6646.

📒 Files selected for processing (1)
  • test/e2e/member_selfheal_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/e2e/member_selfheal_test.go

📝 Walkthrough

Walkthrough

The self-healing end-to-end test selects a non-bootstrap member as its victim and reports the victim Pod’s etcd restart count and readiness when the recovery wait condition fails.

Changes

Self-healing test reliability

Layer / File(s) Summary
Select a replaceable member
test/e2e/member_selfheal_test.go
The test validates one bootstrap seed, selects the first eligible non-bootstrap member, and uses it as the victim.
Report victim runtime state
test/e2e/member_selfheal_test.go
Wait-condition failures include etcd restart and readiness details, with fallback messages for unavailable Pod status data.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: lllamnyp

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: selecting a non-seed victim in the self-heal E2E test.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/selfheal-e2e-victim-must-not-be-seed

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@androndo
Andrey Kolkov (androndo) marked this pull request as ready for review July 28, 2026 10:07
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@androndo
Andrey Kolkov (androndo) force-pushed the fix/selfheal-e2e-victim-must-not-be-seed branch from 4fb0401 to 9e572e6 Compare July 28, 2026 10:08

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Correctly identifies the root cause: original[0] picks the alphabetically-first member while names are apiserver-assigned random suffixes, so the seed became the victim in roughly one run in three — and the seed is permanently excluded from self-heal by the !member.Spec.Bootstrap gate, so the test then waited out its full 15m for a deletion that was never coming. Verified against three runs (this branch's own history, plus #343 where it failed identically at 940.9s and passed on a re-run).

The replacement picks deterministically and asserts the single-seed invariant rather than silently proceeding on a wrongly-shaped cluster, and the failure message now reports actual restart state instead of unconditionally claiming "not yet past threshold" — which is what made the real failure mode unreadable.

One forward-looking note, no change requested here: the comment at the PVC assertion says the volume is GC'd via owner-ref. #347 removes that owner reference and reclaims the volume explicitly in this exact path instead, so the assertion keeps passing but the comment goes stale. I'll fix the wording in #347 once this lands, to keep the two from conflicting.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `@test/e2e/member_selfheal_test.go`:
- Line 209: Update selfHealReplaceableMember to sort the eligible members
deterministically before returning eligible[0]. Preserve the existing
eligibility filtering and selection behavior, adding only the minimal ordering
logic needed to make the chosen non-bootstrap member independent of kube.List()
order.
🪄 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: 587675a2-1e13-4575-855c-07b645c9059c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e572e6 and ff888ee.

📒 Files selected for processing (1)
  • test/e2e/member_selfheal_test.go

if len(eligible) == 0 {
t.Fatalf("no non-bootstrap member to corrupt; seeds=%v", seeds)
}
return eligible[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files matching test filename =="
fd -a 'member_selfheal_test.go' . || true

file="$(fd 'member_selfheal_test.go' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  echo "== line count =="
  wc -l "$file"
  echo "== relevant helper and imports around candidate =="
  sed -n '1,260p' "$file" | nl -ba | sed -n '1,260p'
fi

echo "== usages/selectors for eligible helper =="
rg -n "eligible|GetEligible|eligibleMembers|List\\(" test/e2e/member_selfheal_test.go test/e2e 2>/dev/null || true

echo "== controller-runtime dependency refs =="
rg -n "controller-runtime|sigs.k8s.io/controller-runtime" go.mod go.sum vendor 2>/dev/null || true

Repository: cozystack/etcd-operator

Length of output: 401


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="./test/e2e/member_selfheal_test.go"

echo "== imports and top of file =="
sed -n '1,60p' "$file" | cat -n

echo "== helper and lines 180-220 =="
sed -n '180,225p' "$file" | cat -n

echo "== usages in test/e2e member_selfheal_test.go =="
rg -n "eligible|GetEl|^/\*|List\\(" "$file" || true

echo "== dependency version refs =="
sed -n '/controller-runtime/,+5p' go.mod 2>/dev/null || true
rg -n "controller-runtime|sigs.k8s.io/controller-runtime" go.mod go.sum 2>/dev/null | head -n 20 || true

Repository: cozystack/etcd-operator

Length of output: 6341


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== kube definitions/usages in e2e =="
rg -n "var kube|kube :=|controllerruntime|client\\.New|NewClient" test go.mod 2>/dev/null || true

echo "== Kubernetes controller-runtime List behavior docs/source if vendored or Go module cache =="
(GOPATH="$HOME/go" go env GOPATH) 2>/dev/null || true
for p in "$HOME/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.21.0/pkg/client/client.go" \
         "$HOME/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.21.0/pkg/cache/cache.go"; do
  echo "-- $p --"
  if [ -f "$p" ]; then
    rg -n "func .*List\\(|Cache|List\\(" "$p" | head -n 40
  else
    echo "missing"
  fi
done

echo "== deterministic sort probe for eligible selection =="
python3 - <<'PY'
eligible = ["member-b", "member-a", "member-c"]
print("unsorted first:", eligible[0])
eligible.sort()
print("sorted first:", eligible[0])
print("require_sort:", eligible[0] != ["member-b", "member-a", "member-c"][0])
PY

Repository: cozystack/etcd-operator

Length of output: 3641


Sort eligible members before selecting eligible[0].

selfHealReplaceableMember builds an unsorted slice from kube.List(), so slicing eligible[0] can pick different non-bootstrap victims depending on listing order. Sorting keeps only the deterministic helper needed.

Proposed fix
+import "sort" // add to the existing import block
+
 ...
+	sort.Strings(eligible)
 	return eligible[0]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return eligible[0]
sort.Strings(eligible)
return eligible[0]
🤖 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 `@test/e2e/member_selfheal_test.go` at line 209, Update
selfHealReplaceableMember to sort the eligible members deterministically before
returning eligible[0]. Preserve the existing eligibility filtering and selection
behavior, adding only the minimal ordering logic needed to make the chosen
non-bootstrap member independent of kube.List() order.

@lllamnyp

Copy link
Copy Markdown
Member

The diagnosis in this PR is correct and it is the thing I built on, so I want to be specific about that before proposing something different. The root cause here — original[0] against a name-sorted list, member names being apiserver-assigned random suffixes, therefore landing on the seed about a third of the time — is exactly right. So is the observation that the victim was well past dataLossRestartThreshold and held back by a gate rather than by the threshold, which is what makes the old timeout message actively misleading. The three-run table, and especially the chore/codeowners run failing identically with no Go code in the diff, is what rules out the change under test and pins it on the operator. None of that needed revisiting.

Where I ended up differing is the conclusion. This PR reads the seed exclusion as a property of the operator to be worked around; I think it is the bug, and I have opened #354 and #355 as a stack that removes it.

The exclusion is safe to delete. The guard was protecting the bootstrap window — deleting the seed while the cluster is still forming destroys the only copy of a cluster nothing else has joined yet — but it was written as a permanent property of a member. spec.bootstrap is set once at seed creation and never cleared, so a fact that should have expired in about a minute instead held for the life of the cluster.

The window turns out to be protected already, by the quorum gate on the very next line. clusterHasQuorumWithout reads status.readyMembers, and while status.clusterID is unlatched the cluster controller routes into bootstrap()/tryDiscoverCluster() and never reaches updateStatus — so readyMembers is 0 for the whole bootstrap window and the gate cannot be satisfied at any replica count. The same arithmetic permanently protects a single-replica cluster's only member. Removing the conjunct therefore drops a redundant guard, not a load-bearing one.

Two things already in the tree confirm that a cluster with no bootstrap=true member is normal and supported: cmd/etcd-migrate creates every adopted member with Bootstrap: false, so no migrated cluster has a seed at all; and the memory pod-loss self-heal a few lines above the crash-loop gate has never consulted spec.bootstrap, so it already deletes seeds today. docs/concepts.md also states outright that once clusterID is set the seed is "just historical metadata" with "no permanent special role" — the self-heal gate was the one place the code disagreed with that.

Why the test change specifically stops being the one we want. Once the operator is fixed, selecting a non-seed victim means the suite no longer exercises the seed at all — and the seed is the member that was uniquely unrecoverable. A future change that reintroduces the exclusion would pass CI unnoticed. Inverting the selection to target the seed deliberately gets the identical determinism and the identical flake fix, and additionally fails on main for the right reason, which makes it a regression guard rather than an avoidance.

That inversion carries over the substance of this PR rather than discarding it: deterministic victim selection instead of list order, the assertion that exactly one bootstrap=true member exists (the cluster controller locates the seed by that field and assumes a single one), and the corrected timeout message, which now names both possibilities instead of only the threshold.

A second issue surfaced while establishing why the exclusion existed, and is the upper layer of the stack: buildPod also derives --initial-cluster-state from spec.bootstrap, so a re-created seed Pod is handed =new for the life of the cluster. etcd honours that flag only on an empty data dir, so it is inert on ordinary restarts and on a corrupt data dir — but not when a seed's data dir returns empty with its PVC still bound, where =new against its frozen self-only --initial-cluster is a complete bootstrap instruction. etcd does not error there; it forms a fresh one-member cluster and goes Ready, which no self-heal trigger can observe. #355 derives the flag from cluster phase instead.

Suggested disposition, though it is your call as the author. I would close this in favour of the stack, since the inverted test covers everything here plus the regression it guards. The one argument for landing this first is timing: it is test-only and safe, the flake is costing CI runs now, and the stack is larger and carries an e2e that has not yet been run against a live cluster. If you would rather stop the bleeding immediately, landing this and then taking the inversion as part of #354 is a perfectly reasonable order — the two touch the same file and will conflict, so it is worth deciding the sequence rather than discovering it at merge time.

Either way, thanks for chasing this down to the gate; the CI evidence made the underlying bug straightforward to argue.

TestPVCMemberCrashLoopSelfHeal corrupted `original[0]`, the
alphabetically-first member, because List returns items name-sorted.
Member names are apiserver-assigned random suffixes, so the bootstrap
seed sorted first — and became the victim — in roughly one run in three.

The seed is permanently excluded from self-heal: the caller of
etcdContainerStuck gates on !member.Spec.Bootstrap, and Spec.Bootstrap is
set once at creation and never cleared. So on those runs the test waited
out its full 15m timeout for a deletion that was by design never coming.

Pick the victim by Spec.Bootstrap == false instead, and assert the
single-seed invariant while we are there. The seed-exclusion contract
itself is already covered by the unit test
TestUpdateStatus_KeepsStuckBootstrapMember; this test covers the
replaceable case, so it must choose a replaceable member deterministically.

Also fix the timeout message, which claimed "crash-loop not yet past
threshold" unconditionally. In both observed failures the victim was well
past the threshold (7 restarts, threshold 5) and held back by the
bootstrap gate, so the message pointed at the wrong cause. Report the
actual restart count and readiness instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
@androndo
Andrey Kolkov (androndo) force-pushed the fix/selfheal-e2e-victim-must-not-be-seed branch from ff888ee to 1ec6646 Compare August 3, 2026 18:34
auto-merge was automatically disabled August 4, 2026 07:10

Pull request was closed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants