From bfe3d8d074a0d17e718211cb24300847ef48b954 Mon Sep 17 00:00:00 2001 From: SkalaNetworks Date: Tue, 21 Jul 2026 17:28:57 +0200 Subject: [PATCH 1/3] feat(replication): pause replications using annotation at namespace/pvc level Signed-off-by: SkalaNetworks --- README.md | 34 ++++++ cmd/main.go | 4 +- go.mod | 2 +- internal/constants/const.go | 1 + internal/k8s/lease.go | 7 +- internal/replicator/handlers.go | 2 +- internal/replicator/informers.go | 2 +- internal/replicator/replicator.go | 9 ++ internal/replicator/replicator_test.go | 149 ++++++++++++++++++++++++- internal/replicator/utils.go | 28 ++++- internal/replicator/utils_test.go | 67 ++++++++++- internal/replicator/vrc.go | 4 +- internal/replicator/vrc_test.go | 4 +- 13 files changed, 295 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 277dd32..59d7500 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ the actual replication itself. - **Automated Lifecycle**: Automatically creates `VolumeReplication` objects for PVCs with the appropriate annotation. - **Inheritance**: Can inherit the `VolumeReplicationClass` from the PVC or from the PVC's namespace (if not specified on the PVC). - **Exclusion by Name**: Supports excluding PVCs from replication using a global regular expression. +- **Pause**: Supports pausing replication on a per-PVC or per-namespace basis via an annotation, freezing `VolumeReplication` objects in place. - **VRC Selector**: Supports selecting a `VolumeReplicationClass` using a selector, allowing for more dynamic configuration based on `StorageClass` groups. - **Cleanup**: Automatically deletes `VolumeReplication` resources when their parent PVC is deleted or when the replication annotation is removed. - **Leader Election**: Supports high availability with leader election to ensure only one instance is active at a time. @@ -148,6 +149,39 @@ If the annotation is modified, the `VolumeReplication` is updated accordingly wi If the annotation is deleted on both the PVC and the namespace, the VolumeReplication is deleted. +### Pausing replication + +Replication can be paused for a specific PVC or for an entire namespace using the `replication.superphenix.net/pause: "true"` annotation. In both cases, the controller skips creating or updating `VolumeReplication` objects, but it **still deletes** the `VolumeReplication` when the PVC itself is deleted. + +Any other value (including `"false"` or the absence of the annotation) means replication is not paused. + +The annotation on the PVC takes precedence over the annotation on the namespace, so a PVC can opt back into normal reconciliation by setting `replication.superphenix.net/pause: "false"` even if its namespace is paused. + +Example — pausing an entire namespace: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: my-namespace + annotations: + replication.superphenix.net/pause: "true" +``` + +Example — pausing a single PVC: + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: my-pvc + annotations: + replication.superphenix.net/pause: "true" +``` + +> [!NOTE] +> While paused, existing `VolumeReplication` objects are frozen in their current state. Unpausing resumes normal reconciliation. + ### Excluding PVCs from replication It is possible to exclude some PVCs from being replicated, even if they have the correct annotations (or their namespace has them). diff --git a/cmd/main.go b/cmd/main.go index 57ece53..99f4dd5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,8 +6,8 @@ import ( "os" "os/signal" - "github.com/skalanetworks/volume-replicator/internal/k8s" - "github.com/skalanetworks/volume-replicator/internal/replicator" + "github.com/super-phenix/volume-replicator/internal/k8s" + "github.com/super-phenix/volume-replicator/internal/replicator" "k8s.io/client-go/tools/leaderelection" "k8s.io/klog/v2" ) diff --git a/go.mod b/go.mod index 84601ef..7437067 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/skalanetworks/volume-replicator +module github.com/super-phenix/volume-replicator go 1.25.3 diff --git a/internal/constants/const.go b/internal/constants/const.go index 80d3301..897b3bb 100644 --- a/internal/constants/const.go +++ b/internal/constants/const.go @@ -4,6 +4,7 @@ const ( LockName = "spx-volume-replicator-leader-election" VrcValueAnnotation = "replication.superphenix.net/class" VrcSelectorAnnotation = "replication.superphenix.net/classSelector" + PauseAnnotation = "replication.superphenix.net/pause" ParentLabel = "replication.superphenix.net/parent" StorageClassGroup = "replication.superphenix.net/storageClassGroup" StorageProvisionerAnnotation = "volume.kubernetes.io/storage-provisioner" diff --git a/internal/k8s/lease.go b/internal/k8s/lease.go index 49fdcb2..f231406 100644 --- a/internal/k8s/lease.go +++ b/internal/k8s/lease.go @@ -2,13 +2,14 @@ package k8s import ( "context" - "github.com/skalanetworks/volume-replicator/internal/constants" + "os" + "time" + + "github.com/super-phenix/volume-replicator/internal/constants" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/leaderelection" "k8s.io/client-go/tools/leaderelection/resourcelock" "k8s.io/klog/v2" - "os" - "time" ) // GetLease returns a Kubernetes lease object diff --git a/internal/replicator/handlers.go b/internal/replicator/handlers.go index 6171bdb..5ea58cc 100644 --- a/internal/replicator/handlers.go +++ b/internal/replicator/handlers.go @@ -3,7 +3,7 @@ package replicator import ( "reflect" - "github.com/skalanetworks/volume-replicator/internal/constants" + "github.com/super-phenix/volume-replicator/internal/constants" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" diff --git a/internal/replicator/informers.go b/internal/replicator/informers.go index 8d30a9a..e6240ab 100644 --- a/internal/replicator/informers.go +++ b/internal/replicator/informers.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/skalanetworks/volume-replicator/internal/k8s" + "github.com/super-phenix/volume-replicator/internal/k8s" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" diff --git a/internal/replicator/replicator.go b/internal/replicator/replicator.go index 34e0229..9e4f719 100644 --- a/internal/replicator/replicator.go +++ b/internal/replicator/replicator.go @@ -98,6 +98,12 @@ func reconcileVolumeReplication(key string) { return } + // Both PVC-level and namespace-level pause skip create/update + if isPvcPaused(pvc, namespace) { + klog.Infof("PVC %s is paused, skipping reconciliation", key) + return + } + // Retrieve the VRC that should apply to this PVC replicationClass := getVolumeReplicationClass(pvc) if replicationClass != "" { @@ -115,6 +121,9 @@ func reconcileVolumeReplication(key string) { if !vrcExists || !vrCorrect { klog.Infof("deleting VolumeReplication %s as it doesn't conform anymore, vrcExists(%t), vrCorrect(%t)", key, vrcExists, vrCorrect) + + // If we're meant to update the VolumeReplication (!vrCorrect), we delete it here, and it will trigger an + // event that will bring us back in this function to re-create it with the correct definition cleanupVolumeReplication(name, namespace) return } diff --git a/internal/replicator/replicator_test.go b/internal/replicator/replicator_test.go index 4bc97f7..9022e56 100644 --- a/internal/replicator/replicator_test.go +++ b/internal/replicator/replicator_test.go @@ -4,9 +4,9 @@ import ( "fmt" "testing" - "github.com/skalanetworks/volume-replicator/internal/constants" - "github.com/skalanetworks/volume-replicator/internal/k8s" "github.com/stretchr/testify/require" + "github.com/super-phenix/volume-replicator/internal/constants" + "github.com/super-phenix/volume-replicator/internal/k8s" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -224,6 +224,148 @@ func TestReconcileVolumeReplication(t *testing.T) { } }, }, + { + name: "PVC paused -> do not create VR", + setup: func() { + pausedPvc := pvc.DeepCopy() + pausedPvc.Annotations[constants.PauseAnnotation] = "true" + err := PvcInformer.Informer().GetIndexer().Add(pausedPvc) + require.NoError(t, err) + }, + verify: func(t *testing.T) { + actions := dynamicClient.Actions() + for _, action := range actions { + require.NotEqual(t, "create", action.GetVerb()) + require.NotEqual(t, "delete", action.GetVerb()) + } + }, + }, + { + name: "PVC paused, PVC being deleted -> delete VR anyway", + setup: func() { + pausedPvc := pvc.DeepCopy() + pausedPvc.Annotations[constants.PauseAnnotation] = "true" + now := metav1.Now() + pausedPvc.DeletionTimestamp = &now + err := PvcInformer.Informer().GetIndexer().Add(pausedPvc) + require.NoError(t, err) + err = VolumeReplicationInformer.Informer().GetIndexer().Add(vr) + require.NoError(t, err) + }, + verify: func(t *testing.T) { + actions := dynamicClient.Actions() + deleted := false + for _, action := range actions { + if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { + deleted = true + break + } + } + require.True(t, deleted, "VR should have been deleted despite PVC pause") + }, + }, + { + name: "Namespace paused, PVC missing -> delete VR", + setup: func() { + err := NamespaceInformer.Informer().GetIndexer().Add(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "true"}, + }, + }) + require.NoError(t, err) + err = VolumeReplicationInformer.Informer().GetIndexer().Add(vr) + require.NoError(t, err) + }, + verify: func(t *testing.T) { + actions := dynamicClient.Actions() + deleted := false + for _, action := range actions { + if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { + deleted = true + break + } + } + require.True(t, deleted, "VR should have been deleted despite namespace pause") + }, + }, + { + name: "Namespace paused, PVC being deleted (no PVC-level pause) -> delete VR", + setup: func() { + err := NamespaceInformer.Informer().GetIndexer().Add(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "true"}, + }, + }) + require.NoError(t, err) + pvcBeingDeleted := pvc.DeepCopy() + now := metav1.Now() + pvcBeingDeleted.DeletionTimestamp = &now + err = PvcInformer.Informer().GetIndexer().Add(pvcBeingDeleted) + require.NoError(t, err) + err = VolumeReplicationInformer.Informer().GetIndexer().Add(vr) + require.NoError(t, err) + }, + verify: func(t *testing.T) { + actions := dynamicClient.Actions() + deleted := false + for _, action := range actions { + if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { + deleted = true + break + } + } + require.True(t, deleted, "VR should have been deleted despite namespace pause") + }, + }, + { + name: "Namespace paused, PVC present without VR -> do not create VR", + setup: func() { + err := NamespaceInformer.Informer().GetIndexer().Add(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "true"}, + }, + }) + require.NoError(t, err) + err = PvcInformer.Informer().GetIndexer().Add(pvc) + require.NoError(t, err) + }, + verify: func(t *testing.T) { + actions := dynamicClient.Actions() + for _, action := range actions { + require.NotEqual(t, "create", action.GetVerb()) + } + }, + }, + { + name: "PVC pause=false overrides namespace pause=true -> create VR", + setup: func() { + err := NamespaceInformer.Informer().GetIndexer().Add(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "true"}, + }, + }) + require.NoError(t, err) + unpausedPvc := pvc.DeepCopy() + unpausedPvc.Annotations[constants.PauseAnnotation] = "false" + err = PvcInformer.Informer().GetIndexer().Add(unpausedPvc) + require.NoError(t, err) + }, + verify: func(t *testing.T) { + actions := dynamicClient.Actions() + created := false + for _, action := range actions { + if action.GetVerb() == "create" && action.GetResource().Resource == "volumereplications" { + created = true + break + } + } + require.True(t, created, "VR should have been created") + }, + }, } for _, tt := range tests { @@ -235,6 +377,9 @@ func TestReconcileVolumeReplication(t *testing.T) { for _, obj := range VolumeReplicationInformer.Informer().GetIndexer().List() { _ = VolumeReplicationInformer.Informer().GetIndexer().Delete(obj) } + for _, obj := range NamespaceInformer.Informer().GetIndexer().List() { + _ = NamespaceInformer.Informer().GetIndexer().Delete(obj) + } dynamicClient.ClearActions() if tt.setup != nil { diff --git a/internal/replicator/utils.go b/internal/replicator/utils.go index 9f67aff..d2359b7 100644 --- a/internal/replicator/utils.go +++ b/internal/replicator/utils.go @@ -5,8 +5,8 @@ import ( "fmt" "regexp" - "github.com/skalanetworks/volume-replicator/internal/constants" - "github.com/skalanetworks/volume-replicator/internal/k8s" + "github.com/super-phenix/volume-replicator/internal/constants" + "github.com/super-phenix/volume-replicator/internal/k8s" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -172,6 +172,30 @@ func getPvcProvisioner(pvc *corev1.PersistentVolumeClaim) string { return pvc.Annotations[constants.DeprecatedStorageProvisionerAnnotation] } +// isPvcPaused returns whether the replication for a PVC is paused +// The PVC-level annotation takes precedence over the namespace-level annotation: +// any explicit value on the PVC (even "false") short-circuits the namespace lookup +func isPvcPaused(pvc *corev1.PersistentVolumeClaim, namespace string) bool { + if pvc != nil { + // If the PVC has the annotation specified, it has priority over the one of the namespace + if value, ok := pvc.Annotations[constants.PauseAnnotation]; ok { + return value == "true" + } + } + + return isNamespacePaused(namespace) +} + +// isNamespacePaused returns whether replication is paused at the namespace level +func isNamespacePaused(namespace string) bool { + ns, err := NamespaceInformer.Lister().Get(namespace) + if err != nil { + return false + } + + return ns.Annotations[constants.PauseAnnotation] == "true" +} + // pvcNameMatchesExclusion returns whether a PVC has a name matching the exclusion regex func pvcNameMatchesExclusion(pvc *corev1.PersistentVolumeClaim) bool { // If no regex is provided, return that it doesn't match diff --git a/internal/replicator/utils_test.go b/internal/replicator/utils_test.go index 374faf2..6669283 100644 --- a/internal/replicator/utils_test.go +++ b/internal/replicator/utils_test.go @@ -5,9 +5,9 @@ import ( "fmt" "testing" - "github.com/skalanetworks/volume-replicator/internal/constants" - "github.com/skalanetworks/volume-replicator/internal/k8s" "github.com/stretchr/testify/require" + "github.com/super-phenix/volume-replicator/internal/constants" + "github.com/super-phenix/volume-replicator/internal/k8s" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -663,6 +663,69 @@ func TestGetPvcProvisioner(t *testing.T) { } } +func TestIsNamespacePaused(t *testing.T) { + client := fake.NewClientset() + informerFactory := informers.NewSharedInformerFactory(client, 0) + NamespaceInformer = informerFactory.Core().V1().Namespaces() + + tests := []struct { + name string + namespace *corev1.Namespace + lookup string + expected bool + }{ + { + name: "Namespace paused=true", + namespace: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-paused", + Annotations: map[string]string{constants.PauseAnnotation: "true"}, + }, + }, + lookup: "ns-paused", + expected: true, + }, + { + name: "Namespace paused=false", + namespace: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ns-not-paused", + Annotations: map[string]string{constants.PauseAnnotation: "false"}, + }, + }, + lookup: "ns-not-paused", + expected: false, + }, + { + name: "Namespace has no annotation", + namespace: &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "ns-plain"}, + }, + lookup: "ns-plain", + expected: false, + }, + { + name: "Namespace not found", + lookup: "ns-missing", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + indexer := NamespaceInformer.Informer().GetIndexer() + for _, obj := range indexer.List() { + _ = indexer.Delete(obj) + } + if tt.namespace != nil { + require.NoError(t, indexer.Add(tt.namespace)) + } + + require.Equal(t, tt.expected, isNamespacePaused(tt.lookup)) + }) + } +} + func TestPvcNameMatchesExclusion(t *testing.T) { tests := []struct { name string diff --git a/internal/replicator/vrc.go b/internal/replicator/vrc.go index 8f73e31..c8d149d 100644 --- a/internal/replicator/vrc.go +++ b/internal/replicator/vrc.go @@ -3,8 +3,8 @@ package replicator import ( "context" - "github.com/skalanetworks/volume-replicator/internal/constants" - "github.com/skalanetworks/volume-replicator/internal/k8s" + "github.com/super-phenix/volume-replicator/internal/constants" + "github.com/super-phenix/volume-replicator/internal/k8s" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" diff --git a/internal/replicator/vrc_test.go b/internal/replicator/vrc_test.go index f5bf9ac..3927bbd 100644 --- a/internal/replicator/vrc_test.go +++ b/internal/replicator/vrc_test.go @@ -5,9 +5,9 @@ import ( "fmt" "testing" - "github.com/skalanetworks/volume-replicator/internal/constants" - "github.com/skalanetworks/volume-replicator/internal/k8s" "github.com/stretchr/testify/require" + "github.com/super-phenix/volume-replicator/internal/constants" + "github.com/super-phenix/volume-replicator/internal/k8s" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" From 28862f3c5a4a2e68e6f5973c23a2a511e52c8c39 Mon Sep 17 00:00:00 2001 From: SkalaNetworks Date: Tue, 21 Jul 2026 17:35:20 +0200 Subject: [PATCH 2/3] chore: upgrade go version Signed-off-by: SkalaNetworks --- README.md | 2 +- internal/replicator/informers.go | 2 +- internal/replicator/replicator.go | 2 +- internal/replicator/replicator_test.go | 102 +++++++++---------------- internal/replicator/utils.go | 19 ++--- internal/replicator/utils_test.go | 71 +++++++++-------- internal/replicator/vrc_test.go | 61 ++++++++------- 7 files changed, 112 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index 59d7500..aef7d1b 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Standard `klog` flags are also supported for logging configuration. ### Prerequisites -- Go 1.22+ +- Go 1.25+ - Docker (optional, for containerized builds) ### Building the binary diff --git a/internal/replicator/informers.go b/internal/replicator/informers.go index e6240ab..8482517 100644 --- a/internal/replicator/informers.go +++ b/internal/replicator/informers.go @@ -71,7 +71,7 @@ func (c *Controller) createPvcInformer(factory informers.SharedInformerFactory) UpdateFunc: func(_, newObj any) { c.pvcUpdate(newObj.(*corev1.PersistentVolumeClaim)) }, - DeleteFunc: func(obj interface{}) { + DeleteFunc: func(obj any) { c.pvcUpdate(obj.(*corev1.PersistentVolumeClaim)) }, }) diff --git a/internal/replicator/replicator.go b/internal/replicator/replicator.go index 9e4f719..24d833d 100644 --- a/internal/replicator/replicator.go +++ b/internal/replicator/replicator.go @@ -30,7 +30,7 @@ func (c *Controller) Run(ctx context.Context, workers int) { defer c.pvcQueue.ShutDown() klog.Info("Starting replication controller") - for i := 0; i < workers; i++ { + for range workers { go wait.UntilWithContext(ctx, c.runWorker, time.Second) } diff --git a/internal/replicator/replicator_test.go b/internal/replicator/replicator_test.go index 9022e56..8723cb0 100644 --- a/internal/replicator/replicator_test.go +++ b/internal/replicator/replicator_test.go @@ -2,6 +2,7 @@ package replicator import ( "fmt" + "slices" "testing" "github.com/stretchr/testify/require" @@ -15,6 +16,7 @@ import ( dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" + k8s_testing "k8s.io/client-go/testing" ) func TestReconcileVolumeReplication(t *testing.T) { @@ -46,20 +48,20 @@ func TestReconcileVolumeReplication(t *testing.T) { } vr := &unstructured.Unstructured{} - vr.SetUnstructuredContent(map[string]interface{}{ + vr.SetUnstructuredContent(map[string]any{ "apiVersion": fmt.Sprintf("%s/%s", VolumeReplicationResource.Group, VolumeReplicationResource.Version), "kind": "VolumeReplication", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": pvcName, "namespace": nsName, - "labels": map[string]interface{}{ + "labels": map[string]any{ constants.ParentLabel: pvcName, }, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "volumeReplicationClass": vrcName, "replicationState": "primary", - "dataSource": map[string]interface{}{ + "dataSource": map[string]any{ "apiGroup": "v1", "kind": "PersistentVolumeClaim", "name": pvcName, @@ -80,13 +82,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - deleted := false - for _, action := range actions { - if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { - deleted = true - break - } - } + deleted := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" + }) require.True(t, deleted, "VR should have been deleted") }, }, @@ -103,13 +101,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - deleted := false - for _, action := range actions { - if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { - deleted = true - break - } - } + deleted := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" + }) require.True(t, deleted, "VR should have been deleted") }, }, @@ -143,13 +137,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - deleted := false - for _, action := range actions { - if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { - deleted = true - break - } - } + deleted := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" + }) require.True(t, deleted, "VR should have been deleted") }, }, @@ -165,13 +155,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - deleted := false - for _, action := range actions { - if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { - deleted = true - break - } - } + deleted := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" + }) require.True(t, deleted, "VR should have been deleted") }, }, @@ -183,13 +169,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - created := false - for _, action := range actions { - if action.GetVerb() == "create" && action.GetResource().Resource == "volumereplications" { - created = true - break - } - } + created := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "create" && action.GetResource().Resource == "volumereplications" + }) require.True(t, created, "VR should have been created") }, }, @@ -254,13 +236,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - deleted := false - for _, action := range actions { - if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { - deleted = true - break - } - } + deleted := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" + }) require.True(t, deleted, "VR should have been deleted despite PVC pause") }, }, @@ -279,13 +257,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - deleted := false - for _, action := range actions { - if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { - deleted = true - break - } - } + deleted := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" + }) require.True(t, deleted, "VR should have been deleted despite namespace pause") }, }, @@ -309,13 +283,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - deleted := false - for _, action := range actions { - if action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" { - deleted = true - break - } - } + deleted := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "delete" && action.GetResource().Resource == "volumereplications" + }) require.True(t, deleted, "VR should have been deleted despite namespace pause") }, }, @@ -356,13 +326,9 @@ func TestReconcileVolumeReplication(t *testing.T) { }, verify: func(t *testing.T) { actions := dynamicClient.Actions() - created := false - for _, action := range actions { - if action.GetVerb() == "create" && action.GetResource().Resource == "volumereplications" { - created = true - break - } - } + created := slices.ContainsFunc(actions, func(action k8s_testing.Action) bool { + return action.GetVerb() == "create" && action.GetResource().Resource == "volumereplications" + }) require.True(t, created, "VR should have been created") }, }, diff --git a/internal/replicator/utils.go b/internal/replicator/utils.go index d2359b7..e8682ea 100644 --- a/internal/replicator/utils.go +++ b/internal/replicator/utils.go @@ -3,6 +3,7 @@ package replicator import ( "context" "fmt" + "maps" "regexp" "github.com/super-phenix/volume-replicator/internal/constants" @@ -68,29 +69,29 @@ func createVolumeReplication(pvc *corev1.PersistentVolumeClaim) error { // Create an unstructured VolumeReplication with the same name and same metadata as the PVC volumeReplication := &unstructured.Unstructured{} - annotations := make(map[string]interface{}) + annotations := make(map[string]any) for k, v := range pvc.Annotations { annotations[k] = v } - labels := make(map[string]interface{}) + labels := make(map[string]any) for k, v := range getLabelsWithParent(pvc.Labels, pvc.Name) { labels[k] = v } - volumeReplication.SetUnstructuredContent(map[string]interface{}{ + volumeReplication.SetUnstructuredContent(map[string]any{ "apiVersion": fmt.Sprintf("%s/%s", VolumeReplicationResource.Group, VolumeReplicationResource.Version), "kind": "VolumeReplication", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": pvc.Name, "namespace": pvc.Namespace, "annotations": annotations, "labels": labels, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "volumeReplicationClass": getVolumeReplicationClass(pvc), "replicationState": "primary", - "dataSource": map[string]interface{}{ + "dataSource": map[string]any{ "apiGroup": "v1", "kind": "PersistentVolumeClaim", "name": pvc.Name, @@ -124,9 +125,9 @@ func isParentLabelPresent(labels map[string]string) bool { // getLabelsWithParent returns a new map of labels for a VolumeReplication with its parent PVC embedded. // It creates a copy of the input map to avoid side effects. func getLabelsWithParent(pvcLabels map[string]string, parent string) map[string]string { - res := make(map[string]string, len(pvcLabels)+1) - for k, v := range pvcLabels { - res[k] = v + res := maps.Clone(pvcLabels) + if res == nil { + res = make(map[string]string) } res[constants.ParentLabel] = parent return res diff --git a/internal/replicator/utils_test.go b/internal/replicator/utils_test.go index 6669283..9636bf3 100644 --- a/internal/replicator/utils_test.go +++ b/internal/replicator/utils_test.go @@ -1,7 +1,6 @@ package replicator import ( - "context" "fmt" "testing" @@ -53,7 +52,7 @@ func TestCreateVolumeReplication(t *testing.T) { require.NoError(t, err) // Verify creation - vr, err := dynamicClient.Resource(VolumeReplicationResource).Namespace(nsName).Get(context.Background(), pvcName, metav1.GetOptions{}) + vr, err := dynamicClient.Resource(VolumeReplicationResource).Namespace(nsName).Get(t.Context(), pvcName, metav1.GetOptions{}) require.NoError(t, err) require.NotNil(t, vr) @@ -66,12 +65,12 @@ func TestCreateVolumeReplication(t *testing.T) { require.Equal(t, pvcName, vr.GetLabels()[constants.ParentLabel]) // Check spec - spec, ok := vr.Object["spec"].(map[string]interface{}) + spec, ok := vr.Object["spec"].(map[string]any) require.True(t, ok) require.Equal(t, vrcName, spec["volumeReplicationClass"]) require.Equal(t, "primary", spec["replicationState"]) - dataSource, ok := spec["dataSource"].(map[string]interface{}) + dataSource, ok := spec["dataSource"].(map[string]any) require.True(t, ok) require.Equal(t, "v1", dataSource["apiGroup"]) require.Equal(t, "PersistentVolumeClaim", dataSource["kind"]) @@ -198,7 +197,7 @@ func TestGetStorageClassLabels(t *testing.T) { Labels: labels, }, } - _, _ = client.StorageV1().StorageClasses().Create(context.Background(), stc, metav1.CreateOptions{}) + _, _ = client.StorageV1().StorageClasses().Create(t.Context(), stc, metav1.CreateOptions{}) pvc := &corev1.PersistentVolumeClaim{ Spec: corev1.PersistentVolumeClaimSpec{ @@ -209,7 +208,7 @@ func TestGetStorageClassLabels(t *testing.T) { require.NoError(t, err) require.Equal(t, labels, result) - _ = client.StorageV1().StorageClasses().Delete(context.Background(), stcName, metav1.DeleteOptions{}) + _ = client.StorageV1().StorageClasses().Delete(t.Context(), stcName, metav1.DeleteOptions{}) }) t.Run("StorageClass exists and has no labels", func(t *testing.T) { @@ -218,7 +217,7 @@ func TestGetStorageClassLabels(t *testing.T) { Name: stcName, }, } - _, _ = client.StorageV1().StorageClasses().Create(context.Background(), stc, metav1.CreateOptions{}) + _, _ = client.StorageV1().StorageClasses().Create(t.Context(), stc, metav1.CreateOptions{}) pvc := &corev1.PersistentVolumeClaim{ Spec: corev1.PersistentVolumeClaimSpec{ @@ -229,7 +228,7 @@ func TestGetStorageClassLabels(t *testing.T) { require.NoError(t, err) require.Nil(t, result) - _ = client.StorageV1().StorageClasses().Delete(context.Background(), stcName, metav1.DeleteOptions{}) + _ = client.StorageV1().StorageClasses().Delete(t.Context(), stcName, metav1.DeleteOptions{}) }) t.Run("StorageClass does not exist", func(t *testing.T) { @@ -269,7 +268,7 @@ func TestGetStorageClassGroup(t *testing.T) { Name: stcName, }, } - _, _ = client.StorageV1().StorageClasses().Create(context.Background(), stc, metav1.CreateOptions{}) + _, _ = client.StorageV1().StorageClasses().Create(t.Context(), stc, metav1.CreateOptions{}) pvc := &corev1.PersistentVolumeClaim{ Spec: corev1.PersistentVolumeClaimSpec{ @@ -280,7 +279,7 @@ func TestGetStorageClassGroup(t *testing.T) { require.NoError(t, err) require.Equal(t, "", result) - _ = client.StorageV1().StorageClasses().Delete(context.Background(), stcName, metav1.DeleteOptions{}) + _ = client.StorageV1().StorageClasses().Delete(t.Context(), stcName, metav1.DeleteOptions{}) }) t.Run("StorageClass has group label", func(t *testing.T) { @@ -292,7 +291,7 @@ func TestGetStorageClassGroup(t *testing.T) { }, }, } - _, _ = client.StorageV1().StorageClasses().Create(context.Background(), stc, metav1.CreateOptions{}) + _, _ = client.StorageV1().StorageClasses().Create(t.Context(), stc, metav1.CreateOptions{}) pvc := &corev1.PersistentVolumeClaim{ Spec: corev1.PersistentVolumeClaimSpec{ @@ -303,7 +302,7 @@ func TestGetStorageClassGroup(t *testing.T) { require.NoError(t, err) require.Equal(t, groupName, result) - _ = client.StorageV1().StorageClasses().Delete(context.Background(), stcName, metav1.DeleteOptions{}) + _ = client.StorageV1().StorageClasses().Delete(t.Context(), stcName, metav1.DeleteOptions{}) }) t.Run("StorageClass does not exist", func(t *testing.T) { @@ -406,13 +405,13 @@ func TestCleanupVolumeReplication(t *testing.T) { vr.SetName(vrName) vr.SetNamespace(nsName) - _, err := dynamicClient.Resource(VolumeReplicationResource).Namespace(nsName).Create(context.Background(), vr, metav1.CreateOptions{}) + _, err := dynamicClient.Resource(VolumeReplicationResource).Namespace(nsName).Create(t.Context(), vr, metav1.CreateOptions{}) require.NoError(t, err) cleanupVolumeReplication(vrName, nsName) // Verify deletion - _, err = dynamicClient.Resource(VolumeReplicationResource).Namespace(nsName).Get(context.Background(), vrName, metav1.GetOptions{}) + _, err = dynamicClient.Resource(VolumeReplicationResource).Namespace(nsName).Get(t.Context(), vrName, metav1.GetOptions{}) require.Error(t, err) require.True(t, errors.IsNotFound(err)) }) @@ -445,10 +444,10 @@ func TestGetVolumeReplication(t *testing.T) { key := ns + "/" + name vr := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": "replication.storage.openshift.io/v1alpha1", "kind": "VolumeReplication", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": name, "namespace": ns, }, @@ -501,14 +500,14 @@ func TestIsVolumeReplicationCorrect(t *testing.T) { { name: "All fields match", vr: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": pvcName, "namespace": nsName, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "volumeReplicationClass": vrcName, - "dataSource": map[string]interface{}{ + "dataSource": map[string]any{ "apiGroup": "v1", "kind": "PersistentVolumeClaim", "name": pvcName, @@ -521,14 +520,14 @@ func TestIsVolumeReplicationCorrect(t *testing.T) { { name: "volumeReplicationClass mismatch", vr: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": pvcName, "namespace": nsName, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "volumeReplicationClass": "wrong-vrc", - "dataSource": map[string]interface{}{ + "dataSource": map[string]any{ "apiGroup": "v1", "kind": "PersistentVolumeClaim", "name": pvcName, @@ -541,14 +540,14 @@ func TestIsVolumeReplicationCorrect(t *testing.T) { { name: "dataSource apiGroup mismatch", vr: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": pvcName, "namespace": nsName, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "volumeReplicationClass": vrcName, - "dataSource": map[string]interface{}{ + "dataSource": map[string]any{ "apiGroup": "wrong-group", "kind": "PersistentVolumeClaim", "name": pvcName, @@ -561,14 +560,14 @@ func TestIsVolumeReplicationCorrect(t *testing.T) { { name: "dataSource kind mismatch", vr: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": pvcName, "namespace": nsName, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "volumeReplicationClass": vrcName, - "dataSource": map[string]interface{}{ + "dataSource": map[string]any{ "apiGroup": "v1", "kind": "WrongKind", "name": pvcName, @@ -581,14 +580,14 @@ func TestIsVolumeReplicationCorrect(t *testing.T) { { name: "dataSource name mismatch", vr: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "metadata": map[string]interface{}{ + Object: map[string]any{ + "metadata": map[string]any{ "name": pvcName, "namespace": nsName, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "volumeReplicationClass": vrcName, - "dataSource": map[string]interface{}{ + "dataSource": map[string]any{ "apiGroup": "v1", "kind": "PersistentVolumeClaim", "name": "wrong-pvc-name", diff --git a/internal/replicator/vrc_test.go b/internal/replicator/vrc_test.go index 3927bbd..edef5aa 100644 --- a/internal/replicator/vrc_test.go +++ b/internal/replicator/vrc_test.go @@ -1,7 +1,6 @@ package replicator import ( - "context" "fmt" "testing" @@ -63,26 +62,26 @@ func TestGetVolumeReplicationClass(t *testing.T) { }, }, } - _, _ = client.StorageV1().StorageClasses().Create(context.Background(), stc, metav1.CreateOptions{}) + _, _ = client.StorageV1().StorageClasses().Create(t.Context(), stc, metav1.CreateOptions{}) // Create a VRC that matches the selector vrc := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": fmt.Sprintf("%s/%s", VolumeReplicationResource.Group, VolumeReplicationResource.Version), "kind": "VolumeReplicationClass", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "vrc-matched", - "labels": map[string]interface{}{ + "labels": map[string]any{ constants.StorageClassGroup: groupName, constants.VrcSelectorAnnotation: selectorValue, }, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "provisioner": provisionerName, }, }, } - _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(context.Background(), vrc, metav1.CreateOptions{}) + _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(t.Context(), vrc, metav1.CreateOptions{}) tests := []struct { name string @@ -451,22 +450,22 @@ func TestGetVolumeReplicationClassFromSelector(t *testing.T) { provisionerName := "test-provisioner" vrc := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": fmt.Sprintf("%s/%s", VolumeReplicationResource.Group, VolumeReplicationResource.Version), "kind": "VolumeReplicationClass", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "vrc-matched", - "labels": map[string]interface{}{ + "labels": map[string]any{ constants.StorageClassGroup: groupName, constants.VrcSelectorAnnotation: selectorValue, }, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "provisioner": provisionerName, }, }, } - _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(context.Background(), vrc, metav1.CreateOptions{}) + _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(t.Context(), vrc, metav1.CreateOptions{}) stc := &storagev1.StorageClass{ ObjectMeta: metav1.ObjectMeta{ @@ -476,7 +475,7 @@ func TestGetVolumeReplicationClassFromSelector(t *testing.T) { }, }, } - _, _ = client.StorageV1().StorageClasses().Create(context.Background(), stc, metav1.CreateOptions{}) + _, _ = client.StorageV1().StorageClasses().Create(t.Context(), stc, metav1.CreateOptions{}) t.Run("PVC without VrcSelectorAnnotation", func(t *testing.T) { pvc := &corev1.PersistentVolumeClaim{ @@ -527,9 +526,9 @@ func TestGetVolumeReplicationClassFromSelector(t *testing.T) { Name: stcNoGroup, }, } - _, _ = client.StorageV1().StorageClasses().Create(context.Background(), stc, metav1.CreateOptions{}) + _, _ = client.StorageV1().StorageClasses().Create(t.Context(), stc, metav1.CreateOptions{}) defer func() { - _ = client.StorageV1().StorageClasses().Delete(context.Background(), stcNoGroup, metav1.DeleteOptions{}) + _ = client.StorageV1().StorageClasses().Delete(t.Context(), stcNoGroup, metav1.DeleteOptions{}) }() pvc := &corev1.PersistentVolumeClaim{ @@ -563,24 +562,24 @@ func TestGetVolumeReplicationClassFromSelector(t *testing.T) { t.Run("Multiple matching VRCs found", func(t *testing.T) { vrc2 := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": fmt.Sprintf("%s/%s", VolumeReplicationResource.Group, VolumeReplicationResource.Version), "kind": "VolumeReplicationClass", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "vrc-matched-2", - "labels": map[string]interface{}{ + "labels": map[string]any{ constants.StorageClassGroup: groupName, constants.VrcSelectorAnnotation: selectorValue, }, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "provisioner": provisionerName, }, }, } - _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(context.Background(), vrc2, metav1.CreateOptions{}) + _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(t.Context(), vrc2, metav1.CreateOptions{}) defer func() { - _ = dynamicClient.Resource(VolumeReplicationClassesResource).Delete(context.Background(), "vrc-matched-2", metav1.DeleteOptions{}) + _ = dynamicClient.Resource(VolumeReplicationClassesResource).Delete(t.Context(), "vrc-matched-2", metav1.DeleteOptions{}) }() pvc := &corev1.PersistentVolumeClaim{ @@ -637,41 +636,41 @@ func TestFilterVrcFromSelector(t *testing.T) { k8s.DynamicClientSet = dynamicClient vrc1 := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": fmt.Sprintf("%s/%s", VolumeReplicationResource.Group, VolumeReplicationResource.Version), "kind": "VolumeReplicationClass", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "vrc-1", - "labels": map[string]interface{}{ + "labels": map[string]any{ constants.StorageClassGroup: "group-1", constants.VrcSelectorAnnotation: "match", }, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "provisioner": "provisioner-1", }, }, } vrc2 := &unstructured.Unstructured{ - Object: map[string]interface{}{ + Object: map[string]any{ "apiVersion": fmt.Sprintf("%s/%s", VolumeReplicationResource.Group, VolumeReplicationResource.Version), "kind": "VolumeReplicationClass", - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": "vrc-2", - "labels": map[string]interface{}{ + "labels": map[string]any{ constants.StorageClassGroup: "group-2", constants.VrcSelectorAnnotation: "no-match", }, }, - "spec": map[string]interface{}{ + "spec": map[string]any{ "provisioner": "provisioner-2", }, }, } - _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(context.Background(), vrc1, metav1.CreateOptions{}) - _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(context.Background(), vrc2, metav1.CreateOptions{}) + _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(t.Context(), vrc1, metav1.CreateOptions{}) + _, _ = dynamicClient.Resource(VolumeReplicationClassesResource).Create(t.Context(), vrc2, metav1.CreateOptions{}) t.Run("Match found with both labels and provisioner", func(t *testing.T) { list, err := filterVrcFromSelector("group-1", "match", "provisioner-1") From fc5f6e24440f3a75142b714bfea8fbf92e53f5a4 Mon Sep 17 00:00:00 2001 From: SkalaNetworks Date: Tue, 21 Jul 2026 17:41:44 +0200 Subject: [PATCH 3/3] chore(tests): add test for isPvcPaused Signed-off-by: SkalaNetworks --- internal/replicator/utils_test.go | 125 ++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/internal/replicator/utils_test.go b/internal/replicator/utils_test.go index 9636bf3..cbf70ac 100644 --- a/internal/replicator/utils_test.go +++ b/internal/replicator/utils_test.go @@ -725,6 +725,131 @@ func TestIsNamespacePaused(t *testing.T) { } } +func TestIsPvcPaused(t *testing.T) { + client := fake.NewClientset() + informerFactory := informers.NewSharedInformerFactory(client, 0) + NamespaceInformer = informerFactory.Core().V1().Namespaces() + + nsName := "test-ns" + pausedNs := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "true"}, + }, + } + unpausedNs := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "false"}, + }, + } + + tests := []struct { + name string + pvc *corev1.PersistentVolumeClaim + namespace *corev1.Namespace + expected bool + }{ + { + name: "PVC is nil, NS not paused", + pvc: nil, + namespace: unpausedNs, + expected: false, + }, + { + name: "PVC is nil, NS paused", + pvc: nil, + namespace: pausedNs, + expected: true, + }, + { + name: "PVC no annotation, NS not paused", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Namespace: nsName}, + }, + namespace: unpausedNs, + expected: false, + }, + { + name: "PVC no annotation, NS paused", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Namespace: nsName}, + }, + namespace: pausedNs, + expected: true, + }, + { + name: "PVC paused=true, NS not paused", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "true"}, + }, + }, + namespace: unpausedNs, + expected: true, + }, + { + name: "PVC paused=true, NS paused", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "true"}, + }, + }, + namespace: pausedNs, + expected: true, + }, + { + name: "PVC paused=false, NS not paused", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "false"}, + }, + }, + namespace: unpausedNs, + expected: false, + }, + { + name: "PVC paused=false, NS paused", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "false"}, + }, + }, + namespace: pausedNs, + expected: false, // PVC takes precedence + }, + { + name: "PVC invalid pause value, NS paused", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: nsName, + Annotations: map[string]string{constants.PauseAnnotation: "invalid"}, + }, + }, + namespace: pausedNs, + expected: false, // PVC takes precedence, and invalid is not true + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + indexer := NamespaceInformer.Informer().GetIndexer() + for _, obj := range indexer.List() { + _ = indexer.Delete(obj) + } + if tt.namespace != nil { + require.NoError(t, indexer.Add(tt.namespace)) + } + + require.Equal(t, tt.expected, isPvcPaused(tt.pvc, nsName)) + }) + } +} + func TestPvcNameMatchesExclusion(t *testing.T) { tests := []struct { name string