Skip to content

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

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

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

📝 Walkthrough

Walkthrough

The self-healing end-to-end test now 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 a single 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

Suggested labels: bugfix

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 summarizes the main change: choosing a non-seed victim for 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.

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>

@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.

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.

2 participants