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))