feat: quorum-gated one-pod-at-a-time version upgrades via OnDelete#403
feat: quorum-gated one-pod-at-a-time version upgrades via OnDelete#403xrl wants to merge 5 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Xavier Lange <xrlange@gmail.com>
A pure .spec.version change previously validated the upgrade path but never re-rendered the StatefulSet, silently leaving the cluster on the old image. The StatefulSet now uses the OnDelete update strategy: a version bump patches the pod template without restarting anything, then the controller deletes one outdated pod per reconcile, gated on every pod Ready, a leader present, and all member revisions within 90% of the leader's. The leader's pod is replaced last. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Xavier Lange <xrlange@gmail.com>
The cached client's first pod Get lazily starts an informer that lists/watches all pods cluster-wide; the operator only ever reads its own N pods by name. Direct API reads also drop the now-unneeded list/watch RBAC verbs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Xavier Lange <xrlange@gmail.com>
…dits Image-string comparison breaks under pod-mutating admission webhooks (mirror/digest rewrites made every pod permanently 'outdated', deleting a healthy member in a loop) and misses non-image template drift, which OnDelete no longer converges on its own. Compare each pod's controller-revision-hash label against status.updateRevision instead, and re-render the template whenever the cluster generation is unobserved so etcdOptions/podTemplate/TLS changes roll out too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Xavier Lange <xrlange@gmail.com>
A pod replaced with a broken image (phantom patch tag, flag removed in the new minor) failed every subsequent health check, which blocked template re-rendering, made spec.version rollback look like a downgrade of the never-run template tag, and left the cluster degraded at N-1 members with no spec-level way out. - validate the upgrade path against status.currentVersion (the version members actually run) so rollback is accepted - on health-check failure, re-sync the StatefulSet template from the spec and, while the remaining members hold quorum, replace one not-Ready outdated pod so the fixed template can land - exempt not-Ready outdated pods from the all-Ready gate; requiring the victim Ready deadlocked its own replacement - document rollback and the manual step needed after quorum loss Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Xavier Lange <xrlange@gmail.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: xrl The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Hi @xrl. Thanks for your PR. I'm waiting for a etcd-io member to verify that this patch is reasonable to test. If it is, they should reply with Regular contributors should join the org to skip this step. Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
What
A pure
.spec.versionchange validated the upgrade path but never re-rendered the StatefulSet — the cluster silently stayed on the old image (repro test in the first commit). This PR makes version changes actually roll out, one pod at a time, gated on cluster health:controller-revision-hashlabel is compared againststatus.updateRevision. Image comparison broke under pod-mutating admission webhooks (digest/mirror rewrites made every pod permanently "outdated", deleting a healthy member in a loop) and missed non-image template drift, which OnDelete no longer converges on its own. The template is also re-rendered whenever the cluster generation is unobserved, soetcdOptions/podTemplate/TLS edits roll out too.mgr.GetAPIReader()): the cached client's first pod Get lazily starts an informer that lists/watches all pods cluster-wide, but the operator only ever reads its own N pods by name. RBAC gainspods get;deleteonly — no list/watch.spec.versionrollback look like a downgrade of the never-run template tag, and left the cluster degraded at N-1 members with no spec-level way out. Upgrade-path validation now runs againststatus.currentVersion(what members actually run) so rollback is accepted; on health-check failure the template is re-synced and, while the remaining members still hold quorum, one not-Ready outdated pod is replaced so the fixed template can land. Not-Ready outdated pods are exempt from the all-Ready gate — requiring the victim Ready deadlocked its own replacement.docs/upgrade.mddocuments the mechanics, rollback, and the manual step needed after quorum loss.Files
internal/controller/upgrade.go—reconcileVersionUpgrade(gates + victim ordering: not-Ready outdated first, then lowest ordinal, leader last),recoverDegradedUpgrade,isPodOutdated,ordinalFromPeerEp(memberHealthsorts lexically by endpoint, so slice index != ordinal once i >= 10).internal/controller/utils.go— OnDelete strategy on the StatefulSet;syncStatefulSetsplit out ofreconcileStatefulSetso the recovery path can apply the template while pods are down, whenwaitForStatefulSetReadycould never succeed.internal/controller/etcdcluster_controller.go—PodReaderfield, the upgrade call where the up-to-date branch used to no-op, the degraded-recovery hook on health-check failure, and thestatus.currentVersionpreference in upgrade-path validation.config/rbac/role.yaml— regenerated viamake manifests.Review
An adversarial review pass produced 3 findings, all blocking/important, each resolved in its own commit: cached-client pod reads silently starting a cluster-wide pod informer (fixed with the uncached reader), image-string outdated detection deleting healthy members in a loop under pod-mutating webhooks and missing non-image drift (fixed with controller-revision detection), and failed upgrades wedging the cluster with no spec-level rollback (fixed with the recovery path).
Testing
upgrade_test.go(envtest): re-render on version bump and on unobserved spec change; one pod deleted per reconcile; leader pod last; gates hold on a lagging member, a not-Ready or terminating pod, and a pending learner; no-op when all pods are updated; requeue on a missing pod; webhook-mutated pod image ignored; non-image template change rolls pods; not-Ready outdated pod exempt from the Ready gate and replaced;recoverDegradedUpgradetable (quorum lost, victim on current template, replacement in flight);ordinalFromPeerEpparsing including ordinals >= 10.TestFetchAndValidateStateextended for rollback viastatus.currentVersion.make testandmake verifypass.Descoped follow-ups
(1) No readiness probe on the etcd container: TLS listener flags arrive only via free-form
spec.etcdOptions(defaultArgshard-codes http,utils.go:80-88), so the probe scheme and client-cert requirements cannot be derived reliably; the gate uses etcd revision checks, which are stronger than pod readiness. Follow-up issue. (2) NoMoveLeaderbefore deleting the leader pod — the helper is absent fromorigin/main(it lives on the unmerged scale-in branch, #400); leader-last ordering is used instead, to be deduped at merge time. (3) No upgrade e2e: the harness requires kind, which this environment cannot run; a test sketch is documented instead. (4) NoProgressingcondition withReason=UpgradeInProgressinupdateConditions— cosmetic, follow-up. (5) No Warning Event when rolling a cluster without persistent storage (r.Recorderis wired but currently unused anywhere, and wiring events adds test surface) — documented indocs/upgrade.mdinstead. (6) Upgrades are not refused/paused forspec.storageSpec == nilclusters — documented caveat only.🤖 Generated with Claude Code
PR series — operability fixes, TLS & EtcdMirror
Small single-purpose PRs from live kind-cluster testing of the operator. Each stands alone unless an After is listed. → = this PR.
events.k8s.ioRBAC so operator Events are actually recordedmembers[]/leaderIDfrom one health snapshot (consistent leader)altNames.ipAddressesinto certificatesvalidityDuration(365d,100d12h) as documentedDegradedcondition (was empty status)--max-concurrent-reconciles)podTemplate.specscheduling fields: topologySpread, resources, priorityClass, schedulerNameconfig/surfacespec.tls.{peer,client}surfaces (breaking alpha API)TLSReadycondition + TLS lifecycle EventsPeerCANotSharedEtcdMirrorCRD: one-way cross-cluster replication API (types + CEL)pkg/mirroragentreplication engine (fenced checkpoint-in-target)mirror-agentbinary: config/TLS-reload/statusz/metrics + kind e2e/metricsendpointEtcdBackupCR → object storage (S3/GCS)🟢 ready · ⚪ draft