From a07ebf162719d4485f9270a20fb8ca7c87fac347 Mon Sep 17 00:00:00 2001 From: wenting Date: Tue, 23 Dec 2025 15:29:45 -0500 Subject: [PATCH 01/19] draft Signed-off-by: wenting --- operator/src/api/preview/documentdb_types.go | 9 + .../controller/documentdb_controller.go | 8 + .../src/internal/controller/pvc_controller.go | 255 ++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 operator/src/internal/controller/pvc_controller.go diff --git a/operator/src/api/preview/documentdb_types.go b/operator/src/api/preview/documentdb_types.go index 2ed37a0e..13b9ec0a 100644 --- a/operator/src/api/preview/documentdb_types.go +++ b/operator/src/api/preview/documentdb_types.go @@ -115,6 +115,15 @@ type StorageConfiguration struct { // StorageClass specifies the storage class for DocumentDB persistent volumes. // If not specified, the cluster's default storage class will be used. StorageClass string `json:"storageClass,omitempty"` + + // PvcRetentionPeriodDays specifies how many days PVCs should be retained after the DocumentDB cluster is deleted. + // This allows for data recovery after accidental cluster deletion. + // Set to 0 for immediate deletion (default behavior: 7 days). + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=365 + // +kubebuilder:default=7 + // +optional + PvcRetentionPeriodDays int `json:"pvcRetentionPeriodDays,omitempty"` } type ClusterReplication struct { diff --git a/operator/src/internal/controller/documentdb_controller.go b/operator/src/internal/controller/documentdb_controller.go index cb6d8010..4e615a6c 100644 --- a/operator/src/internal/controller/documentdb_controller.go +++ b/operator/src/internal/controller/documentdb_controller.go @@ -54,6 +54,7 @@ var reconcileMutex sync.Mutex // +kubebuilder:rbac:groups=documentdb.io,resources=dbs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=documentdb.io,resources=dbs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=documentdb.io,resources=dbs/finalizers,verbs=update +// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;update;patch func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { reconcileMutex.Lock() defer reconcileMutex.Unlock() @@ -257,6 +258,13 @@ func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) } } + // Ensure finalizers are added to PVCs owned by the CNPG cluster + pvcReconciler := &PVCReconciler{Client: r.Client} + if err := pvcReconciler.ensurePVCFinalizers(ctx, currentCnpgCluster, documentdb); err != nil { + logger.Error(err, "Failed to ensure PVC finalizers") + // Don't fail the reconciliation, just log and continue + } + // Don't reque again unless there is a change return ctrl.Result{}, nil } diff --git a/operator/src/internal/controller/pvc_controller.go b/operator/src/internal/controller/pvc_controller.go new file mode 100644 index 00000000..985acc65 --- /dev/null +++ b/operator/src/internal/controller/pvc_controller.go @@ -0,0 +1,255 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package controller + +import ( + "context" + "fmt" + "time" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" +) + +const ( + // PVCFinalizerName is the finalizer added to PVCs to manage retention + PVCFinalizerName = "documentdb.io/pvc-retention" + // PVCDeletionTimeAnnotation stores when a PVC was marked for deletion + PVCDeletionTimeAnnotation = "documentdb.io/deletion-time" +) + +// PVCReconciler handles PVC lifecycle management including retention after cluster deletion +type PVCReconciler struct { + client.Client +} + +// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters,verbs=get;list;watch +// +kubebuilder:rbac:groups=documentdb.io,resources=dbs,verbs=get;list;watch + +// Reconcile handles PVC events, managing finalizers and retention periods +func (r *PVCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Fetch the PVC + pvc := &corev1.PersistentVolumeClaim{} + err := r.Get(ctx, req.NamespacedName, pvc) + if err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + logger.Error(err, "Failed to get PVC") + return ctrl.Result{}, err + } + + // Check if PVC has our finalizer + hasFinalizer := false + for _, finalizer := range pvc.Finalizers { + if finalizer == PVCFinalizerName { + hasFinalizer = true + break + } + } + + if !hasFinalizer { + // Finalizer not present, nothing to do + return ctrl.Result{}, nil + } + + // Check if PVC is being deleted + if pvc.DeletionTimestamp == nil { + // PVC is not being deleted, nothing to do + return ctrl.Result{}, nil + } + + // PVC is being deleted, check if retention period has been set + deletionTimeStr, hasAnnotation := pvc.Annotations[PVCDeletionTimeAnnotation] + if !hasAnnotation { + // Find the DocumentDB instance to get retention period + retentionDays := 7 // default + documentDBName := "" + + // Find CNPG cluster owner + for _, ownerRef := range pvc.OwnerReferences { + if ownerRef.Kind == "Cluster" { + // Get the CNPG cluster + cnpgCluster := &cnpgv1.Cluster{} + if err := r.Get(ctx, types.NamespacedName{Name: ownerRef.Name, Namespace: pvc.Namespace}, cnpgCluster); err == nil { + // Find DocumentDB owner of CNPG cluster + for _, cnpgOwner := range cnpgCluster.OwnerReferences { + if cnpgOwner.Kind == "DocumentDB" { + documentDBName = cnpgOwner.Name + break + } + } + } + break + } + } + + // Get retention period from DocumentDB spec + if documentDBName != "" { + documentDB := &dbpreview.DocumentDB{} + if err := r.Get(ctx, types.NamespacedName{Name: documentDBName, Namespace: pvc.Namespace}, documentDB); err == nil { + retentionDays = documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays + } + } + + // Set deletion timestamp annotation + if pvc.Annotations == nil { + pvc.Annotations = make(map[string]string) + } + pvc.Annotations[PVCDeletionTimeAnnotation] = time.Now().Format(time.RFC3339) + if err := r.Client.Update(ctx, pvc); err != nil { + logger.Error(err, "Failed to add deletion time annotation to PVC") + return ctrl.Result{}, err + } + + logger.Info("PVC marked for deletion with retention period", + "PVC", pvc.Name, + "RetentionDays", retentionDays, + "DeletionTime", pvc.Annotations[PVCDeletionTimeAnnotation]) + + // Requeue to check again later + return ctrl.Result{RequeueAfter: 24 * time.Hour}, nil + } + + // Parse the deletion time + deletionTime, err := time.Parse(time.RFC3339, deletionTimeStr) + if err != nil { + logger.Error(err, "Failed to parse deletion time annotation, removing finalizer immediately") + // Remove finalizer to allow deletion + pvc.Finalizers = removeFinalizer(pvc.Finalizers, PVCFinalizerName) + if err := r.Client.Update(ctx, pvc); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Get retention period from DocumentDB + retentionDays := 7 // default + documentDBName := "" + + // Find CNPG cluster owner + for _, ownerRef := range pvc.OwnerReferences { + if ownerRef.Kind == "Cluster" { + // Get the CNPG cluster + cnpgCluster := &cnpgv1.Cluster{} + if err := r.Get(ctx, types.NamespacedName{Name: ownerRef.Name, Namespace: pvc.Namespace}, cnpgCluster); err == nil { + // Find DocumentDB owner of CNPG cluster + for _, cnpgOwner := range cnpgCluster.OwnerReferences { + if cnpgOwner.Kind == "DocumentDB" { + documentDBName = cnpgOwner.Name + break + } + } + } + break + } + } + + // Get retention period from DocumentDB spec + if documentDBName != "" { + documentDB := &dbpreview.DocumentDB{} + if err := r.Get(ctx, types.NamespacedName{Name: documentDBName, Namespace: pvc.Namespace}, documentDB); err == nil { + retentionDays = documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays + } + } + + // Check if retention period has passed + retentionDuration := time.Duration(retentionDays) * 24 * time.Hour + if time.Since(deletionTime) >= retentionDuration { + logger.Info("Retention period expired, removing finalizer from PVC", + "PVC", pvc.Name, + "RetentionDays", retentionDays, + "DeletionTime", deletionTimeStr) + + // Remove finalizer to allow deletion + pvc.Finalizers = removeFinalizer(pvc.Finalizers, PVCFinalizerName) + if err := r.Client.Update(ctx, pvc); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Retention period not yet expired, requeue + timeRemaining := retentionDuration - time.Since(deletionTime) + logger.Info("PVC retention period not yet expired", + "PVC", pvc.Name, + "TimeRemaining", timeRemaining.String()) + + // Requeue after remaining time or max 24 hours + requeueAfter := timeRemaining + if requeueAfter > 24*time.Hour { + requeueAfter = 24 * time.Hour + } + return ctrl.Result{RequeueAfter: requeueAfter}, nil +} + +// ensurePVCFinalizers ensures that all PVCs owned by the CNPG cluster have the retention finalizer +func (r *PVCReconciler) ensurePVCFinalizers(ctx context.Context, cnpgCluster *cnpgv1.Cluster, documentdb *dbpreview.DocumentDB) error { + logger := log.FromContext(ctx) + + // List all PVCs in the namespace + pvcList := &corev1.PersistentVolumeClaimList{} + if err := r.Client.List(ctx, pvcList, client.InNamespace(cnpgCluster.Namespace)); err != nil { + return fmt.Errorf("failed to list PVCs: %w", err) + } + + // Filter PVCs owned by the CNPG cluster + for i := range pvcList.Items { + pvc := &pvcList.Items[i] + + // Check if this PVC is owned by the CNPG cluster + isOwnedByCNPG := false + for _, ownerRef := range pvc.OwnerReferences { + if ownerRef.Kind == "Cluster" && ownerRef.Name == cnpgCluster.Name { + isOwnedByCNPG = true + break + } + } + + if !isOwnedByCNPG { + continue + } + + // Check if finalizer is already present + hasFinalizer := false + for _, finalizer := range pvc.Finalizers { + if finalizer == PVCFinalizerName { + hasFinalizer = true + break + } + } + + // Add finalizer if not present + if !hasFinalizer { + logger.Info("Adding finalizer to PVC", "PVC", pvc.Name, "Cluster", cnpgCluster.Name) + pvc.Finalizers = append(pvc.Finalizers, PVCFinalizerName) + if err := r.Client.Update(ctx, pvc); err != nil { + return fmt.Errorf("failed to add finalizer to PVC %s: %w", pvc.Name, err) + } + } + } + + return nil +} + +// removeFinalizer removes a specific finalizer from a slice of finalizers +func removeFinalizer(finalizers []string, finalizer string) []string { + result := []string{} + for _, f := range finalizers { + if f != finalizer { + result = append(result, f) + } + } + return result +} From 5f94f2f49f2057e3332fa7bfa948452354ee65d6 Mon Sep 17 00:00:00 2001 From: wenting Date: Tue, 6 Jan 2026 16:10:41 -0500 Subject: [PATCH 02/19] label + annotation Signed-off-by: wenting --- .../controller/documentdb_controller.go | 8 - .../src/internal/controller/pvc_controller.go | 345 +++++----- .../controller/pvc_controller_test.go | 605 ++++++++++++++++++ 3 files changed, 771 insertions(+), 187 deletions(-) create mode 100644 operator/src/internal/controller/pvc_controller_test.go diff --git a/operator/src/internal/controller/documentdb_controller.go b/operator/src/internal/controller/documentdb_controller.go index 4e615a6c..cb6d8010 100644 --- a/operator/src/internal/controller/documentdb_controller.go +++ b/operator/src/internal/controller/documentdb_controller.go @@ -54,7 +54,6 @@ var reconcileMutex sync.Mutex // +kubebuilder:rbac:groups=documentdb.io,resources=dbs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=documentdb.io,resources=dbs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=documentdb.io,resources=dbs/finalizers,verbs=update -// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;update;patch func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { reconcileMutex.Lock() defer reconcileMutex.Unlock() @@ -258,13 +257,6 @@ func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) } } - // Ensure finalizers are added to PVCs owned by the CNPG cluster - pvcReconciler := &PVCReconciler{Client: r.Client} - if err := pvcReconciler.ensurePVCFinalizers(ctx, currentCnpgCluster, documentdb); err != nil { - logger.Error(err, "Failed to ensure PVC finalizers") - // Don't fail the reconciliation, just log and continue - } - // Don't reque again unless there is a change return ctrl.Result{}, nil } diff --git a/operator/src/internal/controller/pvc_controller.go b/operator/src/internal/controller/pvc_controller.go index 985acc65..706c14b3 100644 --- a/operator/src/internal/controller/pvc_controller.go +++ b/operator/src/internal/controller/pvc_controller.go @@ -5,25 +5,24 @@ package controller import ( "context" - "fmt" "time" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + dbpreview "github.com/documentdb/documentdb-operator/api/preview" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" - - dbpreview "github.com/documentdb/documentdb-operator/api/preview" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) const ( // PVCFinalizerName is the finalizer added to PVCs to manage retention PVCFinalizerName = "documentdb.io/pvc-retention" - // PVCDeletionTimeAnnotation stores when a PVC was marked for deletion - PVCDeletionTimeAnnotation = "documentdb.io/deletion-time" ) // PVCReconciler handles PVC lifecycle management including retention after cluster deletion @@ -35,220 +34,208 @@ type PVCReconciler struct { // +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters,verbs=get;list;watch // +kubebuilder:rbac:groups=documentdb.io,resources=dbs,verbs=get;list;watch -// Reconcile handles PVC events, managing finalizers and retention periods +// Reconcile handles PVC events for DocumentDB clusters only, managing finalizers and retention periods. +// PVCs not belonging to a DocumentDB cluster are ignored. func (r *PVCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - // Fetch the PVC - pvc := &corev1.PersistentVolumeClaim{} - err := r.Get(ctx, req.NamespacedName, pvc) - if err != nil { - if errors.IsNotFound(err) { - return ctrl.Result{}, nil - } - logger.Error(err, "Failed to get PVC") + var pvc corev1.PersistentVolumeClaim + if err := r.Get(ctx, req.NamespacedName, &pvc); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Early exit: Only process PVCs belonging to a DocumentDB cluster + clusterName, err := r.getDocumentDBClusterName(ctx, &pvc) + if err != nil || clusterName == "" { return ctrl.Result{}, err } - // Check if PVC has our finalizer - hasFinalizer := false - for _, finalizer := range pvc.Finalizers { - if finalizer == PVCFinalizerName { - hasFinalizer = true - break + // Label the PVC for faster lookup next time if not already labeled + if pvc.Labels["documentdb.io/cluster"] != clusterName { + if pvc.Labels == nil { + pvc.Labels = make(map[string]string) + } + pvc.Labels["documentdb.io/cluster"] = clusterName + if err := r.Update(ctx, &pvc); err != nil { + return ctrl.Result{}, err } } - if !hasFinalizer { - // Finalizer not present, nothing to do - return ctrl.Result{}, nil - } - - // Check if PVC is being deleted - if pvc.DeletionTimestamp == nil { - // PVC is not being deleted, nothing to do - return ctrl.Result{}, nil - } - - // PVC is being deleted, check if retention period has been set - deletionTimeStr, hasAnnotation := pvc.Annotations[PVCDeletionTimeAnnotation] - if !hasAnnotation { - // Find the DocumentDB instance to get retention period - retentionDays := 7 // default - documentDBName := "" - - // Find CNPG cluster owner - for _, ownerRef := range pvc.OwnerReferences { - if ownerRef.Kind == "Cluster" { - // Get the CNPG cluster - cnpgCluster := &cnpgv1.Cluster{} - if err := r.Get(ctx, types.NamespacedName{Name: ownerRef.Name, Namespace: pvc.Namespace}, cnpgCluster); err == nil { - // Find DocumentDB owner of CNPG cluster - for _, cnpgOwner := range cnpgCluster.OwnerReferences { - if cnpgOwner.Kind == "DocumentDB" { - documentDBName = cnpgOwner.Name - break - } - } - } - break - } - } + // Fetch the DocumentDB cluster + var cluster dbpreview.DocumentDB + if err := r.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: req.Namespace}, &cluster); err != nil { + return ctrl.Result{}, err + } - // Get retention period from DocumentDB spec - if documentDBName != "" { - documentDB := &dbpreview.DocumentDB{} - if err := r.Get(ctx, types.NamespacedName{Name: documentDBName, Namespace: pvc.Namespace}, documentDB); err == nil { - retentionDays = documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays - } - } + // Update retention annotation if needed + if err := r.updateRetentionAnnotation(ctx, &pvc, int32(cluster.Spec.Resource.Storage.PvcRetentionPeriodDays)); err != nil { + return ctrl.Result{}, err + } - // Set deletion timestamp annotation - if pvc.Annotations == nil { - pvc.Annotations = make(map[string]string) - } - pvc.Annotations[PVCDeletionTimeAnnotation] = time.Now().Format(time.RFC3339) - if err := r.Client.Update(ctx, pvc); err != nil { - logger.Error(err, "Failed to add deletion time annotation to PVC") - return ctrl.Result{}, err - } + // Manage finalizer based on retention policy + // if err := r.manageFinalizer(ctx, &pvc, &cluster); err != nil { + // return ctrl.Result{}, err + // } - logger.Info("PVC marked for deletion with retention period", - "PVC", pvc.Name, - "RetentionDays", retentionDays, - "DeletionTime", pvc.Annotations[PVCDeletionTimeAnnotation]) + return ctrl.Result{}, nil +} - // Requeue to check again later - return ctrl.Result{RequeueAfter: 24 * time.Hour}, nil +// getDocumentDBClusterName determines if a PVC belongs to a DocumentDB cluster and returns the cluster name. +// Returns empty string if the PVC does not belong to a DocumentDB cluster. +func (r *PVCReconciler) getDocumentDBClusterName(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (string, error) { + // Check if PVC already has the documentdb.io/cluster label + if clusterName, hasLabel := pvc.Labels["documentdb.io/cluster"]; hasLabel { + return clusterName, nil } - // Parse the deletion time - deletionTime, err := time.Parse(time.RFC3339, deletionTimeStr) - if err != nil { - logger.Error(err, "Failed to parse deletion time annotation, removing finalizer immediately") - // Remove finalizer to allow deletion - pvc.Finalizers = removeFinalizer(pvc.Finalizers, PVCFinalizerName) - if err := r.Client.Update(ctx, pvc); err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil + // Try to find DocumentDB ownership through CNPG cluster + clusterName, err := r.findDocumentDBOwnerThroughCNPG(ctx, pvc) + if err != nil || clusterName == "" { + return "", err } - // Get retention period from DocumentDB - retentionDays := 7 // default - documentDBName := "" + return clusterName, nil +} - // Find CNPG cluster owner +// findDocumentDBOwnerThroughCNPG checks if the PVC is owned by a CNPG cluster that is owned by a DocumentDB. +func (r *PVCReconciler) findDocumentDBOwnerThroughCNPG(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (string, error) { for _, ownerRef := range pvc.OwnerReferences { - if ownerRef.Kind == "Cluster" { - // Get the CNPG cluster - cnpgCluster := &cnpgv1.Cluster{} - if err := r.Get(ctx, types.NamespacedName{Name: ownerRef.Name, Namespace: pvc.Namespace}, cnpgCluster); err == nil { - // Find DocumentDB owner of CNPG cluster - for _, cnpgOwner := range cnpgCluster.OwnerReferences { - if cnpgOwner.Kind == "DocumentDB" { - documentDBName = cnpgOwner.Name - break - } - } + if ownerRef.Kind != "Cluster" { + continue + } + + var cnpgCluster cnpgv1.Cluster + err := r.Get(ctx, types.NamespacedName{Name: ownerRef.Name, Namespace: pvc.Namespace}, &cnpgCluster) + if err != nil { + continue + } + + // Check if CNPG cluster is owned by a DocumentDB + for _, cnpgOwnerRef := range cnpgCluster.OwnerReferences { + if cnpgOwnerRef.Kind == "DocumentDB" { + return cnpgOwnerRef.Name, nil } - break } } - // Get retention period from DocumentDB spec - if documentDBName != "" { - documentDB := &dbpreview.DocumentDB{} - if err := r.Get(ctx, types.NamespacedName{Name: documentDBName, Namespace: pvc.Namespace}, documentDB); err == nil { - retentionDays = documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays - } + return "", nil +} + +// updateRetentionAnnotation updates the PVC retention annotation if it has changed. +func (r *PVCReconciler) updateRetentionAnnotation(ctx context.Context, pvc *corev1.PersistentVolumeClaim, retentionDays int32) error { + if pvc.Annotations == nil { + pvc.Annotations = make(map[string]string) } - // Check if retention period has passed - retentionDuration := time.Duration(retentionDays) * 24 * time.Hour - if time.Since(deletionTime) >= retentionDuration { - logger.Info("Retention period expired, removing finalizer from PVC", - "PVC", pvc.Name, - "RetentionDays", retentionDays, - "DeletionTime", deletionTimeStr) + expectedRetention := string(rune(retentionDays)) + currentRetention := pvc.Annotations["documentdb.io/pvc-retention-days"] - // Remove finalizer to allow deletion - pvc.Finalizers = removeFinalizer(pvc.Finalizers, PVCFinalizerName) - if err := r.Client.Update(ctx, pvc); err != nil { - return ctrl.Result{}, err - } - return ctrl.Result{}, nil + if currentRetention != expectedRetention { + pvc.Annotations["documentdb.io/pvc-retention-days"] = expectedRetention + return r.Update(ctx, pvc) } - // Retention period not yet expired, requeue - timeRemaining := retentionDuration - time.Since(deletionTime) - logger.Info("PVC retention period not yet expired", - "PVC", pvc.Name, - "TimeRemaining", timeRemaining.String()) + return nil +} + +// manageFinalizer adds or removes the PVC finalizer based on cluster deletion status and retention period. +func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.PersistentVolumeClaim, cluster *dbpreview.DocumentDB) error { + shouldHaveFinalizer := r.shouldRetainPVC(cluster) + hasFinalizer := containsString(pvc.Finalizers, PVCFinalizerName) + + if shouldHaveFinalizer == hasFinalizer { + // Already in desired state + return nil + } - // Requeue after remaining time or max 24 hours - requeueAfter := timeRemaining - if requeueAfter > 24*time.Hour { - requeueAfter = 24 * time.Hour + if shouldHaveFinalizer { + pvc.Finalizers = append(pvc.Finalizers, PVCFinalizerName) + } else { + pvc.Finalizers = removeString(pvc.Finalizers, PVCFinalizerName) } - return ctrl.Result{RequeueAfter: requeueAfter}, nil + + return r.Update(ctx, pvc) } -// ensurePVCFinalizers ensures that all PVCs owned by the CNPG cluster have the retention finalizer -func (r *PVCReconciler) ensurePVCFinalizers(ctx context.Context, cnpgCluster *cnpgv1.Cluster, documentdb *dbpreview.DocumentDB) error { - logger := log.FromContext(ctx) +// shouldRetainPVC determines if a PVC should be retained based on cluster deletion status and retention period. +func (r *PVCReconciler) shouldRetainPVC(cluster *dbpreview.DocumentDB) bool { + // If cluster is not being deleted, retain the PVC + if cluster.DeletionTimestamp == nil { + return true + } + + retentionDays := cluster.Spec.Resource.Storage.PvcRetentionPeriodDays - // List all PVCs in the namespace - pvcList := &corev1.PersistentVolumeClaimList{} - if err := r.Client.List(ctx, pvcList, client.InNamespace(cnpgCluster.Namespace)); err != nil { - return fmt.Errorf("failed to list PVCs: %w", err) + // Retention days <= 0 means retain forever + if retentionDays <= 0 { + return true } - // Filter PVCs owned by the CNPG cluster - for i := range pvcList.Items { - pvc := &pvcList.Items[i] + // Check if retention period has expired + retentionExpiration := cluster.DeletionTimestamp.AddDate(0, 0, int(retentionDays)) + return time.Now().Before(retentionExpiration) +} - // Check if this PVC is owned by the CNPG cluster - isOwnedByCNPG := false - for _, ownerRef := range pvc.OwnerReferences { - if ownerRef.Kind == "Cluster" && ownerRef.Name == cnpgCluster.Name { - isOwnedByCNPG = true - break - } - } +func (r *PVCReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + // 1. Watch PVCs directly + For(&corev1.PersistentVolumeClaim{}). + // 2. Watch DocumentDB for updates to retention settings + Watches( + &dbpreview.DocumentDB{}, + handler.EnqueueRequestsFromMapFunc(r.findPVCsForCluster), + builder.WithPredicates(ClusterRetentionChangedPredicate()), + ). + Complete(r) +} - if !isOwnedByCNPG { - continue - } +// Maps a Cluster event to a list of PVC Reconcile Requests +func (r *PVCReconciler) findPVCsForCluster(ctx context.Context, cluster client.Object) []reconcile.Request { + pvcList := &corev1.PersistentVolumeClaimList{} - // Check if finalizer is already present - hasFinalizer := false - for _, finalizer := range pvc.Finalizers { - if finalizer == PVCFinalizerName { - hasFinalizer = true - break - } - } + // List PVCs that have a label matching this cluster + if err := r.List(ctx, pvcList, client.MatchingLabels{"documentdb.io/cluster": cluster.GetName()}); err != nil { + return []reconcile.Request{} + } - // Add finalizer if not present - if !hasFinalizer { - logger.Info("Adding finalizer to PVC", "PVC", pvc.Name, "Cluster", cnpgCluster.Name) - pvc.Finalizers = append(pvc.Finalizers, PVCFinalizerName) - if err := r.Client.Update(ctx, pvc); err != nil { - return fmt.Errorf("failed to add finalizer to PVC %s: %w", pvc.Name, err) - } + requests := make([]reconcile.Request, len(pvcList.Items)) + for i, pvc := range pvcList.Items { + requests[i] = reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: pvc.Name, + Namespace: pvc.Namespace, + }, } } + return requests +} - return nil +func ClusterRetentionChangedPredicate() predicate.Predicate { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldCluster := e.ObjectOld.(*dbpreview.DocumentDB) + newCluster := e.ObjectNew.(*dbpreview.DocumentDB) + + // Only trigger if the specific field we care about has changed + return oldCluster.Spec.Resource.Storage.PvcRetentionPeriodDays != newCluster.Spec.Resource.Storage.PvcRetentionPeriodDays + }, + } +} + +// containsString checks if a string slice contains a specific string +func containsString(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false } -// removeFinalizer removes a specific finalizer from a slice of finalizers -func removeFinalizer(finalizers []string, finalizer string) []string { - result := []string{} - for _, f := range finalizers { - if f != finalizer { - result = append(result, f) +// removeString removes a string from a slice +func removeString(slice []string, s string) []string { + result := make([]string, 0, len(slice)) + for _, item := range slice { + if item != s { + result = append(result, item) } } return result diff --git a/operator/src/internal/controller/pvc_controller_test.go b/operator/src/internal/controller/pvc_controller_test.go new file mode 100644 index 00000000..bb429dfe --- /dev/null +++ b/operator/src/internal/controller/pvc_controller_test.go @@ -0,0 +1,605 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package controller + +import ( + "context" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" +) + +var _ = Describe("PVC Controller", func() { + const ( + pvcName = "test-pvc" + pvcNamespace = "default" + clusterName = "test-cluster" + cnpgClusterName = "test-cnpg-cluster" + ) + + var ( + ctx context.Context + scheme *runtime.Scheme + ) + + BeforeEach(func() { + ctx = context.Background() + scheme = runtime.NewScheme() + // Register required schemes + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(dbpreview.AddToScheme(scheme)).To(Succeed()) + Expect(cnpgv1.AddToScheme(scheme)).To(Succeed()) + }) + + // Helper function to create a PVC with optional labels and owner references + createPVC := func(name, namespace string, labels map[string]string, ownerRefs []metav1.OwnerReference) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + OwnerReferences: ownerRefs, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("10Gi"), + }, + }, + }, + } + } + + // Helper function to create a CNPG cluster with optional owner references + createCNPGCluster := func(name, namespace string, ownerRefs []metav1.OwnerReference) *cnpgv1.Cluster { + return &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("cnpg-" + name + "-uid"), + OwnerReferences: ownerRefs, + }, + } + } + + // Helper function to create a DocumentDB cluster + createDocumentDB := func(name, namespace string) *dbpreview.DocumentDB { + return &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("documentdb-" + name + "-uid"), + }, + Spec: dbpreview.DocumentDBSpec{ + NodeCount: 1, + InstancesPerNode: 1, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + }, + }, + }, + } + } + + // Helper function to create an owner reference + createOwnerRef := func(apiVersion, kind, name string, uid types.UID) metav1.OwnerReference { + return metav1.OwnerReference{ + APIVersion: apiVersion, + Kind: kind, + Name: name, + UID: uid, + Controller: func() *bool { b := true; return &b }(), + } + } + + // Helper function to reconcile and verify no error + reconcileAndExpectSuccess := func(reconciler *PVCReconciler, name, namespace string) { + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: name, + Namespace: namespace, + }, + } + result, err := reconciler.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + } + + // Helper function to verify PVC label state + verifyPVCLabel := func(fakeClient client.Client, name, namespace string, shouldHaveLabel bool, expectedClusterName string) { + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, updated)).To(Succeed()) + if shouldHaveLabel { + Expect(updated.Labels).ToNot(BeNil()) + Expect(updated.Labels["documentdb.io/cluster"]).To(Equal(expectedClusterName)) + } else { + _, hasLabel := updated.Labels["documentdb.io/cluster"] + Expect(hasLabel).To(BeFalse()) + } + } + + Describe("Reconcile", func() { + Context("when PVC not found", func() { + It("should handle PVC not found gracefully", func() { + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PVCReconciler{ + Client: fakeClient, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "non-existent-pvc", + Namespace: pvcNamespace, + }, + } + + result, err := reconciler.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + Context("when PVC already has documentdb.io/cluster label", func() { + It("should handle PVC with documentdb.io/cluster label", func() { + documentdb := createDocumentDB(clusterName, pvcNamespace) + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + verifyPVCLabel(fakeClient, pvcName, pvcNamespace, true, clusterName) + }) + }) + + Context("when PVC has no documentdb.io/cluster label", func() { + It("should not add label when PVC has no owner references", func() { + pvc := createPVC(pvcName, pvcNamespace, nil, nil) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") + }) + + It("should not add label when PVC has owner but owner is not CNPG Cluster", func() { + ownerRef := createOwnerRef("apps/v1", "StatefulSet", "test-statefulset", types.UID("statefulset-uid-123")) + pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{ownerRef}) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") + }) + + It("should not add label when PVC owner is CNPG Cluster but CNPG Cluster has no owner", func() { + cnpgCluster := createCNPGCluster(cnpgClusterName, pvcNamespace, nil) + cnpgOwnerRef := createOwnerRef("postgresql.cnpg.io/v1", "Cluster", cnpgClusterName, cnpgCluster.UID) + pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{cnpgOwnerRef}) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cnpgCluster, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") + }) + + It("should not add label when PVC owner is CNPG Cluster but CNPG owner is not DocumentDB", func() { + deploymentOwnerRef := createOwnerRef("apps/v1", "Deployment", "some-deployment", types.UID("deployment-uid-789")) + cnpgCluster := createCNPGCluster(cnpgClusterName, pvcNamespace, []metav1.OwnerReference{deploymentOwnerRef}) + cnpgOwnerRef := createOwnerRef("postgresql.cnpg.io/v1", "Cluster", cnpgClusterName, cnpgCluster.UID) + pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{cnpgOwnerRef}) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cnpgCluster, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") + }) + + It("should add label when PVC owner is CNPG Cluster and CNPG owner is DocumentDB", func() { + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDBOwnerRef := createOwnerRef("documentdb.io/v1alpha1", "DocumentDB", clusterName, documentDB.UID) + cnpgCluster := createCNPGCluster(cnpgClusterName, pvcNamespace, []metav1.OwnerReference{documentDBOwnerRef}) + cnpgOwnerRef := createOwnerRef("postgresql.cnpg.io/v1", "Cluster", cnpgClusterName, cnpgCluster.UID) + pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{cnpgOwnerRef}) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, cnpgCluster, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + verifyPVCLabel(fakeClient, pvcName, pvcNamespace, true, clusterName) + }) + + It("should handle error gracefully when CNPG Cluster does not exist", func() { + cnpgOwnerRef := createOwnerRef("postgresql.cnpg.io/v1", "Cluster", "non-existent-cnpg", types.UID("cnpg-uid-456")) + pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{cnpgOwnerRef}) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") + }) + }) + }) + + Describe("findPVCsForCluster", func() { + It("should return reconcile requests for all PVCs matching cluster label", func() { + pvc1 := createPVC("pvc-1", pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + pvc2 := createPVC("pvc-2", pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + pvc3 := createPVC("pvc-3", pvcNamespace, map[string]string{"documentdb.io/cluster": "different-cluster"}, nil) + cluster := createDocumentDB(clusterName, pvcNamespace) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc1, pvc2, pvc3, cluster). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + requests := reconciler.findPVCsForCluster(ctx, cluster) + + Expect(len(requests)).To(Equal(2)) + Expect(requests).To(ContainElement(reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "pvc-1", + Namespace: pvcNamespace, + }, + })) + Expect(requests).To(ContainElement(reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "pvc-2", + Namespace: pvcNamespace, + }, + })) + }) + + It("should return empty list when no PVCs match cluster label", func() { + cluster := createDocumentDB(clusterName, pvcNamespace) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cluster). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + requests := reconciler.findPVCsForCluster(ctx, cluster) + + Expect(len(requests)).To(Equal(0)) + }) + + It("should return empty list on error listing PVCs", func() { + cluster := createDocumentDB(clusterName, pvcNamespace) + + // Create client without PVC scheme to simulate error + limitedScheme := runtime.NewScheme() + Expect(dbpreview.AddToScheme(limitedScheme)).To(Succeed()) + + fakeClient := fake.NewClientBuilder(). + WithScheme(limitedScheme). + WithObjects(cluster). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + requests := reconciler.findPVCsForCluster(ctx, cluster) + + Expect(len(requests)).To(Equal(0)) + }) + }) + + Describe("Reconcile - Retention Annotation Management", func() { + Context("when PVC has no retention annotation", func() { + It("should add documentdb.io/pvc-retention-days annotation", func() { + // Create DocumentDB with retention period + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + + // Create PVC with documentdb.io/cluster label but no retention annotation + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify annotation was added + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Annotations).ToNot(BeNil()) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(7)))) + }) + }) + + Context("when PVC retention annotation changes", func() { + It("should update documentdb.io/pvc-retention-days annotation when value changes", func() { + // Create DocumentDB with retention period + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 14 + + // Create PVC with old retention value + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + pvc.Annotations = map[string]string{ + "documentdb.io/pvc-retention-days": string(rune(7)), + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify annotation was updated + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Annotations).ToNot(BeNil()) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(14)))) + }) + }) + + Context("when PVC retention annotation matches", func() { + It("should not modify annotation when value is already correct", func() { + // Create DocumentDB with retention period + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + + // Create PVC with correct retention value + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + pvc.Annotations = map[string]string{ + "documentdb.io/pvc-retention-days": string(rune(7)), + "custom-annotation": "custom-value", + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify annotations remain unchanged + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Annotations).ToNot(BeNil()) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(7)))) + Expect(updated.Annotations["custom-annotation"]).To(Equal("custom-value")) + }) + }) + + Context("when retention period is zero", func() { + It("should set annotation to zero", func() { + // Create DocumentDB with zero retention period (retain forever) + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 0 + + // Create PVC with documentdb.io/cluster label + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify annotation was set to zero + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Annotations).ToNot(BeNil()) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(0)))) + }) + }) + }) + + Describe("ClusterRetentionChangedPredicate", func() { + It("should return true when PvcRetentionPeriodDays changes", func() { + oldCluster := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: pvcNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PvcRetentionPeriodDays: 7, + }, + }, + }, + } + + newCluster := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: pvcNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PvcRetentionPeriodDays: 14, + }, + }, + }, + } + + predicate := ClusterRetentionChangedPredicate() + updateEvent := event.UpdateEvent{ + ObjectOld: oldCluster, + ObjectNew: newCluster, + } + + Expect(predicate.Update(updateEvent)).To(BeTrue()) + }) + + It("should return false when PvcRetentionPeriodDays does not change", func() { + oldCluster := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: pvcNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PvcRetentionPeriodDays: 7, + }, + }, + }, + } + + newCluster := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: pvcNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "20Gi", // Different field changed + PvcRetentionPeriodDays: 7, // Same value + }, + }, + }, + } + + predicate := ClusterRetentionChangedPredicate() + updateEvent := event.UpdateEvent{ + ObjectOld: oldCluster, + ObjectNew: newCluster, + } + + Expect(predicate.Update(updateEvent)).To(BeFalse()) + }) + + It("should return false when PvcRetentionPeriodDays is the same (both zero)", func() { + oldCluster := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: pvcNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PvcRetentionPeriodDays: 0, + }, + }, + }, + } + + newCluster := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: pvcNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PvcRetentionPeriodDays: 0, + }, + }, + }, + } + + predicate := ClusterRetentionChangedPredicate() + updateEvent := event.UpdateEvent{ + ObjectOld: oldCluster, + ObjectNew: newCluster, + } + + Expect(predicate.Update(updateEvent)).To(BeFalse()) + }) + + It("should return true when PvcRetentionPeriodDays changes from 0 to non-zero", func() { + oldCluster := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: pvcNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PvcRetentionPeriodDays: 0, + }, + }, + }, + } + + newCluster := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: pvcNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PvcRetentionPeriodDays: 30, + }, + }, + }, + } + + predicate := ClusterRetentionChangedPredicate() + updateEvent := event.UpdateEvent{ + ObjectOld: oldCluster, + ObjectNew: newCluster, + } + + Expect(predicate.Update(updateEvent)).To(BeTrue()) + }) + }) +}) From 002131c0ead2de514ba4766af265e9781a91913e Mon Sep 17 00:00:00 2001 From: wenting Date: Wed, 7 Jan 2026 11:43:13 -0500 Subject: [PATCH 03/19] annotation+ Signed-off-by: wenting --- .../src/internal/controller/pvc_controller.go | 29 ++++++--- .../controller/pvc_controller_test.go | 65 ++++++++++++++++--- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/operator/src/internal/controller/pvc_controller.go b/operator/src/internal/controller/pvc_controller.go index 706c14b3..8a139f9d 100644 --- a/operator/src/internal/controller/pvc_controller.go +++ b/operator/src/internal/controller/pvc_controller.go @@ -59,14 +59,8 @@ func (r *PVCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R } } - // Fetch the DocumentDB cluster - var cluster dbpreview.DocumentDB - if err := r.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: req.Namespace}, &cluster); err != nil { - return ctrl.Result{}, err - } - // Update retention annotation if needed - if err := r.updateRetentionAnnotation(ctx, &pvc, int32(cluster.Spec.Resource.Storage.PvcRetentionPeriodDays)); err != nil { + if err := r.updateRetentionAnnotation(ctx, &pvc, clusterName); err != nil { return ctrl.Result{}, err } @@ -120,11 +114,30 @@ func (r *PVCReconciler) findDocumentDBOwnerThroughCNPG(ctx context.Context, pvc } // updateRetentionAnnotation updates the PVC retention annotation if it has changed. -func (r *PVCReconciler) updateRetentionAnnotation(ctx context.Context, pvc *corev1.PersistentVolumeClaim, retentionDays int32) error { +func (r *PVCReconciler) updateRetentionAnnotation(ctx context.Context, pvc *corev1.PersistentVolumeClaim, clusterName string) error { + var cluster dbpreview.DocumentDB + err := r.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: pvc.Namespace}, &cluster) + + // If cluster doesn't exist + if err != nil { + // If no annotation exists, set default value 7 + if pvc.Annotations == nil || pvc.Annotations["documentdb.io/pvc-retention-days"] == "" { + if pvc.Annotations == nil { + pvc.Annotations = make(map[string]string) + } + pvc.Annotations["documentdb.io/pvc-retention-days"] = "7" + return r.Update(ctx, pvc) + } + // If annotation exists, do nothing + return nil + } + + // Cluster exists - set or update annotation based on cluster value if pvc.Annotations == nil { pvc.Annotations = make(map[string]string) } + retentionDays := cluster.Spec.Resource.Storage.PvcRetentionPeriodDays expectedRetention := string(rune(retentionDays)) currentRetention := pvc.Annotations["documentdb.io/pvc-retention-days"] diff --git a/operator/src/internal/controller/pvc_controller_test.go b/operator/src/internal/controller/pvc_controller_test.go index bb429dfe..ec8a40d6 100644 --- a/operator/src/internal/controller/pvc_controller_test.go +++ b/operator/src/internal/controller/pvc_controller_test.go @@ -332,11 +332,60 @@ var _ = Describe("PVC Controller", func() { }) Describe("Reconcile - Retention Annotation Management", func() { - Context("when PVC has no retention annotation", func() { - It("should add documentdb.io/pvc-retention-days annotation", func() { + Context("when DocumentDB does not exist and PVC has no annotation", func() { + It("should set default value 7 when no cluster and no annotation", func() { + // Create PVC with documentdb.io/cluster label but no retention annotation + // The cluster referenced does not exist + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify annotation was set to default value 7 + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Annotations).ToNot(BeNil()) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("7")) + }) + }) + + Context("when DocumentDB does not exist but PVC has annotation", func() { + It("should do nothing when no cluster but annotation exists", func() { + // Create PVC with documentdb.io/cluster label and existing retention annotation + // The cluster referenced does not exist + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + pvc.Annotations = map[string]string{ + "documentdb.io/pvc-retention-days": "10", + "custom-annotation": "custom-value", + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify annotations remain unchanged + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Annotations).ToNot(BeNil()) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("10")) + Expect(updated.Annotations["custom-annotation"]).To(Equal("custom-value")) + }) + }) + + Context("when DocumentDB exists but PVC has no annotation", func() { + It("should set annotation from cluster when no annotation exists", func() { // Create DocumentDB with retention period documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 14 // Create PVC with documentdb.io/cluster label but no retention annotation pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) @@ -353,12 +402,12 @@ var _ = Describe("PVC Controller", func() { updated := &corev1.PersistentVolumeClaim{} Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(7)))) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(14)))) }) }) - Context("when PVC retention annotation changes", func() { - It("should update documentdb.io/pvc-retention-days annotation when value changes", func() { + Context("when DocumentDB exists and PVC has annotation", func() { + It("should update annotation when value differs from cluster", func() { // Create DocumentDB with retention period documentDB := createDocumentDB(clusterName, pvcNamespace) documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 14 @@ -383,10 +432,8 @@ var _ = Describe("PVC Controller", func() { Expect(updated.Annotations).ToNot(BeNil()) Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(14)))) }) - }) - Context("when PVC retention annotation matches", func() { - It("should not modify annotation when value is already correct", func() { + It("should not modify annotation when value matches cluster", func() { // Create DocumentDB with retention period documentDB := createDocumentDB(clusterName, pvcNamespace) documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 From 622e03ef0d917bac760743e46968c40ea16b58d0 Mon Sep 17 00:00:00 2001 From: wenting Date: Wed, 7 Jan 2026 12:41:51 -0500 Subject: [PATCH 04/19] finalizer Signed-off-by: wenting --- .../src/internal/controller/pvc_controller.go | 40 ++-- .../controller/pvc_controller_test.go | 171 ++++++++++++++++++ 2 files changed, 196 insertions(+), 15 deletions(-) diff --git a/operator/src/internal/controller/pvc_controller.go b/operator/src/internal/controller/pvc_controller.go index 8a139f9d..87ed18c4 100644 --- a/operator/src/internal/controller/pvc_controller.go +++ b/operator/src/internal/controller/pvc_controller.go @@ -5,6 +5,7 @@ package controller import ( "context" + "strconv" "time" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" @@ -65,9 +66,9 @@ func (r *PVCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R } // Manage finalizer based on retention policy - // if err := r.manageFinalizer(ctx, &pvc, &cluster); err != nil { - // return ctrl.Result{}, err - // } + if err := r.manageFinalizer(ctx, &pvc); err != nil { + return ctrl.Result{}, err + } return ctrl.Result{}, nil } @@ -117,7 +118,7 @@ func (r *PVCReconciler) findDocumentDBOwnerThroughCNPG(ctx context.Context, pvc func (r *PVCReconciler) updateRetentionAnnotation(ctx context.Context, pvc *corev1.PersistentVolumeClaim, clusterName string) error { var cluster dbpreview.DocumentDB err := r.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: pvc.Namespace}, &cluster) - + // If cluster doesn't exist if err != nil { // If no annotation exists, set default value 7 @@ -150,8 +151,8 @@ func (r *PVCReconciler) updateRetentionAnnotation(ctx context.Context, pvc *core } // manageFinalizer adds or removes the PVC finalizer based on cluster deletion status and retention period. -func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.PersistentVolumeClaim, cluster *dbpreview.DocumentDB) error { - shouldHaveFinalizer := r.shouldRetainPVC(cluster) +func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { + shouldHaveFinalizer := r.shouldRetainPVC(pvc) hasFinalizer := containsString(pvc.Finalizers, PVCFinalizerName) if shouldHaveFinalizer == hasFinalizer { @@ -160,8 +161,14 @@ func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.Persist } if shouldHaveFinalizer { + if pvc.Finalizers == nil { + pvc.Finalizers = []string{} + } pvc.Finalizers = append(pvc.Finalizers, PVCFinalizerName) } else { + if pvc.Finalizers == nil { + return nil + } pvc.Finalizers = removeString(pvc.Finalizers, PVCFinalizerName) } @@ -169,21 +176,24 @@ func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.Persist } // shouldRetainPVC determines if a PVC should be retained based on cluster deletion status and retention period. -func (r *PVCReconciler) shouldRetainPVC(cluster *dbpreview.DocumentDB) bool { - // If cluster is not being deleted, retain the PVC - if cluster.DeletionTimestamp == nil { +func (r *PVCReconciler) shouldRetainPVC(pvc *corev1.PersistentVolumeClaim) bool { + if pvc.DeletionTimestamp == nil { return true } - retentionDays := cluster.Spec.Resource.Storage.PvcRetentionPeriodDays - - // Retention days <= 0 means retain forever - if retentionDays <= 0 { - return true + retentionDays := pvc.Annotations["documentdb.io/pvc-retention-days"] + if retentionDays == "" { + // Default retention if annotation missing + retentionDays = "7" } // Check if retention period has expired - retentionExpiration := cluster.DeletionTimestamp.AddDate(0, 0, int(retentionDays)) + days, err := strconv.Atoi(retentionDays) + if err != nil { + // If conversion fails, use default of 7 days + days = 7 + } + retentionExpiration := pvc.DeletionTimestamp.AddDate(0, 0, days) return time.Now().Before(retentionExpiration) } diff --git a/operator/src/internal/controller/pvc_controller_test.go b/operator/src/internal/controller/pvc_controller_test.go index ec8a40d6..04a8d20e 100644 --- a/operator/src/internal/controller/pvc_controller_test.go +++ b/operator/src/internal/controller/pvc_controller_test.go @@ -5,6 +5,7 @@ package controller import ( "context" + "time" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" . "github.com/onsi/ginkgo/v2" @@ -488,6 +489,176 @@ var _ = Describe("PVC Controller", func() { }) }) + Describe("Finalizer Management", func() { + Context("when PVC is not deleted (deletionTimestamp is null)", func() { + It("should add finalizer if not exists", func() { + // Create DocumentDB and PVC without finalizer + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + // No finalizers initially + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify finalizer was added + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Finalizers).To(ContainElement(PVCFinalizerName)) + }) + + It("should do nothing if finalizer already exists", func() { + // Create DocumentDB and PVC with finalizer already present + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + pvc.Finalizers = []string{PVCFinalizerName, "some-other-finalizer"} + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify finalizers remain unchanged + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Finalizers).To(Equal([]string{PVCFinalizerName, "some-other-finalizer"})) + }) + }) + + Context("when PVC is deleted (deletionTimestamp is not null)", func() { + Context("and retention period has not been exceeded", func() { + It("should add finalizer if not exists", func() { + // Create DocumentDB + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + + // Create PVC with deletionTimestamp (being deleted) + // Must have at least one finalizer for Kubernetes to accept deletionTimestamp + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + now := metav1.Now() + pvc.DeletionTimestamp = &now + pvc.Finalizers = []string{"some-other-finalizer"} // Need at least one finalizer + pvc.Annotations = map[string]string{ + "documentdb.io/pvc-retention-days": "7", + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify our finalizer was added + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Finalizers).To(ContainElement(PVCFinalizerName)) + Expect(updated.Finalizers).To(ContainElement("some-other-finalizer")) + }) + + It("should do nothing if finalizer already exists", func() { + // Create DocumentDB + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + + // Create PVC with deletionTimestamp and existing finalizer + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + now := metav1.Now() + pvc.DeletionTimestamp = &now + pvc.Finalizers = []string{PVCFinalizerName, "another-finalizer"} + pvc.Annotations = map[string]string{ + "documentdb.io/pvc-retention-days": "7", + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify finalizers remain unchanged + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Finalizers).To(Equal([]string{PVCFinalizerName, "another-finalizer"})) + }) + }) + + Context("and retention period has been exceeded", func() { + It("should remove finalizer if exists", func() { + // Create DocumentDB + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + + // Create PVC with deletionTimestamp from 10 days ago (exceeded 7 day retention) + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + tenDaysAgo := metav1.NewTime(time.Now().AddDate(0, 0, -10)) + pvc.DeletionTimestamp = &tenDaysAgo + pvc.Finalizers = []string{PVCFinalizerName, "another-finalizer"} + pvc.Annotations = map[string]string{ + "documentdb.io/pvc-retention-days": "7", + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify finalizer was removed + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Finalizers).ToNot(ContainElement(PVCFinalizerName)) + Expect(updated.Finalizers).To(Equal([]string{"another-finalizer"})) + }) + + It("should do nothing if finalizer does not exist", func() { + // Create DocumentDB + documentDB := createDocumentDB(clusterName, pvcNamespace) + documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + + // Create PVC with deletionTimestamp from 10 days ago (exceeded 7 day retention) + pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) + tenDaysAgo := metav1.NewTime(time.Now().AddDate(0, 0, -10)) + pvc.DeletionTimestamp = &tenDaysAgo + pvc.Finalizers = []string{"another-finalizer"} + pvc.Annotations = map[string]string{ + "documentdb.io/pvc-retention-days": "7", + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentDB, pvc). + Build() + + reconciler := &PVCReconciler{Client: fakeClient} + reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + + // Verify finalizers remain unchanged (no PVC finalizer to remove) + updated := &corev1.PersistentVolumeClaim{} + Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) + Expect(updated.Finalizers).To(Equal([]string{"another-finalizer"})) + Expect(updated.Finalizers).ToNot(ContainElement(PVCFinalizerName)) + }) + }) + }) + }) + Describe("ClusterRetentionChangedPredicate", func() { It("should return true when PvcRetentionPeriodDays changes", func() { oldCluster := &dbpreview.DocumentDB{ From 58e135471d9e33b3dea4dfba627f92a24aa4955c Mon Sep 17 00:00:00 2001 From: wenting Date: Thu, 8 Jan 2026 12:11:05 -0500 Subject: [PATCH 05/19] recovery from pvc Signed-off-by: wenting --- operator/src/api/preview/documentdb_types.go | 5 + operator/src/cmd/main.go | 7 + operator/src/internal/cnpg/cnpg_cluster.go | 43 ++- .../src/internal/cnpg/cnpg_cluster_test.go | 302 ++++++++++++++++++ operator/src/internal/cnpg/suite_test.go | 16 + .../webhook/preview/documentdb_webhook.go | 130 ++++++++ .../preview/documentdb_webhook_test.go | 172 ++++++++++ .../internal/webhook/preview/suite_test.go | 16 + 8 files changed, 683 insertions(+), 8 deletions(-) create mode 100644 operator/src/internal/cnpg/cnpg_cluster_test.go create mode 100644 operator/src/internal/cnpg/suite_test.go create mode 100644 operator/src/internal/webhook/preview/documentdb_webhook.go create mode 100644 operator/src/internal/webhook/preview/documentdb_webhook_test.go create mode 100644 operator/src/internal/webhook/preview/suite_test.go diff --git a/operator/src/api/preview/documentdb_types.go b/operator/src/api/preview/documentdb_types.go index 13b9ec0a..d51c69c0 100644 --- a/operator/src/api/preview/documentdb_types.go +++ b/operator/src/api/preview/documentdb_types.go @@ -90,6 +90,11 @@ type RecoveryConfiguration struct { // Backup specifies the source backup to restore from. // +optional Backup cnpgv1.LocalObjectReference `json:"backup,omitempty"` + + // PVC specifies the source PVC to restore from. + // Cannot be used together with Backup. + // +optional + PVC cnpgv1.LocalObjectReference `json:"pvc,omitempty"` } // BackupConfiguration defines backup settings for DocumentDB. diff --git a/operator/src/cmd/main.go b/operator/src/cmd/main.go index e0370c12..2e1a7fba 100644 --- a/operator/src/cmd/main.go +++ b/operator/src/cmd/main.go @@ -29,6 +29,7 @@ import ( cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" dbpreview "github.com/documentdb/documentdb-operator/api/preview" "github.com/documentdb/documentdb-operator/internal/controller" + webhookpreview "github.com/documentdb/documentdb-operator/internal/webhook/preview" fleetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -239,6 +240,12 @@ func main() { os.Exit(1) } + // Setup webhooks + if err = webhookpreview.SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "DocumentDB") + os.Exit(1) + } + // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 5761160b..1780ca88 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -8,6 +8,7 @@ import ( cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/pointer" @@ -113,18 +114,44 @@ func getInheritedMetadataLabels(appName string) *cnpgv1.EmbeddedObjectMetadata { } func getBootstrapConfiguration(documentdb *dbpreview.DocumentDB, isPrimaryRegion bool, log logr.Logger) *cnpgv1.BootstrapConfiguration { - if isPrimaryRegion && documentdb.Spec.Bootstrap != nil && documentdb.Spec.Bootstrap.Recovery != nil && documentdb.Spec.Bootstrap.Recovery.Backup.Name != "" { - backupName := documentdb.Spec.Bootstrap.Recovery.Backup.Name - log.Info("DocumentDB cluster will be bootstrapped from backup", "backupName", backupName) - return &cnpgv1.BootstrapConfiguration{ - Recovery: &cnpgv1.BootstrapRecovery{ - Backup: &cnpgv1.BackupSource{ - LocalObjectReference: cnpgv1.LocalObjectReference{Name: backupName}, + if isPrimaryRegion && documentdb.Spec.Bootstrap != nil && documentdb.Spec.Bootstrap.Recovery != nil { + recovery := documentdb.Spec.Bootstrap.Recovery + + // Handle backup recovery + if recovery.Backup.Name != "" { + backupName := recovery.Backup.Name + log.Info("DocumentDB cluster will be bootstrapped from backup", "backupName", backupName) + return &cnpgv1.BootstrapConfiguration{ + Recovery: &cnpgv1.BootstrapRecovery{ + Backup: &cnpgv1.BackupSource{ + LocalObjectReference: cnpgv1.LocalObjectReference{Name: backupName}, + }, }, - }, + } + } + + // Handle PVC recovery + if recovery.PVC.Name != "" { + pvcName := recovery.PVC.Name + log.Info("DocumentDB cluster will be bootstrapped from PVC", "pvcName", pvcName) + return &cnpgv1.BootstrapConfiguration{ + Recovery: &cnpgv1.BootstrapRecovery{ + VolumeSnapshots: &cnpgv1.DataSource{ + Storage: corev1.TypedLocalObjectReference{ + Name: pvcName, + Kind: "PersistentVolumeClaim", + APIGroup: pointer.String(""), + }, + }, + }, + } } } + return getDefaultBootstrapConfiguration() +} + +func getDefaultBootstrapConfiguration() *cnpgv1.BootstrapConfiguration { return &cnpgv1.BootstrapConfiguration{ InitDB: &cnpgv1.BootstrapInitDB{ PostInitSQL: []string{ diff --git a/operator/src/internal/cnpg/cnpg_cluster_test.go b/operator/src/internal/cnpg/cnpg_cluster_test.go new file mode 100644 index 00000000..a9a0899b --- /dev/null +++ b/operator/src/internal/cnpg/cnpg_cluster_test.go @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package cnpg + +import ( + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/utils/pointer" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" +) + +var _ = Describe("getBootstrapConfiguration", func() { + var log = zap.New(zap.WriteTo(GinkgoWriter)) + + It("returns default bootstrap when no bootstrap is configured", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{}, + } + + result := getBootstrapConfiguration(documentdb, true, log) + Expect(result).ToNot(BeNil()) + Expect(result.InitDB).ToNot(BeNil()) + Expect(result.InitDB.PostInitSQL).To(HaveLen(3)) + Expect(result.InitDB.PostInitSQL[0]).To(Equal("CREATE EXTENSION documentdb CASCADE")) + Expect(result.Recovery).To(BeNil()) + }) + + It("returns default bootstrap when not primary region", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: "my-backup", + }, + }, + }, + }, + } + + result := getBootstrapConfiguration(documentdb, false, log) + Expect(result).ToNot(BeNil()) + Expect(result.InitDB).ToNot(BeNil()) + Expect(result.Recovery).To(BeNil()) + }) + + It("returns default bootstrap when recovery is not configured", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{}, + }, + } + + result := getBootstrapConfiguration(documentdb, true, log) + Expect(result).ToNot(BeNil()) + Expect(result.InitDB).ToNot(BeNil()) + Expect(result.Recovery).To(BeNil()) + }) + + It("returns backup recovery when backup name is specified", func() { + backupName := "my-backup" + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: backupName, + }, + }, + }, + }, + } + + result := getBootstrapConfiguration(documentdb, true, log) + Expect(result).ToNot(BeNil()) + Expect(result.Recovery).ToNot(BeNil()) + Expect(result.Recovery.Backup).ToNot(BeNil()) + Expect(result.Recovery.Backup.LocalObjectReference.Name).To(Equal(backupName)) + Expect(result.Recovery.VolumeSnapshots).To(BeNil()) + Expect(result.InitDB).To(BeNil()) + }) + + It("returns PVC recovery when PVC name is specified", func() { + pvcName := "my-pvc" + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + PVC: cnpgv1.LocalObjectReference{ + Name: pvcName, + }, + }, + }, + }, + } + + result := getBootstrapConfiguration(documentdb, true, log) + Expect(result).ToNot(BeNil()) + Expect(result.Recovery).ToNot(BeNil()) + Expect(result.Recovery.VolumeSnapshots).ToNot(BeNil()) + Expect(result.Recovery.VolumeSnapshots.Storage.Name).To(Equal(pvcName)) + Expect(result.Recovery.VolumeSnapshots.Storage.Kind).To(Equal("PersistentVolumeClaim")) + Expect(result.Recovery.VolumeSnapshots.Storage.APIGroup).To(Equal(pointer.String(""))) + Expect(result.Recovery.Backup).To(BeNil()) + Expect(result.InitDB).To(BeNil()) + }) + + It("returns default bootstrap when backup name is empty", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: "", + }, + }, + }, + }, + } + + result := getBootstrapConfiguration(documentdb, true, log) + Expect(result).ToNot(BeNil()) + Expect(result.InitDB).ToNot(BeNil()) + Expect(result.Recovery).To(BeNil()) + }) + + It("returns default bootstrap when PVC name is empty", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + PVC: cnpgv1.LocalObjectReference{ + Name: "", + }, + }, + }, + }, + } + + result := getBootstrapConfiguration(documentdb, true, log) + Expect(result).ToNot(BeNil()) + Expect(result.InitDB).ToNot(BeNil()) + Expect(result.Recovery).To(BeNil()) + }) +}) + +var _ = Describe("getDefaultBootstrapConfiguration", func() { + It("returns a bootstrap configuration with InitDB", func() { + result := getDefaultBootstrapConfiguration() + Expect(result).ToNot(BeNil()) + Expect(result.InitDB).ToNot(BeNil()) + Expect(result.Recovery).To(BeNil()) + }) + + It("includes required PostInitSQL statements", func() { + result := getDefaultBootstrapConfiguration() + Expect(result.InitDB.PostInitSQL).To(HaveLen(3)) + Expect(result.InitDB.PostInitSQL).To(ContainElement("CREATE EXTENSION documentdb CASCADE")) + Expect(result.InitDB.PostInitSQL).To(ContainElement("CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100'")) + Expect(result.InitDB.PostInitSQL).To(ContainElement("ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS")) + }) +}) + +var _ = Describe("GetCnpgClusterSpec", func() { + var log = zap.New(zap.WriteTo(GinkgoWriter)) + + It("creates a CNPG cluster spec with default bootstrap", func() { + req := ctrl.Request{} + req.Name = "test-cluster" + req.Namespace = "default" + + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 3, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + }, + }, + }, + } + + result := GetCnpgClusterSpec(req, documentdb, "postgres:16", "test-sa", "standard", true, log) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal("test-cluster")) + Expect(result.Namespace).To(Equal("default")) + Expect(int(result.Spec.Instances)).To(Equal(3)) + Expect(result.Spec.Bootstrap).ToNot(BeNil()) + Expect(result.Spec.Bootstrap.InitDB).ToNot(BeNil()) + }) + + It("creates a CNPG cluster spec with backup recovery", func() { + req := ctrl.Request{} + req.Name = "test-cluster" + req.Namespace = "default" + + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 3, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + }, + }, + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: "test-backup", + }, + }, + }, + }, + } + + result := GetCnpgClusterSpec(req, documentdb, "postgres:16", "test-sa", "standard", true, log) + Expect(result).ToNot(BeNil()) + Expect(result.Spec.Bootstrap).ToNot(BeNil()) + Expect(result.Spec.Bootstrap.Recovery).ToNot(BeNil()) + Expect(result.Spec.Bootstrap.Recovery.Backup).ToNot(BeNil()) + Expect(result.Spec.Bootstrap.Recovery.Backup.LocalObjectReference.Name).To(Equal("test-backup")) + }) + + It("creates a CNPG cluster spec with PVC recovery", func() { + req := ctrl.Request{} + req.Name = "test-cluster" + req.Namespace = "default" + + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 3, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + }, + }, + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + PVC: cnpgv1.LocalObjectReference{ + Name: "test-pvc", + }, + }, + }, + }, + } + + result := GetCnpgClusterSpec(req, documentdb, "postgres:16", "test-sa", "standard", true, log) + Expect(result).ToNot(BeNil()) + Expect(result.Spec.Bootstrap).ToNot(BeNil()) + Expect(result.Spec.Bootstrap.Recovery).ToNot(BeNil()) + Expect(result.Spec.Bootstrap.Recovery.VolumeSnapshots).ToNot(BeNil()) + Expect(result.Spec.Bootstrap.Recovery.VolumeSnapshots.Storage.Name).To(Equal("test-pvc")) + Expect(result.Spec.Bootstrap.Recovery.VolumeSnapshots.Storage.Kind).To(Equal("PersistentVolumeClaim")) + }) + + It("uses specified storage class", func() { + req := ctrl.Request{} + req.Name = "test-cluster" + req.Namespace = "default" + + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 3, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + }, + }, + }, + } + + result := GetCnpgClusterSpec(req, documentdb, "postgres:16", "test-sa", "premium-storage", true, log) + Expect(result).ToNot(BeNil()) + Expect(result.Spec.StorageConfiguration.StorageClass).ToNot(BeNil()) + Expect(*result.Spec.StorageConfiguration.StorageClass).To(Equal("premium-storage")) + }) + + It("uses nil storage class when empty string is provided", func() { + req := ctrl.Request{} + req.Name = "test-cluster" + req.Namespace = "default" + + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 3, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + }, + }, + }, + } + + result := GetCnpgClusterSpec(req, documentdb, "postgres:16", "test-sa", "", true, log) + Expect(result).ToNot(BeNil()) + Expect(result.Spec.StorageConfiguration.StorageClass).To(BeNil()) + }) +}) diff --git a/operator/src/internal/cnpg/suite_test.go b/operator/src/internal/cnpg/suite_test.go new file mode 100644 index 00000000..287c2cd4 --- /dev/null +++ b/operator/src/internal/cnpg/suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package cnpg + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCNPG(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CNPG Suite") +} diff --git a/operator/src/internal/webhook/preview/documentdb_webhook.go b/operator/src/internal/webhook/preview/documentdb_webhook.go new file mode 100644 index 00000000..3c3fa9b6 --- /dev/null +++ b/operator/src/internal/webhook/preview/documentdb_webhook.go @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package preview + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" +) + +// log is for logging in this package. +var documentdbLog = logf.Log.WithName("documentdb-webhook").WithValues("version", "preview") + +// DocumentDBWebhook handles validation for DocumentDB resources +type DocumentDBWebhook struct{} + +// SetupWebhookWithManager registers the webhook with the manager +func SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&dbpreview.DocumentDB{}). + WithValidator(&DocumentDBWebhook{}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-documentdb-io-preview-documentdb,mutating=false,failurePolicy=fail,sideEffects=None,groups=documentdb.io,resources=dbs,verbs=create;update,versions=preview,name=vdocumentdb.kb.io,admissionReviewVersions=v1 + +var _ admission.CustomValidator = &DocumentDBWebhook{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type +func (w *DocumentDBWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + documentdb, ok := obj.(*dbpreview.DocumentDB) + if !ok { + return nil, fmt.Errorf("expected DocumentDB object but got %T", obj) + } + + documentdbLog.Info("validate create", "name", documentdb.Name, "namespace", documentdb.Namespace) + + allErrs := w.validate(documentdb) + if len(allErrs) == 0 { + return nil, nil + } + + return nil, apierrors.NewInvalid( + schema.GroupKind{Group: "documentdb.io", Kind: "DocumentDB"}, + documentdb.Name, + allErrs, + ) +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type +func (w *DocumentDBWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + documentdb, ok := newObj.(*dbpreview.DocumentDB) + if !ok { + return nil, fmt.Errorf("expected DocumentDB object but got %T", newObj) + } + + documentdbLog.Info("validate update", "name", documentdb.Name, "namespace", documentdb.Namespace) + + allErrs := w.validate(documentdb) + if len(allErrs) == 0 { + return nil, nil + } + + return nil, apierrors.NewInvalid( + schema.GroupKind{Group: "documentdb.io", Kind: "DocumentDB"}, + documentdb.Name, + allErrs, + ) +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type +func (w *DocumentDBWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + documentdb, ok := obj.(*dbpreview.DocumentDB) + if !ok { + return nil, fmt.Errorf("expected DocumentDB object but got %T", obj) + } + + documentdbLog.Info("validate delete", "name", documentdb.Name, "namespace", documentdb.Namespace) + // No validation needed for delete + return nil, nil +} + +// validate groups the validation logic for DocumentDB returning a list of all encountered errors +func (w *DocumentDBWebhook) validate(r *dbpreview.DocumentDB) field.ErrorList { + type validationFunc func(*dbpreview.DocumentDB) field.ErrorList + + validations := []validationFunc{ + w.validateBootstrapRecovery, + // Add more validation functions here as needed + } + + var allErrs field.ErrorList + for _, validate := range validations { + allErrs = append(allErrs, validate(r)...) + } + + return allErrs +} + +// validateBootstrapRecovery validates that backup and PVC recovery are not both specified +func (w *DocumentDBWebhook) validateBootstrapRecovery(documentdb *dbpreview.DocumentDB) field.ErrorList { + // If bootstrap is not configured, everything is ok + if documentdb.Spec.Bootstrap == nil || documentdb.Spec.Bootstrap.Recovery == nil { + return nil + } + + var result field.ErrorList + recovery := documentdb.Spec.Bootstrap.Recovery + + // Validate that both backup and PVC are not specified together + if recovery.Backup.Name != "" && recovery.PVC.Name != "" { + result = append(result, field.Invalid( + field.NewPath("spec", "bootstrap", "recovery"), + recovery, + "cannot specify both backup and PVC recovery at the same time", + )) + } + + return result +} diff --git a/operator/src/internal/webhook/preview/documentdb_webhook_test.go b/operator/src/internal/webhook/preview/documentdb_webhook_test.go new file mode 100644 index 00000000..d9f28a30 --- /dev/null +++ b/operator/src/internal/webhook/preview/documentdb_webhook_test.go @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package preview + +import ( + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" +) + +var _ = Describe("bootstrap recovery validation", func() { + var v *DocumentDBWebhook + + BeforeEach(func() { + v = &DocumentDBWebhook{} + }) + + It("doesn't complain if there isn't a bootstrap configuration", func() { + documentdb := &dbpreview.DocumentDB{} + result := v.validateBootstrapRecovery(documentdb) + Expect(result).To(BeEmpty()) + }) + + It("doesn't complain if there isn't a recovery configuration", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{}, + }, + } + result := v.validateBootstrapRecovery(documentdb) + Expect(result).To(BeEmpty()) + }) + + It("doesn't complain if only backup recovery is specified", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: "my-backup", + }, + }, + }, + }, + } + result := v.validateBootstrapRecovery(documentdb) + Expect(result).To(BeEmpty()) + }) + + It("doesn't complain if only PVC recovery is specified", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + PVC: cnpgv1.LocalObjectReference{ + Name: "my-pvc", + }, + }, + }, + }, + } + result := v.validateBootstrapRecovery(documentdb) + Expect(result).To(BeEmpty()) + }) + + It("complains if both backup and PVC recovery are specified", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: "my-backup", + }, + PVC: cnpgv1.LocalObjectReference{ + Name: "my-pvc", + }, + }, + }, + }, + } + result := v.validateBootstrapRecovery(documentdb) + Expect(result).To(HaveLen(1)) + Expect(result[0].Error()).To(ContainSubstring("cannot specify both backup and PVC recovery")) + }) + + It("doesn't complain if backup name is empty and PVC is specified", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: "", + }, + PVC: cnpgv1.LocalObjectReference{ + Name: "my-pvc", + }, + }, + }, + }, + } + result := v.validateBootstrapRecovery(documentdb) + Expect(result).To(BeEmpty()) + }) + + It("doesn't complain if PVC name is empty and backup is specified", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: "my-backup", + }, + PVC: cnpgv1.LocalObjectReference{ + Name: "", + }, + }, + }, + }, + } + result := v.validateBootstrapRecovery(documentdb) + Expect(result).To(BeEmpty()) + }) +}) + +var _ = Describe("DocumentDB webhook", func() { + var v *DocumentDBWebhook + + BeforeEach(func() { + v = &DocumentDBWebhook{} + }) + + Context("validate method", func() { + It("returns no errors for a valid DocumentDB with no bootstrap", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + Spec: dbpreview.DocumentDBSpec{}, + } + result := v.validate(documentdb) + Expect(result).To(BeEmpty()) + }) + + It("returns errors when both backup and PVC recovery are specified", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + Spec: dbpreview.DocumentDBSpec{ + Bootstrap: &dbpreview.BootstrapConfiguration{ + Recovery: &dbpreview.RecoveryConfiguration{ + Backup: cnpgv1.LocalObjectReference{ + Name: "my-backup", + }, + PVC: cnpgv1.LocalObjectReference{ + Name: "my-pvc", + }, + }, + }, + }, + } + result := v.validate(documentdb) + Expect(result).To(HaveLen(1)) + }) + }) +}) diff --git a/operator/src/internal/webhook/preview/suite_test.go b/operator/src/internal/webhook/preview/suite_test.go new file mode 100644 index 00000000..a083bca8 --- /dev/null +++ b/operator/src/internal/webhook/preview/suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package preview + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWebhook(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "DocumentDB Webhook Suite") +} From abee24551d890b38023785b7d653c2b920787f99 Mon Sep 17 00:00:00 2001 From: wenting Date: Mon, 12 Jan 2026 20:34:00 -0500 Subject: [PATCH 06/19] pvc e2e tests Signed-off-by: wenting --- .github/workflows/test-backup-and-restore.yml | 157 +++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-backup-and-restore.yml b/.github/workflows/test-backup-and-restore.yml index 4949b4df..97331172 100644 --- a/.github/workflows/test-backup-and-restore.yml +++ b/.github/workflows/test-backup-and-restore.yml @@ -354,4 +354,159 @@ jobs: ((++ITER)) done echo "❌ Expired backup was not cleaned up within expected time." - exit 1 \ No newline at end of file + exit 1 + + - name: Test PVC retention after DocumentDB deletion + shell: bash + run: | + echo "Testing PVC retention after DocumentDB deletion..." + + # Get the PVC name before deleting the DocumentDB + pvc_name=$(kubectl -n ${{ env.DB_NS }} get pvc -l documentdb.io/cluster=${{ env.DB_RESTORE_NAME }} -o jsonpath='{.items[0].metadata.name}') + echo "PVC name: $pvc_name" + + if [ -z "$pvc_name" ]; then + echo "❌ Failed to find PVC for cluster ${{ env.DB_RESTORE_NAME }}" + exit 1 + fi + + # Delete the restored DocumentDB cluster + kubectl -n ${{ env.DB_NS }} delete documentdb ${{ env.DB_RESTORE_NAME }} --wait=false + + # Wait for DocumentDB to be deleted + echo "Waiting for DocumentDB to be deleted..." + MAX_RETRIES=30 + SLEEP_INTERVAL=10 + ITER=0 + while [ $ITER -lt $MAX_RETRIES ]; do + db_exists=$(kubectl -n ${{ env.DB_NS }} get documentdb ${{ env.DB_RESTORE_NAME }} --ignore-not-found) + if [ -z "$db_exists" ]; then + echo "✓ DocumentDB deleted successfully." + break + else + echo "DocumentDB still exists. Waiting..." + sleep $SLEEP_INTERVAL + fi + ((++ITER)) + done + + # Verify PVC still exists + pvc_exists=$(kubectl -n ${{ env.DB_NS }} get pvc $pvc_name --ignore-not-found) + if [ -n "$pvc_exists" ]; then + echo "✓ PVC $pvc_name retained after DocumentDB deletion" + else + echo "❌ PVC $pvc_name was deleted unexpectedly" + exit 1 + fi + + # Store PVC name for later steps + echo "$pvc_name" > /tmp/retained_pvc_name + + - name: Restore DocumentDB from retained PVC + shell: bash + run: | + pvc_name=$(cat /tmp/retained_pvc_name) + echo "Restoring DocumentDB from PVC: $pvc_name" + + # Create DocumentDB resource with PVC recovery + cat </dev/null || true + rm -f /tmp/pf_pid + fi + + # Clean up output log + rm -f /tmp/pf_output.log + + - name: Test PVC cleanup after retention period + shell: bash + run: | + pvc_name=$(cat /tmp/retained_pvc_name) + echo "Testing PVC cleanup after retention period expires..." + echo "Using PVC from deleted restored DocumentDB: $pvc_name" + + # Get the retention days from the PVC annotation + retention_days=$(kubectl -n ${{ env.DB_NS }} get pvc $pvc_name -o jsonpath='{.metadata.annotations.documentdb\.io/pvc-retention-days}') + if [ -z "$retention_days" ]; then + echo "❌ No retention days annotation found on PVC $pvc_name" + kubectl -n ${{ env.DB_NS }} get pvc $pvc_name -o jsonpath='{.metadata.annotations}' + exit 1 + fi + echo "Current PVC retention period: $retention_days days" + + # Set retention days to 0 to simulate immediate deletion after retention period + echo "Setting retention days to 0 to simulate expired retention period..." + kubectl -n ${{ env.DB_NS }} annotate pvc $pvc_name documentdb.io/pvc-retention-days=0 --overwrite + + # Wait for PVC to be cleaned up + echo "Waiting for PVC to be cleaned up..." + MAX_RETRIES=30 + SLEEP_INTERVAL=10 + ITER=0 + while [ $ITER -lt $MAX_RETRIES ]; do + pvc_exists=$(kubectl -n ${{ env.DB_NS }} get pvc $pvc_name --ignore-not-found) + if [ -z "$pvc_exists" ]; then + echo "✓ PVC $pvc_name cleaned up successfully after retention period" + exit 0 + else + echo "PVC still exists. Checking annotations..." + kubectl -n ${{ env.DB_NS }} get pvc $pvc_name -o jsonpath='{.metadata.annotations}' + echo "" + sleep $SLEEP_INTERVAL + fi + ((++ITER)) + done + + echo "❌ PVC was not cleaned up within expected time" + kubectl -n ${{ env.DB_NS }} describe pvc $pvc_name + + exit 1 From 15e3f840a94dc01efffc40b7fa7ce62040eb2d84 Mon Sep 17 00:00:00 2001 From: wenting Date: Tue, 13 Jan 2026 22:23:13 -0500 Subject: [PATCH 07/19] Improve code quality Signed-off-by: wenting --- operator/src/api/preview/documentdb_types.go | 4 +- operator/src/internal/cnpg/cnpg_cluster.go | 2 +- .../src/internal/controller/pvc_controller.go | 135 +++++++++++++----- .../controller/pvc_controller_test.go | 100 +++++++------ 4 files changed, 160 insertions(+), 81 deletions(-) diff --git a/operator/src/api/preview/documentdb_types.go b/operator/src/api/preview/documentdb_types.go index d51c69c0..82f5dfb7 100644 --- a/operator/src/api/preview/documentdb_types.go +++ b/operator/src/api/preview/documentdb_types.go @@ -121,14 +121,14 @@ type StorageConfiguration struct { // If not specified, the cluster's default storage class will be used. StorageClass string `json:"storageClass,omitempty"` - // PvcRetentionPeriodDays specifies how many days PVCs should be retained after the DocumentDB cluster is deleted. + // PvcRetentionDays specifies how many days PVCs should be retained after the DocumentDB cluster is deleted. // This allows for data recovery after accidental cluster deletion. // Set to 0 for immediate deletion (default behavior: 7 days). // +kubebuilder:validation:Minimum=0 // +kubebuilder:validation:Maximum=365 // +kubebuilder:default=7 // +optional - PvcRetentionPeriodDays int `json:"pvcRetentionPeriodDays,omitempty"` + PvcRetentionDays int `json:"pvcRetentionDays,omitempty"` } type ClusterReplication struct { diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 1780ca88..bb667df2 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -124,7 +124,7 @@ func getBootstrapConfiguration(documentdb *dbpreview.DocumentDB, isPrimaryRegion return &cnpgv1.BootstrapConfiguration{ Recovery: &cnpgv1.BootstrapRecovery{ Backup: &cnpgv1.BackupSource{ - LocalObjectReference: cnpgv1.LocalObjectReference{Name: backupName}, + LocalObjectReference: recovery.Backup, }, }, } diff --git a/operator/src/internal/controller/pvc_controller.go b/operator/src/internal/controller/pvc_controller.go index 87ed18c4..c9963acb 100644 --- a/operator/src/internal/controller/pvc_controller.go +++ b/operator/src/internal/controller/pvc_controller.go @@ -5,18 +5,21 @@ package controller import ( "context" + "fmt" "strconv" "time" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" dbpreview "github.com/documentdb/documentdb-operator/api/preview" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) @@ -24,6 +27,13 @@ import ( const ( // PVCFinalizerName is the finalizer added to PVCs to manage retention PVCFinalizerName = "documentdb.io/pvc-retention" + + // Annotation and label keys + AnnotationPVCRetentionDays = "documentdb.io/pvc-retention-days" + LabelDocumentDBCluster = "documentdb.io/cluster" + + // DefaultPVCRetentionDays is the default retention period for PVCs after cluster deletion + DefaultPVCRetentionDays = 7 ) // PVCReconciler handles PVC lifecycle management including retention after cluster deletion @@ -38,6 +48,8 @@ type PVCReconciler struct { // Reconcile handles PVC events for DocumentDB clusters only, managing finalizers and retention periods. // PVCs not belonging to a DocumentDB cluster are ignored. func (r *PVCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + var pvc corev1.PersistentVolumeClaim if err := r.Get(ctx, req.NamespacedName, &pvc); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) @@ -45,39 +57,67 @@ func (r *PVCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R // Early exit: Only process PVCs belonging to a DocumentDB cluster clusterName, err := r.getDocumentDBClusterName(ctx, &pvc) - if err != nil || clusterName == "" { + if err != nil { + log.Error(err, "Failed to determine ownership") return ctrl.Result{}, err } + if clusterName == "" { + // Not a DocumentDB PVC, ignore + return ctrl.Result{}, nil + } - // Label the PVC for faster lookup next time if not already labeled - if pvc.Labels["documentdb.io/cluster"] != clusterName { - if pvc.Labels == nil { - pvc.Labels = make(map[string]string) - } - pvc.Labels["documentdb.io/cluster"] = clusterName - if err := r.Update(ctx, &pvc); err != nil { - return ctrl.Result{}, err - } + log.V(1).Info("Processing DocumentDB PVC", "cluster", clusterName) + + // Ensure PVC has cluster label for efficient lookups + if err := r.ensureClusterLabel(ctx, &pvc, clusterName); err != nil { + log.Error(err, "Failed to ensure cluster label") + return ctrl.Result{}, err } // Update retention annotation if needed if err := r.updateRetentionAnnotation(ctx, &pvc, clusterName); err != nil { + log.Error(err, "Failed to update retention annotation") return ctrl.Result{}, err } // Manage finalizer based on retention policy if err := r.manageFinalizer(ctx, &pvc); err != nil { + log.Error(err, "Failed to manage finalizer") return ctrl.Result{}, err } + // Requeue if PVC is being deleted to clean up finalizer after retention expires + if pvc.DeletionTimestamp != nil && containsString(pvc.Finalizers, PVCFinalizerName) { + retentionDays := r.getRetentionDays(&pvc) + retentionExpiration := pvc.DeletionTimestamp.AddDate(0, 0, retentionDays) + requeueAfter := time.Until(retentionExpiration) + if requeueAfter > 0 { + log.Info("PVC retention period active, will requeue", "requeueAfter", requeueAfter) + return ctrl.Result{RequeueAfter: requeueAfter}, nil + } + } + return ctrl.Result{}, nil } +// ensureClusterLabel ensures the PVC has the documentdb.io/cluster label for efficient lookups. +func (r *PVCReconciler) ensureClusterLabel(ctx context.Context, pvc *corev1.PersistentVolumeClaim, clusterName string) error { + if pvc.Labels[LabelDocumentDBCluster] == clusterName { + return nil + } + + if pvc.Labels == nil { + pvc.Labels = make(map[string]string) + } + pvc.Labels[LabelDocumentDBCluster] = clusterName + return r.Update(ctx, pvc) +} + // getDocumentDBClusterName determines if a PVC belongs to a DocumentDB cluster and returns the cluster name. // Returns empty string if the PVC does not belong to a DocumentDB cluster. func (r *PVCReconciler) getDocumentDBClusterName(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (string, error) { // Check if PVC already has the documentdb.io/cluster label - if clusterName, hasLabel := pvc.Labels["documentdb.io/cluster"]; hasLabel { + if clusterName, hasLabel := pvc.Labels[LabelDocumentDBCluster]; hasLabel { return clusterName, nil } @@ -114,44 +154,67 @@ func (r *PVCReconciler) findDocumentDBOwnerThroughCNPG(ctx context.Context, pvc return "", nil } -// updateRetentionAnnotation updates the PVC retention annotation if it has changed. +// updateRetentionAnnotation updates the PVC retention annotation based on cluster configuration. +// If the cluster is deleted, preserves existing annotation or sets default. func (r *PVCReconciler) updateRetentionAnnotation(ctx context.Context, pvc *corev1.PersistentVolumeClaim, clusterName string) error { + log := log.FromContext(ctx) + var cluster dbpreview.DocumentDB err := r.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: pvc.Namespace}, &cluster) - // If cluster doesn't exist + // If cluster doesn't exist (deleted), preserve or set default retention if err != nil { - // If no annotation exists, set default value 7 - if pvc.Annotations == nil || pvc.Annotations["documentdb.io/pvc-retention-days"] == "" { + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get DocumentDB cluster %s: %w", clusterName, err) + } + + // Cluster deleted - ensure annotation exists for retention logic + if pvc.Annotations == nil || pvc.Annotations[AnnotationPVCRetentionDays] == "" { if pvc.Annotations == nil { pvc.Annotations = make(map[string]string) } - pvc.Annotations["documentdb.io/pvc-retention-days"] = "7" + pvc.Annotations[AnnotationPVCRetentionDays] = strconv.Itoa(DefaultPVCRetentionDays) + log.Info("Setting default retention for PVC from deleted cluster", "retentionDays", DefaultPVCRetentionDays) return r.Update(ctx, pvc) } - // If annotation exists, do nothing return nil } - // Cluster exists - set or update annotation based on cluster value + // Cluster exists - sync annotation from cluster spec if pvc.Annotations == nil { pvc.Annotations = make(map[string]string) } - retentionDays := cluster.Spec.Resource.Storage.PvcRetentionPeriodDays - expectedRetention := string(rune(retentionDays)) - currentRetention := pvc.Annotations["documentdb.io/pvc-retention-days"] + retentionDays := cluster.Spec.Resource.Storage.PvcRetentionDays + expectedRetention := strconv.Itoa(retentionDays) + currentRetention := pvc.Annotations[AnnotationPVCRetentionDays] if currentRetention != expectedRetention { - pvc.Annotations["documentdb.io/pvc-retention-days"] = expectedRetention + log.Info("Updating PVC retention annotation", "oldValue", currentRetention, "newValue", expectedRetention) + pvc.Annotations[AnnotationPVCRetentionDays] = expectedRetention return r.Update(ctx, pvc) } return nil } +// getRetentionDays extracts and validates the retention days from PVC annotations. +func (r *PVCReconciler) getRetentionDays(pvc *corev1.PersistentVolumeClaim) int { + retentionStr := pvc.Annotations[AnnotationPVCRetentionDays] + if retentionStr == "" { + return DefaultPVCRetentionDays + } + + days, err := strconv.Atoi(retentionStr) + if err != nil { + return DefaultPVCRetentionDays + } + return days +} + // manageFinalizer adds or removes the PVC finalizer based on cluster deletion status and retention period. func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { + log := log.FromContext(ctx) shouldHaveFinalizer := r.shouldRetainPVC(pvc) hasFinalizer := containsString(pvc.Finalizers, PVCFinalizerName) @@ -161,11 +224,13 @@ func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.Persist } if shouldHaveFinalizer { + log.Info("Adding retention finalizer to PVC") if pvc.Finalizers == nil { pvc.Finalizers = []string{} } pvc.Finalizers = append(pvc.Finalizers, PVCFinalizerName) } else { + log.Info("Removing retention finalizer from PVC (retention period expired)") if pvc.Finalizers == nil { return nil } @@ -175,25 +240,19 @@ func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.Persist return r.Update(ctx, pvc) } -// shouldRetainPVC determines if a PVC should be retained based on cluster deletion status and retention period. +// shouldRetainPVC determines if a PVC should have a retention finalizer. +// Returns true if: +// 1. PVC is not being deleted (actively in use) +// 2. PVC is being deleted but retention period has not expired func (r *PVCReconciler) shouldRetainPVC(pvc *corev1.PersistentVolumeClaim) bool { if pvc.DeletionTimestamp == nil { + // PVC is active, should have finalizer for future retention return true } - retentionDays := pvc.Annotations["documentdb.io/pvc-retention-days"] - if retentionDays == "" { - // Default retention if annotation missing - retentionDays = "7" - } - - // Check if retention period has expired - days, err := strconv.Atoi(retentionDays) - if err != nil { - // If conversion fails, use default of 7 days - days = 7 - } - retentionExpiration := pvc.DeletionTimestamp.AddDate(0, 0, days) + // PVC is being deleted - check if retention period has expired + retentionDays := r.getRetentionDays(pvc) + retentionExpiration := pvc.DeletionTimestamp.AddDate(0, 0, retentionDays) return time.Now().Before(retentionExpiration) } @@ -215,7 +274,7 @@ func (r *PVCReconciler) findPVCsForCluster(ctx context.Context, cluster client.O pvcList := &corev1.PersistentVolumeClaimList{} // List PVCs that have a label matching this cluster - if err := r.List(ctx, pvcList, client.MatchingLabels{"documentdb.io/cluster": cluster.GetName()}); err != nil { + if err := r.List(ctx, pvcList, client.InNamespace(cluster.GetNamespace()), client.MatchingLabels{LabelDocumentDBCluster: cluster.GetName()}); err != nil { return []reconcile.Request{} } @@ -238,7 +297,7 @@ func ClusterRetentionChangedPredicate() predicate.Predicate { newCluster := e.ObjectNew.(*dbpreview.DocumentDB) // Only trigger if the specific field we care about has changed - return oldCluster.Spec.Resource.Storage.PvcRetentionPeriodDays != newCluster.Spec.Resource.Storage.PvcRetentionPeriodDays + return oldCluster.Spec.Resource.Storage.PvcRetentionDays != newCluster.Spec.Resource.Storage.PvcRetentionDays }, } } diff --git a/operator/src/internal/controller/pvc_controller_test.go b/operator/src/internal/controller/pvc_controller_test.go index 04a8d20e..ef2c212d 100644 --- a/operator/src/internal/controller/pvc_controller_test.go +++ b/operator/src/internal/controller/pvc_controller_test.go @@ -386,7 +386,7 @@ var _ = Describe("PVC Controller", func() { It("should set annotation from cluster when no annotation exists", func() { // Create DocumentDB with retention period documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 14 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 14 // Create PVC with documentdb.io/cluster label but no retention annotation pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) @@ -403,7 +403,7 @@ var _ = Describe("PVC Controller", func() { updated := &corev1.PersistentVolumeClaim{} Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(14)))) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("14")) }) }) @@ -411,12 +411,12 @@ var _ = Describe("PVC Controller", func() { It("should update annotation when value differs from cluster", func() { // Create DocumentDB with retention period documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 14 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 14 // Create PVC with old retention value pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": string(rune(7)), + "documentdb.io/pvc-retention-days": "7", } fakeClient := fake.NewClientBuilder(). @@ -431,18 +431,18 @@ var _ = Describe("PVC Controller", func() { updated := &corev1.PersistentVolumeClaim{} Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(14)))) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("14")) }) It("should not modify annotation when value matches cluster", func() { // Create DocumentDB with retention period documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 // Create PVC with correct retention value pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": string(rune(7)), + "documentdb.io/pvc-retention-days": "7", "custom-annotation": "custom-value", } @@ -458,7 +458,7 @@ var _ = Describe("PVC Controller", func() { updated := &corev1.PersistentVolumeClaim{} Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(7)))) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("7")) Expect(updated.Annotations["custom-annotation"]).To(Equal("custom-value")) }) }) @@ -467,7 +467,7 @@ var _ = Describe("PVC Controller", func() { It("should set annotation to zero", func() { // Create DocumentDB with zero retention period (retain forever) documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 0 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 0 // Create PVC with documentdb.io/cluster label pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) @@ -484,7 +484,7 @@ var _ = Describe("PVC Controller", func() { updated := &corev1.PersistentVolumeClaim{} Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal(string(rune(0)))) + Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("0")) }) }) }) @@ -494,7 +494,7 @@ var _ = Describe("PVC Controller", func() { It("should add finalizer if not exists", func() { // Create DocumentDB and PVC without finalizer documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) // No finalizers initially @@ -516,7 +516,7 @@ var _ = Describe("PVC Controller", func() { It("should do nothing if finalizer already exists", func() { // Create DocumentDB and PVC with finalizer already present documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) pvc.Finalizers = []string{PVCFinalizerName, "some-other-finalizer"} @@ -538,10 +538,10 @@ var _ = Describe("PVC Controller", func() { Context("when PVC is deleted (deletionTimestamp is not null)", func() { Context("and retention period has not been exceeded", func() { - It("should add finalizer if not exists", func() { + It("should add finalizer if not exists and requeue after retention period", func() { // Create DocumentDB documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 // Create PVC with deletionTimestamp (being deleted) // Must have at least one finalizer for Kubernetes to accept deletionTimestamp @@ -559,7 +559,17 @@ var _ = Describe("PVC Controller", func() { Build() reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: pvcName, + Namespace: pvcNamespace, + }, + } + result, err := reconciler.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + // Should requeue to check retention expiration later + Expect(result.Requeue).To(BeFalse()) + Expect(result.RequeueAfter).To(BeNumerically(">", 0)) // Verify our finalizer was added updated := &corev1.PersistentVolumeClaim{} @@ -568,10 +578,10 @@ var _ = Describe("PVC Controller", func() { Expect(updated.Finalizers).To(ContainElement("some-other-finalizer")) }) - It("should do nothing if finalizer already exists", func() { + It("should keep finalizer and requeue after retention period if finalizer already exists", func() { // Create DocumentDB documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 // Create PVC with deletionTimestamp and existing finalizer pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) @@ -588,7 +598,17 @@ var _ = Describe("PVC Controller", func() { Build() reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: pvcName, + Namespace: pvcNamespace, + }, + } + result, err := reconciler.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + // Should requeue to check retention expiration later + Expect(result.Requeue).To(BeFalse()) + Expect(result.RequeueAfter).To(BeNumerically(">", 0)) // Verify finalizers remain unchanged updated := &corev1.PersistentVolumeClaim{} @@ -601,7 +621,7 @@ var _ = Describe("PVC Controller", func() { It("should remove finalizer if exists", func() { // Create DocumentDB documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 // Create PVC with deletionTimestamp from 10 days ago (exceeded 7 day retention) pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) @@ -630,7 +650,7 @@ var _ = Describe("PVC Controller", func() { It("should do nothing if finalizer does not exist", func() { // Create DocumentDB documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionPeriodDays = 7 + documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 // Create PVC with deletionTimestamp from 10 days ago (exceeded 7 day retention) pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) @@ -660,7 +680,7 @@ var _ = Describe("PVC Controller", func() { }) Describe("ClusterRetentionChangedPredicate", func() { - It("should return true when PvcRetentionPeriodDays changes", func() { + It("should return true when PvcRetentionDays changes", func() { oldCluster := &dbpreview.DocumentDB{ ObjectMeta: metav1.ObjectMeta{ Name: clusterName, @@ -669,8 +689,8 @@ var _ = Describe("PVC Controller", func() { Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionPeriodDays: 7, + PvcSize: "10Gi", + PvcRetentionDays: 7, }, }, }, @@ -684,8 +704,8 @@ var _ = Describe("PVC Controller", func() { Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionPeriodDays: 14, + PvcSize: "10Gi", + PvcRetentionDays: 14, }, }, }, @@ -700,7 +720,7 @@ var _ = Describe("PVC Controller", func() { Expect(predicate.Update(updateEvent)).To(BeTrue()) }) - It("should return false when PvcRetentionPeriodDays does not change", func() { + It("should return false when PvcRetentionDays does not change", func() { oldCluster := &dbpreview.DocumentDB{ ObjectMeta: metav1.ObjectMeta{ Name: clusterName, @@ -709,8 +729,8 @@ var _ = Describe("PVC Controller", func() { Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionPeriodDays: 7, + PvcSize: "10Gi", + PvcRetentionDays: 7, }, }, }, @@ -724,8 +744,8 @@ var _ = Describe("PVC Controller", func() { Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ Storage: dbpreview.StorageConfiguration{ - PvcSize: "20Gi", // Different field changed - PvcRetentionPeriodDays: 7, // Same value + PvcSize: "20Gi", // Different field changed + PvcRetentionDays: 7, // Same value }, }, }, @@ -740,7 +760,7 @@ var _ = Describe("PVC Controller", func() { Expect(predicate.Update(updateEvent)).To(BeFalse()) }) - It("should return false when PvcRetentionPeriodDays is the same (both zero)", func() { + It("should return false when PvcRetentionDays is the same (both zero)", func() { oldCluster := &dbpreview.DocumentDB{ ObjectMeta: metav1.ObjectMeta{ Name: clusterName, @@ -749,8 +769,8 @@ var _ = Describe("PVC Controller", func() { Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionPeriodDays: 0, + PvcSize: "10Gi", + PvcRetentionDays: 0, }, }, }, @@ -764,8 +784,8 @@ var _ = Describe("PVC Controller", func() { Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionPeriodDays: 0, + PvcSize: "10Gi", + PvcRetentionDays: 0, }, }, }, @@ -780,7 +800,7 @@ var _ = Describe("PVC Controller", func() { Expect(predicate.Update(updateEvent)).To(BeFalse()) }) - It("should return true when PvcRetentionPeriodDays changes from 0 to non-zero", func() { + It("should return true when PvcRetentionDays changes from 0 to non-zero", func() { oldCluster := &dbpreview.DocumentDB{ ObjectMeta: metav1.ObjectMeta{ Name: clusterName, @@ -789,8 +809,8 @@ var _ = Describe("PVC Controller", func() { Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionPeriodDays: 0, + PvcSize: "10Gi", + PvcRetentionDays: 0, }, }, }, @@ -804,8 +824,8 @@ var _ = Describe("PVC Controller", func() { Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionPeriodDays: 30, + PvcSize: "10Gi", + PvcRetentionDays: 30, }, }, }, From 19ce7848971b618533701c0b7eb37c6afb753b92 Mon Sep 17 00:00:00 2001 From: wenting Date: Wed, 14 Jan 2026 13:08:32 -0500 Subject: [PATCH 08/19] validation via CEL, not webhook because webhook need cert rotation Signed-off-by: wenting --- .../crds/documentdb.io_dbs.yaml | 25 +++ operator/src/api/preview/documentdb_types.go | 1 + operator/src/cmd/main.go | 7 - .../config/crd/bases/documentdb.io_dbs.yaml | 25 +++ operator/src/config/rbac/role.yaml | 18 ++ .../src/internal/controller/pvc_controller.go | 2 +- .../webhook/preview/documentdb_webhook.go | 130 ------------- .../preview/documentdb_webhook_test.go | 172 ------------------ .../internal/webhook/preview/suite_test.go | 16 -- 9 files changed, 70 insertions(+), 326 deletions(-) delete mode 100644 operator/src/internal/webhook/preview/documentdb_webhook.go delete mode 100644 operator/src/internal/webhook/preview/documentdb_webhook_test.go delete mode 100644 operator/src/internal/webhook/preview/suite_test.go diff --git a/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml b/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml index b5e41e80..c1bf03b2 100644 --- a/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml +++ b/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml @@ -79,7 +79,23 @@ spec: required: - name type: object + pvc: + description: |- + PVC specifies the source PVC to restore from. + Cannot be used together with Backup. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object type: object + x-kubernetes-validations: + - message: cannot specify both backup and pvc recovery at the + same time + rule: '!(has(self.backup) && self.backup.name != '''' && has(self.pvc) + && self.pvc.name != '''')' type: object clusterReplication: description: ClusterReplication configures cross-cluster replication @@ -198,6 +214,15 @@ spec: storage: description: Storage configuration for DocumentDB persistent volumes. properties: + pvcRetentionDays: + default: 7 + description: |- + PvcRetentionDays specifies how many days PVCs should be retained after the DocumentDB cluster is deleted. + This allows for data recovery after accidental cluster deletion. + Set to 0 for immediate deletion (default behavior: 7 days). + maximum: 365 + minimum: 0 + type: integer pvcSize: description: PvcSize is the size of the persistent volume claim for DocumentDB storage (e.g., "10Gi"). diff --git a/operator/src/api/preview/documentdb_types.go b/operator/src/api/preview/documentdb_types.go index 82f5dfb7..874a487f 100644 --- a/operator/src/api/preview/documentdb_types.go +++ b/operator/src/api/preview/documentdb_types.go @@ -86,6 +86,7 @@ type BootstrapConfiguration struct { } // RecoveryConfiguration defines backup recovery settings. +// +kubebuilder:validation:XValidation:rule="!(has(self.backup) && self.backup.name != '' && has(self.pvc) && self.pvc.name != '')",message="cannot specify both backup and pvc recovery at the same time" type RecoveryConfiguration struct { // Backup specifies the source backup to restore from. // +optional diff --git a/operator/src/cmd/main.go b/operator/src/cmd/main.go index 2e1a7fba..e0370c12 100644 --- a/operator/src/cmd/main.go +++ b/operator/src/cmd/main.go @@ -29,7 +29,6 @@ import ( cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" dbpreview "github.com/documentdb/documentdb-operator/api/preview" "github.com/documentdb/documentdb-operator/internal/controller" - webhookpreview "github.com/documentdb/documentdb-operator/internal/webhook/preview" fleetv1alpha1 "go.goms.io/fleet-networking/api/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -240,12 +239,6 @@ func main() { os.Exit(1) } - // Setup webhooks - if err = webhookpreview.SetupWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "DocumentDB") - os.Exit(1) - } - // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/operator/src/config/crd/bases/documentdb.io_dbs.yaml b/operator/src/config/crd/bases/documentdb.io_dbs.yaml index b5e41e80..c1bf03b2 100644 --- a/operator/src/config/crd/bases/documentdb.io_dbs.yaml +++ b/operator/src/config/crd/bases/documentdb.io_dbs.yaml @@ -79,7 +79,23 @@ spec: required: - name type: object + pvc: + description: |- + PVC specifies the source PVC to restore from. + Cannot be used together with Backup. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object type: object + x-kubernetes-validations: + - message: cannot specify both backup and pvc recovery at the + same time + rule: '!(has(self.backup) && self.backup.name != '''' && has(self.pvc) + && self.pvc.name != '''')' type: object clusterReplication: description: ClusterReplication configures cross-cluster replication @@ -198,6 +214,15 @@ spec: storage: description: Storage configuration for DocumentDB persistent volumes. properties: + pvcRetentionDays: + default: 7 + description: |- + PvcRetentionDays specifies how many days PVCs should be retained after the DocumentDB cluster is deleted. + This allows for data recovery after accidental cluster deletion. + Set to 0 for immediate deletion (default behavior: 7 days). + maximum: 365 + minimum: 0 + type: integer pvcSize: description: PvcSize is the size of the persistent volume claim for DocumentDB storage (e.g., "10Gi"). diff --git a/operator/src/config/rbac/role.yaml b/operator/src/config/rbac/role.yaml index fbc26477..e62e8cb4 100644 --- a/operator/src/config/rbac/role.yaml +++ b/operator/src/config/rbac/role.yaml @@ -4,6 +4,16 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - get + - list + - patch + - update + - watch - apiGroups: - "" resources: @@ -47,3 +57,11 @@ rules: - get - patch - update +- apiGroups: + - postgresql.cnpg.io + resources: + - clusters + verbs: + - get + - list + - watch diff --git a/operator/src/internal/controller/pvc_controller.go b/operator/src/internal/controller/pvc_controller.go index c9963acb..a4dcffe2 100644 --- a/operator/src/internal/controller/pvc_controller.go +++ b/operator/src/internal/controller/pvc_controller.go @@ -296,7 +296,7 @@ func ClusterRetentionChangedPredicate() predicate.Predicate { oldCluster := e.ObjectOld.(*dbpreview.DocumentDB) newCluster := e.ObjectNew.(*dbpreview.DocumentDB) - // Only trigger if the specific field we care about has changed + // Only trigger if PvcRetentionDays has changed return oldCluster.Spec.Resource.Storage.PvcRetentionDays != newCluster.Spec.Resource.Storage.PvcRetentionDays }, } diff --git a/operator/src/internal/webhook/preview/documentdb_webhook.go b/operator/src/internal/webhook/preview/documentdb_webhook.go deleted file mode 100644 index 3c3fa9b6..00000000 --- a/operator/src/internal/webhook/preview/documentdb_webhook.go +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package preview - -import ( - "context" - "fmt" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/validation/field" - ctrl "sigs.k8s.io/controller-runtime" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" - - dbpreview "github.com/documentdb/documentdb-operator/api/preview" -) - -// log is for logging in this package. -var documentdbLog = logf.Log.WithName("documentdb-webhook").WithValues("version", "preview") - -// DocumentDBWebhook handles validation for DocumentDB resources -type DocumentDBWebhook struct{} - -// SetupWebhookWithManager registers the webhook with the manager -func SetupWebhookWithManager(mgr ctrl.Manager) error { - return ctrl.NewWebhookManagedBy(mgr). - For(&dbpreview.DocumentDB{}). - WithValidator(&DocumentDBWebhook{}). - Complete() -} - -// +kubebuilder:webhook:path=/validate-documentdb-io-preview-documentdb,mutating=false,failurePolicy=fail,sideEffects=None,groups=documentdb.io,resources=dbs,verbs=create;update,versions=preview,name=vdocumentdb.kb.io,admissionReviewVersions=v1 - -var _ admission.CustomValidator = &DocumentDBWebhook{} - -// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type -func (w *DocumentDBWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { - documentdb, ok := obj.(*dbpreview.DocumentDB) - if !ok { - return nil, fmt.Errorf("expected DocumentDB object but got %T", obj) - } - - documentdbLog.Info("validate create", "name", documentdb.Name, "namespace", documentdb.Namespace) - - allErrs := w.validate(documentdb) - if len(allErrs) == 0 { - return nil, nil - } - - return nil, apierrors.NewInvalid( - schema.GroupKind{Group: "documentdb.io", Kind: "DocumentDB"}, - documentdb.Name, - allErrs, - ) -} - -// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type -func (w *DocumentDBWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { - documentdb, ok := newObj.(*dbpreview.DocumentDB) - if !ok { - return nil, fmt.Errorf("expected DocumentDB object but got %T", newObj) - } - - documentdbLog.Info("validate update", "name", documentdb.Name, "namespace", documentdb.Namespace) - - allErrs := w.validate(documentdb) - if len(allErrs) == 0 { - return nil, nil - } - - return nil, apierrors.NewInvalid( - schema.GroupKind{Group: "documentdb.io", Kind: "DocumentDB"}, - documentdb.Name, - allErrs, - ) -} - -// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type -func (w *DocumentDBWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { - documentdb, ok := obj.(*dbpreview.DocumentDB) - if !ok { - return nil, fmt.Errorf("expected DocumentDB object but got %T", obj) - } - - documentdbLog.Info("validate delete", "name", documentdb.Name, "namespace", documentdb.Namespace) - // No validation needed for delete - return nil, nil -} - -// validate groups the validation logic for DocumentDB returning a list of all encountered errors -func (w *DocumentDBWebhook) validate(r *dbpreview.DocumentDB) field.ErrorList { - type validationFunc func(*dbpreview.DocumentDB) field.ErrorList - - validations := []validationFunc{ - w.validateBootstrapRecovery, - // Add more validation functions here as needed - } - - var allErrs field.ErrorList - for _, validate := range validations { - allErrs = append(allErrs, validate(r)...) - } - - return allErrs -} - -// validateBootstrapRecovery validates that backup and PVC recovery are not both specified -func (w *DocumentDBWebhook) validateBootstrapRecovery(documentdb *dbpreview.DocumentDB) field.ErrorList { - // If bootstrap is not configured, everything is ok - if documentdb.Spec.Bootstrap == nil || documentdb.Spec.Bootstrap.Recovery == nil { - return nil - } - - var result field.ErrorList - recovery := documentdb.Spec.Bootstrap.Recovery - - // Validate that both backup and PVC are not specified together - if recovery.Backup.Name != "" && recovery.PVC.Name != "" { - result = append(result, field.Invalid( - field.NewPath("spec", "bootstrap", "recovery"), - recovery, - "cannot specify both backup and PVC recovery at the same time", - )) - } - - return result -} diff --git a/operator/src/internal/webhook/preview/documentdb_webhook_test.go b/operator/src/internal/webhook/preview/documentdb_webhook_test.go deleted file mode 100644 index d9f28a30..00000000 --- a/operator/src/internal/webhook/preview/documentdb_webhook_test.go +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package preview - -import ( - cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - dbpreview "github.com/documentdb/documentdb-operator/api/preview" -) - -var _ = Describe("bootstrap recovery validation", func() { - var v *DocumentDBWebhook - - BeforeEach(func() { - v = &DocumentDBWebhook{} - }) - - It("doesn't complain if there isn't a bootstrap configuration", func() { - documentdb := &dbpreview.DocumentDB{} - result := v.validateBootstrapRecovery(documentdb) - Expect(result).To(BeEmpty()) - }) - - It("doesn't complain if there isn't a recovery configuration", func() { - documentdb := &dbpreview.DocumentDB{ - Spec: dbpreview.DocumentDBSpec{ - Bootstrap: &dbpreview.BootstrapConfiguration{}, - }, - } - result := v.validateBootstrapRecovery(documentdb) - Expect(result).To(BeEmpty()) - }) - - It("doesn't complain if only backup recovery is specified", func() { - documentdb := &dbpreview.DocumentDB{ - Spec: dbpreview.DocumentDBSpec{ - Bootstrap: &dbpreview.BootstrapConfiguration{ - Recovery: &dbpreview.RecoveryConfiguration{ - Backup: cnpgv1.LocalObjectReference{ - Name: "my-backup", - }, - }, - }, - }, - } - result := v.validateBootstrapRecovery(documentdb) - Expect(result).To(BeEmpty()) - }) - - It("doesn't complain if only PVC recovery is specified", func() { - documentdb := &dbpreview.DocumentDB{ - Spec: dbpreview.DocumentDBSpec{ - Bootstrap: &dbpreview.BootstrapConfiguration{ - Recovery: &dbpreview.RecoveryConfiguration{ - PVC: cnpgv1.LocalObjectReference{ - Name: "my-pvc", - }, - }, - }, - }, - } - result := v.validateBootstrapRecovery(documentdb) - Expect(result).To(BeEmpty()) - }) - - It("complains if both backup and PVC recovery are specified", func() { - documentdb := &dbpreview.DocumentDB{ - Spec: dbpreview.DocumentDBSpec{ - Bootstrap: &dbpreview.BootstrapConfiguration{ - Recovery: &dbpreview.RecoveryConfiguration{ - Backup: cnpgv1.LocalObjectReference{ - Name: "my-backup", - }, - PVC: cnpgv1.LocalObjectReference{ - Name: "my-pvc", - }, - }, - }, - }, - } - result := v.validateBootstrapRecovery(documentdb) - Expect(result).To(HaveLen(1)) - Expect(result[0].Error()).To(ContainSubstring("cannot specify both backup and PVC recovery")) - }) - - It("doesn't complain if backup name is empty and PVC is specified", func() { - documentdb := &dbpreview.DocumentDB{ - Spec: dbpreview.DocumentDBSpec{ - Bootstrap: &dbpreview.BootstrapConfiguration{ - Recovery: &dbpreview.RecoveryConfiguration{ - Backup: cnpgv1.LocalObjectReference{ - Name: "", - }, - PVC: cnpgv1.LocalObjectReference{ - Name: "my-pvc", - }, - }, - }, - }, - } - result := v.validateBootstrapRecovery(documentdb) - Expect(result).To(BeEmpty()) - }) - - It("doesn't complain if PVC name is empty and backup is specified", func() { - documentdb := &dbpreview.DocumentDB{ - Spec: dbpreview.DocumentDBSpec{ - Bootstrap: &dbpreview.BootstrapConfiguration{ - Recovery: &dbpreview.RecoveryConfiguration{ - Backup: cnpgv1.LocalObjectReference{ - Name: "my-backup", - }, - PVC: cnpgv1.LocalObjectReference{ - Name: "", - }, - }, - }, - }, - } - result := v.validateBootstrapRecovery(documentdb) - Expect(result).To(BeEmpty()) - }) -}) - -var _ = Describe("DocumentDB webhook", func() { - var v *DocumentDBWebhook - - BeforeEach(func() { - v = &DocumentDBWebhook{} - }) - - Context("validate method", func() { - It("returns no errors for a valid DocumentDB with no bootstrap", func() { - documentdb := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cluster", - Namespace: "default", - }, - Spec: dbpreview.DocumentDBSpec{}, - } - result := v.validate(documentdb) - Expect(result).To(BeEmpty()) - }) - - It("returns errors when both backup and PVC recovery are specified", func() { - documentdb := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-cluster", - Namespace: "default", - }, - Spec: dbpreview.DocumentDBSpec{ - Bootstrap: &dbpreview.BootstrapConfiguration{ - Recovery: &dbpreview.RecoveryConfiguration{ - Backup: cnpgv1.LocalObjectReference{ - Name: "my-backup", - }, - PVC: cnpgv1.LocalObjectReference{ - Name: "my-pvc", - }, - }, - }, - }, - } - result := v.validate(documentdb) - Expect(result).To(HaveLen(1)) - }) - }) -}) diff --git a/operator/src/internal/webhook/preview/suite_test.go b/operator/src/internal/webhook/preview/suite_test.go deleted file mode 100644 index a083bca8..00000000 --- a/operator/src/internal/webhook/preview/suite_test.go +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package preview - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestWebhook(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "DocumentDB Webhook Suite") -} From 97421bb45af02c6ca92290ec69d9cf769ae18b8b Mon Sep 17 00:00:00 2001 From: wenting Date: Wed, 14 Jan 2026 14:44:15 -0500 Subject: [PATCH 09/19] add pvc controller to main Signed-off-by: wenting --- operator/src/cmd/main.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/operator/src/cmd/main.go b/operator/src/cmd/main.go index e0370c12..184dd0fc 100644 --- a/operator/src/cmd/main.go +++ b/operator/src/cmd/main.go @@ -239,6 +239,13 @@ func main() { os.Exit(1) } + if err = (&controller.PVCReconciler{ + Client: mgr.GetClient(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PVC") + os.Exit(1) + } + // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { From 7c7420644bc1cc6735051d8b2d702f671544f8bd Mon Sep 17 00:00:00 2001 From: wenting Date: Thu, 15 Jan 2026 12:49:37 -0500 Subject: [PATCH 10/19] retain pv Signed-off-by: wenting --- .../crds/documentdb.io_dbs.yaml | 22 +- operator/src/api/preview/documentdb_types.go | 16 +- .../src/api/preview/zz_generated.deepcopy.go | 1 + operator/src/cmd/main.go | 4 +- .../config/crd/bases/documentdb.io_dbs.yaml | 22 +- operator/src/config/rbac/role.yaml | 15 +- .../src/internal/controller/pv_controller.go | 335 +++++++ .../src/internal/controller/pvc_controller.go | 324 ------- .../controller/pvc_controller_test.go | 843 ------------------ 9 files changed, 374 insertions(+), 1208 deletions(-) create mode 100644 operator/src/internal/controller/pv_controller.go delete mode 100644 operator/src/internal/controller/pvc_controller.go delete mode 100644 operator/src/internal/controller/pvc_controller_test.go diff --git a/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml b/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml index c1bf03b2..b966bd7d 100644 --- a/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml +++ b/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml @@ -94,8 +94,8 @@ spec: x-kubernetes-validations: - message: cannot specify both backup and pvc recovery at the same time - rule: '!(has(self.backup) && self.backup.name != '''' && has(self.pvc) - && self.pvc.name != '''')' + rule: '!(has(self.backup) && size(self.backup.name) > 0 && has(self.pvc) + && size(self.pvc.name) > 0)' type: object clusterReplication: description: ClusterReplication configures cross-cluster replication @@ -214,15 +214,17 @@ spec: storage: description: Storage configuration for DocumentDB persistent volumes. properties: - pvcRetentionDays: - default: 7 + persistentVolumeReclaimPolicy: + default: Delete description: |- - PvcRetentionDays specifies how many days PVCs should be retained after the DocumentDB cluster is deleted. - This allows for data recovery after accidental cluster deletion. - Set to 0 for immediate deletion (default behavior: 7 days). - maximum: 365 - minimum: 0 - type: integer + PersistentVolumeReclaimPolicy controls what happens to the PersistentVolume when + the DocumentDB cluster is deleted. + Retain: The PV is kept after cluster deletion, allowing data recovery. + Delete: The PV is deleted with the cluster (default behavior). + enum: + - Retain + - Delete + type: string pvcSize: description: PvcSize is the size of the persistent volume claim for DocumentDB storage (e.g., "10Gi"). diff --git a/operator/src/api/preview/documentdb_types.go b/operator/src/api/preview/documentdb_types.go index 874a487f..58836e2e 100644 --- a/operator/src/api/preview/documentdb_types.go +++ b/operator/src/api/preview/documentdb_types.go @@ -86,7 +86,7 @@ type BootstrapConfiguration struct { } // RecoveryConfiguration defines backup recovery settings. -// +kubebuilder:validation:XValidation:rule="!(has(self.backup) && self.backup.name != '' && has(self.pvc) && self.pvc.name != '')",message="cannot specify both backup and pvc recovery at the same time" +// +kubebuilder:validation:XValidation:rule="!(has(self.backup) && size(self.backup.name) > 0 && has(self.pvc) && size(self.pvc.name) > 0)",message="cannot specify both backup and pvc recovery at the same time" type RecoveryConfiguration struct { // Backup specifies the source backup to restore from. // +optional @@ -122,14 +122,14 @@ type StorageConfiguration struct { // If not specified, the cluster's default storage class will be used. StorageClass string `json:"storageClass,omitempty"` - // PvcRetentionDays specifies how many days PVCs should be retained after the DocumentDB cluster is deleted. - // This allows for data recovery after accidental cluster deletion. - // Set to 0 for immediate deletion (default behavior: 7 days). - // +kubebuilder:validation:Minimum=0 - // +kubebuilder:validation:Maximum=365 - // +kubebuilder:default=7 + // PersistentVolumeReclaimPolicy controls what happens to the PersistentVolume when + // the DocumentDB cluster is deleted. + // Retain: The PV is kept after cluster deletion, allowing data recovery. + // Delete: The PV is deleted with the cluster (default behavior). + // +kubebuilder:validation:Enum=Retain;Delete + // +kubebuilder:default=Delete // +optional - PvcRetentionDays int `json:"pvcRetentionDays,omitempty"` + PersistentVolumeReclaimPolicy string `json:"persistentVolumeReclaimPolicy,omitempty"` } type ClusterReplication struct { diff --git a/operator/src/api/preview/zz_generated.deepcopy.go b/operator/src/api/preview/zz_generated.deepcopy.go index 07a84559..3e55f1a3 100644 --- a/operator/src/api/preview/zz_generated.deepcopy.go +++ b/operator/src/api/preview/zz_generated.deepcopy.go @@ -430,6 +430,7 @@ func (in *ProvidedTLS) DeepCopy() *ProvidedTLS { func (in *RecoveryConfiguration) DeepCopyInto(out *RecoveryConfiguration) { *out = *in in.Backup.DeepCopyInto(&out.Backup) + in.PVC.DeepCopyInto(&out.PVC) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecoveryConfiguration. diff --git a/operator/src/cmd/main.go b/operator/src/cmd/main.go index 184dd0fc..bf5f2ddf 100644 --- a/operator/src/cmd/main.go +++ b/operator/src/cmd/main.go @@ -239,10 +239,10 @@ func main() { os.Exit(1) } - if err = (&controller.PVCReconciler{ + if err = (&controller.PersistentVolumeReconciler{ Client: mgr.GetClient(), }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "PVC") + setupLog.Error(err, "unable to create controller", "controller", "PersistentVolume") os.Exit(1) } diff --git a/operator/src/config/crd/bases/documentdb.io_dbs.yaml b/operator/src/config/crd/bases/documentdb.io_dbs.yaml index c1bf03b2..b966bd7d 100644 --- a/operator/src/config/crd/bases/documentdb.io_dbs.yaml +++ b/operator/src/config/crd/bases/documentdb.io_dbs.yaml @@ -94,8 +94,8 @@ spec: x-kubernetes-validations: - message: cannot specify both backup and pvc recovery at the same time - rule: '!(has(self.backup) && self.backup.name != '''' && has(self.pvc) - && self.pvc.name != '''')' + rule: '!(has(self.backup) && size(self.backup.name) > 0 && has(self.pvc) + && size(self.pvc.name) > 0)' type: object clusterReplication: description: ClusterReplication configures cross-cluster replication @@ -214,15 +214,17 @@ spec: storage: description: Storage configuration for DocumentDB persistent volumes. properties: - pvcRetentionDays: - default: 7 + persistentVolumeReclaimPolicy: + default: Delete description: |- - PvcRetentionDays specifies how many days PVCs should be retained after the DocumentDB cluster is deleted. - This allows for data recovery after accidental cluster deletion. - Set to 0 for immediate deletion (default behavior: 7 days). - maximum: 365 - minimum: 0 - type: integer + PersistentVolumeReclaimPolicy controls what happens to the PersistentVolume when + the DocumentDB cluster is deleted. + Retain: The PV is kept after cluster deletion, allowing data recovery. + Delete: The PV is deleted with the cluster (default behavior). + enum: + - Retain + - Delete + type: string pvcSize: description: PvcSize is the size of the persistent volume claim for DocumentDB storage (e.g., "10Gi"). diff --git a/operator/src/config/rbac/role.yaml b/operator/src/config/rbac/role.yaml index e62e8cb4..3c0b4b79 100644 --- a/operator/src/config/rbac/role.yaml +++ b/operator/src/config/rbac/role.yaml @@ -8,19 +8,20 @@ rules: - "" resources: - persistentvolumeclaims + - secrets verbs: - get - list - - patch - - update - watch - apiGroups: - "" resources: - - secrets + - persistentvolumes verbs: - get - list + - patch + - update - watch - apiGroups: - cert-manager.io @@ -57,11 +58,3 @@ rules: - get - patch - update -- apiGroups: - - postgresql.cnpg.io - resources: - - clusters - verbs: - - get - - list - - watch diff --git a/operator/src/internal/controller/pv_controller.go b/operator/src/internal/controller/pv_controller.go new file mode 100644 index 00000000..9e0d1a71 --- /dev/null +++ b/operator/src/internal/controller/pv_controller.go @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package controller + +import ( + "context" + "strings" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" +) + +// PersistentVolumeReconciler reconciles PersistentVolume objects +// to set their ReclaimPolicy based on the associated DocumentDB configuration +type PersistentVolumeReconciler struct { + client.Client +} + +// +kubebuilder:rbac:groups="",resources=persistentvolumes,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch + +func (r *PersistentVolumeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Fetch the PersistentVolume + pv := &corev1.PersistentVolume{} + if err := r.Get(ctx, req.NamespacedName, pv); err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + logger.Error(err, "Failed to get PersistentVolume") + return ctrl.Result{}, err + } + + // Skip if PV is not bound to a PVC + if pv.Spec.ClaimRef == nil { + logger.V(1).Info("PV has no claimRef, skipping", "pv", pv.Name) + return ctrl.Result{}, nil + } + + // Find the associated DocumentDB through the ownership chain: + // PV -> PVC -> CNPG Cluster -> DocumentDB + documentdb, err := r.findDocumentDBForPV(ctx, pv) + if err != nil { + if errors.IsNotFound(err) { + logger.V(1).Info("No DocumentDB found for PV, skipping", "pv", pv.Name) + return ctrl.Result{}, nil + } + logger.Error(err, "Failed to find DocumentDB for PV") + return ctrl.Result{}, err + } + + if documentdb == nil { + logger.V(1).Info("PV is not associated with a DocumentDB cluster, skipping", "pv", pv.Name) + return ctrl.Result{}, nil + } + + // Determine the desired reclaim policy from DocumentDB spec + desiredPolicy := r.getDesiredReclaimPolicy(documentdb) + currentPolicy := pv.Spec.PersistentVolumeReclaimPolicy + + // Update the PV's reclaim policy if it differs from the desired policy + if currentPolicy != desiredPolicy { + logger.Info("Updating PV reclaim policy", + "pv", pv.Name, + "currentPolicy", currentPolicy, + "desiredPolicy", desiredPolicy, + "documentdb", documentdb.Name) + + pv.Spec.PersistentVolumeReclaimPolicy = desiredPolicy + if err := r.Update(ctx, pv); err != nil { + logger.Error(err, "Failed to update PV reclaim policy") + return ctrl.Result{}, err + } + + logger.Info("Successfully updated PV reclaim policy", + "pv", pv.Name, + "newPolicy", desiredPolicy) + } + + return ctrl.Result{}, nil +} + +// findDocumentDBForPV traverses the ownership chain to find the DocumentDB +// associated with a PersistentVolume: +// PV.claimRef -> PVC -> (ownerRef) CNPG Cluster -> (ownerRef) DocumentDB +func (r *PersistentVolumeReconciler) findDocumentDBForPV(ctx context.Context, pv *corev1.PersistentVolume) (*dbpreview.DocumentDB, error) { + logger := log.FromContext(ctx) + + // Step 1: Get the PVC from PV's claimRef + if pv.Spec.ClaimRef == nil { + return nil, nil + } + + pvc := &corev1.PersistentVolumeClaim{} + pvcKey := types.NamespacedName{ + Name: pv.Spec.ClaimRef.Name, + Namespace: pv.Spec.ClaimRef.Namespace, + } + if err := r.Get(ctx, pvcKey, pvc); err != nil { + if errors.IsNotFound(err) { + logger.V(1).Info("PVC not found for PV", "pvc", pvcKey, "pv", pv.Name) + return nil, nil + } + return nil, err + } + + // Step 2: Find CNPG Cluster that owns the PVC + cnpgCluster := r.findCNPGClusterOwner(ctx, pvc) + if cnpgCluster == nil { + logger.V(1).Info("No CNPG Cluster owner found for PVC", "pvc", pvc.Name) + return nil, nil + } + + // Step 3: Find DocumentDB that owns the CNPG Cluster + documentdb := r.findDocumentDBOwner(ctx, cnpgCluster) + if documentdb == nil { + logger.V(1).Info("No DocumentDB owner found for CNPG Cluster", "cluster", cnpgCluster.Name) + return nil, nil + } + + logger.V(1).Info("Found DocumentDB for PV", + "pv", pv.Name, + "pvc", pvc.Name, + "cluster", cnpgCluster.Name, + "documentdb", documentdb.Name) + + return documentdb, nil +} + +// findCNPGClusterOwner finds the CNPG Cluster that owns the given PVC +func (r *PersistentVolumeReconciler) findCNPGClusterOwner(ctx context.Context, pvc *corev1.PersistentVolumeClaim) *cnpgv1.Cluster { + logger := log.FromContext(ctx) + + // Check owner references for CNPG Cluster + for _, ownerRef := range pvc.OwnerReferences { + if ownerRef.Kind == "Cluster" && strings.Contains(ownerRef.APIVersion, "cnpg") { + cluster := &cnpgv1.Cluster{} + if err := r.Get(ctx, types.NamespacedName{ + Name: ownerRef.Name, + Namespace: pvc.Namespace, + }, cluster); err != nil { + if !errors.IsNotFound(err) { + logger.Error(err, "Failed to get CNPG Cluster", "name", ownerRef.Name) + } + continue + } + return cluster + } + } + + return nil +} + +// findDocumentDBOwner finds the DocumentDB that owns the given CNPG Cluster +func (r *PersistentVolumeReconciler) findDocumentDBOwner(ctx context.Context, cluster *cnpgv1.Cluster) *dbpreview.DocumentDB { + logger := log.FromContext(ctx) + + // Check owner references for DocumentDB + for _, ownerRef := range cluster.OwnerReferences { + if ownerRef.Kind == "DocumentDB" { + documentdb := &dbpreview.DocumentDB{} + if err := r.Get(ctx, types.NamespacedName{ + Name: ownerRef.Name, + Namespace: cluster.Namespace, + }, documentdb); err != nil { + if !errors.IsNotFound(err) { + logger.Error(err, "Failed to get DocumentDB", "name", ownerRef.Name) + } + continue + } + return documentdb + } + } + + return nil +} + +// getDesiredReclaimPolicy returns the reclaim policy based on DocumentDB configuration +func (r *PersistentVolumeReconciler) getDesiredReclaimPolicy(documentdb *dbpreview.DocumentDB) corev1.PersistentVolumeReclaimPolicy { + policy := documentdb.Spec.Resource.Storage.PersistentVolumeReclaimPolicy + + switch policy { + case "Retain": + return corev1.PersistentVolumeReclaimRetain + case "Delete": + return corev1.PersistentVolumeReclaimDelete + default: + // Default to Delete if not specified + return corev1.PersistentVolumeReclaimDelete + } +} + +// pvPredicate filters PV events to only process bound PVs +func pvPredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + pv, ok := e.Object.(*corev1.PersistentVolume) + if !ok { + return false + } + // Only process PVs that are bound and have a claimRef + return pv.Status.Phase == corev1.VolumeBound && pv.Spec.ClaimRef != nil + }, + UpdateFunc: func(e event.UpdateEvent) bool { + newPV, ok := e.ObjectNew.(*corev1.PersistentVolume) + if !ok { + return false + } + // Process when PV becomes bound or when claimRef changes + return newPV.Status.Phase == corev1.VolumeBound && newPV.Spec.ClaimRef != nil + }, + DeleteFunc: func(e event.DeleteEvent) bool { + // No need to reconcile deleted PVs + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + pv, ok := e.Object.(*corev1.PersistentVolume) + if !ok { + return false + } + return pv.Status.Phase == corev1.VolumeBound && pv.Spec.ClaimRef != nil + }, + } +} + +// SetupWithManager sets up the controller with the Manager +func (r *PersistentVolumeReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.PersistentVolume{}). + WithEventFilter(pvPredicate()). + // Watch DocumentDB changes and trigger reconciliation of associated PVs + Watches( + &dbpreview.DocumentDB{}, + handler.EnqueueRequestsFromMapFunc(r.findPVsForDocumentDB), + builder.WithPredicates(documentDBReclaimPolicyPredicate()), + ). + Named("pv-controller"). + Complete(r) +} + +// documentDBReclaimPolicyPredicate only triggers when the reclaim policy field changes +func documentDBReclaimPolicyPredicate() predicate.Predicate { + return predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldDB, ok := e.ObjectOld.(*dbpreview.DocumentDB) + if !ok { + return false + } + newDB, ok := e.ObjectNew.(*dbpreview.DocumentDB) + if !ok { + return false + } + return oldDB.Spec.Resource.Storage.PersistentVolumeReclaimPolicy != newDB.Spec.Resource.Storage.PersistentVolumeReclaimPolicy + }, + CreateFunc: func(e event.CreateEvent) bool { return false }, + DeleteFunc: func(e event.DeleteEvent) bool { return false }, + GenericFunc: func(e event.GenericEvent) bool { return false }, + } +} + +// findPVsForDocumentDB finds all PVs associated with a DocumentDB and returns reconcile requests for them +func (r *PersistentVolumeReconciler) findPVsForDocumentDB(ctx context.Context, obj client.Object) []reconcile.Request { + logger := log.FromContext(ctx) + documentdb, ok := obj.(*dbpreview.DocumentDB) + if !ok { + return nil + } + + // Find CNPG Cluster owned by this DocumentDB + clusterList := &cnpgv1.ClusterList{} + if err := r.List(ctx, clusterList, client.InNamespace(documentdb.Namespace)); err != nil { + logger.Error(err, "Failed to list CNPG Clusters") + return nil + } + + var requests []reconcile.Request + + for _, cluster := range clusterList.Items { + // Check if this cluster is owned by the DocumentDB + isOwned := false + for _, ownerRef := range cluster.OwnerReferences { + if ownerRef.Kind == "DocumentDB" && ownerRef.Name == documentdb.Name { + isOwned = true + break + } + } + if !isOwned { + continue + } + + // Find PVCs owned by this cluster + pvcList := &corev1.PersistentVolumeClaimList{} + if err := r.List(ctx, pvcList, client.InNamespace(cluster.Namespace)); err != nil { + logger.Error(err, "Failed to list PVCs") + continue + } + + for _, pvc := range pvcList.Items { + // Check if this PVC is owned by the cluster + for _, ownerRef := range pvc.OwnerReferences { + if ownerRef.Kind == "Cluster" && ownerRef.Name == cluster.Name && strings.Contains(ownerRef.APIVersion, "cnpg") { + // Find the PV bound to this PVC + if pvc.Spec.VolumeName != "" { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: pvc.Spec.VolumeName, + }, + }) + } + break + } + } + } + } + + logger.Info("Found PVs to reconcile for DocumentDB update", + "documentdb", documentdb.Name, + "pvCount", len(requests)) + + return requests +} diff --git a/operator/src/internal/controller/pvc_controller.go b/operator/src/internal/controller/pvc_controller.go deleted file mode 100644 index a4dcffe2..00000000 --- a/operator/src/internal/controller/pvc_controller.go +++ /dev/null @@ -1,324 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package controller - -import ( - "context" - "fmt" - "strconv" - "time" - - cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" - dbpreview "github.com/documentdb/documentdb-operator/api/preview" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/event" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/predicate" - "sigs.k8s.io/controller-runtime/pkg/reconcile" -) - -const ( - // PVCFinalizerName is the finalizer added to PVCs to manage retention - PVCFinalizerName = "documentdb.io/pvc-retention" - - // Annotation and label keys - AnnotationPVCRetentionDays = "documentdb.io/pvc-retention-days" - LabelDocumentDBCluster = "documentdb.io/cluster" - - // DefaultPVCRetentionDays is the default retention period for PVCs after cluster deletion - DefaultPVCRetentionDays = 7 -) - -// PVCReconciler handles PVC lifecycle management including retention after cluster deletion -type PVCReconciler struct { - client.Client -} - -// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;update;patch -// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters,verbs=get;list;watch -// +kubebuilder:rbac:groups=documentdb.io,resources=dbs,verbs=get;list;watch - -// Reconcile handles PVC events for DocumentDB clusters only, managing finalizers and retention periods. -// PVCs not belonging to a DocumentDB cluster are ignored. -func (r *PVCReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - log := log.FromContext(ctx) - - var pvc corev1.PersistentVolumeClaim - if err := r.Get(ctx, req.NamespacedName, &pvc); err != nil { - return ctrl.Result{}, client.IgnoreNotFound(err) - } - - // Early exit: Only process PVCs belonging to a DocumentDB cluster - clusterName, err := r.getDocumentDBClusterName(ctx, &pvc) - if err != nil { - log.Error(err, "Failed to determine ownership") - return ctrl.Result{}, err - } - if clusterName == "" { - // Not a DocumentDB PVC, ignore - return ctrl.Result{}, nil - } - - log.V(1).Info("Processing DocumentDB PVC", "cluster", clusterName) - - // Ensure PVC has cluster label for efficient lookups - if err := r.ensureClusterLabel(ctx, &pvc, clusterName); err != nil { - log.Error(err, "Failed to ensure cluster label") - return ctrl.Result{}, err - } - - // Update retention annotation if needed - if err := r.updateRetentionAnnotation(ctx, &pvc, clusterName); err != nil { - log.Error(err, "Failed to update retention annotation") - return ctrl.Result{}, err - } - - // Manage finalizer based on retention policy - if err := r.manageFinalizer(ctx, &pvc); err != nil { - log.Error(err, "Failed to manage finalizer") - return ctrl.Result{}, err - } - - // Requeue if PVC is being deleted to clean up finalizer after retention expires - if pvc.DeletionTimestamp != nil && containsString(pvc.Finalizers, PVCFinalizerName) { - retentionDays := r.getRetentionDays(&pvc) - retentionExpiration := pvc.DeletionTimestamp.AddDate(0, 0, retentionDays) - requeueAfter := time.Until(retentionExpiration) - if requeueAfter > 0 { - log.Info("PVC retention period active, will requeue", "requeueAfter", requeueAfter) - return ctrl.Result{RequeueAfter: requeueAfter}, nil - } - } - - return ctrl.Result{}, nil -} - -// ensureClusterLabel ensures the PVC has the documentdb.io/cluster label for efficient lookups. -func (r *PVCReconciler) ensureClusterLabel(ctx context.Context, pvc *corev1.PersistentVolumeClaim, clusterName string) error { - if pvc.Labels[LabelDocumentDBCluster] == clusterName { - return nil - } - - if pvc.Labels == nil { - pvc.Labels = make(map[string]string) - } - pvc.Labels[LabelDocumentDBCluster] = clusterName - return r.Update(ctx, pvc) -} - -// getDocumentDBClusterName determines if a PVC belongs to a DocumentDB cluster and returns the cluster name. -// Returns empty string if the PVC does not belong to a DocumentDB cluster. -func (r *PVCReconciler) getDocumentDBClusterName(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (string, error) { - // Check if PVC already has the documentdb.io/cluster label - if clusterName, hasLabel := pvc.Labels[LabelDocumentDBCluster]; hasLabel { - return clusterName, nil - } - - // Try to find DocumentDB ownership through CNPG cluster - clusterName, err := r.findDocumentDBOwnerThroughCNPG(ctx, pvc) - if err != nil || clusterName == "" { - return "", err - } - - return clusterName, nil -} - -// findDocumentDBOwnerThroughCNPG checks if the PVC is owned by a CNPG cluster that is owned by a DocumentDB. -func (r *PVCReconciler) findDocumentDBOwnerThroughCNPG(ctx context.Context, pvc *corev1.PersistentVolumeClaim) (string, error) { - for _, ownerRef := range pvc.OwnerReferences { - if ownerRef.Kind != "Cluster" { - continue - } - - var cnpgCluster cnpgv1.Cluster - err := r.Get(ctx, types.NamespacedName{Name: ownerRef.Name, Namespace: pvc.Namespace}, &cnpgCluster) - if err != nil { - continue - } - - // Check if CNPG cluster is owned by a DocumentDB - for _, cnpgOwnerRef := range cnpgCluster.OwnerReferences { - if cnpgOwnerRef.Kind == "DocumentDB" { - return cnpgOwnerRef.Name, nil - } - } - } - - return "", nil -} - -// updateRetentionAnnotation updates the PVC retention annotation based on cluster configuration. -// If the cluster is deleted, preserves existing annotation or sets default. -func (r *PVCReconciler) updateRetentionAnnotation(ctx context.Context, pvc *corev1.PersistentVolumeClaim, clusterName string) error { - log := log.FromContext(ctx) - - var cluster dbpreview.DocumentDB - err := r.Get(ctx, types.NamespacedName{Name: clusterName, Namespace: pvc.Namespace}, &cluster) - - // If cluster doesn't exist (deleted), preserve or set default retention - if err != nil { - if !apierrors.IsNotFound(err) { - return fmt.Errorf("failed to get DocumentDB cluster %s: %w", clusterName, err) - } - - // Cluster deleted - ensure annotation exists for retention logic - if pvc.Annotations == nil || pvc.Annotations[AnnotationPVCRetentionDays] == "" { - if pvc.Annotations == nil { - pvc.Annotations = make(map[string]string) - } - pvc.Annotations[AnnotationPVCRetentionDays] = strconv.Itoa(DefaultPVCRetentionDays) - log.Info("Setting default retention for PVC from deleted cluster", "retentionDays", DefaultPVCRetentionDays) - return r.Update(ctx, pvc) - } - return nil - } - - // Cluster exists - sync annotation from cluster spec - if pvc.Annotations == nil { - pvc.Annotations = make(map[string]string) - } - - retentionDays := cluster.Spec.Resource.Storage.PvcRetentionDays - expectedRetention := strconv.Itoa(retentionDays) - currentRetention := pvc.Annotations[AnnotationPVCRetentionDays] - - if currentRetention != expectedRetention { - log.Info("Updating PVC retention annotation", "oldValue", currentRetention, "newValue", expectedRetention) - pvc.Annotations[AnnotationPVCRetentionDays] = expectedRetention - return r.Update(ctx, pvc) - } - - return nil -} - -// getRetentionDays extracts and validates the retention days from PVC annotations. -func (r *PVCReconciler) getRetentionDays(pvc *corev1.PersistentVolumeClaim) int { - retentionStr := pvc.Annotations[AnnotationPVCRetentionDays] - if retentionStr == "" { - return DefaultPVCRetentionDays - } - - days, err := strconv.Atoi(retentionStr) - if err != nil { - return DefaultPVCRetentionDays - } - return days -} - -// manageFinalizer adds or removes the PVC finalizer based on cluster deletion status and retention period. -func (r *PVCReconciler) manageFinalizer(ctx context.Context, pvc *corev1.PersistentVolumeClaim) error { - log := log.FromContext(ctx) - shouldHaveFinalizer := r.shouldRetainPVC(pvc) - hasFinalizer := containsString(pvc.Finalizers, PVCFinalizerName) - - if shouldHaveFinalizer == hasFinalizer { - // Already in desired state - return nil - } - - if shouldHaveFinalizer { - log.Info("Adding retention finalizer to PVC") - if pvc.Finalizers == nil { - pvc.Finalizers = []string{} - } - pvc.Finalizers = append(pvc.Finalizers, PVCFinalizerName) - } else { - log.Info("Removing retention finalizer from PVC (retention period expired)") - if pvc.Finalizers == nil { - return nil - } - pvc.Finalizers = removeString(pvc.Finalizers, PVCFinalizerName) - } - - return r.Update(ctx, pvc) -} - -// shouldRetainPVC determines if a PVC should have a retention finalizer. -// Returns true if: -// 1. PVC is not being deleted (actively in use) -// 2. PVC is being deleted but retention period has not expired -func (r *PVCReconciler) shouldRetainPVC(pvc *corev1.PersistentVolumeClaim) bool { - if pvc.DeletionTimestamp == nil { - // PVC is active, should have finalizer for future retention - return true - } - - // PVC is being deleted - check if retention period has expired - retentionDays := r.getRetentionDays(pvc) - retentionExpiration := pvc.DeletionTimestamp.AddDate(0, 0, retentionDays) - return time.Now().Before(retentionExpiration) -} - -func (r *PVCReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - // 1. Watch PVCs directly - For(&corev1.PersistentVolumeClaim{}). - // 2. Watch DocumentDB for updates to retention settings - Watches( - &dbpreview.DocumentDB{}, - handler.EnqueueRequestsFromMapFunc(r.findPVCsForCluster), - builder.WithPredicates(ClusterRetentionChangedPredicate()), - ). - Complete(r) -} - -// Maps a Cluster event to a list of PVC Reconcile Requests -func (r *PVCReconciler) findPVCsForCluster(ctx context.Context, cluster client.Object) []reconcile.Request { - pvcList := &corev1.PersistentVolumeClaimList{} - - // List PVCs that have a label matching this cluster - if err := r.List(ctx, pvcList, client.InNamespace(cluster.GetNamespace()), client.MatchingLabels{LabelDocumentDBCluster: cluster.GetName()}); err != nil { - return []reconcile.Request{} - } - - requests := make([]reconcile.Request, len(pvcList.Items)) - for i, pvc := range pvcList.Items { - requests[i] = reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: pvc.Name, - Namespace: pvc.Namespace, - }, - } - } - return requests -} - -func ClusterRetentionChangedPredicate() predicate.Predicate { - return predicate.Funcs{ - UpdateFunc: func(e event.UpdateEvent) bool { - oldCluster := e.ObjectOld.(*dbpreview.DocumentDB) - newCluster := e.ObjectNew.(*dbpreview.DocumentDB) - - // Only trigger if PvcRetentionDays has changed - return oldCluster.Spec.Resource.Storage.PvcRetentionDays != newCluster.Spec.Resource.Storage.PvcRetentionDays - }, - } -} - -// containsString checks if a string slice contains a specific string -func containsString(slice []string, s string) bool { - for _, item := range slice { - if item == s { - return true - } - } - return false -} - -// removeString removes a string from a slice -func removeString(slice []string, s string) []string { - result := make([]string, 0, len(slice)) - for _, item := range slice { - if item != s { - result = append(result, item) - } - } - return result -} diff --git a/operator/src/internal/controller/pvc_controller_test.go b/operator/src/internal/controller/pvc_controller_test.go deleted file mode 100644 index ef2c212d..00000000 --- a/operator/src/internal/controller/pvc_controller_test.go +++ /dev/null @@ -1,843 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package controller - -import ( - "context" - "time" - - cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/event" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - dbpreview "github.com/documentdb/documentdb-operator/api/preview" -) - -var _ = Describe("PVC Controller", func() { - const ( - pvcName = "test-pvc" - pvcNamespace = "default" - clusterName = "test-cluster" - cnpgClusterName = "test-cnpg-cluster" - ) - - var ( - ctx context.Context - scheme *runtime.Scheme - ) - - BeforeEach(func() { - ctx = context.Background() - scheme = runtime.NewScheme() - // Register required schemes - Expect(corev1.AddToScheme(scheme)).To(Succeed()) - Expect(dbpreview.AddToScheme(scheme)).To(Succeed()) - Expect(cnpgv1.AddToScheme(scheme)).To(Succeed()) - }) - - // Helper function to create a PVC with optional labels and owner references - createPVC := func(name, namespace string, labels map[string]string, ownerRefs []metav1.OwnerReference) *corev1.PersistentVolumeClaim { - return &corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - Labels: labels, - OwnerReferences: ownerRefs, - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{ - corev1.ReadWriteOnce, - }, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceStorage: resource.MustParse("10Gi"), - }, - }, - }, - } - } - - // Helper function to create a CNPG cluster with optional owner references - createCNPGCluster := func(name, namespace string, ownerRefs []metav1.OwnerReference) *cnpgv1.Cluster { - return &cnpgv1.Cluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - UID: types.UID("cnpg-" + name + "-uid"), - OwnerReferences: ownerRefs, - }, - } - } - - // Helper function to create a DocumentDB cluster - createDocumentDB := func(name, namespace string) *dbpreview.DocumentDB { - return &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - UID: types.UID("documentdb-" + name + "-uid"), - }, - Spec: dbpreview.DocumentDBSpec{ - NodeCount: 1, - InstancesPerNode: 1, - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - }, - }, - }, - } - } - - // Helper function to create an owner reference - createOwnerRef := func(apiVersion, kind, name string, uid types.UID) metav1.OwnerReference { - return metav1.OwnerReference{ - APIVersion: apiVersion, - Kind: kind, - Name: name, - UID: uid, - Controller: func() *bool { b := true; return &b }(), - } - } - - // Helper function to reconcile and verify no error - reconcileAndExpectSuccess := func(reconciler *PVCReconciler, name, namespace string) { - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: name, - Namespace: namespace, - }, - } - result, err := reconciler.Reconcile(ctx, req) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(ctrl.Result{})) - } - - // Helper function to verify PVC label state - verifyPVCLabel := func(fakeClient client.Client, name, namespace string, shouldHaveLabel bool, expectedClusterName string) { - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: name, Namespace: namespace}, updated)).To(Succeed()) - if shouldHaveLabel { - Expect(updated.Labels).ToNot(BeNil()) - Expect(updated.Labels["documentdb.io/cluster"]).To(Equal(expectedClusterName)) - } else { - _, hasLabel := updated.Labels["documentdb.io/cluster"] - Expect(hasLabel).To(BeFalse()) - } - } - - Describe("Reconcile", func() { - Context("when PVC not found", func() { - It("should handle PVC not found gracefully", func() { - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - Build() - - reconciler := &PVCReconciler{ - Client: fakeClient, - } - - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: "non-existent-pvc", - Namespace: pvcNamespace, - }, - } - - result, err := reconciler.Reconcile(ctx, req) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(ctrl.Result{})) - }) - }) - - Context("when PVC already has documentdb.io/cluster label", func() { - It("should handle PVC with documentdb.io/cluster label", func() { - documentdb := createDocumentDB(clusterName, pvcNamespace) - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentdb, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - verifyPVCLabel(fakeClient, pvcName, pvcNamespace, true, clusterName) - }) - }) - - Context("when PVC has no documentdb.io/cluster label", func() { - It("should not add label when PVC has no owner references", func() { - pvc := createPVC(pvcName, pvcNamespace, nil, nil) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") - }) - - It("should not add label when PVC has owner but owner is not CNPG Cluster", func() { - ownerRef := createOwnerRef("apps/v1", "StatefulSet", "test-statefulset", types.UID("statefulset-uid-123")) - pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{ownerRef}) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") - }) - - It("should not add label when PVC owner is CNPG Cluster but CNPG Cluster has no owner", func() { - cnpgCluster := createCNPGCluster(cnpgClusterName, pvcNamespace, nil) - cnpgOwnerRef := createOwnerRef("postgresql.cnpg.io/v1", "Cluster", cnpgClusterName, cnpgCluster.UID) - pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{cnpgOwnerRef}) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(cnpgCluster, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") - }) - - It("should not add label when PVC owner is CNPG Cluster but CNPG owner is not DocumentDB", func() { - deploymentOwnerRef := createOwnerRef("apps/v1", "Deployment", "some-deployment", types.UID("deployment-uid-789")) - cnpgCluster := createCNPGCluster(cnpgClusterName, pvcNamespace, []metav1.OwnerReference{deploymentOwnerRef}) - cnpgOwnerRef := createOwnerRef("postgresql.cnpg.io/v1", "Cluster", cnpgClusterName, cnpgCluster.UID) - pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{cnpgOwnerRef}) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(cnpgCluster, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") - }) - - It("should add label when PVC owner is CNPG Cluster and CNPG owner is DocumentDB", func() { - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDBOwnerRef := createOwnerRef("documentdb.io/v1alpha1", "DocumentDB", clusterName, documentDB.UID) - cnpgCluster := createCNPGCluster(cnpgClusterName, pvcNamespace, []metav1.OwnerReference{documentDBOwnerRef}) - cnpgOwnerRef := createOwnerRef("postgresql.cnpg.io/v1", "Cluster", cnpgClusterName, cnpgCluster.UID) - pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{cnpgOwnerRef}) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, cnpgCluster, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - verifyPVCLabel(fakeClient, pvcName, pvcNamespace, true, clusterName) - }) - - It("should handle error gracefully when CNPG Cluster does not exist", func() { - cnpgOwnerRef := createOwnerRef("postgresql.cnpg.io/v1", "Cluster", "non-existent-cnpg", types.UID("cnpg-uid-456")) - pvc := createPVC(pvcName, pvcNamespace, nil, []metav1.OwnerReference{cnpgOwnerRef}) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - verifyPVCLabel(fakeClient, pvcName, pvcNamespace, false, "") - }) - }) - }) - - Describe("findPVCsForCluster", func() { - It("should return reconcile requests for all PVCs matching cluster label", func() { - pvc1 := createPVC("pvc-1", pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - pvc2 := createPVC("pvc-2", pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - pvc3 := createPVC("pvc-3", pvcNamespace, map[string]string{"documentdb.io/cluster": "different-cluster"}, nil) - cluster := createDocumentDB(clusterName, pvcNamespace) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(pvc1, pvc2, pvc3, cluster). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - requests := reconciler.findPVCsForCluster(ctx, cluster) - - Expect(len(requests)).To(Equal(2)) - Expect(requests).To(ContainElement(reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: "pvc-1", - Namespace: pvcNamespace, - }, - })) - Expect(requests).To(ContainElement(reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: "pvc-2", - Namespace: pvcNamespace, - }, - })) - }) - - It("should return empty list when no PVCs match cluster label", func() { - cluster := createDocumentDB(clusterName, pvcNamespace) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(cluster). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - requests := reconciler.findPVCsForCluster(ctx, cluster) - - Expect(len(requests)).To(Equal(0)) - }) - - It("should return empty list on error listing PVCs", func() { - cluster := createDocumentDB(clusterName, pvcNamespace) - - // Create client without PVC scheme to simulate error - limitedScheme := runtime.NewScheme() - Expect(dbpreview.AddToScheme(limitedScheme)).To(Succeed()) - - fakeClient := fake.NewClientBuilder(). - WithScheme(limitedScheme). - WithObjects(cluster). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - requests := reconciler.findPVCsForCluster(ctx, cluster) - - Expect(len(requests)).To(Equal(0)) - }) - }) - - Describe("Reconcile - Retention Annotation Management", func() { - Context("when DocumentDB does not exist and PVC has no annotation", func() { - It("should set default value 7 when no cluster and no annotation", func() { - // Create PVC with documentdb.io/cluster label but no retention annotation - // The cluster referenced does not exist - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify annotation was set to default value 7 - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("7")) - }) - }) - - Context("when DocumentDB does not exist but PVC has annotation", func() { - It("should do nothing when no cluster but annotation exists", func() { - // Create PVC with documentdb.io/cluster label and existing retention annotation - // The cluster referenced does not exist - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": "10", - "custom-annotation": "custom-value", - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify annotations remain unchanged - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("10")) - Expect(updated.Annotations["custom-annotation"]).To(Equal("custom-value")) - }) - }) - - Context("when DocumentDB exists but PVC has no annotation", func() { - It("should set annotation from cluster when no annotation exists", func() { - // Create DocumentDB with retention period - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 14 - - // Create PVC with documentdb.io/cluster label but no retention annotation - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify annotation was added - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("14")) - }) - }) - - Context("when DocumentDB exists and PVC has annotation", func() { - It("should update annotation when value differs from cluster", func() { - // Create DocumentDB with retention period - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 14 - - // Create PVC with old retention value - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": "7", - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify annotation was updated - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("14")) - }) - - It("should not modify annotation when value matches cluster", func() { - // Create DocumentDB with retention period - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 - - // Create PVC with correct retention value - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": "7", - "custom-annotation": "custom-value", - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify annotations remain unchanged - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("7")) - Expect(updated.Annotations["custom-annotation"]).To(Equal("custom-value")) - }) - }) - - Context("when retention period is zero", func() { - It("should set annotation to zero", func() { - // Create DocumentDB with zero retention period (retain forever) - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 0 - - // Create PVC with documentdb.io/cluster label - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify annotation was set to zero - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Annotations).ToNot(BeNil()) - Expect(updated.Annotations["documentdb.io/pvc-retention-days"]).To(Equal("0")) - }) - }) - }) - - Describe("Finalizer Management", func() { - Context("when PVC is not deleted (deletionTimestamp is null)", func() { - It("should add finalizer if not exists", func() { - // Create DocumentDB and PVC without finalizer - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 - - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - // No finalizers initially - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify finalizer was added - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Finalizers).To(ContainElement(PVCFinalizerName)) - }) - - It("should do nothing if finalizer already exists", func() { - // Create DocumentDB and PVC with finalizer already present - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 - - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - pvc.Finalizers = []string{PVCFinalizerName, "some-other-finalizer"} - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify finalizers remain unchanged - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Finalizers).To(Equal([]string{PVCFinalizerName, "some-other-finalizer"})) - }) - }) - - Context("when PVC is deleted (deletionTimestamp is not null)", func() { - Context("and retention period has not been exceeded", func() { - It("should add finalizer if not exists and requeue after retention period", func() { - // Create DocumentDB - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 - - // Create PVC with deletionTimestamp (being deleted) - // Must have at least one finalizer for Kubernetes to accept deletionTimestamp - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - now := metav1.Now() - pvc.DeletionTimestamp = &now - pvc.Finalizers = []string{"some-other-finalizer"} // Need at least one finalizer - pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": "7", - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: pvcName, - Namespace: pvcNamespace, - }, - } - result, err := reconciler.Reconcile(ctx, req) - Expect(err).ToNot(HaveOccurred()) - // Should requeue to check retention expiration later - Expect(result.Requeue).To(BeFalse()) - Expect(result.RequeueAfter).To(BeNumerically(">", 0)) - - // Verify our finalizer was added - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Finalizers).To(ContainElement(PVCFinalizerName)) - Expect(updated.Finalizers).To(ContainElement("some-other-finalizer")) - }) - - It("should keep finalizer and requeue after retention period if finalizer already exists", func() { - // Create DocumentDB - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 - - // Create PVC with deletionTimestamp and existing finalizer - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - now := metav1.Now() - pvc.DeletionTimestamp = &now - pvc.Finalizers = []string{PVCFinalizerName, "another-finalizer"} - pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": "7", - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - req := ctrl.Request{ - NamespacedName: types.NamespacedName{ - Name: pvcName, - Namespace: pvcNamespace, - }, - } - result, err := reconciler.Reconcile(ctx, req) - Expect(err).ToNot(HaveOccurred()) - // Should requeue to check retention expiration later - Expect(result.Requeue).To(BeFalse()) - Expect(result.RequeueAfter).To(BeNumerically(">", 0)) - - // Verify finalizers remain unchanged - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Finalizers).To(Equal([]string{PVCFinalizerName, "another-finalizer"})) - }) - }) - - Context("and retention period has been exceeded", func() { - It("should remove finalizer if exists", func() { - // Create DocumentDB - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 - - // Create PVC with deletionTimestamp from 10 days ago (exceeded 7 day retention) - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - tenDaysAgo := metav1.NewTime(time.Now().AddDate(0, 0, -10)) - pvc.DeletionTimestamp = &tenDaysAgo - pvc.Finalizers = []string{PVCFinalizerName, "another-finalizer"} - pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": "7", - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify finalizer was removed - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Finalizers).ToNot(ContainElement(PVCFinalizerName)) - Expect(updated.Finalizers).To(Equal([]string{"another-finalizer"})) - }) - - It("should do nothing if finalizer does not exist", func() { - // Create DocumentDB - documentDB := createDocumentDB(clusterName, pvcNamespace) - documentDB.Spec.Resource.Storage.PvcRetentionDays = 7 - - // Create PVC with deletionTimestamp from 10 days ago (exceeded 7 day retention) - pvc := createPVC(pvcName, pvcNamespace, map[string]string{"documentdb.io/cluster": clusterName}, nil) - tenDaysAgo := metav1.NewTime(time.Now().AddDate(0, 0, -10)) - pvc.DeletionTimestamp = &tenDaysAgo - pvc.Finalizers = []string{"another-finalizer"} - pvc.Annotations = map[string]string{ - "documentdb.io/pvc-retention-days": "7", - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(documentDB, pvc). - Build() - - reconciler := &PVCReconciler{Client: fakeClient} - reconcileAndExpectSuccess(reconciler, pvcName, pvcNamespace) - - // Verify finalizers remain unchanged (no PVC finalizer to remove) - updated := &corev1.PersistentVolumeClaim{} - Expect(fakeClient.Get(ctx, client.ObjectKey{Name: pvcName, Namespace: pvcNamespace}, updated)).To(Succeed()) - Expect(updated.Finalizers).To(Equal([]string{"another-finalizer"})) - Expect(updated.Finalizers).ToNot(ContainElement(PVCFinalizerName)) - }) - }) - }) - }) - - Describe("ClusterRetentionChangedPredicate", func() { - It("should return true when PvcRetentionDays changes", func() { - oldCluster := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, - Namespace: pvcNamespace, - }, - Spec: dbpreview.DocumentDBSpec{ - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionDays: 7, - }, - }, - }, - } - - newCluster := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, - Namespace: pvcNamespace, - }, - Spec: dbpreview.DocumentDBSpec{ - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionDays: 14, - }, - }, - }, - } - - predicate := ClusterRetentionChangedPredicate() - updateEvent := event.UpdateEvent{ - ObjectOld: oldCluster, - ObjectNew: newCluster, - } - - Expect(predicate.Update(updateEvent)).To(BeTrue()) - }) - - It("should return false when PvcRetentionDays does not change", func() { - oldCluster := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, - Namespace: pvcNamespace, - }, - Spec: dbpreview.DocumentDBSpec{ - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionDays: 7, - }, - }, - }, - } - - newCluster := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, - Namespace: pvcNamespace, - }, - Spec: dbpreview.DocumentDBSpec{ - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "20Gi", // Different field changed - PvcRetentionDays: 7, // Same value - }, - }, - }, - } - - predicate := ClusterRetentionChangedPredicate() - updateEvent := event.UpdateEvent{ - ObjectOld: oldCluster, - ObjectNew: newCluster, - } - - Expect(predicate.Update(updateEvent)).To(BeFalse()) - }) - - It("should return false when PvcRetentionDays is the same (both zero)", func() { - oldCluster := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, - Namespace: pvcNamespace, - }, - Spec: dbpreview.DocumentDBSpec{ - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionDays: 0, - }, - }, - }, - } - - newCluster := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, - Namespace: pvcNamespace, - }, - Spec: dbpreview.DocumentDBSpec{ - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionDays: 0, - }, - }, - }, - } - - predicate := ClusterRetentionChangedPredicate() - updateEvent := event.UpdateEvent{ - ObjectOld: oldCluster, - ObjectNew: newCluster, - } - - Expect(predicate.Update(updateEvent)).To(BeFalse()) - }) - - It("should return true when PvcRetentionDays changes from 0 to non-zero", func() { - oldCluster := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, - Namespace: pvcNamespace, - }, - Spec: dbpreview.DocumentDBSpec{ - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionDays: 0, - }, - }, - }, - } - - newCluster := &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, - Namespace: pvcNamespace, - }, - Spec: dbpreview.DocumentDBSpec{ - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{ - PvcSize: "10Gi", - PvcRetentionDays: 30, - }, - }, - }, - } - - predicate := ClusterRetentionChangedPredicate() - updateEvent := event.UpdateEvent{ - ObjectOld: oldCluster, - ObjectNew: newCluster, - } - - Expect(predicate.Update(updateEvent)).To(BeTrue()) - }) - }) -}) From 3107300d05fa24999300d3de944d1bcb11228e4f Mon Sep 17 00:00:00 2001 From: wenting Date: Thu, 15 Jan 2026 13:16:17 -0500 Subject: [PATCH 11/19] e2e tests Signed-off-by: wenting --- .github/workflows/test-backup-and-restore.yml | 142 +++++++++++------- 1 file changed, 84 insertions(+), 58 deletions(-) diff --git a/.github/workflows/test-backup-and-restore.yml b/.github/workflows/test-backup-and-restore.yml index 97331172..f71f6fc7 100644 --- a/.github/workflows/test-backup-and-restore.yml +++ b/.github/workflows/test-backup-and-restore.yml @@ -285,6 +285,7 @@ jobs: storage: pvcSize: 5Gi storageClass: csi-hostpath-sc + persistentVolumeReclaimPolicy: Retain exposeViaService: serviceType: ClusterIP bootstrap: @@ -356,12 +357,12 @@ jobs: echo "❌ Expired backup was not cleaned up within expected time." exit 1 - - name: Test PVC retention after DocumentDB deletion + - name: Test PV retention after DocumentDB deletion shell: bash run: | - echo "Testing PVC retention after DocumentDB deletion..." + echo "Testing PV retention after DocumentDB deletion..." - # Get the PVC name before deleting the DocumentDB + # Get the PVC name and PV name before deleting the DocumentDB pvc_name=$(kubectl -n ${{ env.DB_NS }} get pvc -l documentdb.io/cluster=${{ env.DB_RESTORE_NAME }} -o jsonpath='{.items[0].metadata.name}') echo "PVC name: $pvc_name" @@ -370,6 +371,15 @@ jobs: exit 1 fi + # Get the PV name bound to this PVC + pv_name=$(kubectl -n ${{ env.DB_NS }} get pvc $pvc_name -o jsonpath='{.spec.volumeName}') + echo "PV name: $pv_name" + + if [ -z "$pv_name" ]; then + echo "❌ Failed to find PV bound to PVC $pvc_name" + exit 1 + fi + # Delete the restored DocumentDB cluster kubectl -n ${{ env.DB_NS }} delete documentdb ${{ env.DB_RESTORE_NAME }} --wait=false @@ -390,25 +400,85 @@ jobs: ((++ITER)) done - # Verify PVC still exists - pvc_exists=$(kubectl -n ${{ env.DB_NS }} get pvc $pvc_name --ignore-not-found) - if [ -n "$pvc_exists" ]; then - echo "✓ PVC $pvc_name retained after DocumentDB deletion" + # Verify PV still exists + pv_exists=$(kubectl get pv $pv_name --ignore-not-found) + if [ -n "$pv_exists" ]; then + echo "✓ PV $pv_name retained after DocumentDB deletion" else - echo "❌ PVC $pvc_name was deleted unexpectedly" + echo "❌ PV $pv_name was deleted unexpectedly" exit 1 fi - # Store PVC name for later steps - echo "$pvc_name" > /tmp/retained_pvc_name + # Store PV name for later steps + echo "$pv_name" > /tmp/retained_pv_name - - name: Restore DocumentDB from retained PVC + - name: Restore DocumentDB from retained PV shell: bash run: | - pvc_name=$(cat /tmp/retained_pvc_name) - echo "Restoring DocumentDB from PVC: $pvc_name" + pv_name=$(cat /tmp/retained_pv_name) + echo "Restoring DocumentDB from retained PV: $pv_name" + + # Check the PV status - it should be in "Released" state after PVC deletion + pv_status=$(kubectl get pv $pv_name -o jsonpath='{.status.phase}') + echo "PV status: $pv_status" + + # Clear the claimRef from the PV so a new PVC can bind to it + # When a PV is in "Released" state, it still has a claimRef to the old deleted PVC + echo "Clearing claimRef from PV $pv_name to allow new PVC binding..." + kubectl patch pv $pv_name --type=json -p='[{"op": "remove", "path": "/spec/claimRef"}]' + + # Verify PV is now Available + pv_status=$(kubectl get pv $pv_name -o jsonpath='{.status.phase}') + echo "PV status after clearing claimRef: $pv_status" + + # Create a new PVC that binds to the retained PV + new_pvc_name="recovered-pvc-from-pv" + echo "Creating new PVC $new_pvc_name to bind to retained PV $pv_name" + + # Get the storage capacity from the PV + pv_capacity=$(kubectl get pv $pv_name -o jsonpath='{.spec.capacity.storage}') + echo "PV capacity: $pv_capacity" + + cat < Date: Thu, 15 Jan 2026 13:50:29 -0500 Subject: [PATCH 12/19] fixup Signed-off-by: wenting --- .github/workflows/test-backup-and-restore.yml | 38 ++++++++++++++++++- .../templates/05_clusterrole.yaml | 4 ++ .../src/internal/controller/pv_controller.go | 4 +- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-backup-and-restore.yml b/.github/workflows/test-backup-and-restore.yml index f71f6fc7..49c84f61 100644 --- a/.github/workflows/test-backup-and-restore.yml +++ b/.github/workflows/test-backup-and-restore.yml @@ -285,7 +285,6 @@ jobs: storage: pvcSize: 5Gi storageClass: csi-hostpath-sc - persistentVolumeReclaimPolicy: Retain exposeViaService: serviceType: ClusterIP bootstrap: @@ -380,6 +379,41 @@ jobs: exit 1 fi + # Check current PV reclaim policy - should be Delete by default + current_policy=$(kubectl get pv $pv_name -o jsonpath='{.spec.persistentVolumeReclaimPolicy}') + echo "Current PV reclaim policy: $current_policy" + + if [ "$current_policy" != "Delete" ]; then + echo "⚠️ Expected PV reclaim policy to be 'Delete', but got '$current_policy'" + fi + + # Patch DocumentDB to set persistentVolumeReclaimPolicy to Retain + echo "Patching DocumentDB to set persistentVolumeReclaimPolicy to Retain..." + kubectl -n ${{ env.DB_NS }} patch documentdb ${{ env.DB_RESTORE_NAME }} --type=merge \ + -p '{"spec":{"resource":{"storage":{"persistentVolumeReclaimPolicy":"Retain"}}}}' + + # Wait for PV controller to update the PV reclaim policy + echo "Waiting for PV reclaim policy to be updated to Retain..." + MAX_RETRIES=30 + SLEEP_INTERVAL=5 + ITER=0 + while [ $ITER -lt $MAX_RETRIES ]; do + new_policy=$(kubectl get pv $pv_name -o jsonpath='{.spec.persistentVolumeReclaimPolicy}') + if [ "$new_policy" == "Retain" ]; then + echo "✓ PV reclaim policy updated to Retain" + break + else + echo "PV reclaim policy is still '$new_policy'. Waiting..." + sleep $SLEEP_INTERVAL + fi + ((++ITER)) + done + + if [ "$new_policy" != "Retain" ]; then + echo "❌ PV reclaim policy was not updated to Retain within expected time" + exit 1 + fi + # Delete the restored DocumentDB cluster kubectl -n ${{ env.DB_NS }} delete documentdb ${{ env.DB_RESTORE_NAME }} --wait=false @@ -400,7 +434,7 @@ jobs: ((++ITER)) done - # Verify PV still exists + # Verify PV still exists (because reclaim policy is Retain) pv_exists=$(kubectl get pv $pv_name --ignore-not-found) if [ -n "$pv_exists" ]; then echo "✓ PV $pv_name retained after DocumentDB deletion" diff --git a/operator/documentdb-helm-chart/templates/05_clusterrole.yaml b/operator/documentdb-helm-chart/templates/05_clusterrole.yaml index bc8393e0..77a345c4 100644 --- a/operator/documentdb-helm-chart/templates/05_clusterrole.yaml +++ b/operator/documentdb-helm-chart/templates/05_clusterrole.yaml @@ -56,3 +56,7 @@ rules: - apiGroups: ["snapshot.storage.k8s.io"] resources: ["volumesnapshotclasses"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# PersistentVolume permissions for PV controller +- apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["get", "list", "watch", "update", "patch"] diff --git a/operator/src/internal/controller/pv_controller.go b/operator/src/internal/controller/pv_controller.go index 9e0d1a71..39a24101 100644 --- a/operator/src/internal/controller/pv_controller.go +++ b/operator/src/internal/controller/pv_controller.go @@ -240,8 +240,8 @@ func pvPredicate() predicate.Predicate { // SetupWithManager sets up the controller with the Manager func (r *PersistentVolumeReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). - For(&corev1.PersistentVolume{}). - WithEventFilter(pvPredicate()). + // Apply pvPredicate only to PersistentVolume events, not globally + For(&corev1.PersistentVolume{}, builder.WithPredicates(pvPredicate())). // Watch DocumentDB changes and trigger reconciliation of associated PVs Watches( &dbpreview.DocumentDB{}, From 0af6b9c428b8d0451d05a1771edc97049e0ff4ed Mon Sep 17 00:00:00 2001 From: wenting Date: Tue, 20 Jan 2026 16:01:32 -0500 Subject: [PATCH 13/19] mount options Signed-off-by: wenting --- .../src/internal/controller/pv_controller.go | 201 +++- .../internal/controller/pv_controller_test.go | 1067 +++++++++++++++++ 2 files changed, 1207 insertions(+), 61 deletions(-) create mode 100644 operator/src/internal/controller/pv_controller_test.go diff --git a/operator/src/internal/controller/pv_controller.go b/operator/src/internal/controller/pv_controller.go index 39a24101..b7291c4d 100644 --- a/operator/src/internal/controller/pv_controller.go +++ b/operator/src/internal/controller/pv_controller.go @@ -5,11 +5,14 @@ package controller import ( "context" + "slices" + "sort" "strings" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -23,8 +26,31 @@ import ( dbpreview "github.com/documentdb/documentdb-operator/api/preview" ) +const ( + // cnpgAPIVersionPrefix is the prefix for CNPG API versions + cnpgAPIVersionPrefix = "postgresql.cnpg.io" + + // ownerRefKindCluster is the Kind for CNPG Cluster owner references + ownerRefKindCluster = "Cluster" + + // ownerRefKindDocumentDB is the Kind for DocumentDB owner references + ownerRefKindDocumentDB = "DocumentDB" + + // reclaimPolicyRetain is the string value for Retain policy in DocumentDB spec + reclaimPolicyRetain = "Retain" + + // reclaimPolicyDelete is the string value for Delete policy in DocumentDB spec + reclaimPolicyDelete = "Delete" +) + +// securityMountOptions defines the mount options applied to PVs for security hardening: +// - nodev: Prevents device files from being interpreted on the filesystem +// - nosuid: Prevents setuid/setgid bits from taking effect +// - noexec: Prevents execution of binaries on the filesystem +var securityMountOptions = []string{"nodev", "noexec", "nosuid"} + // PersistentVolumeReconciler reconciles PersistentVolume objects -// to set their ReclaimPolicy based on the associated DocumentDB configuration +// to set their ReclaimPolicy and mount options based on the associated DocumentDB configuration type PersistentVolumeReconciler struct { client.Client } @@ -55,10 +81,6 @@ func (r *PersistentVolumeReconciler) Reconcile(ctx context.Context, req ctrl.Req // PV -> PVC -> CNPG Cluster -> DocumentDB documentdb, err := r.findDocumentDBForPV(ctx, pv) if err != nil { - if errors.IsNotFound(err) { - logger.V(1).Info("No DocumentDB found for PV, skipping", "pv", pv.Name) - return ctrl.Result{}, nil - } logger.Error(err, "Failed to find DocumentDB for PV") return ctrl.Result{}, err } @@ -68,32 +90,84 @@ func (r *PersistentVolumeReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrl.Result{}, nil } - // Determine the desired reclaim policy from DocumentDB spec - desiredPolicy := r.getDesiredReclaimPolicy(documentdb) - currentPolicy := pv.Spec.PersistentVolumeReclaimPolicy - - // Update the PV's reclaim policy if it differs from the desired policy - if currentPolicy != desiredPolicy { - logger.Info("Updating PV reclaim policy", - "pv", pv.Name, - "currentPolicy", currentPolicy, - "desiredPolicy", desiredPolicy, - "documentdb", documentdb.Name) + // Apply desired configuration to PV + needsUpdate := r.applyDesiredPVConfiguration(ctx, pv, documentdb) - pv.Spec.PersistentVolumeReclaimPolicy = desiredPolicy + if needsUpdate { if err := r.Update(ctx, pv); err != nil { - logger.Error(err, "Failed to update PV reclaim policy") + logger.Error(err, "Failed to update PV") return ctrl.Result{}, err } - logger.Info("Successfully updated PV reclaim policy", + logger.Info("Successfully updated PV", "pv", pv.Name, - "newPolicy", desiredPolicy) + "reclaimPolicy", pv.Spec.PersistentVolumeReclaimPolicy, + "mountOptions", pv.Spec.MountOptions) } return ctrl.Result{}, nil } +// applyDesiredPVConfiguration applies the desired reclaim policy and mount options to a PV. +// Returns true if any changes were made. +func (r *PersistentVolumeReconciler) applyDesiredPVConfiguration(ctx context.Context, pv *corev1.PersistentVolume, documentdb *dbpreview.DocumentDB) bool { + logger := log.FromContext(ctx) + needsUpdate := false + + // Check if reclaim policy needs update + desiredPolicy := r.getDesiredReclaimPolicy(documentdb) + if pv.Spec.PersistentVolumeReclaimPolicy != desiredPolicy { + logger.Info("PV reclaim policy needs update", + "pv", pv.Name, + "currentPolicy", pv.Spec.PersistentVolumeReclaimPolicy, + "desiredPolicy", desiredPolicy, + "documentdb", documentdb.Name) + pv.Spec.PersistentVolumeReclaimPolicy = desiredPolicy + needsUpdate = true + } + + // Check if mount options need update + if !containsAllMountOptions(pv.Spec.MountOptions, securityMountOptions) { + logger.Info("PV mount options need update", + "pv", pv.Name, + "currentMountOptions", pv.Spec.MountOptions, + "desiredMountOptions", securityMountOptions) + pv.Spec.MountOptions = mergeMountOptions(pv.Spec.MountOptions, securityMountOptions) + needsUpdate = true + } + + return needsUpdate +} + +// containsAllMountOptions checks if all desired mount options are present in current options +func containsAllMountOptions(current, desired []string) bool { + for _, opt := range desired { + if !slices.Contains(current, opt) { + return false + } + } + return true +} + +// mergeMountOptions merges desired mount options into current, avoiding duplicates. +// Returns a sorted slice for deterministic output. +func mergeMountOptions(current, desired []string) []string { + optSet := make(map[string]struct{}, len(current)+len(desired)) + for _, opt := range current { + optSet[opt] = struct{}{} + } + for _, opt := range desired { + optSet[opt] = struct{}{} + } + + result := make([]string, 0, len(optSet)) + for opt := range optSet { + result = append(result, opt) + } + sort.Strings(result) + return result +} + // findDocumentDBForPV traverses the ownership chain to find the DocumentDB // associated with a PersistentVolume: // PV.claimRef -> PVC -> (ownerRef) CNPG Cluster -> (ownerRef) DocumentDB @@ -145,21 +219,22 @@ func (r *PersistentVolumeReconciler) findDocumentDBForPV(ctx context.Context, pv func (r *PersistentVolumeReconciler) findCNPGClusterOwner(ctx context.Context, pvc *corev1.PersistentVolumeClaim) *cnpgv1.Cluster { logger := log.FromContext(ctx) - // Check owner references for CNPG Cluster for _, ownerRef := range pvc.OwnerReferences { - if ownerRef.Kind == "Cluster" && strings.Contains(ownerRef.APIVersion, "cnpg") { - cluster := &cnpgv1.Cluster{} - if err := r.Get(ctx, types.NamespacedName{ - Name: ownerRef.Name, - Namespace: pvc.Namespace, - }, cluster); err != nil { - if !errors.IsNotFound(err) { - logger.Error(err, "Failed to get CNPG Cluster", "name", ownerRef.Name) - } - continue + if !isCNPGClusterOwnerRef(ownerRef) { + continue + } + + cluster := &cnpgv1.Cluster{} + if err := r.Get(ctx, types.NamespacedName{ + Name: ownerRef.Name, + Namespace: pvc.Namespace, + }, cluster); err != nil { + if !errors.IsNotFound(err) { + logger.Error(err, "Failed to get CNPG Cluster", "name", ownerRef.Name) } - return cluster + continue } + return cluster } return nil @@ -169,34 +244,48 @@ func (r *PersistentVolumeReconciler) findCNPGClusterOwner(ctx context.Context, p func (r *PersistentVolumeReconciler) findDocumentDBOwner(ctx context.Context, cluster *cnpgv1.Cluster) *dbpreview.DocumentDB { logger := log.FromContext(ctx) - // Check owner references for DocumentDB for _, ownerRef := range cluster.OwnerReferences { - if ownerRef.Kind == "DocumentDB" { - documentdb := &dbpreview.DocumentDB{} - if err := r.Get(ctx, types.NamespacedName{ - Name: ownerRef.Name, - Namespace: cluster.Namespace, - }, documentdb); err != nil { - if !errors.IsNotFound(err) { - logger.Error(err, "Failed to get DocumentDB", "name", ownerRef.Name) - } - continue + if ownerRef.Kind != ownerRefKindDocumentDB { + continue + } + + documentdb := &dbpreview.DocumentDB{} + if err := r.Get(ctx, types.NamespacedName{ + Name: ownerRef.Name, + Namespace: cluster.Namespace, + }, documentdb); err != nil { + if !errors.IsNotFound(err) { + logger.Error(err, "Failed to get DocumentDB", "name", ownerRef.Name) } - return documentdb + continue } + return documentdb } return nil } +// isCNPGClusterOwnerRef checks if an owner reference refers to a CNPG Cluster +func isCNPGClusterOwnerRef(ownerRef metav1.OwnerReference) bool { + return ownerRef.Kind == ownerRefKindCluster && strings.Contains(ownerRef.APIVersion, cnpgAPIVersionPrefix) +} + +// isOwnedByDocumentDB checks if a CNPG Cluster is owned by a specific DocumentDB +func isOwnedByDocumentDB(cluster *cnpgv1.Cluster, documentdbName string) bool { + for _, ownerRef := range cluster.OwnerReferences { + if ownerRef.Kind == ownerRefKindDocumentDB && ownerRef.Name == documentdbName { + return true + } + } + return false +} + // getDesiredReclaimPolicy returns the reclaim policy based on DocumentDB configuration func (r *PersistentVolumeReconciler) getDesiredReclaimPolicy(documentdb *dbpreview.DocumentDB) corev1.PersistentVolumeReclaimPolicy { - policy := documentdb.Spec.Resource.Storage.PersistentVolumeReclaimPolicy - - switch policy { - case "Retain": + switch documentdb.Spec.Resource.Storage.PersistentVolumeReclaimPolicy { + case reclaimPolicyRetain: return corev1.PersistentVolumeReclaimRetain - case "Delete": + case reclaimPolicyDelete: return corev1.PersistentVolumeReclaimDelete default: // Default to Delete if not specified @@ -290,15 +379,7 @@ func (r *PersistentVolumeReconciler) findPVsForDocumentDB(ctx context.Context, o var requests []reconcile.Request for _, cluster := range clusterList.Items { - // Check if this cluster is owned by the DocumentDB - isOwned := false - for _, ownerRef := range cluster.OwnerReferences { - if ownerRef.Kind == "DocumentDB" && ownerRef.Name == documentdb.Name { - isOwned = true - break - } - } - if !isOwned { + if !isOwnedByDocumentDB(&cluster, documentdb.Name) { continue } @@ -310,10 +391,8 @@ func (r *PersistentVolumeReconciler) findPVsForDocumentDB(ctx context.Context, o } for _, pvc := range pvcList.Items { - // Check if this PVC is owned by the cluster for _, ownerRef := range pvc.OwnerReferences { - if ownerRef.Kind == "Cluster" && ownerRef.Name == cluster.Name && strings.Contains(ownerRef.APIVersion, "cnpg") { - // Find the PV bound to this PVC + if isCNPGClusterOwnerRef(ownerRef) && ownerRef.Name == cluster.Name { if pvc.Spec.VolumeName != "" { requests = append(requests, reconcile.Request{ NamespacedName: types.NamespacedName{ diff --git a/operator/src/internal/controller/pv_controller_test.go b/operator/src/internal/controller/pv_controller_test.go new file mode 100644 index 00000000..acf93539 --- /dev/null +++ b/operator/src/internal/controller/pv_controller_test.go @@ -0,0 +1,1067 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package controller + +import ( + "context" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" +) + +var _ = Describe("PersistentVolume Controller", func() { + const ( + pvName = "test-pv" + pvcName = "test-pvc" + clusterName = "test-cluster" + documentdbName = "test-documentdb" + testNamespace = "default" + ) + + var ( + ctx context.Context + scheme *runtime.Scheme + ) + + BeforeEach(func() { + ctx = context.Background() + scheme = runtime.NewScheme() + Expect(dbpreview.AddToScheme(scheme)).To(Succeed()) + Expect(cnpgv1.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + }) + + Describe("containsAllMountOptions", func() { + It("returns true when all desired options are present", func() { + current := []string{"nodev", "nosuid", "noexec", "rw"} + desired := []string{"nodev", "nosuid", "noexec"} + Expect(containsAllMountOptions(current, desired)).To(BeTrue()) + }) + + It("returns false when some desired options are missing", func() { + current := []string{"nodev", "rw"} + desired := []string{"nodev", "nosuid", "noexec"} + Expect(containsAllMountOptions(current, desired)).To(BeFalse()) + }) + + It("returns true when desired is empty", func() { + current := []string{"nodev", "rw"} + desired := []string{} + Expect(containsAllMountOptions(current, desired)).To(BeTrue()) + }) + + It("returns false when current is empty but desired is not", func() { + current := []string{} + desired := []string{"nodev"} + Expect(containsAllMountOptions(current, desired)).To(BeFalse()) + }) + }) + + Describe("mergeMountOptions", func() { + It("merges options without duplicates", func() { + current := []string{"rw", "nodev"} + desired := []string{"nodev", "nosuid", "noexec"} + result := mergeMountOptions(current, desired) + Expect(result).To(HaveLen(4)) + Expect(result).To(ContainElements("rw", "nodev", "nosuid", "noexec")) + }) + + It("returns sorted result for deterministic output", func() { + current := []string{"zz", "aa"} + desired := []string{"mm", "bb"} + result := mergeMountOptions(current, desired) + Expect(result).To(Equal([]string{"aa", "bb", "mm", "zz"})) + }) + + It("handles empty current slice", func() { + current := []string{} + desired := []string{"nodev", "nosuid"} + result := mergeMountOptions(current, desired) + Expect(result).To(Equal([]string{"nodev", "nosuid"})) + }) + + It("handles empty desired slice", func() { + current := []string{"rw", "nodev"} + desired := []string{} + result := mergeMountOptions(current, desired) + Expect(result).To(Equal([]string{"nodev", "rw"})) + }) + }) + + Describe("isCNPGClusterOwnerRef", func() { + It("returns true for valid CNPG Cluster owner reference", func() { + ownerRef := metav1.OwnerReference{ + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Name: "test-cluster", + } + Expect(isCNPGClusterOwnerRef(ownerRef)).To(BeTrue()) + }) + + It("returns false for non-Cluster kind", func() { + ownerRef := metav1.OwnerReference{ + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Backup", + Name: "test-backup", + } + Expect(isCNPGClusterOwnerRef(ownerRef)).To(BeFalse()) + }) + + It("returns false for non-CNPG API version", func() { + ownerRef := metav1.OwnerReference{ + APIVersion: "apps/v1", + Kind: "Cluster", + Name: "test-cluster", + } + Expect(isCNPGClusterOwnerRef(ownerRef)).To(BeFalse()) + }) + }) + + Describe("isOwnedByDocumentDB", func() { + It("returns true when cluster is owned by the specified DocumentDB", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "documentdb.io/v1", + Kind: "DocumentDB", + Name: documentdbName, + }, + }, + }, + } + Expect(isOwnedByDocumentDB(cluster, documentdbName)).To(BeTrue()) + }) + + It("returns false when cluster is owned by a different DocumentDB", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "documentdb.io/v1", + Kind: "DocumentDB", + Name: "other-documentdb", + }, + }, + }, + } + Expect(isOwnedByDocumentDB(cluster, documentdbName)).To(BeFalse()) + }) + + It("returns false when cluster has no owner references", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + }, + } + Expect(isOwnedByDocumentDB(cluster, documentdbName)).To(BeFalse()) + }) + }) + + Describe("getDesiredReclaimPolicy", func() { + var reconciler *PersistentVolumeReconciler + + BeforeEach(func() { + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + reconciler = &PersistentVolumeReconciler{Client: fakeClient} + }) + + It("returns Retain when spec specifies Retain", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimRetain)) + }) + + It("returns Delete when spec specifies Delete", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Delete", + }, + }, + }, + } + Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimDelete)) + }) + + It("returns Delete when spec is empty (default)", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{}, + }, + }, + } + Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimDelete)) + }) + + It("returns Delete for unknown policy value", func() { + documentdb := &dbpreview.DocumentDB{ + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Unknown", + }, + }, + }, + } + Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimDelete)) + }) + }) + + Describe("applyDesiredPVConfiguration", func() { + var reconciler *PersistentVolumeReconciler + + BeforeEach(func() { + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + reconciler = &PersistentVolumeReconciler{Client: fakeClient} + }) + + It("returns true and updates PV when reclaim policy differs", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + MountOptions: []string{"nodev", "noexec", "nosuid"}, + }, + } + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName}, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + + needsUpdate := reconciler.applyDesiredPVConfiguration(ctx, pv, documentdb) + Expect(needsUpdate).To(BeTrue()) + Expect(pv.Spec.PersistentVolumeReclaimPolicy).To(Equal(corev1.PersistentVolumeReclaimRetain)) + }) + + It("returns true and updates PV when mount options are missing", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + MountOptions: []string{"rw"}, + }, + } + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName}, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Delete", + }, + }, + }, + } + + needsUpdate := reconciler.applyDesiredPVConfiguration(ctx, pv, documentdb) + Expect(needsUpdate).To(BeTrue()) + Expect(pv.Spec.MountOptions).To(ContainElements("nodev", "noexec", "nosuid", "rw")) + }) + + It("returns false when no changes are needed", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain, + MountOptions: []string{"nodev", "noexec", "nosuid"}, + }, + } + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName}, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + + needsUpdate := reconciler.applyDesiredPVConfiguration(ctx, pv, documentdb) + Expect(needsUpdate).To(BeFalse()) + }) + }) + + Describe("Reconcile", func() { + It("skips PV without claimRef", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pv). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: pvName}, + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + + // Verify PV was not modified + updatedPV := &corev1.PersistentVolume{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: pvName}, updatedPV)).To(Succeed()) + Expect(updatedPV.Spec.MountOptions).To(BeEmpty()) + }) + + It("skips PV not associated with DocumentDB", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: pvName, + }, + } + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + ClaimRef: &corev1.ObjectReference{ + Name: pvcName, + Namespace: testNamespace, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pv, pvc). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: pvName}, + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("updates PV when associated with DocumentDB and changes needed", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbName, + Namespace: testNamespace, + UID: "documentdb-uid", + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + + trueVal := true + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + UID: "cluster-uid", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "documentdb.io/v1", + Kind: "DocumentDB", + Name: documentdbName, + UID: "documentdb-uid", + Controller: &trueVal, + }, + }, + }, + } + + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Name: clusterName, + UID: "cluster-uid", + Controller: &trueVal, + }, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: pvName, + }, + } + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + ClaimRef: &corev1.ObjectReference{ + Name: pvcName, + Namespace: testNamespace, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb, cluster, pvc, pv). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: pvName}, + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + + // Verify PV was updated + updatedPV := &corev1.PersistentVolume{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: pvName}, updatedPV)).To(Succeed()) + Expect(updatedPV.Spec.PersistentVolumeReclaimPolicy).To(Equal(corev1.PersistentVolumeReclaimRetain)) + Expect(updatedPV.Spec.MountOptions).To(ContainElements("nodev", "noexec", "nosuid")) + }) + + It("returns empty result when PV not found", func() { + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "non-existent-pv"}, + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + Describe("findPVsForDocumentDB", func() { + It("returns reconcile requests for PVs associated with DocumentDB", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbName, + Namespace: testNamespace, + UID: "documentdb-uid", + }, + } + + trueVal := true + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + UID: "cluster-uid", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "documentdb.io/v1", + Kind: "DocumentDB", + Name: documentdbName, + UID: "documentdb-uid", + Controller: &trueVal, + }, + }, + }, + } + + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Name: clusterName, + UID: "cluster-uid", + Controller: &trueVal, + }, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: pvName, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb, cluster, pvc). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + requests := reconciler.findPVsForDocumentDB(ctx, documentdb) + Expect(requests).To(HaveLen(1)) + Expect(requests[0].Name).To(Equal(pvName)) + }) + + It("returns empty when DocumentDB has no associated clusters", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbName, + Namespace: testNamespace, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + requests := reconciler.findPVsForDocumentDB(ctx, documentdb) + Expect(requests).To(BeEmpty()) + }) + + It("returns empty when object is not DocumentDB", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + requests := reconciler.findPVsForDocumentDB(ctx, pvc) + Expect(requests).To(BeNil()) + }) + }) + + Describe("pvPredicate", func() { + var pred predicate.Predicate + + BeforeEach(func() { + pred = pvPredicate() + }) + + Describe("CreateFunc", func() { + It("returns true for bound PV with claimRef", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + ClaimRef: &corev1.ObjectReference{Name: pvcName, Namespace: testNamespace}, + }, + Status: corev1.PersistentVolumeStatus{ + Phase: corev1.VolumeBound, + }, + } + e := event.CreateEvent{Object: pv} + Expect(pred.Create(e)).To(BeTrue()) + }) + + It("returns false for unbound PV", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + ClaimRef: &corev1.ObjectReference{Name: pvcName, Namespace: testNamespace}, + }, + Status: corev1.PersistentVolumeStatus{ + Phase: corev1.VolumeAvailable, + }, + } + e := event.CreateEvent{Object: pv} + Expect(pred.Create(e)).To(BeFalse()) + }) + + It("returns false for PV without claimRef", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Status: corev1.PersistentVolumeStatus{ + Phase: corev1.VolumeBound, + }, + } + e := event.CreateEvent{Object: pv} + Expect(pred.Create(e)).To(BeFalse()) + }) + + It("returns false for non-PV object", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: pvcName, Namespace: testNamespace}, + } + e := event.CreateEvent{Object: pvc} + Expect(pred.Create(e)).To(BeFalse()) + }) + }) + + Describe("UpdateFunc", func() { + It("returns true for bound PV with claimRef", func() { + oldPV := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Status: corev1.PersistentVolumeStatus{ + Phase: corev1.VolumeAvailable, + }, + } + newPV := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + ClaimRef: &corev1.ObjectReference{Name: pvcName, Namespace: testNamespace}, + }, + Status: corev1.PersistentVolumeStatus{ + Phase: corev1.VolumeBound, + }, + } + e := event.UpdateEvent{ObjectOld: oldPV, ObjectNew: newPV} + Expect(pred.Update(e)).To(BeTrue()) + }) + + It("returns false for non-PV object", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: pvcName, Namespace: testNamespace}, + } + e := event.UpdateEvent{ObjectOld: pvc, ObjectNew: pvc} + Expect(pred.Update(e)).To(BeFalse()) + }) + }) + + Describe("DeleteFunc", func() { + It("always returns false", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + } + e := event.DeleteEvent{Object: pv} + Expect(pred.Delete(e)).To(BeFalse()) + }) + }) + }) + + Describe("documentDBReclaimPolicyPredicate", func() { + var pred predicate.Predicate + + BeforeEach(func() { + pred = documentDBReclaimPolicyPredicate() + }) + + Describe("UpdateFunc", func() { + It("returns true when reclaim policy changes", func() { + oldDB := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName, Namespace: testNamespace}, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Delete", + }, + }, + }, + } + newDB := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName, Namespace: testNamespace}, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + e := event.UpdateEvent{ObjectOld: oldDB, ObjectNew: newDB} + Expect(pred.Update(e)).To(BeTrue()) + }) + + It("returns false when reclaim policy unchanged", func() { + oldDB := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName, Namespace: testNamespace}, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + newDB := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName, Namespace: testNamespace}, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + e := event.UpdateEvent{ObjectOld: oldDB, ObjectNew: newDB} + Expect(pred.Update(e)).To(BeFalse()) + }) + + It("returns false for non-DocumentDB objects", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: pvcName, Namespace: testNamespace}, + } + e := event.UpdateEvent{ObjectOld: pvc, ObjectNew: pvc} + Expect(pred.Update(e)).To(BeFalse()) + }) + }) + + Describe("CreateFunc", func() { + It("always returns false", func() { + db := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName, Namespace: testNamespace}, + } + e := event.CreateEvent{Object: db} + Expect(pred.Create(e)).To(BeFalse()) + }) + }) + + Describe("DeleteFunc", func() { + It("always returns false", func() { + db := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: documentdbName, Namespace: testNamespace}, + } + e := event.DeleteEvent{Object: db} + Expect(pred.Delete(e)).To(BeFalse()) + }) + }) + }) + + Describe("findDocumentDBForPV", func() { + It("returns nil when PV has no claimRef", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.findDocumentDBForPV(ctx, pv) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("returns nil when PVC is not found", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + ClaimRef: &corev1.ObjectReference{ + Name: "non-existent-pvc", + Namespace: testNamespace, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.findDocumentDBForPV(ctx, pv) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("returns nil when PVC has no CNPG Cluster owner", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + }, + } + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + ClaimRef: &corev1.ObjectReference{ + Name: pvcName, + Namespace: testNamespace, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.findDocumentDBForPV(ctx, pv) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("returns DocumentDB when full ownership chain exists", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbName, + Namespace: testNamespace, + UID: "documentdb-uid", + }, + } + + trueVal := true + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + UID: "cluster-uid", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "documentdb.io/v1", + Kind: "DocumentDB", + Name: documentdbName, + UID: "documentdb-uid", + Controller: &trueVal, + }, + }, + }, + } + + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Name: clusterName, + UID: "cluster-uid", + Controller: &trueVal, + }, + }, + }, + } + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + ClaimRef: &corev1.ObjectReference{ + Name: pvcName, + Namespace: testNamespace, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb, cluster, pvc). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.findDocumentDBForPV(ctx, pv) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal(documentdbName)) + }) + }) + + Describe("findCNPGClusterOwner", func() { + It("returns nil when PVC has no owner references", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result := reconciler.findCNPGClusterOwner(ctx, pvc) + Expect(result).To(BeNil()) + }) + + It("returns nil when owner is not a CNPG Cluster", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "StatefulSet", + Name: "some-statefulset", + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result := reconciler.findCNPGClusterOwner(ctx, pvc) + Expect(result).To(BeNil()) + }) + + It("returns cluster when PVC is owned by CNPG Cluster", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + UID: "cluster-uid", + }, + } + + trueVal := true + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Name: clusterName, + UID: "cluster-uid", + Controller: &trueVal, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cluster). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result := reconciler.findCNPGClusterOwner(ctx, pvc) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal(clusterName)) + }) + }) + + Describe("findDocumentDBOwner", func() { + It("returns nil when cluster has no owner references", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result := reconciler.findDocumentDBOwner(ctx, cluster) + Expect(result).To(BeNil()) + }) + + It("returns nil when owner is not DocumentDB", func() { + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "some-deployment", + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result := reconciler.findDocumentDBOwner(ctx, cluster) + Expect(result).To(BeNil()) + }) + + It("returns DocumentDB when cluster is owned by DocumentDB", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbName, + Namespace: testNamespace, + UID: "documentdb-uid", + }, + } + + trueVal := true + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "documentdb.io/v1", + Kind: "DocumentDB", + Name: documentdbName, + UID: "documentdb-uid", + Controller: &trueVal, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result := reconciler.findDocumentDBOwner(ctx, cluster) + Expect(result).ToNot(BeNil()) + Expect(result.Name).To(Equal(documentdbName)) + }) + }) +}) From 7a8310df4459ebf12e9a31d327b94baf48aca97e Mon Sep 17 00:00:00 2001 From: wenting Date: Wed, 21 Jan 2026 10:11:38 -0500 Subject: [PATCH 14/19] fixup e2e test Signed-off-by: wenting --- .github/workflows/test-backup-and-restore.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-backup-and-restore.yml b/.github/workflows/test-backup-and-restore.yml index 49c84f61..90615d4d 100644 --- a/.github/workflows/test-backup-and-restore.yml +++ b/.github/workflows/test-backup-and-restore.yml @@ -362,7 +362,8 @@ jobs: echo "Testing PV retention after DocumentDB deletion..." # Get the PVC name and PV name before deleting the DocumentDB - pvc_name=$(kubectl -n ${{ env.DB_NS }} get pvc -l documentdb.io/cluster=${{ env.DB_RESTORE_NAME }} -o jsonpath='{.items[0].metadata.name}') + # PVCs are created by CNPG and labeled with cnpg.io/cluster + pvc_name=$(kubectl -n ${{ env.DB_NS }} get pvc -l cnpg.io/cluster=${{ env.DB_RESTORE_NAME }} -o jsonpath='{.items[0].metadata.name}') echo "PVC name: $pvc_name" if [ -z "$pvc_name" ]; then From 6f7572aea4e5f36f6c66069016a77b3ae4bc56fc Mon Sep 17 00:00:00 2001 From: wenting Date: Wed, 21 Jan 2026 11:33:53 -0500 Subject: [PATCH 15/19] documentation Signed-off-by: wenting --- .../preview/advanced-configuration/README.md | 105 +++++++++++++++++ .../preview/backup-and-restore.md | 111 +++++++++++++++++- 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/docs/operator-public-documentation/preview/advanced-configuration/README.md b/docs/operator-public-documentation/preview/advanced-configuration/README.md index cda1cacd..4aa1bc67 100644 --- a/docs/operator-public-documentation/preview/advanced-configuration/README.md +++ b/docs/operator-public-documentation/preview/advanced-configuration/README.md @@ -257,6 +257,111 @@ kubectl patch documentdb -n --type='json' \ -p='[{"op": "replace", "path": "/spec/storage/size", "value":"200Gi"}]' ``` +### PersistentVolume Security + +The DocumentDB operator automatically applies security-hardening mount options to all PersistentVolumes associated with DocumentDB clusters: + +| Mount Option | Description | +|--------------|-------------| +| `nodev` | Prevents device files from being interpreted on the filesystem | +| `nosuid` | Prevents setuid/setgid bits from taking effect | +| `noexec` | Prevents execution of binaries on the filesystem | + +These options are automatically applied by the PV controller and require no additional configuration. + +### Disk Encryption + +Encryption at rest is essential for protecting sensitive database data. Here's how to configure disk encryption for each cloud provider: + +#### Azure Kubernetes Service (AKS) + +AKS encrypts all managed disks by default using Azure Storage Service Encryption (SSE) with platform-managed keys. No additional configuration is required. + +For customer-managed keys (CMK), use Azure Disk Encryption: + +```yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: managed-csi-encrypted +provisioner: disk.csi.azure.com +parameters: + skuName: Premium_LRS + # For customer-managed keys, specify the disk encryption set + diskEncryptionSetID: /subscriptions//resourceGroups//providers/Microsoft.Compute/diskEncryptionSets/ +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +allowVolumeExpansion: true +``` + +#### Google Kubernetes Engine (GKE) + +GKE encrypts all persistent disks by default using Google-managed encryption keys. No additional configuration is required. + +For customer-managed encryption keys (CMEK): + +```yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: pd-ssd-encrypted +provisioner: pd.csi.storage.gke.io +parameters: + type: pd-ssd + # For CMEK, specify the key + disk-encryption-kms-key: projects//locations//keyRings//cryptoKeys/ +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +allowVolumeExpansion: true +``` + +#### Amazon Elastic Kubernetes Service (EKS) + +**Important**: Unlike AKS and GKE, EBS volumes on EKS are **not encrypted by default**. You must explicitly enable encryption in the StorageClass: + +```yaml +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: ebs-sc-encrypted +provisioner: ebs.csi.aws.com +parameters: + type: gp3 + encrypted: "true" # Required for encryption + # Optional: specify a KMS key for customer-managed encryption + # kmsKeyId: arn:aws:kms:::key/ +reclaimPolicy: Delete +volumeBindingMode: WaitForFirstConsumer +allowVolumeExpansion: true +``` + +To use the encrypted storage class with DocumentDB: + +```yaml +apiVersion: documentdb.io/preview +kind: DocumentDB +metadata: + name: my-cluster + namespace: default +spec: + environment: eks + resource: + storage: + pvcSize: 100Gi + storageClass: ebs-sc-encrypted # Use the encrypted storage class + # ... other configuration +``` + +### Encryption Summary + +| Provider | Default Encryption | Customer-Managed Keys | +|----------|-------------------|----------------------| +| AKS | ✅ Enabled (SSE) | Optional via DiskEncryptionSet | +| GKE | ✅ Enabled (Google-managed) | Optional via CMEK | +| EKS | ❌ **Not enabled** | Required: set `encrypted: "true"` in StorageClass | + +**Recommendation**: For production deployments on EKS, always create a StorageClass with `encrypted: "true"` to ensure data at rest is protected. + --- ## Resource Management diff --git a/docs/operator-public-documentation/preview/backup-and-restore.md b/docs/operator-public-documentation/preview/backup-and-restore.md index 43de4743..2e006fe4 100644 --- a/docs/operator-public-documentation/preview/backup-and-restore.md +++ b/docs/operator-public-documentation/preview/backup-and-restore.md @@ -288,4 +288,113 @@ spec: - Changing `DocumentDB.spec.backup.retentionDays` doesn’t retroactively update existing backups. - Failed backups still expire (timer starts at creation). - Deleting the cluster does NOT delete its Backup objects immediately—they still wait for expiration. -- No "keep forever" mode—export externally if you need permanent archival. \ No newline at end of file +- No "keep forever" mode—export externally if you need permanent archival. + +## PersistentVolume Retention and Recovery + +The DocumentDB operator supports retaining PersistentVolumes (PVs) after cluster deletion, allowing you to recover data by creating a new cluster from the retained PV. + +### Configuring PV Retention + +By default, PVs are deleted when the DocumentDB cluster is deleted. To retain the PV, set the `persistentVolumeReclaimPolicy` to `Retain`: + +```yaml +apiVersion: documentdb.io/preview +kind: DocumentDB +metadata: + name: my-cluster + namespace: default +spec: + resource: + storage: + pvcSize: 10Gi + persistentVolumeReclaimPolicy: Retain # Keep PV after cluster deletion + # ... other configuration +``` + +### How PV Retention Works + +1. When you set `persistentVolumeReclaimPolicy: Retain`, the operator updates the underlying PersistentVolume's reclaim policy +2. When the DocumentDB cluster is deleted, the PVC is deleted but the PV is retained in a "Released" state +3. The retained PV contains all the database data and can be used to recover the cluster + +### Recovering from a Retained PV + +To restore a DocumentDB cluster from a retained PV: + +**Step 1: Identify the retained PV** + +```bash +# List PVs in Released state +kubectl get pv | grep Released +``` + +**Step 2: Clear the claimRef to make the PV available** + +The PV in "Released" state still has a reference to the old deleted PVC. Clear it: + +```bash +kubectl patch pv --type=json -p='[{"op": "remove", "path": "/spec/claimRef"}]' +``` + +Verify the PV is now "Available": + +```bash +kubectl get pv +``` + +**Step 3: Create a new PVC bound to the retained PV** + +```yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: recovered-pvc + namespace: default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi # Must match the PV capacity + storageClassName: managed-csi # Must match the PV's storage class + volumeName: # Specify the retained PV name +``` + +Apply and verify the PVC is bound: + +```bash +kubectl apply -f recovered-pvc.yaml +kubectl get pvc recovered-pvc +``` + +**Step 4: Create a new DocumentDB cluster with PVC recovery** + +```yaml +apiVersion: documentdb.io/preview +kind: DocumentDB +metadata: + name: my-recovered-cluster + namespace: default +spec: + nodeCount: 1 + instancesPerNode: 1 + resource: + storage: + pvcSize: 10Gi + storageClass: managed-csi + exposeViaService: + serviceType: ClusterIP + bootstrap: + recovery: + pvc: + name: recovered-pvc # Reference the PVC bound to the retained PV +``` + +### Important Notes for PV Recovery + +- The new cluster must be in the same namespace as the PVC +- Storage size and class should match the original configuration +- You cannot specify both `backup` and `pvc` recovery at the same time +- PVC recovery preserves all data including users, roles, and collections +- After successful recovery, consider setting up regular backups for the new cluster From 117741c5ccaeec9068d6734c2c97ba76ef5f82d5 Mon Sep 17 00:00:00 2001 From: wenting Date: Wed, 21 Jan 2026 11:36:18 -0500 Subject: [PATCH 16/19] mountOptions in e2e tests Signed-off-by: wenting --- .github/workflows/test-backup-and-restore.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/test-backup-and-restore.yml b/.github/workflows/test-backup-and-restore.yml index 90615d4d..8dfb7d4d 100644 --- a/.github/workflows/test-backup-and-restore.yml +++ b/.github/workflows/test-backup-and-restore.yml @@ -415,6 +415,21 @@ jobs: exit 1 fi + # Verify mount options are set by the PV controller + echo "Verifying PV mount options are set..." + mount_options=$(kubectl get pv $pv_name -o jsonpath='{.spec.mountOptions}') + echo "PV mount options: $mount_options" + + # Check for security mount options (nodev, nosuid, noexec) + if echo "$mount_options" | grep -q "nodev" && \ + echo "$mount_options" | grep -q "nosuid" && \ + echo "$mount_options" | grep -q "noexec"; then + echo "✓ PV mount options (nodev, nosuid, noexec) are set correctly" + else + echo "❌ PV mount options are missing. Expected nodev, nosuid, noexec" + exit 1 + fi + # Delete the restored DocumentDB cluster kubectl -n ${{ env.DB_NS }} delete documentdb ${{ env.DB_RESTORE_NAME }} --wait=false From 640f6760ca5825086923e691775a159a25db9149 Mon Sep 17 00:00:00 2001 From: wenting Date: Mon, 2 Feb 2026 13:33:11 -0500 Subject: [PATCH 17/19] Change default PersistentVolumeReclaimPolicy from Delete to Retain - Default to Retain for safer database workloads (data preservation) - Add comprehensive documentation for when to use each option - Add WARNING about data loss when using Delete - Update controller default fallback to Retain - Update tests to reflect new default behavior Signed-off-by: wenting --- operator/src/api/preview/documentdb_types.go | 15 ++++++++++++--- operator/src/internal/controller/pv_controller.go | 4 ++-- .../src/internal/controller/pv_controller_test.go | 8 ++++---- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/operator/src/api/preview/documentdb_types.go b/operator/src/api/preview/documentdb_types.go index 58836e2e..bd45bea5 100644 --- a/operator/src/api/preview/documentdb_types.go +++ b/operator/src/api/preview/documentdb_types.go @@ -124,10 +124,19 @@ type StorageConfiguration struct { // PersistentVolumeReclaimPolicy controls what happens to the PersistentVolume when // the DocumentDB cluster is deleted. - // Retain: The PV is kept after cluster deletion, allowing data recovery. - // Delete: The PV is deleted with the cluster (default behavior). + // + // Options: + // - Retain (default): The PV is preserved after cluster deletion, allowing manual + // data recovery or forensic analysis. Use for production workloads where data + // safety is critical. Orphaned PVs must be manually deleted when no longer needed. + // - Delete: The PV is automatically deleted with the cluster. Use for development, + // testing, or ephemeral environments where data persistence is not required. + // + // WARNING: Setting this to "Delete" means all data will be permanently lost when + // the DocumentDB cluster is deleted. This cannot be undone. + // // +kubebuilder:validation:Enum=Retain;Delete - // +kubebuilder:default=Delete + // +kubebuilder:default=Retain // +optional PersistentVolumeReclaimPolicy string `json:"persistentVolumeReclaimPolicy,omitempty"` } diff --git a/operator/src/internal/controller/pv_controller.go b/operator/src/internal/controller/pv_controller.go index b7291c4d..f82afad0 100644 --- a/operator/src/internal/controller/pv_controller.go +++ b/operator/src/internal/controller/pv_controller.go @@ -288,8 +288,8 @@ func (r *PersistentVolumeReconciler) getDesiredReclaimPolicy(documentdb *dbprevi case reclaimPolicyDelete: return corev1.PersistentVolumeReclaimDelete default: - // Default to Delete if not specified - return corev1.PersistentVolumeReclaimDelete + // Default to Retain if not specified - safer for database workloads + return corev1.PersistentVolumeReclaimRetain } } diff --git a/operator/src/internal/controller/pv_controller_test.go b/operator/src/internal/controller/pv_controller_test.go index acf93539..a034ebae 100644 --- a/operator/src/internal/controller/pv_controller_test.go +++ b/operator/src/internal/controller/pv_controller_test.go @@ -209,7 +209,7 @@ var _ = Describe("PersistentVolume Controller", func() { Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimDelete)) }) - It("returns Delete when spec is empty (default)", func() { + It("returns Retain when spec is empty (default)", func() { documentdb := &dbpreview.DocumentDB{ Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ @@ -217,10 +217,10 @@ var _ = Describe("PersistentVolume Controller", func() { }, }, } - Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimDelete)) + Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimRetain)) }) - It("returns Delete for unknown policy value", func() { + It("returns Retain for unknown policy value", func() { documentdb := &dbpreview.DocumentDB{ Spec: dbpreview.DocumentDBSpec{ Resource: dbpreview.Resource{ @@ -230,7 +230,7 @@ var _ = Describe("PersistentVolume Controller", func() { }, }, } - Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimDelete)) + Expect(reconciler.getDesiredReclaimPolicy(documentdb)).To(Equal(corev1.PersistentVolumeReclaimRetain)) }) }) From 34efedd0151dc1fdc73d913cf1a502ef4c1eea8c Mon Sep 17 00:00:00 2001 From: wenting Date: Mon, 2 Feb 2026 14:24:07 -0500 Subject: [PATCH 18/19] Move PV mount options and Delete policy tests to E2E workflow - Move 'Verify mount options are set by PV controller' test to E2E - Update 'Test PV reclaim policy default and explicit Delete' to: - Verify default Retain policy on existing cluster - Patch cluster to Delete policy and verify PV update - Verify PV cleanup after cluster deletion Signed-off-by: wenting --- .github/workflows/test-E2E.yml | 137 ++++ .github/workflows/test-backup-and-restore.yml | 58 +- .../controller/certificate_controller_test.go | 143 ++++ .../controller/documentdb_controller.go | 114 +++ .../controller/documentdb_controller_test.go | 727 ++++++++++++++---- 5 files changed, 1006 insertions(+), 173 deletions(-) create mode 100644 operator/src/internal/controller/certificate_controller_test.go diff --git a/.github/workflows/test-E2E.yml b/.github/workflows/test-E2E.yml index 321e1c46..0540590e 100644 --- a/.github/workflows/test-E2E.yml +++ b/.github/workflows/test-E2E.yml @@ -280,6 +280,143 @@ jobs: # Check events kubectl get events -n $DB_NS --sort-by='.lastTimestamp' + - name: Verify mount options are set by PV controller + run: | + echo "Verifying PV mount options are set by the PV controller..." + + # Get the PVC and PV names from the existing cluster + pvc_name=$(kubectl -n ${{ env.DB_NS }} get pvc -l cnpg.io/cluster=${{ env.DB_NAME }} -o jsonpath='{.items[0].metadata.name}') + pv_name=$(kubectl -n ${{ env.DB_NS }} get pvc $pvc_name -o jsonpath='{.spec.volumeName}') + + echo "PVC name: $pvc_name" + echo "PV name: $pv_name" + + if [ -z "$pv_name" ]; then + echo "❌ Failed to find PV bound to PVC $pvc_name" + exit 1 + fi + + # Get mount options from PV + mount_options=$(kubectl get pv $pv_name -o jsonpath='{.spec.mountOptions}') + echo "PV mount options: $mount_options" + + # Check for security mount options (nodev, nosuid, noexec) + if echo "$mount_options" | grep -q "nodev" && \ + echo "$mount_options" | grep -q "nosuid" && \ + echo "$mount_options" | grep -q "noexec"; then + echo "✓ PV mount options (nodev, nosuid, noexec) are set correctly" + else + echo "❌ PV mount options are missing. Expected nodev, nosuid, noexec" + exit 1 + fi + + - name: Test PV reclaim policy default and explicit Delete + shell: bash + run: | + echo "Testing PV reclaim policy - default (Retain) and explicit Delete..." + + # Test 1: Verify default policy is Retain on the existing cluster + echo "=== Test 1: Verify default PV reclaim policy is Retain ===" + + # Get the PVC and PV names from the existing cluster + pvc_name=$(kubectl -n ${{ env.DB_NS }} get pvc -l cnpg.io/cluster=${{ env.DB_NAME }} -o jsonpath='{.items[0].metadata.name}') + pv_name=$(kubectl -n ${{ env.DB_NS }} get pvc $pvc_name -o jsonpath='{.spec.volumeName}') + + echo "PVC name: $pvc_name" + echo "PV name: $pv_name" + + if [ -z "$pv_name" ]; then + echo "❌ Failed to find PV bound to PVC $pvc_name" + exit 1 + fi + + # Verify default PV reclaim policy is Retain + current_policy=$(kubectl get pv $pv_name -o jsonpath='{.spec.persistentVolumeReclaimPolicy}') + echo "Current PV reclaim policy: $current_policy" + + if [ "$current_policy" != "Retain" ]; then + echo "❌ Expected default PV reclaim policy to be 'Retain', but got '$current_policy'" + exit 1 + fi + echo "✓ Default PV reclaim policy is correctly set to Retain" + + # Test 2: Change policy to Delete and verify PV is deleted with cluster + echo "" + echo "=== Test 2: Change policy to Delete and verify PV cleanup ===" + + # Patch the existing DocumentDB to set persistentVolumeReclaimPolicy to Delete + echo "Patching DocumentDB to set persistentVolumeReclaimPolicy to Delete..." + kubectl -n ${{ env.DB_NS }} patch documentdb ${{ env.DB_NAME }} --type=merge \ + -p '{"spec":{"resource":{"storage":{"persistentVolumeReclaimPolicy":"Delete"}}}}' + + # Wait for PV controller to update the PV reclaim policy + echo "Waiting for PV reclaim policy to be updated to Delete..." + MAX_RETRIES=30 + SLEEP_INTERVAL=5 + ITER=0 + while [ $ITER -lt $MAX_RETRIES ]; do + new_policy=$(kubectl get pv $pv_name -o jsonpath='{.spec.persistentVolumeReclaimPolicy}') + if [ "$new_policy" == "Delete" ]; then + echo "✓ PV reclaim policy updated to Delete" + break + else + echo "PV reclaim policy is still '$new_policy'. Waiting..." + sleep $SLEEP_INTERVAL + fi + ((++ITER)) + done + + if [ "$new_policy" != "Delete" ]; then + echo "❌ PV reclaim policy was not updated to Delete within expected time" + exit 1 + fi + + # Delete the DocumentDB cluster + echo "Deleting DocumentDB cluster to test PV cleanup with Delete policy..." + kubectl -n ${{ env.DB_NS }} delete documentdb ${{ env.DB_NAME }} --wait=false + + # Wait for DocumentDB to be deleted + echo "Waiting for DocumentDB to be deleted..." + MAX_RETRIES=30 + SLEEP_INTERVAL=10 + ITER=0 + while [ $ITER -lt $MAX_RETRIES ]; do + db_exists=$(kubectl -n ${{ env.DB_NS }} get documentdb ${{ env.DB_NAME }} --ignore-not-found) + if [ -z "$db_exists" ]; then + echo "✓ DocumentDB deleted successfully." + break + else + echo "DocumentDB still exists. Waiting..." + sleep $SLEEP_INTERVAL + fi + ((++ITER)) + done + + # Verify no PVsRetained warning event was emitted (since policy is Delete) + events=$(kubectl -n ${{ env.DB_NS }} get events --field-selector reason=PVsRetained,involvedObject.name=${{ env.DB_NAME }} --ignore-not-found -o jsonpath='{.items}') + if [ -z "$events" ] || [ "$events" == "[]" ]; then + echo "✓ No PVsRetained warning event emitted (expected for Delete policy)" + else + echo "⚠️ Unexpected PVsRetained event found for Delete policy cluster" + fi + + # Wait a bit for PV to be deleted (the storage class handles actual deletion) + echo "Waiting for PV to be deleted..." + sleep 30 + + # Verify PV was deleted (because reclaim policy is Delete) + pv_exists=$(kubectl get pv $pv_name --ignore-not-found 2>/dev/null) + if [ -z "$pv_exists" ]; then + echo "✓ PV $pv_name was deleted as expected (Delete policy)" + else + pv_status=$(kubectl get pv $pv_name -o jsonpath='{.status.phase}') + echo "⚠️ PV $pv_name still exists with status: $pv_status" + echo "Note: PV deletion depends on the storage provisioner. The reclaim policy was correctly set to Delete." + fi + + echo "" + echo "✓ PV reclaim policy test completed successfully" + - name: Collect comprehensive logs on failure if: failure() uses: ./.github/actions/collect-logs diff --git a/.github/workflows/test-backup-and-restore.yml b/.github/workflows/test-backup-and-restore.yml index 8dfb7d4d..bf9f3825 100644 --- a/.github/workflows/test-backup-and-restore.yml +++ b/.github/workflows/test-backup-and-restore.yml @@ -380,55 +380,15 @@ jobs: exit 1 fi - # Check current PV reclaim policy - should be Delete by default + # Check current PV reclaim policy - should be Retain by default current_policy=$(kubectl get pv $pv_name -o jsonpath='{.spec.persistentVolumeReclaimPolicy}') echo "Current PV reclaim policy: $current_policy" - if [ "$current_policy" != "Delete" ]; then - echo "⚠️ Expected PV reclaim policy to be 'Delete', but got '$current_policy'" - fi - - # Patch DocumentDB to set persistentVolumeReclaimPolicy to Retain - echo "Patching DocumentDB to set persistentVolumeReclaimPolicy to Retain..." - kubectl -n ${{ env.DB_NS }} patch documentdb ${{ env.DB_RESTORE_NAME }} --type=merge \ - -p '{"spec":{"resource":{"storage":{"persistentVolumeReclaimPolicy":"Retain"}}}}' - - # Wait for PV controller to update the PV reclaim policy - echo "Waiting for PV reclaim policy to be updated to Retain..." - MAX_RETRIES=30 - SLEEP_INTERVAL=5 - ITER=0 - while [ $ITER -lt $MAX_RETRIES ]; do - new_policy=$(kubectl get pv $pv_name -o jsonpath='{.spec.persistentVolumeReclaimPolicy}') - if [ "$new_policy" == "Retain" ]; then - echo "✓ PV reclaim policy updated to Retain" - break - else - echo "PV reclaim policy is still '$new_policy'. Waiting..." - sleep $SLEEP_INTERVAL - fi - ((++ITER)) - done - - if [ "$new_policy" != "Retain" ]; then - echo "❌ PV reclaim policy was not updated to Retain within expected time" - exit 1 - fi - - # Verify mount options are set by the PV controller - echo "Verifying PV mount options are set..." - mount_options=$(kubectl get pv $pv_name -o jsonpath='{.spec.mountOptions}') - echo "PV mount options: $mount_options" - - # Check for security mount options (nodev, nosuid, noexec) - if echo "$mount_options" | grep -q "nodev" && \ - echo "$mount_options" | grep -q "nosuid" && \ - echo "$mount_options" | grep -q "noexec"; then - echo "✓ PV mount options (nodev, nosuid, noexec) are set correctly" - else - echo "❌ PV mount options are missing. Expected nodev, nosuid, noexec" + if [ "$current_policy" != "Retain" ]; then + echo "❌ Expected PV reclaim policy to be 'Retain' (default), but got '$current_policy'" exit 1 fi + echo "✓ PV reclaim policy is correctly set to Retain (default)" # Delete the restored DocumentDB cluster kubectl -n ${{ env.DB_NS }} delete documentdb ${{ env.DB_RESTORE_NAME }} --wait=false @@ -586,3 +546,13 @@ jobs: # Clean up output log rm -f /tmp/pf_output.log + + - name: Collect logs on failure + if: failure() + uses: ./.github/actions/collect-logs + with: + architecture: ${{ matrix.architecture }} + operator-namespace: ${{ env.OPERATOR_NS }} + db-namespace: ${{ env.DB_NS }} + db-cluster-name: ${{ env.DB_NAME }} + cert-manager-namespace: ${{ env.CERT_MANAGER_NS }} diff --git a/operator/src/internal/controller/certificate_controller_test.go b/operator/src/internal/controller/certificate_controller_test.go new file mode 100644 index 00000000..96620a3a --- /dev/null +++ b/operator/src/internal/controller/certificate_controller_test.go @@ -0,0 +1,143 @@ +package controller + +import ( + "context" + "testing" + "time" + + cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" + util "github.com/documentdb/documentdb-operator/internal/utils" +) + +// helper to build TLS reconciler with objects +func buildCertificateReconciler(t *testing.T, objs ...runtime.Object) *CertificateReconciler { + scheme := runtime.NewScheme() + require.NoError(t, dbpreview.AddToScheme(scheme)) + require.NoError(t, cmapi.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + builder := fake.NewClientBuilder().WithScheme(scheme) + if len(objs) > 0 { + builder = builder.WithRuntimeObjects(objs...) + clientObjs := make([]client.Object, 0, len(objs)) + for _, obj := range objs { + if co, ok := obj.(client.Object); ok { + clientObjs = append(clientObjs, co) + } + } + if len(clientObjs) > 0 { + builder = builder.WithStatusSubresource(clientObjs...) + } + } + c := builder.Build() + return &CertificateReconciler{Client: c, Scheme: scheme} +} + +func baseDocumentDB(name, ns string) *dbpreview.DocumentDB { + return &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: dbpreview.DocumentDBSpec{ + NodeCount: 1, + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "1Gi"}}, + DocumentDBImage: "test-image", + ExposeViaService: dbpreview.ExposeViaService{ServiceType: "ClusterIP"}, + }, + } +} + +func TestEnsureProvidedSecret(t *testing.T) { + ctx := context.Background() + ddb := baseDocumentDB("ddb-prov", "default") + ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "Provided", Provided: &dbpreview.ProvidedTLS{SecretName: "mycert"}}} + // Secret missing first + r := buildCertificateReconciler(t, ddb) + res, err := r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Equal(t, RequeueAfterShort, res.RequeueAfter) + require.False(t, ddb.Status.TLS.Ready, "Should not be ready until secret exists") + + // Create secret with required keys then reconcile again + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "mycert", Namespace: "default"}, Data: map[string][]byte{"tls.crt": []byte("crt"), "tls.key": []byte("key")}} + require.NoError(t, r.Client.Create(ctx, secret)) + res, err = r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Zero(t, res.RequeueAfter) + require.True(t, ddb.Status.TLS.Ready, "Provided secret should mark TLS ready") + require.Equal(t, "mycert", ddb.Status.TLS.SecretName) +} + +func TestEnsureCertManagerManagedCert(t *testing.T) { + ctx := context.Background() + ddb := baseDocumentDB("ddb-cm", "default") + ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "CertManager", CertManager: &dbpreview.CertManagerTLS{IssuerRef: dbpreview.IssuerRef{Name: "test-issuer", Kind: "Issuer"}, DNSNames: []string{"custom.example"}}}} + ddb.Status.TLS = &dbpreview.TLSStatus{} + issuer := &cmapi.Issuer{ObjectMeta: metav1.ObjectMeta{Name: "test-issuer", Namespace: "default"}, Spec: cmapi.IssuerSpec{IssuerConfig: cmapi.IssuerConfig{SelfSigned: &cmapi.SelfSignedIssuer{}}}} + r := buildCertificateReconciler(t, ddb, issuer) + + // Call certificate ensure twice to mimic reconcile loops + res, err := r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Equal(t, RequeueAfterShort, res.RequeueAfter) + res, err = r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Equal(t, RequeueAfterShort, res.RequeueAfter) + + cert := &cmapi.Certificate{} + // fetch certificate (self-created by reconcile). If not found, run reconcile again once. + require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Name: "ddb-cm-gateway-cert", Namespace: "default"}, cert)) + // Debug: list all certificates to ensure store functioning + certList := &cmapi.CertificateList{} + _ = r.Client.List(ctx, certList) + for _, c := range certList.Items { + t.Logf("Found certificate: %s/%s secret=%s", c.Namespace, c.Name, c.Spec.SecretName) + } + require.Contains(t, cert.Spec.DNSNames, "custom.example") + // Should include service DNS names + serviceBase := util.DOCUMENTDB_SERVICE_PREFIX + ddb.Name + require.Contains(t, cert.Spec.DNSNames, serviceBase) + + // Simulate readiness condition then invoke ensure again (mimic reconcile loop) + cert.Status.Conditions = append(cert.Status.Conditions, cmapi.CertificateCondition{Type: cmapi.CertificateConditionReady, Status: cmmeta.ConditionTrue, LastTransitionTime: &metav1.Time{Time: time.Now()}}) + require.NoError(t, r.Client.Update(ctx, cert)) + res, err = r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Zero(t, res.RequeueAfter) + require.True(t, ddb.Status.TLS.Ready, "Cert-manager managed cert should mark ready after condition true") + require.NotEmpty(t, ddb.Status.TLS.SecretName) +} + +func TestEnsureSelfSignedCert(t *testing.T) { + ctx := context.Background() + ddb := baseDocumentDB("ddb-ss", "default") + ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "SelfSigned"}} + ddb.Status.TLS = &dbpreview.TLSStatus{} + r := buildCertificateReconciler(t, ddb) + + // First call should create issuer and certificate + res, err := r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Equal(t, RequeueAfterShort, res.RequeueAfter) + + // Certificate should exist + cert := &cmapi.Certificate{} + require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Name: "ddb-ss-gateway-cert", Namespace: "default"}, cert)) + + // Simulate ready condition and call again + cert.Status.Conditions = append(cert.Status.Conditions, cmapi.CertificateCondition{Type: cmapi.CertificateConditionReady, Status: cmmeta.ConditionTrue, LastTransitionTime: &metav1.Time{Time: time.Now()}}) + require.NoError(t, r.Client.Update(ctx, cert)) + res, err = r.reconcileCertificates(ctx, ddb) + require.NoError(t, err) + require.Zero(t, res.RequeueAfter) + require.True(t, ddb.Status.TLS.Ready) + require.NotEmpty(t, ddb.Status.TLS.SecretName) +} diff --git a/operator/src/internal/controller/documentdb_controller.go b/operator/src/internal/controller/documentdb_controller.go index cb6d8010..90aff359 100644 --- a/operator/src/internal/controller/documentdb_controller.go +++ b/operator/src/internal/controller/documentdb_controller.go @@ -23,10 +23,12 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + "k8s.io/client-go/tools/record" "k8s.io/client-go/tools/remotecommand" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -39,6 +41,12 @@ import ( const ( RequeueAfterShort = 10 * time.Second RequeueAfterLong = 30 * time.Second + + // documentDBFinalizer ensures we can emit PV retention warnings before deletion completes + documentDBFinalizer = "documentdb.io/pv-retention-finalizer" + + // CNPG label used to identify PVCs belonging to a cluster + cnpgClusterLabel = "cnpg.io/cluster" ) // DocumentDBReconciler reconciles a DocumentDB object @@ -47,6 +55,7 @@ type DocumentDBReconciler struct { Scheme *runtime.Scheme Config *rest.Config Clientset kubernetes.Interface + Recorder record.EventRecorder } var reconcileMutex sync.Mutex @@ -54,6 +63,7 @@ var reconcileMutex sync.Mutex // +kubebuilder:rbac:groups=documentdb.io,resources=dbs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=documentdb.io,resources=dbs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=documentdb.io,resources=dbs/finalizers,verbs=update +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { reconcileMutex.Lock() defer reconcileMutex.Unlock() @@ -76,6 +86,22 @@ func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, err } + // Handle deletion with finalizer + if !documentdb.ObjectMeta.DeletionTimestamp.IsZero() { + return r.handleDeletion(ctx, documentdb) + } + + // Ensure finalizer is present for non-deleting resources + if !controllerutil.ContainsFinalizer(documentdb, documentDBFinalizer) { + controllerutil.AddFinalizer(documentdb, documentDBFinalizer) + if err := r.Update(ctx, documentdb); err != nil { + logger.Error(err, "Failed to add finalizer") + return ctrl.Result{}, err + } + logger.Info("Added finalizer to DocumentDB") + return ctrl.Result{Requeue: true}, nil + } + replicationContext, err := util.GetReplicationContext(ctx, r.Client, *documentdb) if err != nil { logger.Error(err, "Failed to determine replication context") @@ -285,6 +311,94 @@ func (r *DocumentDBReconciler) cleanupResources(ctx context.Context, req ctrl.Re return nil } +// handleDeletion processes DocumentDB deletion, emitting warnings about retained PVs if needed +func (r *DocumentDBReconciler) handleDeletion(ctx context.Context, documentdb *dbpreview.DocumentDB) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + if !controllerutil.ContainsFinalizer(documentdb, documentDBFinalizer) { + // Finalizer already removed, nothing to do + return ctrl.Result{}, nil + } + + // Check if PVs will be retained and emit warning + if r.shouldWarnAboutRetainedPVs(documentdb) { + if err := r.emitPVRetentionWarning(ctx, documentdb); err != nil { + // Log but don't block deletion + logger.Error(err, "Failed to emit PV retention warning, continuing with deletion") + } + } + + // Remove finalizer to allow deletion to proceed + controllerutil.RemoveFinalizer(documentdb, documentDBFinalizer) + if err := r.Update(ctx, documentdb); err != nil { + logger.Error(err, "Failed to remove finalizer") + return ctrl.Result{}, err + } + + logger.Info("Removed finalizer, deletion will proceed") + return ctrl.Result{}, nil +} + +// shouldWarnAboutRetainedPVs returns true if the reclaim policy is Retain (explicitly or by default) +func (r *DocumentDBReconciler) shouldWarnAboutRetainedPVs(documentdb *dbpreview.DocumentDB) bool { + policy := documentdb.Spec.Resource.Storage.PersistentVolumeReclaimPolicy + // Default is Retain, so warn unless explicitly set to Delete + return policy == "" || policy == reclaimPolicyRetain +} + +// emitPVRetentionWarning emits a warning event listing PVs that will be retained after deletion +func (r *DocumentDBReconciler) emitPVRetentionWarning(ctx context.Context, documentdb *dbpreview.DocumentDB) error { + logger := log.FromContext(ctx) + + if r.Recorder == nil { + logger.Info("Event recorder not configured, skipping PV retention warning") + return nil + } + + // Find PVs associated with this DocumentDB + pvNames, err := r.findPVsForDocumentDB(ctx, documentdb) + if err != nil { + return fmt.Errorf("failed to find PVs: %w", err) + } + + if len(pvNames) == 0 { + logger.V(1).Info("No PVs found for DocumentDB") + return nil + } + + // Emit actionable warning event + message := fmt.Sprintf( + "PersistentVolumes retained after cluster deletion (policy=Retain). "+ + "To delete when no longer needed: kubectl delete pv %s", + strings.Join(pvNames, " ")) + + r.Recorder.Event(documentdb, corev1.EventTypeWarning, "PVsRetained", message) + logger.Info("Emitted PV retention warning", "pvCount", len(pvNames), "pvNames", pvNames) + + return nil +} + +// findPVsForDocumentDB finds all PV names associated with a DocumentDB cluster using CNPG labels +func (r *DocumentDBReconciler) findPVsForDocumentDB(ctx context.Context, documentdb *dbpreview.DocumentDB) ([]string, error) { + // CNPG cluster name matches DocumentDB name + pvcList := &corev1.PersistentVolumeClaimList{} + if err := r.List(ctx, pvcList, + client.InNamespace(documentdb.Namespace), + client.MatchingLabels{cnpgClusterLabel: documentdb.Name}, + ); err != nil { + return nil, err + } + + pvNames := make([]string, 0, len(pvcList.Items)) + for _, pvc := range pvcList.Items { + if pvc.Status.Phase == corev1.ClaimBound && pvc.Spec.VolumeName != "" { + pvNames = append(pvNames, pvc.Spec.VolumeName) + } + } + + return pvNames, nil +} + func (r *DocumentDBReconciler) EnsureServiceAccountRoleAndRoleBinding(ctx context.Context, documentdb *dbpreview.DocumentDB, namespace string) error { log := log.FromContext(ctx) diff --git a/operator/src/internal/controller/documentdb_controller_test.go b/operator/src/internal/controller/documentdb_controller_test.go index 96620a3a..378eccf2 100644 --- a/operator/src/internal/controller/documentdb_controller_test.go +++ b/operator/src/internal/controller/documentdb_controller_test.go @@ -1,143 +1,612 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + package controller import ( "context" - "testing" - "time" - cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" - cmmeta "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" - "github.com/stretchr/testify/require" + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" + "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" dbpreview "github.com/documentdb/documentdb-operator/api/preview" - util "github.com/documentdb/documentdb-operator/internal/utils" ) -// helper to build TLS reconciler with objects -func buildCertificateReconciler(t *testing.T, objs ...runtime.Object) *CertificateReconciler { - scheme := runtime.NewScheme() - require.NoError(t, dbpreview.AddToScheme(scheme)) - require.NoError(t, cmapi.AddToScheme(scheme)) - require.NoError(t, corev1.AddToScheme(scheme)) - builder := fake.NewClientBuilder().WithScheme(scheme) - if len(objs) > 0 { - builder = builder.WithRuntimeObjects(objs...) - clientObjs := make([]client.Object, 0, len(objs)) - for _, obj := range objs { - if co, ok := obj.(client.Object); ok { - clientObjs = append(clientObjs, co) - } - } - if len(clientObjs) > 0 { - builder = builder.WithStatusSubresource(clientObjs...) - } - } - c := builder.Build() - return &CertificateReconciler{Client: c, Scheme: scheme} -} - -func baseDocumentDB(name, ns string) *dbpreview.DocumentDB { - return &dbpreview.DocumentDB{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, - Spec: dbpreview.DocumentDBSpec{ - NodeCount: 1, - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "1Gi"}}, - DocumentDBImage: "test-image", - ExposeViaService: dbpreview.ExposeViaService{ServiceType: "ClusterIP"}, - }, - } -} - -func TestEnsureProvidedSecret(t *testing.T) { - ctx := context.Background() - ddb := baseDocumentDB("ddb-prov", "default") - ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "Provided", Provided: &dbpreview.ProvidedTLS{SecretName: "mycert"}}} - // Secret missing first - r := buildCertificateReconciler(t, ddb) - res, err := r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Equal(t, RequeueAfterShort, res.RequeueAfter) - require.False(t, ddb.Status.TLS.Ready, "Should not be ready until secret exists") - - // Create secret with required keys then reconcile again - secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "mycert", Namespace: "default"}, Data: map[string][]byte{"tls.crt": []byte("crt"), "tls.key": []byte("key")}} - require.NoError(t, r.Client.Create(ctx, secret)) - res, err = r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Zero(t, res.RequeueAfter) - require.True(t, ddb.Status.TLS.Ready, "Provided secret should mark TLS ready") - require.Equal(t, "mycert", ddb.Status.TLS.SecretName) -} - -func TestEnsureCertManagerManagedCert(t *testing.T) { - ctx := context.Background() - ddb := baseDocumentDB("ddb-cm", "default") - ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "CertManager", CertManager: &dbpreview.CertManagerTLS{IssuerRef: dbpreview.IssuerRef{Name: "test-issuer", Kind: "Issuer"}, DNSNames: []string{"custom.example"}}}} - ddb.Status.TLS = &dbpreview.TLSStatus{} - issuer := &cmapi.Issuer{ObjectMeta: metav1.ObjectMeta{Name: "test-issuer", Namespace: "default"}, Spec: cmapi.IssuerSpec{IssuerConfig: cmapi.IssuerConfig{SelfSigned: &cmapi.SelfSignedIssuer{}}}} - r := buildCertificateReconciler(t, ddb, issuer) - - // Call certificate ensure twice to mimic reconcile loops - res, err := r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Equal(t, RequeueAfterShort, res.RequeueAfter) - res, err = r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Equal(t, RequeueAfterShort, res.RequeueAfter) - - cert := &cmapi.Certificate{} - // fetch certificate (self-created by reconcile). If not found, run reconcile again once. - require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Name: "ddb-cm-gateway-cert", Namespace: "default"}, cert)) - // Debug: list all certificates to ensure store functioning - certList := &cmapi.CertificateList{} - _ = r.Client.List(ctx, certList) - for _, c := range certList.Items { - t.Logf("Found certificate: %s/%s secret=%s", c.Namespace, c.Name, c.Spec.SecretName) - } - require.Contains(t, cert.Spec.DNSNames, "custom.example") - // Should include service DNS names - serviceBase := util.DOCUMENTDB_SERVICE_PREFIX + ddb.Name - require.Contains(t, cert.Spec.DNSNames, serviceBase) - - // Simulate readiness condition then invoke ensure again (mimic reconcile loop) - cert.Status.Conditions = append(cert.Status.Conditions, cmapi.CertificateCondition{Type: cmapi.CertificateConditionReady, Status: cmmeta.ConditionTrue, LastTransitionTime: &metav1.Time{Time: time.Now()}}) - require.NoError(t, r.Client.Update(ctx, cert)) - res, err = r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Zero(t, res.RequeueAfter) - require.True(t, ddb.Status.TLS.Ready, "Cert-manager managed cert should mark ready after condition true") - require.NotEmpty(t, ddb.Status.TLS.SecretName) -} - -func TestEnsureSelfSignedCert(t *testing.T) { - ctx := context.Background() - ddb := baseDocumentDB("ddb-ss", "default") - ddb.Spec.TLS = &dbpreview.TLSConfiguration{Gateway: &dbpreview.GatewayTLS{Mode: "SelfSigned"}} - ddb.Status.TLS = &dbpreview.TLSStatus{} - r := buildCertificateReconciler(t, ddb) - - // First call should create issuer and certificate - res, err := r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Equal(t, RequeueAfterShort, res.RequeueAfter) - - // Certificate should exist - cert := &cmapi.Certificate{} - require.NoError(t, r.Client.Get(ctx, types.NamespacedName{Name: "ddb-ss-gateway-cert", Namespace: "default"}, cert)) - - // Simulate ready condition and call again - cert.Status.Conditions = append(cert.Status.Conditions, cmapi.CertificateCondition{Type: cmapi.CertificateConditionReady, Status: cmmeta.ConditionTrue, LastTransitionTime: &metav1.Time{Time: time.Now()}}) - require.NoError(t, r.Client.Update(ctx, cert)) - res, err = r.reconcileCertificates(ctx, ddb) - require.NoError(t, err) - require.Zero(t, res.RequeueAfter) - require.True(t, ddb.Status.TLS.Ready) - require.NotEmpty(t, ddb.Status.TLS.SecretName) -} +var _ = Describe("DocumentDB Controller", func() { + const ( + documentDBName = "test-documentdb" + documentDBNamespace = "default" + ) + + var ( + ctx context.Context + scheme *runtime.Scheme + recorder *record.FakeRecorder + ) + + BeforeEach(func() { + ctx = context.Background() + scheme = runtime.NewScheme() + recorder = record.NewFakeRecorder(10) + Expect(dbpreview.AddToScheme(scheme)).To(Succeed()) + Expect(cnpgv1.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + }) + + Describe("shouldWarnAboutRetainedPVs", func() { + var reconciler *DocumentDBReconciler + + BeforeEach(func() { + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + reconciler = &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + }) + + It("returns true when policy is empty (default Retain)", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PersistentVolumeReclaimPolicy: "", // empty = default Retain + }, + }, + }, + } + Expect(reconciler.shouldWarnAboutRetainedPVs(documentdb)).To(BeTrue()) + }) + + It("returns true when policy is explicitly Retain", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + Expect(reconciler.shouldWarnAboutRetainedPVs(documentdb)).To(BeTrue()) + }) + + It("returns false when policy is Delete", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PersistentVolumeReclaimPolicy: "Delete", + }, + }, + }, + } + Expect(reconciler.shouldWarnAboutRetainedPVs(documentdb)).To(BeFalse()) + }) + }) + + Describe("findPVsForDocumentDB", func() { + It("returns PV names for bound PVCs with matching cluster label", func() { + pvc1 := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName + "-1", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: documentDBName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-abc123", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + } + pvc2 := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName + "-2", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: documentDBName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-def456", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc1, pvc2). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + } + + pvNames, err := reconciler.findPVsForDocumentDB(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + Expect(pvNames).To(HaveLen(2)) + Expect(pvNames).To(ContainElements("pv-abc123", "pv-def456")) + }) + + It("excludes PVCs that are not bound", func() { + boundPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName + "-bound", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: documentDBName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-bound", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + } + pendingPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName + "-pending", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: documentDBName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-pending", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimPending, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(boundPVC, pendingPVC). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + } + + pvNames, err := reconciler.findPVsForDocumentDB(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + Expect(pvNames).To(HaveLen(1)) + Expect(pvNames).To(ContainElement("pv-bound")) + }) + + It("excludes PVCs from different clusters", func() { + matchingPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName + "-match", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: documentDBName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-match", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + } + otherClusterPVC := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-cluster-pvc", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: "other-cluster", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-other", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(matchingPVC, otherClusterPVC). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + } + + pvNames, err := reconciler.findPVsForDocumentDB(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + Expect(pvNames).To(HaveLen(1)) + Expect(pvNames).To(ContainElement("pv-match")) + }) + + It("returns empty slice when no PVCs exist", func() { + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + } + + pvNames, err := reconciler.findPVsForDocumentDB(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + Expect(pvNames).To(BeEmpty()) + }) + }) + + Describe("emitPVRetentionWarning", func() { + It("emits warning event with PV names when PVCs exist", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName + "-1", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: documentDBName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-test123", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pvc). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + } + + err := reconciler.emitPVRetentionWarning(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + + // Check that an event was recorded + Eventually(recorder.Events).Should(Receive(ContainSubstring("PVsRetained"))) + }) + + It("does not emit event when no PVCs exist", func() { + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + } + + err := reconciler.emitPVRetentionWarning(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + + // No event should be recorded + Consistently(recorder.Events).ShouldNot(Receive()) + }) + + It("does not panic when Recorder is nil", func() { + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: nil, // No recorder + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + } + + err := reconciler.emitPVRetentionWarning(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("handleDeletion", func() { + It("removes finalizer and allows deletion when finalizer is present", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + Finalizers: []string{documentDBFinalizer}, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PersistentVolumeReclaimPolicy: "Delete", // No warning should be emitted + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + // Call handleDeletion - it checks for finalizer, not DeletionTimestamp + result, err := reconciler.handleDeletion(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Requeue).To(BeFalse()) + + // Verify finalizer was removed + updated := &dbpreview.DocumentDB{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: documentDBName, Namespace: documentDBNamespace}, updated)).To(Succeed()) + Expect(controllerutil.ContainsFinalizer(updated, documentDBFinalizer)).To(BeFalse()) + }) + + It("emits warning event when policy is Retain and PVCs exist", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName + "-1", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: documentDBName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-retained", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + Finalizers: []string{documentDBFinalizer}, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb, pvc). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + result, err := reconciler.handleDeletion(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Requeue).To(BeFalse()) + + // Verify warning event was emitted + Eventually(recorder.Events).Should(Receive(ContainSubstring("pv-retained"))) + + // Verify finalizer was removed + updated := &dbpreview.DocumentDB{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: documentDBName, Namespace: documentDBNamespace}, updated)).To(Succeed()) + Expect(controllerutil.ContainsFinalizer(updated, documentDBFinalizer)).To(BeFalse()) + }) + + It("returns without action when finalizer is not present", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + Finalizers: []string{}, // No finalizer + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb). + Build() + + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: recorder, + } + + result, err := reconciler.handleDeletion(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Requeue).To(BeFalse()) + + // Verify object still exists (no Update was called) + existing := &dbpreview.DocumentDB{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: documentDBName, Namespace: documentDBNamespace}, existing)).To(Succeed()) + }) + + It("does not emit warning when policy is Delete", func() { + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName + "-1", + Namespace: documentDBNamespace, + Labels: map[string]string{ + cnpgClusterLabel: documentDBName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "pv-will-be-deleted", + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: corev1.ClaimBound, + }, + } + + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + Finalizers: []string{documentDBFinalizer}, + }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + PersistentVolumeReclaimPolicy: "Delete", + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb, pvc). + Build() + + // Create a new recorder to ensure it's empty + localRecorder := record.NewFakeRecorder(10) + reconciler := &DocumentDBReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: localRecorder, + } + + result, err := reconciler.handleDeletion(ctx, documentdb) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Requeue).To(BeFalse()) + + // Verify NO warning event was emitted (policy is Delete) + Consistently(localRecorder.Events).ShouldNot(Receive()) + }) + }) + + Describe("Finalizer management in Reconcile", func() { + It("adds finalizer to new DocumentDB resource", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentDBName, + Namespace: documentDBNamespace, + }, + Spec: dbpreview.DocumentDBSpec{ + NodeCount: 1, + InstancesPerNode: 1, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PvcSize: "10Gi", + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb). + Build() + + // Verify resource starts without finalizer + Expect(controllerutil.ContainsFinalizer(documentdb, documentDBFinalizer)).To(BeFalse()) + + // Add finalizer like the controller does + controllerutil.AddFinalizer(documentdb, documentDBFinalizer) + Expect(fakeClient.Update(ctx, documentdb)).To(Succeed()) + + // Verify finalizer was added + updated := &dbpreview.DocumentDB{} + Expect(fakeClient.Get(ctx, types.NamespacedName{Name: documentDBName, Namespace: documentDBNamespace}, updated)).To(Succeed()) + Expect(controllerutil.ContainsFinalizer(updated, documentDBFinalizer)).To(BeTrue()) + }) + }) +}) From 2794f879a6f1069107afdec3a278eed162a5b7af Mon Sep 17 00:00:00 2001 From: wenting Date: Mon, 2 Feb 2026 14:46:12 -0500 Subject: [PATCH 19/19] Improve PV controller based on PR feedback - Optimize findPVsForDocumentDB with CNPG cluster label selector - Use GitHub Actions outputs instead of temp files in workflow - Add error scenario tests for PV update and API failures - Improve CEL validation for RecoveryConfiguration - Clarify PV deletion chain in documentation - Add compatibility notes for security mount options Signed-off-by: wenting --- .github/workflows/test-backup-and-restore.yml | 7 +- .../crds/documentdb.io_dbs.yaml | 21 +- operator/src/api/preview/documentdb_types.go | 7 +- .../config/crd/bases/documentdb.io_dbs.yaml | 21 +- .../src/internal/controller/pv_controller.go | 55 ++-- .../internal/controller/pv_controller_test.go | 291 +++++++++++++++++- 6 files changed, 350 insertions(+), 52 deletions(-) diff --git a/.github/workflows/test-backup-and-restore.yml b/.github/workflows/test-backup-and-restore.yml index bf9f3825..60058a14 100644 --- a/.github/workflows/test-backup-and-restore.yml +++ b/.github/workflows/test-backup-and-restore.yml @@ -357,6 +357,7 @@ jobs: exit 1 - name: Test PV retention after DocumentDB deletion + id: test-pv-retention shell: bash run: | echo "Testing PV retention after DocumentDB deletion..." @@ -419,13 +420,13 @@ jobs: exit 1 fi - # Store PV name for later steps - echo "$pv_name" > /tmp/retained_pv_name + # Store PV name for later steps using GitHub Actions output (more robust than temp files) + echo "pv_name=$pv_name" >> $GITHUB_OUTPUT - name: Restore DocumentDB from retained PV shell: bash run: | - pv_name=$(cat /tmp/retained_pv_name) + pv_name="${{ steps.test-pv-retention.outputs.pv_name }}" echo "Restoring DocumentDB from retained PV: $pv_name" # Check the PV status - it should be in "Released" state after PVC deletion diff --git a/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml b/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml index b966bd7d..2f575552 100644 --- a/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml +++ b/operator/documentdb-helm-chart/crds/documentdb.io_dbs.yaml @@ -94,8 +94,8 @@ spec: x-kubernetes-validations: - message: cannot specify both backup and pvc recovery at the same time - rule: '!(has(self.backup) && size(self.backup.name) > 0 && has(self.pvc) - && size(self.pvc.name) > 0)' + rule: '!(has(self.backup) && self.backup.name != '''' && has(self.pvc) + && self.pvc.name != '''')' type: object clusterReplication: description: ClusterReplication configures cross-cluster replication @@ -215,12 +215,23 @@ spec: description: Storage configuration for DocumentDB persistent volumes. properties: persistentVolumeReclaimPolicy: - default: Delete + default: Retain description: |- PersistentVolumeReclaimPolicy controls what happens to the PersistentVolume when the DocumentDB cluster is deleted. - Retain: The PV is kept after cluster deletion, allowing data recovery. - Delete: The PV is deleted with the cluster (default behavior). + + When a DocumentDB cluster is deleted, the following chain of deletions occurs: + DocumentDB deletion → CNPG Cluster deletion → PVC deletion → PV deletion (based on this policy) + + Options: + - Retain (default): The PV is preserved after cluster deletion, allowing manual + data recovery or forensic analysis. Use for production workloads where data + safety is critical. Orphaned PVs must be manually deleted when no longer needed. + - Delete: The PV is automatically deleted when the PVC is deleted. Use for development, + testing, or ephemeral environments where data persistence is not required. + + WARNING: Setting this to "Delete" means all data will be permanently lost when + the DocumentDB cluster is deleted. This cannot be undone. enum: - Retain - Delete diff --git a/operator/src/api/preview/documentdb_types.go b/operator/src/api/preview/documentdb_types.go index bd45bea5..d87001b8 100644 --- a/operator/src/api/preview/documentdb_types.go +++ b/operator/src/api/preview/documentdb_types.go @@ -86,7 +86,7 @@ type BootstrapConfiguration struct { } // RecoveryConfiguration defines backup recovery settings. -// +kubebuilder:validation:XValidation:rule="!(has(self.backup) && size(self.backup.name) > 0 && has(self.pvc) && size(self.pvc.name) > 0)",message="cannot specify both backup and pvc recovery at the same time" +// +kubebuilder:validation:XValidation:rule="!(has(self.backup) && self.backup.name != ” && has(self.pvc) && self.pvc.name != ”)",message="cannot specify both backup and pvc recovery at the same time" type RecoveryConfiguration struct { // Backup specifies the source backup to restore from. // +optional @@ -125,11 +125,14 @@ type StorageConfiguration struct { // PersistentVolumeReclaimPolicy controls what happens to the PersistentVolume when // the DocumentDB cluster is deleted. // + // When a DocumentDB cluster is deleted, the following chain of deletions occurs: + // DocumentDB deletion → CNPG Cluster deletion → PVC deletion → PV deletion (based on this policy) + // // Options: // - Retain (default): The PV is preserved after cluster deletion, allowing manual // data recovery or forensic analysis. Use for production workloads where data // safety is critical. Orphaned PVs must be manually deleted when no longer needed. - // - Delete: The PV is automatically deleted with the cluster. Use for development, + // - Delete: The PV is automatically deleted when the PVC is deleted. Use for development, // testing, or ephemeral environments where data persistence is not required. // // WARNING: Setting this to "Delete" means all data will be permanently lost when diff --git a/operator/src/config/crd/bases/documentdb.io_dbs.yaml b/operator/src/config/crd/bases/documentdb.io_dbs.yaml index b966bd7d..2f575552 100644 --- a/operator/src/config/crd/bases/documentdb.io_dbs.yaml +++ b/operator/src/config/crd/bases/documentdb.io_dbs.yaml @@ -94,8 +94,8 @@ spec: x-kubernetes-validations: - message: cannot specify both backup and pvc recovery at the same time - rule: '!(has(self.backup) && size(self.backup.name) > 0 && has(self.pvc) - && size(self.pvc.name) > 0)' + rule: '!(has(self.backup) && self.backup.name != '''' && has(self.pvc) + && self.pvc.name != '''')' type: object clusterReplication: description: ClusterReplication configures cross-cluster replication @@ -215,12 +215,23 @@ spec: description: Storage configuration for DocumentDB persistent volumes. properties: persistentVolumeReclaimPolicy: - default: Delete + default: Retain description: |- PersistentVolumeReclaimPolicy controls what happens to the PersistentVolume when the DocumentDB cluster is deleted. - Retain: The PV is kept after cluster deletion, allowing data recovery. - Delete: The PV is deleted with the cluster (default behavior). + + When a DocumentDB cluster is deleted, the following chain of deletions occurs: + DocumentDB deletion → CNPG Cluster deletion → PVC deletion → PV deletion (based on this policy) + + Options: + - Retain (default): The PV is preserved after cluster deletion, allowing manual + data recovery or forensic analysis. Use for production workloads where data + safety is critical. Orphaned PVs must be manually deleted when no longer needed. + - Delete: The PV is automatically deleted when the PVC is deleted. Use for development, + testing, or ephemeral environments where data persistence is not required. + + WARNING: Setting this to "Delete" means all data will be permanently lost when + the DocumentDB cluster is deleted. This cannot be undone. enum: - Retain - Delete diff --git a/operator/src/internal/controller/pv_controller.go b/operator/src/internal/controller/pv_controller.go index f82afad0..2b70a88f 100644 --- a/operator/src/internal/controller/pv_controller.go +++ b/operator/src/internal/controller/pv_controller.go @@ -47,6 +47,12 @@ const ( // - nodev: Prevents device files from being interpreted on the filesystem // - nosuid: Prevents setuid/setgid bits from taking effect // - noexec: Prevents execution of binaries on the filesystem +// +// NOTE: These mount options are compatible with most CSI drivers and storage providers. +// However, some storage classes may not support these options, which could cause PV +// binding issues or pod startup failures. If you encounter issues with PV binding after +// deploying the operator, verify your storage provider supports these mount options. +// Common compatible providers: Azure Disk, AWS EBS, GCE PD, most NFS implementations. var securityMountOptions = []string{"nodev", "noexec", "nosuid"} // PersistentVolumeReconciler reconciles PersistentVolume objects @@ -361,7 +367,8 @@ func documentDBReclaimPolicyPredicate() predicate.Predicate { } } -// findPVsForDocumentDB finds all PVs associated with a DocumentDB and returns reconcile requests for them +// findPVsForDocumentDB finds all PVs associated with a DocumentDB and returns reconcile requests for them. +// Uses CNPG's cluster label (cnpg.io/cluster) for efficient PVC filtering instead of listing all resources. func (r *PersistentVolumeReconciler) findPVsForDocumentDB(ctx context.Context, obj client.Object) []reconcile.Request { logger := log.FromContext(ctx) documentdb, ok := obj.(*dbpreview.DocumentDB) @@ -369,40 +376,26 @@ func (r *PersistentVolumeReconciler) findPVsForDocumentDB(ctx context.Context, o return nil } - // Find CNPG Cluster owned by this DocumentDB - clusterList := &cnpgv1.ClusterList{} - if err := r.List(ctx, clusterList, client.InNamespace(documentdb.Namespace)); err != nil { - logger.Error(err, "Failed to list CNPG Clusters") + // Use CNPG's cluster label to efficiently find PVCs belonging to this DocumentDB. + // CNPG automatically labels PVCs with "cnpg.io/cluster" set to the cluster name, + // and CNPG cluster name matches DocumentDB name by convention. + pvcList := &corev1.PersistentVolumeClaimList{} + if err := r.List(ctx, pvcList, + client.InNamespace(documentdb.Namespace), + client.MatchingLabels{cnpgClusterLabel: documentdb.Name}, + ); err != nil { + logger.Error(err, "Failed to list PVCs for DocumentDB") return nil } var requests []reconcile.Request - - for _, cluster := range clusterList.Items { - if !isOwnedByDocumentDB(&cluster, documentdb.Name) { - continue - } - - // Find PVCs owned by this cluster - pvcList := &corev1.PersistentVolumeClaimList{} - if err := r.List(ctx, pvcList, client.InNamespace(cluster.Namespace)); err != nil { - logger.Error(err, "Failed to list PVCs") - continue - } - - for _, pvc := range pvcList.Items { - for _, ownerRef := range pvc.OwnerReferences { - if isCNPGClusterOwnerRef(ownerRef) && ownerRef.Name == cluster.Name { - if pvc.Spec.VolumeName != "" { - requests = append(requests, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: pvc.Spec.VolumeName, - }, - }) - } - break - } - } + for _, pvc := range pvcList.Items { + if pvc.Spec.VolumeName != "" { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: pvc.Spec.VolumeName, + }, + }) } } diff --git a/operator/src/internal/controller/pv_controller_test.go b/operator/src/internal/controller/pv_controller_test.go index a034ebae..7f346314 100644 --- a/operator/src/internal/controller/pv_controller_test.go +++ b/operator/src/internal/controller/pv_controller_test.go @@ -5,6 +5,7 @@ package controller import ( "context" + "fmt" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" . "github.com/onsi/ginkgo/v2" @@ -14,7 +15,9 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -479,16 +482,46 @@ var _ = Describe("PersistentVolume Controller", func() { Expect(err).ToNot(HaveOccurred()) Expect(result).To(Equal(ctrl.Result{})) }) - }) - Describe("findPVsForDocumentDB", func() { - It("returns reconcile requests for PVs associated with DocumentDB", func() { + It("returns error when Get PV fails with non-NotFound error", func() { + expectedErr := fmt.Errorf("api server unavailable") + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.PersistentVolume); ok { + return expectedErr + } + return client.Get(ctx, key, obj, opts...) + }, + }). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: pvName}, + }) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("api server unavailable")) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("returns error when PV update fails", func() { documentdb := &dbpreview.DocumentDB{ ObjectMeta: metav1.ObjectMeta{ Name: documentdbName, Namespace: testNamespace, UID: "documentdb-uid", }, + Spec: dbpreview.DocumentDBSpec{ + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{ + PersistentVolumeReclaimPolicy: "Retain", + }, + }, + }, } trueVal := true @@ -528,9 +561,107 @@ var _ = Describe("PersistentVolume Controller", func() { }, } + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + ClaimRef: &corev1.ObjectReference{ + Name: pvcName, + Namespace: testNamespace, + }, + }, + } + + expectedErr := fmt.Errorf("update conflict") fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(documentdb, cluster, pvc). + WithObjects(documentdb, cluster, pvc, pv). + WithInterceptorFuncs(interceptor.Funcs{ + Update: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.UpdateOption) error { + if _, ok := obj.(*corev1.PersistentVolume); ok { + return expectedErr + } + return client.Update(ctx, obj, opts...) + }, + }). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: pvName}, + }) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("update conflict")) + Expect(result).To(Equal(ctrl.Result{})) + }) + + It("returns error when findDocumentDBForPV fails due to PVC Get error", func() { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: pvName}, + Spec: corev1.PersistentVolumeSpec{ + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete, + ClaimRef: &corev1.ObjectReference{ + Name: pvcName, + Namespace: testNamespace, + }, + }, + } + + expectedErr := fmt.Errorf("permission denied") + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(pv). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.PersistentVolumeClaim); ok { + return expectedErr + } + return client.Get(ctx, key, obj, opts...) + }, + }). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: pvName}, + }) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("permission denied")) + Expect(result).To(Equal(ctrl.Result{})) + }) + }) + + Describe("findPVsForDocumentDB", func() { + It("returns reconcile requests for PVs associated with DocumentDB using CNPG cluster label", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbName, + Namespace: testNamespace, + UID: "documentdb-uid", + }, + } + + // PVC with CNPG cluster label (this is how CNPG labels its PVCs) + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + Labels: map[string]string{ + "cnpg.io/cluster": documentdbName, // CNPG cluster name matches DocumentDB name + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: pvName, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb, pvc). Build() reconciler := &PersistentVolumeReconciler{Client: fakeClient} @@ -540,7 +671,7 @@ var _ = Describe("PersistentVolume Controller", func() { Expect(requests[0].Name).To(Equal(pvName)) }) - It("returns empty when DocumentDB has no associated clusters", func() { + It("returns empty when no PVCs have matching CNPG cluster label", func() { documentdb := &dbpreview.DocumentDB{ ObjectMeta: metav1.ObjectMeta{ Name: documentdbName, @@ -548,9 +679,23 @@ var _ = Describe("PersistentVolume Controller", func() { }, } + // PVC without CNPG cluster label or with different label value + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + Labels: map[string]string{ + "cnpg.io/cluster": "different-cluster", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: pvName, + }, + } + fakeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(documentdb). + WithObjects(documentdb, pvc). Build() reconciler := &PersistentVolumeReconciler{Client: fakeClient} @@ -576,6 +721,66 @@ var _ = Describe("PersistentVolume Controller", func() { requests := reconciler.findPVsForDocumentDB(ctx, pvc) Expect(requests).To(BeNil()) }) + + It("returns nil when listing PVCs fails", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbName, + Namespace: testNamespace, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(ctx context.Context, client client.WithWatch, list client.ObjectList, opts ...client.ListOption) error { + if _, ok := list.(*corev1.PersistentVolumeClaimList); ok { + return fmt.Errorf("pvc list error") + } + return client.List(ctx, list, opts...) + }, + }). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + requests := reconciler.findPVsForDocumentDB(ctx, documentdb) + Expect(requests).To(BeNil()) + }) + + It("skips PVCs without volume name", func() { + documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: documentdbName, + Namespace: testNamespace, + }, + } + + // PVC with CNPG cluster label but no volume name yet (not bound) + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + Labels: map[string]string{ + "cnpg.io/cluster": documentdbName, + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + VolumeName: "", // Not yet bound + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(documentdb, pvc). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + requests := reconciler.findPVsForDocumentDB(ctx, documentdb) + Expect(requests).To(BeEmpty()) + }) }) Describe("pvPredicate", func() { @@ -980,6 +1185,43 @@ var _ = Describe("PersistentVolume Controller", func() { Expect(result).ToNot(BeNil()) Expect(result.Name).To(Equal(clusterName)) }) + + It("returns nil and continues when Get CNPG Cluster fails with non-NotFound error", func() { + trueVal := true + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "postgresql.cnpg.io/v1", + Kind: "Cluster", + Name: clusterName, + UID: "cluster-uid", + Controller: &trueVal, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*cnpgv1.Cluster); ok { + return fmt.Errorf("api timeout") + } + return client.Get(ctx, key, obj, opts...) + }, + }). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + // Should return nil and continue (error is logged but not returned) + result := reconciler.findCNPGClusterOwner(ctx, pvc) + Expect(result).To(BeNil()) + }) }) Describe("findDocumentDBOwner", func() { @@ -1063,5 +1305,42 @@ var _ = Describe("PersistentVolume Controller", func() { Expect(result).ToNot(BeNil()) Expect(result.Name).To(Equal(documentdbName)) }) + + It("returns nil and continues when Get DocumentDB fails with non-NotFound error", func() { + trueVal := true + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: testNamespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "documentdb.io/v1", + Kind: "DocumentDB", + Name: documentdbName, + UID: "documentdb-uid", + Controller: &trueVal, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*dbpreview.DocumentDB); ok { + return fmt.Errorf("api timeout") + } + return client.Get(ctx, key, obj, opts...) + }, + }). + Build() + + reconciler := &PersistentVolumeReconciler{Client: fakeClient} + + // Should return nil and continue (error is logged but not returned) + result := reconciler.findDocumentDBOwner(ctx, cluster) + Expect(result).To(BeNil()) + }) }) })