diff --git a/apis/operations/v1alpha1/opsrequest_types.go b/apis/operations/v1alpha1/opsrequest_types.go
index 1bfaeba4415..0196df8f600 100644
--- a/apis/operations/v1alpha1/opsrequest_types.go
+++ b/apis/operations/v1alpha1/opsrequest_types.go
@@ -518,6 +518,13 @@ type FromBackup struct {
// +optional
Namespace string `json:"namespace,omitempty"`
+ // Specifies the backup source target name to restore from.
+ // This field is required when the referenced Backup has multiple source targets.
+ // It is propagated to Restore.spec.backup.sourceTargetName.
+ //
+ // +optional
+ SourceTargetName string `json:"sourceTargetName,omitempty"`
+
// Defines container environment variables for the restore process.
// merged with the ones specified in the Backup and ActionSet resources.
//
diff --git a/config/crd/bases/operations.kubeblocks.io_opsrequests.yaml b/config/crd/bases/operations.kubeblocks.io_opsrequests.yaml
index 34eecba8374..5a00527e4d1 100644
--- a/config/crd/bases/operations.kubeblocks.io_opsrequests.yaml
+++ b/config/crd/bases/operations.kubeblocks.io_opsrequests.yaml
@@ -757,6 +757,12 @@ spec:
- RFC3339 format, e.g. "2023-11-25T18:52:53Z"
- A human-readable date-time format, e.g. "Jul 25,2023 18:52:53 UTC+0800"
type: string
+ sourceTargetName:
+ description: |-
+ Specifies the backup source target name to restore from.
+ This field is required when the referenced Backup has multiple source targets.
+ It is propagated to Restore.spec.backup.sourceTargetName.
+ type: string
required:
- name
type: object
diff --git a/deploy/helm/crds/operations.kubeblocks.io_opsrequests.yaml b/deploy/helm/crds/operations.kubeblocks.io_opsrequests.yaml
index 34eecba8374..5a00527e4d1 100755
--- a/deploy/helm/crds/operations.kubeblocks.io_opsrequests.yaml
+++ b/deploy/helm/crds/operations.kubeblocks.io_opsrequests.yaml
@@ -757,6 +757,12 @@ spec:
- RFC3339 format, e.g. "2023-11-25T18:52:53Z"
- A human-readable date-time format, e.g. "Jul 25,2023 18:52:53 UTC+0800"
type: string
+ sourceTargetName:
+ description: |-
+ Specifies the backup source target name to restore from.
+ This field is required when the referenced Backup has multiple source targets.
+ It is propagated to Restore.spec.backup.sourceTargetName.
+ type: string
required:
- name
type: object
diff --git a/docs/developer_docs/api-reference/operations.md b/docs/developer_docs/api-reference/operations.md
index e362819aa47..f46da177933 100644
--- a/docs/developer_docs/api-reference/operations.md
+++ b/docs/developer_docs/api-reference/operations.md
@@ -1095,6 +1095,20 @@ If not specified, the namespace of the OpsRequest will be used.
+sourceTargetName
+
+string
+
+ |
+
+(Optional)
+ Specifies the backup source target name to restore from.
+This field is required when the referenced Backup has multiple source targets.
+It is propagated to Restore.spec.backup.sourceTargetName.
+ |
+
+
+
restoreEnv
diff --git a/pkg/controller/plan/restore.go b/pkg/controller/plan/restore.go
index 41212a2aaff..bfbe15849c5 100644
--- a/pkg/controller/plan/restore.go
+++ b/pkg/controller/plan/restore.go
@@ -60,6 +60,7 @@ type RestoreManager struct {
replicas int32
restoreLabels map[string]string
RestoreNamePrefix string
+ SourceTargetName string
}
func NewRestoreManager(ctx context.Context,
@@ -184,6 +185,14 @@ func (r *RestoreManager) BuildPrepareDataRestore(comp *component.SynthesizedComp
return nil, nil
}
sourceTargetName, sourceTarget := backupSourceTargetForRestore(backupObj)
+ requiredPolicy := r.buildRequiredPolicy(sourceTarget)
+ if r.SourceTargetName != "" {
+ // DataProtection owns resolving and validating an explicit source target.
+ sourceTargetName = r.SourceTargetName
+ requiredPolicy = &dpv1alpha1.RequiredPolicyForAllPodSelection{
+ DataRestorePolicy: dpv1alpha1.OneToOneRestorePolicy,
+ }
+ }
restore := &dpv1alpha1.Restore{
ObjectMeta: r.GetRestoreObjectMeta(comp, dpv1alpha1.PrepareData, templateName),
Spec: dpv1alpha1.RestoreSpec{
@@ -196,7 +205,7 @@ func (r *RestoreManager) BuildPrepareDataRestore(comp *component.SynthesizedComp
Env: r.env,
Parameters: r.parameters,
PrepareDataConfig: &dpv1alpha1.PrepareDataConfig{
- RequiredPolicyForAllPodSelection: r.buildRequiredPolicy(sourceTarget),
+ RequiredPolicyForAllPodSelection: requiredPolicy,
SchedulingSpec: r.buildSchedulingSpec(comp, template),
VolumeClaimRestorePolicy: r.volumeRestorePolicy,
RestoreVolumeClaimsTemplate: &dpv1alpha1.RestoreVolumeClaimsTemplate{
diff --git a/pkg/controller/plan/restore_test.go b/pkg/controller/plan/restore_test.go
index f6c3706cf35..5af5cf30c33 100644
--- a/pkg/controller/plan/restore_test.go
+++ b/pkg/controller/plan/restore_test.go
@@ -236,7 +236,7 @@ func TestRestoreManagerBuildPrepareDataRestore(t *testing.T) {
SchedulingPolicy: &appsv1.SchedulingPolicy{NodeName: "node-a"},
}
backup := &dpv1alpha1.Backup{
- ObjectMeta: metav1.ObjectMeta{Name: "backup"},
+ ObjectMeta: metav1.ObjectMeta{Name: "backup", Namespace: "default"},
Status: dpv1alpha1.BackupStatus{
Targets: []dpv1alpha1.BackupStatusTarget{{
BackupTarget: dpv1alpha1.BackupTarget{
@@ -298,6 +298,95 @@ func TestRestoreManagerBuildPrepareDataRestore(t *testing.T) {
}
}
+func TestRestoreManagerBuildPrepareDataRestoreWithExplicitSourceTarget(t *testing.T) {
+ manager := newRestoreManagerForTest()
+ manager.SourceTargetName = "target-b"
+ comp := &component.SynthesizedComponent{
+ Name: "mysql",
+ Replicas: 1,
+ VolumeClaimTemplates: []corev1.PersistentVolumeClaimTemplate{{
+ ObjectMeta: metav1.ObjectMeta{Name: "data"},
+ }},
+ }
+ backup := &dpv1alpha1.Backup{
+ ObjectMeta: metav1.ObjectMeta{Name: "backup"},
+ Status: dpv1alpha1.BackupStatus{
+ Targets: []dpv1alpha1.BackupStatusTarget{
+ {BackupTarget: dpv1alpha1.BackupTarget{
+ Name: "target-a",
+ PodSelector: &dpv1alpha1.PodSelector{},
+ }},
+ {BackupTarget: dpv1alpha1.BackupTarget{
+ Name: "target-b",
+ PodSelector: &dpv1alpha1.PodSelector{Strategy: dpv1alpha1.PodSelectionStrategyAll},
+ }},
+ },
+ BackupMethod: &dpv1alpha1.BackupMethod{
+ Name: "snapshot",
+ TargetVolumes: &dpv1alpha1.TargetVolumeInfo{
+ Volumes: []string{"data"},
+ },
+ },
+ },
+ }
+
+ restore, err := manager.BuildPrepareDataRestore(comp, backup, nil)
+ if err != nil {
+ t.Fatalf("BuildPrepareDataRestore() error = %v", err)
+ }
+ if restore == nil {
+ t.Fatal("restore is nil")
+ }
+ if restore.Spec.Backup.SourceTargetName != "target-b" {
+ t.Fatalf("source target name = %q, want target-b", restore.Spec.Backup.SourceTargetName)
+ }
+ policy := restore.Spec.PrepareDataConfig.RequiredPolicyForAllPodSelection
+ if policy == nil || policy.DataRestorePolicy != dpv1alpha1.OneToOneRestorePolicy {
+ t.Fatalf("required policy = %#v, want one-to-one policy for target-b", policy)
+ }
+}
+
+func TestRestoreManagerBuildPrepareDataRestorePropagatesUnknownSourceTarget(t *testing.T) {
+ manager := newRestoreManagerForTest()
+ manager.SourceTargetName = "missing-target"
+ comp := &component.SynthesizedComponent{
+ Name: "mysql",
+ Replicas: 1,
+ VolumeClaimTemplates: []corev1.PersistentVolumeClaimTemplate{{
+ ObjectMeta: metav1.ObjectMeta{Name: "data"},
+ }},
+ }
+ backup := &dpv1alpha1.Backup{
+ ObjectMeta: metav1.ObjectMeta{Name: "backup"},
+ Status: dpv1alpha1.BackupStatus{
+ Targets: []dpv1alpha1.BackupStatusTarget{{
+ BackupTarget: dpv1alpha1.BackupTarget{
+ Name: "target-a",
+ PodSelector: &dpv1alpha1.PodSelector{},
+ },
+ }},
+ BackupMethod: &dpv1alpha1.BackupMethod{
+ Name: "snapshot",
+ TargetVolumes: &dpv1alpha1.TargetVolumeInfo{
+ Volumes: []string{"data"},
+ },
+ },
+ },
+ }
+
+ restore, err := manager.BuildPrepareDataRestore(comp, backup, nil)
+ if err != nil {
+ t.Fatalf("BuildPrepareDataRestore() error = %v, want DataProtection to validate source target", err)
+ }
+ if restore.Spec.Backup.SourceTargetName != "missing-target" {
+ t.Fatalf("source target name = %q, want missing-target propagated", restore.Spec.Backup.SourceTargetName)
+ }
+ policy := restore.Spec.PrepareDataConfig.RequiredPolicyForAllPodSelection
+ if policy == nil || policy.DataRestorePolicy != dpv1alpha1.OneToOneRestorePolicy {
+ t.Fatalf("required policy = %#v, want one-to-one for explicit source target", policy)
+ }
+}
+
func TestRestoreManagerBuildPrepareDataRestoreWithoutVolumes(t *testing.T) {
manager := newRestoreManagerForTest()
comp := &component.SynthesizedComponent{
diff --git a/pkg/operations/horizontal_scaling.go b/pkg/operations/horizontal_scaling.go
index 444bd199ecf..520b3b344d4 100644
--- a/pkg/operations/horizontal_scaling.go
+++ b/pkg/operations/horizontal_scaling.go
@@ -224,10 +224,6 @@ func (hs horizontalScalingOpsHandler) createRestore(reqCtx intctrlutil.RequestCt
}
return nil
}
- if len(backupObj.Status.Targets) > 1 {
- // TODO: support explicit source target selection for scale-out restore from multi-target backups.
- return intctrlutil.NewFatalError(fmt.Sprintf("scale-out from backup %s/%s is not supported because it has multiple source targets", backupObj.Namespace, backupObj.Name))
- }
// create restore
restore, err := restoreMGR.BuildPrepareDataRestore(synthesizedComponent, backupObj, getTemplate(templateName))
if err != nil {
@@ -303,6 +299,7 @@ func (hs horizontalScalingOpsHandler) restoreDataFromBackup(reqCtx intctrlutil.R
restoreMGR.RestoreTime = fromBackup.RestorePointInTime
restoreMGR.SetRestoreEnv(fromBackup.RestoreEnv)
restoreMGR.RestoreNamePrefix = string(opsRes.OpsRequest.UID[:8])
+ restoreMGR.SourceTargetName = fromBackup.SourceTargetName
// check restore status
restoreMeta := restoreMGR.GetRestoreObjectMeta(synthesizedComponent, dpv1alpha1.PrepareData, templateName)
restore := &dpv1alpha1.Restore{}
diff --git a/pkg/operations/horizontal_scaling_test.go b/pkg/operations/horizontal_scaling_test.go
index 8f7e13c3d77..b67b0873905 100644
--- a/pkg/operations/horizontal_scaling_test.go
+++ b/pkg/operations/horizontal_scaling_test.go
@@ -278,25 +278,48 @@ var _ = Describe("HorizontalScaling OpsRequest", func() {
Expect(testapps.ChangeObjStatus(&testCtx, backup, func() {
backup.Status.Phase = dpv1alpha1.BackupPhaseCompleted
backup.Status.BackupMethod = &dpv1alpha1.BackupMethod{
- Name: testdp.VSBackupMethodName,
+ Name: testdp.VSBackupMethodName,
+ SnapshotVolumes: pointer.Bool(true),
TargetVolumes: &dpv1alpha1.TargetVolumeInfo{
Volumes: []string{"data"},
},
}
+ backup.Status.Targets = []dpv1alpha1.BackupStatusTarget{
+ {BackupTarget: dpv1alpha1.BackupTarget{
+ Name: "target-a",
+ PodSelector: &dpv1alpha1.PodSelector{},
+ }},
+ {BackupTarget: dpv1alpha1.BackupTarget{
+ Name: "target-b",
+ PodSelector: &dpv1alpha1.PodSelector{Strategy: dpv1alpha1.PodSelectionStrategyAll},
+ }},
+ }
})).Should(Succeed())
By("scale out replicas from a full backup")
restoreEnv := []corev1.EnvVar{{Name: "RESTORE_ENV", Value: "true"}}
horizontalScaling := opsv1alpha1.HorizontalScaling{ScaleOut: &opsv1alpha1.ScaleOut{
FromBackup: &opsv1alpha1.FromBackup{
- Name: backupName,
- RestoreEnv: restoreEnv,
+ Name: backupName,
+ SourceTargetName: "target-b",
+ RestoreEnv: restoreEnv,
},
}}
horizontalScaling.ScaleOut.ReplicaChanges = pointer.Int32(2)
reqCtx := intctrlutil.RequestCtx{Ctx: testCtx.Ctx, Recorder: eventRecorder}
opsRes, _ := commonHScaleConsensusCompTest(reqCtx, nil, horizontalScaling, false, true)
+ restoreList := &dpv1alpha1.RestoreList{}
+ Expect(k8sClient.List(ctx, restoreList, client.MatchingLabels{
+ constant.OpsRequestNameLabelKey: opsRes.OpsRequest.Name,
+ }, client.InNamespace(opsRes.OpsRequest.Namespace))).Should(Succeed())
+ Expect(restoreList.Items).Should(HaveLen(2))
+ for i := range restoreList.Items {
+ Expect(restoreList.Items[i].Spec.Backup.SourceTargetName).Should(Equal("target-b"))
+ Expect(restoreList.Items[i].Spec.PrepareDataConfig.RequiredPolicyForAllPodSelection).NotTo(BeNil())
+ Expect(restoreList.Items[i].Spec.PrepareDataConfig.RequiredPolicyForAllPodSelection.DataRestorePolicy).
+ Should(Equal(dpv1alpha1.OneToOneRestorePolicy))
+ }
By("mock restore phase to completed")
comp, compDef, err := component.GetCompNCompDefByName(reqCtx.Ctx, k8sClient, opsRes.Cluster.Namespace, constant.GenerateClusterComponentName(opsRes.Cluster.Name, defaultCompName))
|