Repository: apecloud/kubeblocks

Issue:
[BUG] After deleting the restored mongodb cluster, its PVC still exists
**Describe the bug**
```
Kubernetes: v1.31.1-aliyun.1
KubeBlocks: 1.0.0-beta.32
kbcli: 1.0.0-beta.15

```
**To Reproduce**
Steps to reproduce the behavior:
1. Create mongodb cluster with yaml below
```
apiVersion: apps.kubeblocks.io/v1
kind: Cluster
metadata:
  name: mongo-reprod
  namespace: default
spec:
  clusterDef: mongodb
  topology: replicaset
  terminationPolicy: Delete
  componentSpecs:
    - name: mongodb
      serviceVersion: 8.0.4
      replicas: 3
      resources:
        limits:
          cpu: 100m
          memory: 0.5Gi
        requests:
          cpu: 100m
          memory: 0.5Gi
      volumeClaimTemplates:
        - name: data
          spec:
            accessModes:
              - ReadWriteOnce
            resources:
              requests:
                storage: 20Gi
```
2. Check pvc
```
k get pvc | grep mongo-reprod
data-mongo-reprod-mongodb-0             Bound    d-8vbdur4g4r8b4e65rjxn   20Gi       RWO            kb-default-sc   <unset>                 4m35s
data-mongo-reprod-mongodb-1             Bound    d-8vb7yunlpf3zy6ozjqr0   20Gi       RWO            kb-default-sc   <unset>                 4m16s
data-mongo-reprod-mongodb-2             Bound    d-8vbbywseff12jug24sbj   20Gi       RWO            kb-default-sc   <unset>                 3m52s

k get pvc data-mongo-reprod-mongodb-0 -oyaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  annotations:
    pv.kubernetes.io/bind-completed: "yes"
    pv.kubernetes.io/bound-by-controller: "yes"
    volume.beta.kubernetes.io/storage-provisioner: diskplugin.csi.alibabacloud.com
    volume.kubernetes.io/selected-node: cn-zhangjiakou.10.0.0.144
    volume.kubernetes.io/storage-provisioner: diskplugin.csi.alibabacloud.com
  creationTimestamp: "2025-03-07T05:57:37Z"
  finalizers:
  - kubernetes.io/pvc-protection
  labels:
    app.kubernetes.io/instance: mongo-reprod
    app.kubernetes.io/managed-by: kubeblocks
    apps.kubeblocks.io/component-name: mongodb
    apps.kubeblocks.io/pod-name: mongo-reprod-mongodb-0
    apps.kubeblocks.io/vct-name: data
    workloads.kubeblocks.io/instance: mongo-reprod-mongodb
    workloads.kubeblocks.io/managed-by: InstanceSet
  name: data-mongo-reprod-mongodb-0
  namespace: default
  resourceVersion: "68207933"
  uid: 5cea2652-f047-425c-b580-3f21b3e1861a
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
  storageClassName: kb-default-sc
  volumeMode: Filesystem
  volumeName: d-8vbdur4g4r8b4e65rjxn
status:
  accessModes:
  - ReadWriteOnce
  capacity:
    storage: 20Gi
  phase: Bound
```

3. backup and restore
```
kbcli cluster backup mongo-reprod --method volume-snapshot --namespace default 
Backup backup-default-mongo-reprod-20250307140347 created successfully, you can view the progress:
	kbcli cluster list-backups --name=backup-default-mongo-reprod-20250307140347 -n default
tianyue@apeclouds-MacBook-Pro kubeblocks-addons % kbcli cluster list-backups mongo-reprod -n default
NAME                                         NAMESPACE   SOURCE-CLUSTER   METHOD            STATUS      TOTAL-SIZE   DURATION   DELETION-POLICY   CREATE-TIME                  COMPLETION-TIME              EXPIRATION   
backup-default-mongo-reprod-20250307140347   default     mongo-reprod     volume-snapshot   Completed   20Gi         12s        Delete            Mar 07,2025 14:03 UTC+0800   Mar 07,2025 14:03 UTC+0800                

kbcli cluster restore mongo-reprod-backup --backup backup-default-mongo-reprod-20250307140347 --namespace default
Cluster mongo-reprod-backup created

k get cluster | grep mongo-reprod
mongo-reprod          mongodb              Delete               Running    12m
mongo-reprod-backup   mongodb              Delete               Running    4m6s
```
4. check pvc of the restored cluster
 ```
k get pvc | grep mongo-reprod
data-mongo-reprod-backup-mongodb-0      Bound    d-8vb1bhiygfo0bigpcgm6   20Gi       RWO            kb-default-sc   <unset>                 4m32s
data-mongo-reprod-backup-mongodb-1      Bound    d-8vbbywseff12jye4cwfz   20Gi       RWO            kb-default-sc   <unset>                 4m31s
data-mongo-reprod-backup-mongodb-2      Bound    d-8vbar8k98n87bept7x0t   20Gi       RWO            kb-default-sc   <unset>                 4m31s
data-mongo-reprod-mongodb-0             Bound    d-8vbdur4g4r8b4e65rjxn   20Gi       RWO            kb-default-sc   <unset>                 12m
data-mongo-reprod-mongodb-1             Bound    d-8vb7yunlpf3zy6ozjqr0   20Gi       RWO            kb-default-sc   <unset>                 12m
data-mongo-reprod-mongodb-2             Bound    d-8vbbywseff12jug24sbj   20Gi       RWO            kb-default-sc   <unset>                 11m

k get pvc data-mongo-reprod-backup-mongodb-0 -oyaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  annotations:
    pv.kubernetes.io/bind-completed: "yes"
    pv.kubernetes.io/bound-by-controller: "yes"
    volume.beta.kubernetes.io/storage-provisioner: diskplugin.csi.alibabacloud.com
    volume.kubernetes.io/selected-node: cn-zhangjiakou.10.0.0.144
    volume.kubernetes.io/storage-provisioner: diskplugin.csi.alibabacloud.com
  creationTimestamp: "2025-03-07T06:05:46Z"
  finalizers:
  - kubernetes.io/pvc-protection
  labels:
    app.kubernetes.io/instance: mongo-reprod-backup
    app.kubernetes.io/managed-by: kubeblocks
    apps.kubeblocks.io/component-name: mongodb
    apps.kubeblocks.io/vct-name: data
  name: data-mongo-reprod-backup-mongodb-0
  namespace: default
  resourceVersion: "68212019"
  uid: ceb3780a-92fa-4ebd-b112-016c0e6c65cd
spec:
  accessModes:
  - ReadWriteOnce
  dataSource:
    apiGroup: snapshot.storage.k8s.io
    kind: VolumeSnapshot
    name: backup-default-mongo-reprod-20250307140347-0-data
  dataSourceRef:
    apiGroup: snapshot.storage.k8s.io
    kind: VolumeSnapshot
    name: backup-default-mongo-reprod-20250307140347-0-data
  resources:
    requests:
      storage: 20Gi
  storageClassName: kb-default-sc
  volumeMode: Filesystem
  volumeName: d-8vb1bhiygfo0bigpcgm6
status:
  accessModes:
  - ReadWriteOnce
  capacity:
    storage: 20Gi
  phase: Bound
```
5. After deleting the restored cluster, its PVC still exists. However, if the original cluster is deleted, the PVC will be deleted as well.

```
kbcli cluster delete mongo-reprod-backup --auto-approve --namespace default
Cluster mongo-reprod-backup deleted

k get pvc | grep mongo-reprod
data-mongo-reprod-backup-mongodb-0      Bound    d-8vb1bhiygfo0bigpcgm6   20Gi       RWO            kb-default-sc   <unset>                 10m
data-mongo-reprod-backup-mongodb-1      Bound    d-8vbbywseff12jye4cwfz   20Gi       RWO            kb-default-sc   <unset>                 10m
data-mongo-reprod-backup-mongodb-2      Bound    d-8vbar8k98n87bept7x0t   20Gi       RWO            kb-default-sc   <unset>                 10m
```


**Expected behavior**A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
 - OS: [e.g. iOS]
 - Browser [e.g. chrome, safari]
 - Version [e.g. 22]

**Additional context**
Add any other context about the problem here.


Discussion:
@shanshanying: reproduce the same bug for MySQL .

Compared PVC labels,

<img width="642" alt="Image" src="https://github.com/user-attachments/assets/e80dd5f9-6603-4ea9-b917-ab0acbcd6e10" />

Noticed that the PVC restored, missing labels such as
- pod-name
- instance
- managed-by

Solution patch:
diff --git a/controllers/apps/cluster/transformer_cluster_component.go b/controllers/apps/cluster/transformer_cluster_component.go
index 41fa68a86..4e23d8c75 100644
--- a/controllers/apps/cluster/transformer_cluster_component.go
+++ b/controllers/apps/cluster/transformer_cluster_component.go
@@ -30,6 +30,7 @@ import (
 	"golang.org/x/exp/maps"
 	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/sets"
@@ -185,11 +186,51 @@ func copyAndMergeComponent(oldCompObj, newCompObj *appsv1.Component) *appsv1.Com
 	compObjCopy := oldCompObj.DeepCopy()
 	compProto := newCompObj
 
-	// merge labels and annotations
+	normalizeQuantity := func(name corev1.ResourceName, q resource.Quantity) resource.Quantity {
+		switch name {
+		case corev1.ResourceCPU:
+			return resource.MustParse(fmt.Sprintf("%dm", q.MilliValue()))
+		case corev1.ResourceMemory, corev1.ResourceStorage, corev1.ResourceEphemeralStorage:
+			return resource.MustParse(fmt.Sprintf("%dMi", q.Value()/1024/1024))
+		default:
+			return q.DeepCopy()
+		}
+	}
+
+	normalizeResourceList := func(resources *corev1.ResourceList) {
+		if resources == nil {
+			return
+		}
+		for k, v := range *resources {
+			(*resources)[k] = normalizeQuantity(k, v)
+		}
+	}
+
+	normalize := func(spec appsv1.ComponentSpec) appsv1.ComponentSpec {
+		normalized := spec.DeepCopy()
+		if normalized.Resources.Limits != nil {
+			normalizeResourceList(&normalized.Resources.Limits)
+		}
+		if normalized.Resources.Requests != nil {
+			normalizeResourceList(&normalized.Resources.Requests)
+		}
+		for i := range normalized.VolumeClaimTemplates {
+			vct := &normalized.VolumeClaimTemplates[i]
+			if vct.Spec.Resources.Limits != nil {
+				normalizeResourceList(&vct.Spec.Resources.Limits)
+			}
+			if vct.Spec.Resources.Requests != nil {
+				normalizeResourceList(&vct.Spec.Resources.Requests)
+			}
+		}
+		return *normalized
+	}
+
+	// Merge metadata
 	ictrlutil.MergeMetadataMapInplace(compProto.Annotations, &compObjCopy.Annotations)
 	ictrlutil.MergeMetadataMapInplace(compProto.Labels, &compObjCopy.Labels)
 
-	// merge spec
+	// Merge all spec fields
 	compObjCopy.Spec.TerminationPolicy = compProto.Spec.TerminationPolicy
 	compObjCopy.Spec.CompDef = compProto.Spec.CompDef
 	compObjCopy.Spec.ServiceVersion = compProto.Spec.ServiceVersion
@@ -197,7 +238,6 @@ func copyAndMergeComponent(oldCompObj, newCompObj *appsv1.Component) *appsv1.Com
 	compObjCopy.Spec.Labels = compProto.Spec.Labels
 	compObjCopy.Spec.Annotations = compProto.Spec.Annotations
 	compObjCopy.Spec.Env = compProto.Spec.Env
-	compObjCopy.Spec.Resources = compProto.Spec.Resources
 	compObjCopy.Spec.VolumeClaimTemplates = compProto.Spec.VolumeClaimTemplates
 	compObjCopy.Spec.PersistentVolumeClaimRetentionPolicy = compProto.Spec.PersistentVolumeClaimRetentionPolicy
 	compObjCopy.Spec.Volumes = compProto.Spec.Volumes
@@ -217,12 +257,17 @@ func copyAndMergeComponent(oldCompObj, newCompObj *appsv1.Component) *appsv1.Com
 	compObjCopy.Spec.DisableExporter = compProto.Spec.DisableExporter
 	compObjCopy.Spec.Stop = compProto.Spec.Stop
 	compObjCopy.Spec.Sidecars = compProto.Spec.Sidecars
+	compObjCopy.Spec.Resources = compProto.Spec.Resources
 
-	if reflect.DeepEqual(oldCompObj.Annotations, compObjCopy.Annotations) &&
-		reflect.DeepEqual(oldCompObj.Labels, compObjCopy.Labels) &&
-		reflect.DeepEqual(oldCompObj.Spec, compObjCopy.Spec) {
+	metadataChanged := !reflect.DeepEqual(oldCompObj.Annotations, compObjCopy.Annotations) ||
+		!reflect.DeepEqual(oldCompObj.Labels, compObjCopy.Labels)
+	specChanged := !reflect.DeepEqual(normalize(oldCompObj.Spec), normalize(compObjCopy.Spec))
+
+	// If nothing changed after normalization, return nil
+	if !metadataChanged && !specChanged {
 		return nil
 	}
+
 	return compObjCopy
 }
 
diff --git a/controllers/apps/component/transformer_component_workload.go b/controllers/apps/component/transformer_component_workload.go
index 5f3ecb8f4..24d777ec7 100644
--- a/controllers/apps/component/transformer_component_workload.go
+++ b/controllers/apps/component/transformer_component_workload.go
@@ -32,6 +32,7 @@ import (
 	"github.com/spf13/viper"
 	"golang.org/x/exp/maps"
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/equality"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
@@ -1021,7 +1022,7 @@ func (r *componentWorkloadOps) updatePVCSize(pvcKey types.NamespacedName,
 		// if both pvc and pv not found, do nothing
 		return nil
 	}
-	if reflect.DeepEqual(pvc.Spec.Resources, newPVC.Spec.Resources) &&
+	if equality.Semantic.DeepEqual(pvc.Spec.Resources, newPVC.Spec.Resources) &&
 		pv.Spec.PersistentVolumeReclaimPolicy == corev1.PersistentVolumeReclaimRetain &&
 		pv.Annotations != nil &&
 		len(pv.Annotations[constant.PVLastClaimPolicyAnnotationKey]) > 0 &&
diff --git a/pkg/controller/instanceset/utils.go b/pkg/controller/instanceset/utils.go
index dddca5fbf..9689f432b 100644
--- a/pkg/controller/instanceset/utils.go
+++ b/pkg/controller/instanceset/utils.go
@@ -134,6 +134,12 @@ func getMatchLabels(name string) map[string]string {
 	}
 }
 
+// GetMatchLabels exposes getMatchLabels for external usages
+// TODO: remove this method when no usage
+func GetMatchLabels(name string) map[string]string {
+	return getMatchLabels(name)
+}
+
 func getHeadlessSvcSelector(its *workloads.InstanceSet) map[string]string {
 	selectors := make(map[string]string)
 
diff --git a/pkg/dataprotection/restore/manager.go b/pkg/dataprotection/restore/manager.go
index 5479ad63b..ded2e7df2 100644
--- a/pkg/dataprotection/restore/manager.go
+++ b/pkg/dataprotection/restore/manager.go
@@ -37,6 +37,7 @@ import (
 
 	dpv1alpha1 "github.com/apecloud/kubeblocks/apis/dataprotection/v1alpha1"
 	"github.com/apecloud/kubeblocks/pkg/constant"
+	"github.com/apecloud/kubeblocks/pkg/controller/instanceset"
 	intctrlutil "github.com/apecloud/kubeblocks/pkg/controllerutil"
 	dptypes "github.com/apecloud/kubeblocks/pkg/dataprotection/types"
 	"github.com/apecloud/kubeblocks/pkg/dataprotection/utils"
@@ -330,12 +331,32 @@ func (r *RestoreManager) AnalysisRestoreActionsWithBackup(stage dpv1alpha1.Resto
 	return allActionsFinished, existFailedAction
 }
 
+func addItsManagingLabels(claim *dpv1alpha1.RestoreVolumeClaim, index int) {
+	clusterName := claim.Labels[constant.AppInstanceLabelKey]
+	compName := claim.Labels[constant.KBAppComponentLabelKey]
+	if clusterName == "" || compName == "" {
+		return
+	}
+	if claim.Labels == nil {
+		claim.Labels = make(map[string]string)
+	}
+
+	itsName := constant.GenerateWorkloadNamePattern(clusterName, compName)
+	itsMatchLabels := instanceset.GetMatchLabels(itsName)
+	intctrlutil.MergeMetadataMapInplace(itsMatchLabels, &claim.Labels)
+
+	if claim.Labels[constant.KBAppPodNameLabelKey] == "" {
+		podName := fmt.Sprintf("%s-%d", itsName, index)
+		claim.Labels[constant.KBAppPodNameLabelKey] = podName
+	}
+}
+
 func (r *RestoreManager) RestorePVCFromSnapshot(reqCtx intctrlutil.RequestCtx, cli client.Client, backupSet BackupActionSet, target *dpv1alpha1.BackupStatusTarget) error {
 	prepareDataConfig := r.Restore.Spec.PrepareDataConfig
 	if prepareDataConfig == nil {
 		return nil
 	}
-	createPVCWithSnapshot := func(claim dpv1alpha1.RestoreVolumeClaim, claimIndex int) error {
+	createPVCWithSnapshot := func(claim dpv1alpha1.RestoreVolumeClaim) error {
 		if claim.VolumeSource == "" {
 			return intctrlutil.NewFatalError(fmt.Sprintf(`claim "%s"" volumeSource can not be empty if the backup uses volume snapshot`, claim.Name))
 		}
@@ -371,7 +392,7 @@ func (r *RestoreManager) RestorePVCFromSnapshot(reqCtx intctrlutil.RequestCtx, c
 		return r.createPVCIfNotExist(reqCtx, cli, claim.ObjectMeta, claim.VolumeClaimSpec)
 	}
 	for i := range prepareDataConfig.RestoreVolumeClaims {
-		if err := createPVCWithSnapshot(prepareDataConfig.RestoreVolumeClaims[i], i); err != nil {
+		if err := createPVCWithSnapshot(prepareDataConfig.RestoreVolumeClaims[i]); err != nil {
 			return err
 		}
 	}
@@ -381,8 +402,12 @@ func (r *RestoreManager) RestorePVCFromSnapshot(reqCtx intctrlutil.RequestCtx, c
 		for i := 0; i < restoreJobReplicas; i++ {
 			//  create pvc from claims template, build volumes and volumeMounts
 			for _, claim := range prepareDataConfig.RestoreVolumeClaimsTemplate.Templates {
-				claim.Name = fmt.Sprintf("%s-%d", claim.Name, i+int(claimTemplate.StartingIndex))
-				if err := createPVCWithSnapshot(claim, i); err != nil {
+				index := i + int(claimTemplate.StartingIndex)
+				claim.Name = fmt.Sprintf("%s-%d", claim.Name, index)
+				// HACK: add InstanceSet related labels to the PVC,
+				// so that it can be managed by InstanceSet
+				addItsManagingLabels(&claim, index)
+				if err := createPVCWithSnapshot(claim); err != nil {
 					return err
 				}
 			}
@@ -476,7 +501,11 @@ func (r *RestoreManager) BuildPrepareDataJobs(reqCtx intctrlutil.RequestCtx, cli
 		if claimsTemplate != nil {
 			//  create pvc from claims template, build volumes and volumeMounts
 			for _, claim := range claimsTemplate.Templates {
-				claim.Name = fmt.Sprintf("%s-%d", claim.Name, i+int(claimsTemplate.StartingIndex))
+				index := i + int(claimsTemplate.StartingIndex)
+				claim.Name = fmt.Sprintf("%s-%d", claim.Name, index)
+				// HACK: add InstanceSet related labels to the PVC,
+				// so that it can be managed by InstanceSet
+				addItsManagingLabels(&claim, index)
 				volume, volumeMount, err := createPVCIfNotExistsAndBuildVolume(claim, "dp-claim-tpl")
 				if err != nil {
 					return nil, err



Test patch:
diff --git a/controllers/apps/cluster/transformer_cluster_component_test.go b/controllers/apps/cluster/transformer_cluster_component_test.go
index 064c5fd16..6c73a6cad 100644
--- a/controllers/apps/cluster/transformer_cluster_component_test.go
+++ b/controllers/apps/cluster/transformer_cluster_component_test.go
@@ -28,6 +28,7 @@ import (
 
 	"github.com/onsi/gomega/types"
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
 	"k8s.io/utils/pointer"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
@@ -1660,4 +1661,189 @@ var _ = Describe("cluster component transformer test", func() {
 			}
 		})
 	})
+
+	Context("testing component merge functionality", func() {
+		var (
+			oldCompObj *appsv1.Component
+			newCompObj *appsv1.Component
+		)
+
+		BeforeEach(func() {
+			// Initialize a base component
+			oldCompObj = &appsv1.Component{
+				Spec: appsv1.ComponentSpec{
+					Resources: corev1.ResourceRequirements{
+						Limits: corev1.ResourceList{
+							corev1.ResourceCPU:    resource.MustParse("1"),
+							corev1.ResourceMemory: resource.MustParse("1Gi"),
+						},
+						Requests: corev1.ResourceList{
+							corev1.ResourceCPU:    resource.MustParse("500m"),
+							corev1.ResourceMemory: resource.MustParse("512Mi"),
+						},
+					},
+				},
+			}
+			newCompObj = oldCompObj.DeepCopy()
+		})
+
+		It("should return nil when no changes are made", func() {
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).To(BeNil())
+		})
+
+		It("should detect annotation changes", func() {
+			newCompObj.Annotations = map[string]string{"key": "value"}
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).NotTo(BeNil())
+			Expect(result.Annotations).To(HaveKeyWithValue("key", "value"))
+		})
+
+		It("should detect label changes", func() {
+			newCompObj.Labels = map[string]string{"app": "test"}
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).NotTo(BeNil())
+			Expect(result.Labels).To(HaveKeyWithValue("app", "test"))
+		})
+
+		It("should detect resource changes", func() {
+			// Change CPU resource
+			newCompObj.Spec.Resources.Limits[corev1.ResourceCPU] = resource.MustParse("2")
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).NotTo(BeNil())
+			Expect(result.Spec.Resources.Limits[corev1.ResourceCPU]).To(Equal(resource.MustParse("2")))
+		})
+
+		It("should detect VolumeClaimTemplate changes", func() {
+			// Add a volume claim template
+			newCompObj.Spec.VolumeClaimTemplates = []appsv1.ClusterComponentVolumeClaimTemplate{
+				{
+					Name: "app-data",
+					Spec: appsv1.PersistentVolumeClaimSpec{
+						Resources: corev1.VolumeResourceRequirements{
+							Requests: corev1.ResourceList{
+								corev1.ResourceStorage: resource.MustParse("1Gi"),
+							},
+						},
+					},
+				},
+			}
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).NotTo(BeNil())
+			Expect(result.Spec.VolumeClaimTemplates).To(HaveLen(1))
+		})
+
+		It("should normalize CPU resources", func() {
+			// 1000m is equivalent to 1
+			oldCompObj.Spec.Resources.Limits[corev1.ResourceCPU] = resource.MustParse("1")
+			newCompObj.Spec.Resources.Limits[corev1.ResourceCPU] = resource.MustParse("1000m")
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).To(BeNil()) // No change after normalization
+		})
+
+		It("should normalize memory resources", func() {
+			// 1024Mi is equivalent to 1Gi
+			oldCompObj.Spec.Resources.Limits[corev1.ResourceMemory] = resource.MustParse("1Gi")
+			newCompObj.Spec.Resources.Limits[corev1.ResourceMemory] = resource.MustParse("1024Mi")
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).To(BeNil()) // No change after normalization
+		})
+
+		It("should handle nil resource limits", func() {
+			oldCompObj.Spec.Resources.Limits = nil
+			newCompObj.Spec.Resources.Limits = nil
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).To(BeNil())
+		})
+
+		It("should handle nil resource requests", func() {
+			oldCompObj.Spec.Resources.Requests = nil
+			newCompObj.Spec.Resources.Requests = nil
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).To(BeNil())
+		})
+
+		It("should detect changes when adding limits", func() {
+			oldCompObj.Spec.Resources.Limits = nil
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).NotTo(BeNil())
+			Expect(result.Spec.Resources.Limits).NotTo(BeNil())
+		})
+
+		It("should detect changes in VolumeClaimTemplate storage requests", func() {
+			vct := appsv1.ClusterComponentVolumeClaimTemplate{
+				Name: "app-data",
+				Spec: appsv1.PersistentVolumeClaimSpec{
+					Resources: corev1.VolumeResourceRequirements{
+						Requests: corev1.ResourceList{
+							corev1.ResourceStorage: resource.MustParse("1Gi"),
+						},
+					},
+				},
+			}
+
+			oldCompObj.Spec.VolumeClaimTemplates = []appsv1.ClusterComponentVolumeClaimTemplate{vct}
+			newCompObj.Spec.VolumeClaimTemplates = []appsv1.ClusterComponentVolumeClaimTemplate{*vct.DeepCopy()}
+			newCompObj.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests[corev1.ResourceStorage] =
+				resource.MustParse("2Gi")
+			// Change storage request
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).NotTo(BeNil())
+			Expect(result.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests[corev1.ResourceStorage]).
+				To(Equal(resource.MustParse("2Gi")))
+		})
+
+		It("should normalize storage resources in VolumeClaimTemplates", func() {
+			vct := appsv1.ClusterComponentVolumeClaimTemplate{
+				Name: "app-data",
+				Spec: appsv1.PersistentVolumeClaimSpec{
+					Resources: corev1.VolumeResourceRequirements{
+						Requests: corev1.ResourceList{
+							corev1.ResourceStorage: resource.MustParse("1Gi"),
+						},
+					},
+				},
+			}
+			oldCompObj.Spec.VolumeClaimTemplates = []appsv1.ClusterComponentVolumeClaimTemplate{vct}
+			newCompObj.Spec.VolumeClaimTemplates = []appsv1.ClusterComponentVolumeClaimTemplate{*vct.DeepCopy()}
+
+			// 1536Mi is equivalent to 1.5Gi
+			oldCompObj.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests[corev1.ResourceStorage] =
+				resource.MustParse("1.5Gi")
+			newCompObj.Spec.VolumeClaimTemplates[0].Spec.Resources.Requests[corev1.ResourceStorage] =
+				resource.MustParse("1536Mi")
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).To(BeNil()) // No change after normalization
+		})
+
+		It("should handle zero resource values", func() {
+			oldCompObj.Spec.Resources.Limits[corev1.ResourceCPU] = resource.MustParse("0")
+			newCompObj.Spec.Resources.Limits[corev1.ResourceCPU] = resource.MustParse("0m")
+
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).To(BeNil()) // No change after normalization
+		})
+
+		It("should handle non-standard resource types", func() {
+			customResource := "example.com/custom-resource"
+			oldCompObj.Spec.Resources.Limits[corev1.ResourceName(customResource)] = resource.MustParse("5")
+			newCompObj.Spec.Resources.Limits[corev1.ResourceName(customResource)] = resource.MustParse("10")
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).NotTo(BeNil())
+			Expect(result.Spec.Resources.Limits[corev1.ResourceName(customResource)]).
+				To(Equal(resource.MustParse("10")))
+		})
+
+		It("should detect all changes when multiple fields change", func() {
+			newCompObj.Labels = map[string]string{"app": "test"}
+			newCompObj.Spec.Resources.Limits[corev1.ResourceCPU] = resource.MustParse("2")
+			newCompObj.Spec.Replicas = 3
+
+			result := copyAndMergeComponent(oldCompObj, newCompObj)
+			Expect(result).NotTo(BeNil())
+			Expect(result.Labels).To(HaveKeyWithValue("app", "test"))
+			Expect(result.Spec.Resources.Limits[corev1.ResourceCPU]).To(Equal(resource.MustParse("2")))
+			Expect(result.Spec.Replicas).To(Equal(int32(3)))
+		})
+	})
 })
diff --git a/pkg/dataprotection/restore/manager_test.go b/pkg/dataprotection/restore/manager_test.go
index ace16a0eb..39509000d 100644
--- a/pkg/dataprotection/restore/manager_test.go
+++ b/pkg/dataprotection/restore/manager_test.go
@@ -45,7 +45,7 @@ import (
 	viper "github.com/apecloud/kubeblocks/pkg/viperx"
 )
 
-var _ = Describe("Backup Deleter Test", func() {
+var _ = Describe("RestoreManager Test", func() {
 
 	cleanEnv := func() {
 		By("clean resources")