Repository: apecloud/kubeblocks

Issue:
[BUG]Restore cluster with 'restore-from-backup' did not respect InstanceTemplate's Ordinals ranges
**Describe the bug**
Restore cluster with 'restore-from-backup' did not respect InstanceTemplate's Ordinals ranges

**To Reproduce**
Steps to reproduce the behavior:
1. Create cluster using restore from backup, which also using InstanceTemplate and ordinals, cluster yaml likes below
```
apiVersion: apps.kubeblocks.io/v1
kind: Cluster
metadata:
  annotations:
    kubeblocks.io/crd-api-version: apps.kubeblocks.io/v1
    kubeblocks.io/restore-from-backup: '{"mysql":{"encryptedSystemAccounts":"abc","name":"t-vip-mysql57-xaz-backup-qz4nw","namespace":"thomas-demo","volumeRestorePolicy":"Serial"}}'
  name: mysql57-restore
  namespace: demo
spec:
  clusterDef: vip-mysql
  componentSpecs:
  - componentDef: vip-mysql-5.7-1.0.0-alpha.0
    instanceUpdateStrategy:
      type: OnDelete
    instances:
    - annotations:
        tpl: q9n0
      name: q9n0
      ordinals:
        ranges:
        - end: 299
          start: 0
      replicas: 2
    - annotations:
        tpl: iph1
      name: iph1
      ordinals:
        ranges:
        - end: 599
          start: 300
      replicas: 1
    - annotations:
        tpl: wte2
      name: wte2
      ordinals:
        ranges:
        - end: 899
          start: 600
      replicas: 2
    name: mysql
    persistentVolumeClaimRetentionPolicy:
      whenDeleted: Delete
      whenScaled: Retain
    replicas: 5
    resources:
      limits:
        cpu: "2"
        memory: 4G
      requests:
        cpu: "2"
        memory: 4G
    # serviceVersion: 5.7.44-vip.1
    volumeClaimTemplates:
    - name: data
      spec:
        accessModes:
        - ReadWriteOnce
        resources:
          requests:
            storage: 10G
        # storageClassName: open-local-lvm
  terminationPolicy: Delete
  # topology: replset
```
3. KB create restore before creating pods, Restore's volumeTemplate did not respect ordinals, likes below
```
apiVersion: dataprotection.kubeblocks.io/v1alpha1
kind: Restore
metadata:
  creationTimestamp: "2025-07-21T11:03:44Z"
  finalizers:
  - dataprotection.kubeblocks.io/finalizer
  generation: 1
  labels:
    app.kubernetes.io/instance: mysql57-restore
    app.kubernetes.io/managed-by: kubeblocks
    apps.kubeblocks.io/component-name: mysql
    dataprotection.kubeblocks.io/backup-repo-name: gfs-hostpath-repo
  name: mysql57-restore-mysql-68ed5294-preparedata-q9n0
  namespace: demo
  ownerReferences:
  - apiVersion: apps.kubeblocks.io/v1
    blockOwnerDeletion: true
    controller: true
    kind: Component
    name: mysql57-restore-mysql
    uid: b671d73f-9b12-463a-9557-d8279dcb83c5
  resourceVersion: "104547817"
  uid: a798129e-2ac0-4ee7-b3a6-a738651d9b16
spec:
  backup:
    name: t-vip-mysql57-xaz-backup-qz4nw
    namespace: thomas-demo
  containerResources: {}
  prepareDataConfig:
    schedulingSpec: {}
    volumeClaimRestorePolicy: Serial
    volumeClaimsTemplate:
      replicas: 2
      templates:
      - metadata:
          labels:
            app.kubernetes.io/component: vip-mysql-5.7-1.0.0-alpha.0
            app.kubernetes.io/instance: mysql57-restore
            app.kubernetes.io/managed-by: kubeblocks
            apps.kubeblocks.io/component-name: mysql
            apps.kubeblocks.io/instance-template: q9n0
            apps.kubeblocks.io/vct-name: data
            componentdefinition.kubeblocks.io/name: vip-mysql-5.7-1.0.0-alpha.0
          name: data-mysql57-restore-mysql-q9n0
        volumeClaimSpec:
          accessModes:
          - ReadWriteOnce
          resources:
            requests:
              storage: 10G
        volumeSource: data
status:
  actions:
    prepareData:
    - backupName: t-vip-mysql57-xaz-backup-qz4nw
      message: '"Job/restore-preparedata-a798129e-t-vip-mysql57-xaz-backup-qz4nw-0"
        is processing'
      name: prepareData-0
      objectKey: Job/restore-preparedata-a798129e-t-vip-mysql57-xaz-backup-qz4nw-0
      startTime: "2025-07-21T11:03:44Z"
      status: Processing
  conditions:
  - lastTransitionTime: "2025-07-21T11:03:44Z"
    message: ""
    reason: CheckBackupRepoSuccessfully
    status: "True"
    type: CheckBackupRepo
  - lastTransitionTime: "2025-07-21T11:03:44Z"
    message: validate restore spec successfully
    reason: ValidateSuccessfully
    status: "True"
    type: ValidationPassed
  - lastTransitionTime: "2025-07-21T11:03:44Z"
    message: processing prepareData stage.
    reason: Processing
    status: "False"
    type: PrepareData
  phase: Running
  startTimestamp: "2025-07-21T11:03:44Z"
```

**Expected behavior**
PVC should respect InstanceTemplate's ordinals

**Desktop (please complete the following information):**
 - KB v1.0



Hints from issue comments:

Solution patch:
diff --git a/controllers/dataprotection/backup_controller.go b/controllers/dataprotection/backup_controller.go
index 6c373fd50..0abc1bb02 100644
--- a/controllers/dataprotection/backup_controller.go
+++ b/controllers/dataprotection/backup_controller.go
@@ -609,8 +609,9 @@ func (r *BackupReconciler) handleRunningPhase(
 		// check all actions status, if any action failed, update backup status to failed
 		// if all actions completed, update backup status to completed, otherwise,
 		// continue to handle following actions.
-	actions:
 		for targetPodName, acts := range actions {
+			// the backup actions for selected pod
+		targetPodBackupActions:
 			for _, act := range acts {
 				status, err := act.Execute(actionCtx)
 				if err != nil {
@@ -624,10 +625,10 @@ func (r *BackupReconciler) handleRunningPhase(
 					continue
 				case dpv1alpha1.ActionPhaseFailed:
 					existFailedAction = true
-					break actions
+					break targetPodBackupActions
 				case dpv1alpha1.ActionPhaseRunning:
 					waiting = true
-					break actions
+					break targetPodBackupActions
 				}
 			}
 		}
diff --git a/pkg/controller/plan/restore.go b/pkg/controller/plan/restore.go
index 68d628f8d..e0982af41 100644
--- a/pkg/controller/plan/restore.go
+++ b/pkg/controller/plan/restore.go
@@ -133,7 +133,7 @@ func (r *RestoreManager) DoPrepareData(comp *component.SynthesizedComponent,
 	for _, v := range comp.Instances {
 		r.replicas = replicas(v)
 		templateReplicas += r.replicas
-		restore, err := r.BuildPrepareDataRestore(comp, backupObj, v.Name)
+		restore, err := r.BuildPrepareDataRestore(comp, backupObj, &v)
 		if err != nil {
 			return err
 		}
@@ -144,7 +144,7 @@ func (r *RestoreManager) DoPrepareData(comp *component.SynthesizedComponent,
 	compReplicas := comp.Replicas - templateReplicas
 	if compReplicas > 0 {
 		r.replicas = compReplicas
-		restore, err := r.BuildPrepareDataRestore(comp, backupObj, "")
+		restore, err := r.BuildPrepareDataRestore(comp, backupObj, nil)
 		if err != nil {
 			return err
 		}
@@ -155,7 +155,17 @@ func (r *RestoreManager) DoPrepareData(comp *component.SynthesizedComponent,
 	return r.createRestoreAndWait(compObj, restores...)
 }
 
-func (r *RestoreManager) BuildPrepareDataRestore(comp *component.SynthesizedComponent, backupObj *dpv1alpha1.Backup, templateName string) (*dpv1alpha1.Restore, error) {
+func (r *RestoreManager) BuildPrepareDataRestore(comp *component.SynthesizedComponent, backupObj *dpv1alpha1.Backup, template *appsv1.InstanceTemplate) (*dpv1alpha1.Restore, error) {
+	templateName := ""
+	startingIndex := r.startingIndex
+	if template != nil {
+		templateName = template.Name
+		if len(template.Ordinals.Ranges) > 0 {
+			// todo: currently restore api does not support multiple ranges, if implement in current way it
+			// need to use multiple restore objects
+			startingIndex = template.Ordinals.Ranges[0].Start
+		}
+	}
 	backupMethod := backupObj.Status.BackupMethod
 	if backupMethod == nil {
 		return nil, intctrlutil.NewErrorf(intctrlutil.ErrorTypeRestoreFailed, `status.backupMethod of backup "%s" can not be empty`, backupObj.Name)
@@ -217,7 +227,7 @@ func (r *RestoreManager) BuildPrepareDataRestore(comp *component.SynthesizedComp
 				VolumeClaimRestorePolicy:         r.volumeRestorePolicy,
 				RestoreVolumeClaimsTemplate: &dpv1alpha1.RestoreVolumeClaimsTemplate{
 					Replicas:      r.replicas,
-					StartingIndex: r.startingIndex,
+					StartingIndex: startingIndex,
 					Templates:     templates,
 				},
 			},



Test patch:
diff --git a/pkg/controller/plan/restore_test.go b/pkg/controller/plan/restore_test.go
index 29445b73f..8388b3d2f 100644
--- a/pkg/controller/plan/restore_test.go
+++ b/pkg/controller/plan/restore_test.go
@@ -249,6 +249,207 @@ var _ = Describe("Restore", func() {
 				restore.Status.Phase = dpv1alpha1.RestorePhaseCompleted
 			})()).ShouldNot(HaveOccurred())
 
+			By("clean up annotations after cluster running")
+			_ = restoreMGR.DoRestore(synthesizedComponent, compObj, true)
+			Eventually(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(cluster), func(g Gomega, tmpCluster *appsv1.Cluster) {
+				g.Expect(tmpCluster.Annotations[constant.RestoreFromBackupAnnotationKey]).Should(BeEmpty())
+			})).Should(Succeed())
+		})
+	})
+	Context("Cluster InstanceTemplate ranges Restore", func() {
+		const (
+			compDefName     = "test-compdef-1"
+			defaultCompName = "mysql"
+			topologyKey     = "testTopologyKey"
+			labelKey        = "testNodeLabelKey"
+			labelValue      = "testLabelValue"
+		)
+
+		var (
+			compDef                 *appsv1.ComponentDefinition
+			cluster                 *appsv1.Cluster
+			synthesizedComponent    *component.SynthesizedComponent
+			compObj                 *appsv1.Component
+			pvc                     *corev1.PersistentVolumeClaim
+			backup                  *dpv1alpha1.Backup
+			fullBackupActionSet     *dpv1alpha1.ActionSet
+			fullBackupActionSetName string
+			startIndex = int32(10)
+		)
+
+		BeforeEach(func() {
+			By("By creating backup policyTemplate ")
+			compDef = testapps.NewComponentDefinitionFactory(compDefName).
+				SetDefaultSpec().
+				Create(&testCtx).GetObject()
+
+			testdp.NewBackupPolicyTemplateFactory("backup-policy-template").
+				SetCompDefs(compDef.Name).
+				WithRandomName().
+				AddBackupMethod(testdp.BackupMethodName, false, fullBackupActionSetName).
+				SetBackupMethodVolumeMounts(testapps.DataVolumeName, "/data").Create(&testCtx).Get()
+
+			pvcSpec := testapps.NewPVCSpec("1Gi")
+			cluster = testapps.NewClusterFactory(testCtx.DefaultNamespace, clusterName, "").
+				AddMultipleTemplateComponentRange(defaultCompName, compDefName).
+				SetReplicas(3).
+				AddVolumeClaimTemplate(testapps.DataVolumeName, pvcSpec).
+				Create(&testCtx).GetObject()
+
+			By("By mocking a pvc")
+			pvc = testapps.NewPersistentVolumeClaimFactory(
+				testCtx.DefaultNamespace, "data-"+clusterName+"-"+defaultCompName+"-0", clusterName, defaultCompName, "data").
+				SetStorage("1Gi").
+				Create(&testCtx).GetObject()
+
+			By("By mocking a pod")
+			volume := corev1.Volume{Name: pvc.Name, VolumeSource: corev1.VolumeSource{
+				PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: pvc.Name}}}
+			_ = testapps.NewPodFactory(testCtx.DefaultNamespace, clusterName+"-"+defaultCompName+"-0").
+				AddAppInstanceLabel(clusterName).
+				AddAppComponentLabel(defaultCompName).
+				AddAppManagedByLabel().
+				AddVolume(volume).
+				AddContainer(corev1.Container{Name: testapps.DefaultMySQLContainerName, Image: testapps.ApeCloudMySQLImage}).
+				AddNodeName("fake-node-name").
+				Create(&testCtx).GetObject()
+
+			By("create actionset of full backup")
+			fullBackupActionSet = testapps.CreateCustomizedObj(&testCtx, "backup/actionset.yaml", &dpv1alpha1.ActionSet{}, testapps.RandomizedObjName())
+			fullBackupActionSetName = fullBackupActionSet.Name
+			synthesizedComponent = &component.SynthesizedComponent{
+				PodSpec:              &compDef.Spec.Runtime,
+				VolumeClaimTemplates: intctrlutil.ToCoreV1PVCTs(cluster.Spec.ComponentSpecs[0].VolumeClaimTemplates),
+				Name:                 defaultCompName,
+				Replicas:             3,
+				Roles: []appsv1.ReplicaRole{
+					{
+						Name:           "leader",
+						UpdatePriority: 2,
+					},
+					{
+						Name:           "follower",
+						UpdatePriority: 1,
+					},
+				},
+				Instances: []appsv1.InstanceTemplate{{
+					Name:     "foo",
+					Replicas: func() *int32 { replicas := int32(1); return &replicas }(),
+					Ordinals: appsv1.Ordinals{
+						Ranges: []appsv1.Range{
+							{Start: startIndex, End: 20},
+						},
+					},
+				}},
+			}
+			By("create component object")
+			compObj = testapps.NewComponentFactory(testCtx.DefaultNamespace, cluster.Name+"-"+synthesizedComponent.Name, "").
+				AddAnnotations(constant.KBAppClusterUIDKey, string(cluster.UID)).
+				AddLabels(constant.AppInstanceLabelKey, cluster.Name).
+				SetReplicas(1).
+				Create(&testCtx).
+				GetObject()
+
+			By("By creating remote pvc: ")
+			remotePVC := testapps.NewPersistentVolumeClaimFactory(
+				testCtx.DefaultNamespace, "remote-pvc", clusterName, defaultCompName, "log").
+				SetStorage("1Gi").
+				Create(&testCtx).GetObject()
+
+			By("By creating base backup: ")
+			backupLabels := map[string]string{
+				constant.AppInstanceLabelKey:    sourceCluster,
+				constant.KBAppComponentLabelKey: defaultCompName,
+			}
+			backup = testdp.NewBackupFactory(testCtx.DefaultNamespace, backupName).
+				WithRandomName().SetLabels(backupLabels).
+				SetBackupPolicyName("test-fake").
+				SetBackupMethod(testdp.VSBackupMethodName).
+				Create(&testCtx).GetObject()
+			baseStartTime := &startTime
+			baseStopTime := &now
+			backup.Status = dpv1alpha1.BackupStatus{
+				Phase:                     dpv1alpha1.BackupPhaseCompleted,
+				StartTimestamp:            baseStartTime,
+				CompletionTimestamp:       baseStopTime,
+				PersistentVolumeClaimName: remotePVC.Name,
+			}
+			testdp.MockBackupStatusMethod(backup, testdp.VSBackupMethodName, testapps.DataVolumeName, testdp.ActionSetName)
+			patchBackupStatus(backup.Status, client.ObjectKeyFromObject(backup))
+		})
+
+		It("Test restore", func() {
+			By("restore from backup")
+			restoreFromBackup := fmt.Sprintf(`{"%s": {"name":"%s"}}`, defaultCompName, backup.Name)
+			Expect(testapps.ChangeObj(&testCtx, cluster, func(tmpCluster *appsv1.Cluster) {
+				tmpCluster.Annotations = map[string]string{
+					constant.RestoreFromBackupAnnotationKey: restoreFromBackup,
+				}
+			})).Should(Succeed())
+			Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(cluster), cluster)).Should(Succeed())
+			restoreMGR := NewRestoreManager(ctx, k8sClient, cluster, scheme.Scheme, nil, 3, 0)
+
+			By("restore from template")
+			err := restoreMGR.DoRestore(synthesizedComponent, compObj, true)
+			Expect(intctrlutil.IsTargetError(err, intctrlutil.ErrorTypeNeedWaiting)).Should(BeTrue())
+
+			By("verify template restore")
+			restore := &dpv1alpha1.Restore{}
+			restoreMeta := restoreMGR.GetRestoreObjectMeta(synthesizedComponent, dpv1alpha1.PrepareData, "foo")
+			namedspace := types.NamespacedName{Name: restoreMeta.Name, Namespace: restoreMeta.Namespace}
+			Expect(k8sClient.Get(ctx, namedspace, restore)).Should(Succeed())
+			Expect(restore.Spec.PrepareDataConfig.RestoreVolumeClaimsTemplate.StartingIndex).Should(Equal(startIndex))
+			Expect(restore.Spec.PrepareDataConfig.RestoreVolumeClaimsTemplate.Replicas).Should(Equal(int32(1)))
+
+			By("mock template restore of prepareData stage to Completed")
+			Expect(testapps.GetAndChangeObjStatus(&testCtx, namedspace, func(restore *dpv1alpha1.Restore) {
+				restore.Status.Phase = dpv1alpha1.RestorePhaseCompleted
+			})()).ShouldNot(HaveOccurred())
+
+			By("restore from default")
+			err = restoreMGR.DoRestore(synthesizedComponent, compObj, true)
+			Expect(intctrlutil.IsTargetError(err, intctrlutil.ErrorTypeNeedWaiting)).Should(BeTrue())
+
+			By("verify default restore")
+			defaultMeta := restoreMGR.GetRestoreObjectMeta(synthesizedComponent, dpv1alpha1.PrepareData, "")
+			defaultNs := types.NamespacedName{Name: defaultMeta.Name, Namespace: defaultMeta.Namespace}
+			defaultRestore := &dpv1alpha1.Restore{}
+			Expect(k8sClient.Get(ctx, defaultNs, defaultRestore)).Should(Succeed())
+			Expect(defaultRestore.Spec.PrepareDataConfig.RestoreVolumeClaimsTemplate.StartingIndex).Should(Equal(int32(0)))
+			Expect(defaultRestore.Spec.PrepareDataConfig.RestoreVolumeClaimsTemplate.Replicas).Should(Equal(int32(2)))
+
+			By("mock default restore of prepareData stage to Completed")
+			Expect(testapps.GetAndChangeObjStatus(&testCtx, defaultNs, func(restore *dpv1alpha1.Restore) {
+				restore.Status.Phase = dpv1alpha1.RestorePhaseCompleted
+			})()).ShouldNot(HaveOccurred())
+
+			By("mock component and cluster phase to Running")
+			Expect(testapps.ChangeObjStatus(&testCtx, cluster, func() {
+				cluster.Status.Phase = appsv1.RunningClusterPhase
+				cluster.Status.Components = map[string]appsv1.ClusterComponentStatus{
+					defaultCompName: {
+						Phase: appsv1.RunningComponentPhase,
+					},
+				}
+			})).Should(Succeed())
+			Expect(testapps.ChangeObjStatus(&testCtx, compObj, func() {
+				compObj.Status.Phase = appsv1.RunningComponentPhase
+			})).Should(Succeed())
+
+			By("wait for postReady restore created and mock it to Completed")
+			restoreMGR.Cluster = cluster
+			_ = restoreMGR.DoRestore(synthesizedComponent, compObj, true)
+
+			// check if restore CR of postReady stage is created.
+			restoreMeta = restoreMGR.GetRestoreObjectMeta(synthesizedComponent, dpv1alpha1.PostReady, "")
+			namedspace = types.NamespacedName{Name: restoreMeta.Name, Namespace: restoreMeta.Namespace}
+			Eventually(testapps.CheckObjExists(&testCtx, namedspace,
+				&dpv1alpha1.Restore{}, true)).Should(Succeed())
+			// set restore to Completed
+			Expect(testapps.GetAndChangeObjStatus(&testCtx, namedspace, func(restore *dpv1alpha1.Restore) {
+				restore.Status.Phase = dpv1alpha1.RestorePhaseCompleted
+			})()).ShouldNot(HaveOccurred())
+
 			By("clean up annotations after cluster running")
 			_ = restoreMGR.DoRestore(synthesizedComponent, compObj, true)
 			Eventually(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(cluster), func(g Gomega, tmpCluster *appsv1.Cluster) {
diff --git a/pkg/testutil/apps/cluster_factory.go b/pkg/testutil/apps/cluster_factory.go
index 90eb98ddf..3285dbccf 100644
--- a/pkg/testutil/apps/cluster_factory.go
+++ b/pkg/testutil/apps/cluster_factory.go
@@ -95,6 +95,24 @@ func (factory *MockClusterFactory) AddMultipleTemplateComponent(compName string,
 	return factory
 }
 
+func (factory *MockClusterFactory) AddMultipleTemplateComponentRange(compName string, compDefName string) *MockClusterFactory {
+	comp := appsv1.ClusterComponentSpec{
+		Name:         compName,
+		ComponentDef: compDefName,
+		Instances: []appsv1.InstanceTemplate{{
+			Name:     "foo",
+			Replicas: func() *int32 { replicas := int32(1); return &replicas }(),
+			Ordinals: appsv1.Ordinals{
+				Ranges: []appsv1.Range{
+					{Start: 10, End: 20},
+				},
+			},
+		}},
+	}
+	factory.Get().Spec.ComponentSpecs = append(factory.Get().Spec.ComponentSpecs, comp)
+	return factory
+}
+
 func (factory *MockClusterFactory) AddInstances(compName string, instance appsv1.InstanceTemplate) *MockClusterFactory {
 	for i, compSpec := range factory.Get().Spec.ComponentSpecs {
 		if compSpec.Name != compName {