From ba2a902926f3f55de2929d1e68ba6baf4fc9ef41 Mon Sep 17 00:00:00 2001 From: florent1s Date: Thu, 22 Oct 2026 12:27:36 +0200 Subject: [PATCH 1/3] feat(etcdcluster): preserve all PVCs when putting cluster to sleep Signed-off-by: florent1s --- controllers/etcdcluster_controller.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/controllers/etcdcluster_controller.go b/controllers/etcdcluster_controller.go index 49464ae3..60e0dbf8 100644 --- a/controllers/etcdcluster_controller.go +++ b/controllers/etcdcluster_controller.go @@ -317,7 +317,11 @@ func (r *EtcdClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) return r.scaleUp(ctx, cluster, active) } if current > desired { - log.Info("scaling down", "current", current, "desired", desired) + if desired == 0 { + log.Info("Putting etcdmember to sleep", "current", current, "desired", desired) + } else { + log.Info("scaling down", "current", current, "desired", desired) + } return r.scaleDown(ctx, cluster, running) } @@ -1315,7 +1319,7 @@ func (r *EtcdClusterReconciler) scaleDown( // false. Keeping the CR across the pause means no Create-by-fixed- // name on resume, no PVC reparenting, no cross-resource annotation // dance, and no stale status field to sweep. - if cluster.Status.Observed != nil && cluster.Status.Observed.Replicas == 0 && len(members) == 1 { + if cluster.Status.Observed != nil && cluster.Status.Observed.Replicas == 0 { if !victim.Spec.Dormant { original := victim.DeepCopy() victim.Spec.Dormant = true From 9ccf129fad08b0328dc964548b545b827706da9c Mon Sep 17 00:00:00 2001 From: florent1s Date: Tue, 3 Nov 2026 15:34:52 +0100 Subject: [PATCH 2/3] feat(etcdcluster): PreserveMembersOnSleep option Signed-off-by: florent1s --- api/v1alpha2/etcdcluster_types.go | 7 +++++++ .../crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml | 7 +++++++ controllers/etcdcluster_controller.go | 4 ++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/api/v1alpha2/etcdcluster_types.go b/api/v1alpha2/etcdcluster_types.go index 8bdd5253..04520e9f 100644 --- a/api/v1alpha2/etcdcluster_types.go +++ b/api/v1alpha2/etcdcluster_types.go @@ -464,6 +464,13 @@ type EtcdClusterSpec struct { // +optional Storage StorageSpec `json:"storage,omitempty"` + // PreserveMembersOnSleep prevents deletion of etcdmembers and their PVCs when + // putting a cluster to sleep (replicas set to 0). Instead the operator + // flips spec.dormant=true on all existing members. + // +kubebuilder:default=false + // +optional + PreserveMembersOnSleep bool `json:"preserveMembersOnSleep,omitempty"` + // ProgressDeadlineSeconds bounds the time the operator spends trying to // reach a desired state before abandoning the in-flight target and // adopting whatever the user has set as the new spec. Defaults to 600 diff --git a/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml b/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml index ed4d78ad..0709bb06 100644 --- a/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml +++ b/charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdclusters.yaml @@ -1244,6 +1244,13 @@ spec: minimum: 1 type: integer type: object + preserveMembersOnSleep: + default: false + description: |- + PreserveMembersOnSleep prevents deletion of etcdmembers and their PVCs when + putting a cluster to sleep (replicas set to 0). Instead the operator + flips spec.dormant=true on all existing members. + type: boolean progressDeadlineSeconds: default: 600 description: |- diff --git a/controllers/etcdcluster_controller.go b/controllers/etcdcluster_controller.go index 60e0dbf8..97dbb57c 100644 --- a/controllers/etcdcluster_controller.go +++ b/controllers/etcdcluster_controller.go @@ -317,7 +317,7 @@ func (r *EtcdClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) return r.scaleUp(ctx, cluster, active) } if current > desired { - if desired == 0 { + if desired == 0 && (current == 1 || cluster.Spec.PreserveMembersOnSleep) { log.Info("Putting etcdmember to sleep", "current", current, "desired", desired) } else { log.Info("scaling down", "current", current, "desired", desired) @@ -1319,7 +1319,7 @@ func (r *EtcdClusterReconciler) scaleDown( // false. Keeping the CR across the pause means no Create-by-fixed- // name on resume, no PVC reparenting, no cross-resource annotation // dance, and no stale status field to sweep. - if cluster.Status.Observed != nil && cluster.Status.Observed.Replicas == 0 { + if cluster.Status.Observed != nil && cluster.Status.Observed.Replicas == 0 && (len(members) == 1 || cluster.Spec.PreserveMembersOnSleep) { if !victim.Spec.Dormant { original := victim.DeepCopy() victim.Spec.Dormant = true From 9b6aa774d521981c6c7eb16fa238d8b720f72c7f Mon Sep 17 00:00:00 2001 From: florent1s Date: Wed, 4 Nov 2026 15:58:26 +0100 Subject: [PATCH 3/3] feat(etcdclusters): add test for PreserveMembersOnSleep Signed-off-by: florent1s --- controllers/etcdcluster_controller.go | 2 + controllers/etcdcluster_controller_test.go | 71 ++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/controllers/etcdcluster_controller.go b/controllers/etcdcluster_controller.go index 97dbb57c..6ff4a7a2 100644 --- a/controllers/etcdcluster_controller.go +++ b/controllers/etcdcluster_controller.go @@ -1298,6 +1298,7 @@ func isAuthFailedErr(err error) bool { // scaleUp by Patching Spec.Dormant back to false. The CR is never // deleted across the pause cycle, so no name lookup / Create-by-fixed- // name / PVC reparenting is needed. +// Same logic applies to X→0 transistion when PreserveMembersOnSleep is true func (r *EtcdClusterReconciler) scaleDown( ctx context.Context, cluster *lll.EtcdCluster, @@ -1319,6 +1320,7 @@ func (r *EtcdClusterReconciler) scaleDown( // false. Keeping the CR across the pause means no Create-by-fixed- // name on resume, no PVC reparenting, no cross-resource annotation // dance, and no stale status field to sweep. + // Same logic applies to X→0 transistion when PreserveMembersOnSleep is true if cluster.Status.Observed != nil && cluster.Status.Observed.Replicas == 0 && (len(members) == 1 || cluster.Spec.PreserveMembersOnSleep) { if !victim.Spec.Dormant { original := victim.DeepCopy() diff --git a/controllers/etcdcluster_controller_test.go b/controllers/etcdcluster_controller_test.go index 74ac7caa..3087029c 100644 --- a/controllers/etcdcluster_controller_test.go +++ b/controllers/etcdcluster_controller_test.go @@ -1504,6 +1504,77 @@ func TestScaleDown_OneToZeroPatchesDormant(t *testing.T) { } } +// TestScaleDown_ManyToZeroPatchesDormant covers the scale-to-zero +// "pause" path with etcdmember preservation. When desired=0 and +// PreserveMembersOnSleep=true, scaleDown must Patch Spec.Dormant=true +// on the all existing members rather than delete them. The members CRs +// (and their PVCs owner-refs) stay in place; the +// member controller's dormant gate handles deleting the Pod. +func TestScaleDown_ManyToZeroPatchesDormant(t *testing.T) { + ctx := context.Background() + now := metav1.Now() + oldT := metav1.NewTime(now.Add(-2 * time.Hour)) + midT := metav1.NewTime(now.Add(-1 * time.Hour)) + cluster := &lll.EtcdCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, + Spec: lll.EtcdClusterSpec{ + Replicas: ptrInt32(0), + Version: "3.5.17", + Storage: lll.StorageSpec{Size: quickQty(t, "1Gi")}, + PreserveMembersOnSleep: true, + }, + Status: lll.EtcdClusterStatus{ + ClusterToken: "ns-test-x", + ClusterID: "deadbeef", + Observed: &lll.ObservedClusterSpec{ + Replicas: 0, Version: "3.5.17", Storage: lll.StorageSpec{Size: quickQty(t, "1Gi")}, + }, + ProgressDeadline: &metav1.Time{Time: metav1.Now().Add(time.Hour)}, + }, + } + members := []lll.EtcdMember{ + { + ObjectMeta: metav1.ObjectMeta{Name: "test-0", Namespace: "ns", CreationTimestamp: oldT, Labels: memberLabels("test", "test-0")}, + Spec: lll.EtcdMemberSpec{ClusterName: "test", InitialCluster: "x", Bootstrap: true}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "test-1", Namespace: "ns", CreationTimestamp: midT, Labels: memberLabels("test", "test-1")}, + Spec: lll.EtcdMemberSpec{ClusterName: "test", InitialCluster: "x"}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "test-2", Namespace: "ns", CreationTimestamp: now, Labels: memberLabels("test", "test-2")}, + Spec: lll.EtcdMemberSpec{ClusterName: "test", InitialCluster: "x"}, + }, + } + objs := []client.Object{cluster} + for i := range members { + m := members[i] + objs = append(objs, &m) + } + c, _ := newTestClient(t, objs...) + r := &EtcdClusterReconciler{Client: c, Scheme: testScheme(t), EtcdClientFactory: factoryReturning(newFakeEtcd(0xdead))} + + // scaleDown handles the most recent member first, therfore we go in + // decreasing order to check test-2 then test-1 and finally test-0 + for i := 2; i >= 0; i-- { + if _, err := r.scaleDown(ctx, cluster, members); err != nil { + t.Fatalf("scaleDown: %v", err) + } + // remove the member that was put to sleep + members = members[1:] + + // The CRs must still exist (no Delete on X→0), with Spec.Dormant=true. + got := mustGet(t, c, fmt.Sprintf("test-%d", i), "ns", &lll.EtcdMember{}) + + if !got.Spec.Dormant { + t.Fatalf("scaleDown X→0 must Patch Spec.Dormant=true; got Dormant=%v on member test-%d.", got.Spec.Dormant, i) + } + if !got.DeletionTimestamp.IsZero() { + t.Fatalf("CRs must NOT have a DeletionTimestamp after X→0; the pause keeps the CRs alive") + } + } +} + // TestScaleUp_WakesFromDormant covers the scale-back-up path. When a // dormant member exists and scaleUp runs, it must Patch // Spec.Dormant=false on that member rather than create anything. The