diff --git a/apis/dataprotection/v1alpha1/actionset_types.go b/apis/dataprotection/v1alpha1/actionset_types.go
index 3df07149a4f..66f88a3faa7 100644
--- a/apis/dataprotection/v1alpha1/actionset_types.go
+++ b/apis/dataprotection/v1alpha1/actionset_types.go
@@ -168,6 +168,14 @@ type RestoreActionSpec struct {
// +optional
PostReady []ActionSpec `json:"postReady,omitempty"`
+ // Specifies how postReady actions run when they target multiple pods.
+ // Parallel preserves the existing behavior. Serial waits for each target
+ // action to complete before starting the next one.
+ //
+ // +optional
+ // +kubebuilder:default=Parallel
+ PostReadyExecutionPolicy PostReadyExecutionPolicy `json:"postReadyExecutionPolicy,omitempty"`
+
// Determines if a base backup is required during restoration.
//
// +optional
@@ -180,6 +188,16 @@ type RestoreActionSpec struct {
WithParameters []string `json:"withParameters,omitempty"`
}
+// PostReadyExecutionPolicy specifies how postReady actions execute across target pods.
+// +enum
+// +kubebuilder:validation:Enum={Parallel,Serial}
+type PostReadyExecutionPolicy string
+
+const (
+ PostReadyExecutionPolicyParallel PostReadyExecutionPolicy = "Parallel"
+ PostReadyExecutionPolicySerial PostReadyExecutionPolicy = "Serial"
+)
+
// ActionSpec defines an action that should be executed. Only one of the fields may be set.
type ActionSpec struct {
// Specifies that the action should be executed using the pod's exec API within a container.
diff --git a/config/crd/bases/dataprotection.kubeblocks.io_actionsets.yaml b/config/crd/bases/dataprotection.kubeblocks.io_actionsets.yaml
index 15b2de1b464..4196b5ed700 100644
--- a/config/crd/bases/dataprotection.kubeblocks.io_actionsets.yaml
+++ b/config/crd/bases/dataprotection.kubeblocks.io_actionsets.yaml
@@ -553,6 +553,16 @@ spec:
type: object
type: object
type: array
+ postReadyExecutionPolicy:
+ default: Parallel
+ description: |-
+ Specifies how postReady actions run when they target multiple pods.
+ Parallel preserves the existing behavior. Serial waits for each target
+ action to complete before starting the next one.
+ enum:
+ - Parallel
+ - Serial
+ type: string
prepareData:
description: Specifies the action required to prepare data for
restoration.
diff --git a/controllers/dataprotection/restore_controller.go b/controllers/dataprotection/restore_controller.go
index 4c94a3cd6e1..efd773c90a6 100644
--- a/controllers/dataprotection/restore_controller.go
+++ b/controllers/dataprotection/restore_controller.go
@@ -62,6 +62,7 @@ type RestoreReconciler struct {
// +kubebuilder:rbac:groups=dataprotection.kubeblocks.io,resources=restores/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=dataprotection.kubeblocks.io,resources=restores/finalizers,verbs=update
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete
@@ -105,6 +106,7 @@ func (r *RestoreReconciler) SetupWithManager(mgr ctrl.Manager) error {
return intctrlutil.NewControllerManagedBy(mgr).
For(&dpv1alpha1.Restore{}).
Owns(&batchv1.Job{}).
+ Owns(&corev1.Secret{}).
Watches(&batchv1.Job{}, handler.EnqueueRequestsFromMapFunc(r.parseRestoreJob)).
// to watch the `restore` container if it is terminated
Watches(&corev1.Pod{}, handler.EnqueueRequestsFromMapFunc(r.parseRestorePod)).
@@ -155,7 +157,11 @@ func (r *RestoreReconciler) deleteExternalResources(reqCtx intctrlutil.RequestCt
viper.GetString(constant.CfgKeyCtrlrMgrNS): {},
}
- return deleteRelatedObjectList(reqCtx, r.Client, &batchv1.JobList{}, namespaces, labels)
+ if err := deleteRelatedObjectList(reqCtx, r.Client, &batchv1.JobList{}, namespaces, labels); err != nil {
+ return err
+ }
+ return deleteRelatedObjectList(reqCtx, r.Client, &corev1.SecretList{},
+ map[string]sets.Empty{restore.Namespace: {}}, labels)
}
func CheckBackupRepoForRestore(reqCtx intctrlutil.RequestCtx, cli client.Client, restore *dpv1alpha1.Restore) (string, error) {
@@ -407,37 +413,31 @@ func (r *RestoreReconciler) prepareData(reqCtx intctrlutil.RequestCtx, restoreMg
}
func (r *RestoreReconciler) postReady(reqCtx intctrlutil.RequestCtx, restoreMgr *dprestore.RestoreManager) (bool, error) {
- readyConfig := restoreMgr.Restore.Spec.ReadyConfig
- if len(restoreMgr.PostReadyBackupSets) == 0 || readyConfig == nil {
- return true, nil
+ foundStage, err := restoreMgr.EnsurePostReadyStagePlan(reqCtx, r.Client)
+ if err != nil {
+ return false, err
+ }
+ if !foundStage {
+ if restoreMgr.Restore.Spec.ReadyConfig == nil || len(restoreMgr.PostReadyBackupSets) == 0 {
+ return true, nil
+ }
+ return false, intctrlutil.NewFatalError("postReady stage has actions but no committed execution plan")
}
if meta.IsStatusConditionTrue(restoreMgr.Restore.Status.Conditions, dprestore.ConditionTypeRestorePostReady) {
return true, nil
}
dprestore.SetRestoreStageCondition(restoreMgr.Restore, dpv1alpha1.PostReady, dprestore.ReasonProcessing, "processing postReady stage")
- var (
- err error
- isCompleted bool
- )
defer func() {
r.handleRestoreStageError(restoreMgr.Restore, dpv1alpha1.PrepareData, err)
}()
- if readyConfig.ReadinessProbe != nil && !meta.IsStatusConditionTrue(restoreMgr.Restore.Status.Conditions, dprestore.ConditionTypeReadinessProbe) {
+ readyConfig := restoreMgr.Restore.Spec.ReadyConfig
+ if readyConfig != nil && readyConfig.ReadinessProbe != nil && !meta.IsStatusConditionTrue(restoreMgr.Restore.Status.Conditions, dprestore.ConditionTypeReadinessProbe) {
// TODO: check readiness probe, use a job and kubectl exec?
_ = klog.TODO()
}
- for _, v := range restoreMgr.PostReadyBackupSets {
- // handle postReady actions
- for i := range v.ActionSet.Spec.Restore.PostReady {
- isCompleted, err = r.handleBackupActionSet(reqCtx, restoreMgr, v, dpv1alpha1.PostReady, i)
- if err != nil {
- return false, err
- }
- // waiting for restore jobs finished.
- if !isCompleted {
- return false, nil
- }
- }
+ isCompleted, err := restoreMgr.ReconcileOrphanedPostReadyActions(reqCtx, r.Client)
+ if err != nil || !isCompleted {
+ return false, err
}
dprestore.SetRestoreStageCondition(restoreMgr.Restore, dpv1alpha1.PostReady, dprestore.ReasonSucceed, "processing postReady stage successfully")
return true, nil
@@ -469,21 +469,39 @@ func (r *RestoreReconciler) handleBackupActionSet(reqCtx intctrlutil.RequestCtx,
}
actionName := fmt.Sprintf("%s-%d", stage, step)
+ var jobs []*batchv1.Job
+ var err error
+ expectedActionCount := 0
+ if stage == dpv1alpha1.PostReady {
+ // Load the immutable plan before interpreting mutable completion
+ // status. Otherwise one completed ordinal can make a partially
+ // created multi-Job action look complete.
+ jobs, err = restoreMgr.GetExistingActionJobs(reqCtx, r.Client, stage, backupSet.Backup.Name, actionName)
+ if err != nil {
+ return false, err
+ }
+ expectedActionCount, err = dprestore.PostReadyActionExpectedJobCount(jobs)
+ if err != nil {
+ return false, err
+ }
+ }
// 1. check if the restore actions are completed from status.actions firstly.
- allActionsFinished, existFailedAction := restoreMgr.AnalysisRestoreActionsWithBackup(stage, backupSet.Backup.Name, actionName)
+ allActionsFinished, existFailedAction := restoreMgr.AnalysisRestoreActionsWithBackupExpected(
+ stage, backupSet.Backup.Name, actionName, expectedActionCount)
isCompleted, err := checkIsCompleted(allActionsFinished, existFailedAction)
if isCompleted || err != nil {
return isCompleted, err
}
- var jobs []*batchv1.Job
// For in-flight actions, check the recorded Job before rebuilding the Job
// spec from the current target pod selector. The target pod may become
// unavailable after the Job is created, but the existing Job is still the
// action fact source that should drive convergence.
- jobs, err = restoreMgr.GetExistingActionJobs(reqCtx, r.Client, stage, backupSet.Backup.Name, actionName)
- if err != nil {
- return false, err
+ if stage != dpv1alpha1.PostReady {
+ jobs, err = restoreMgr.GetExistingActionJobs(reqCtx, r.Client, stage, backupSet.Backup.Name, actionName)
+ if err != nil {
+ return false, err
+ }
}
switch stage {
case dpv1alpha1.PrepareData:
@@ -507,11 +525,37 @@ func (r *RestoreReconciler) handleBackupActionSet(reqCtx intctrlutil.RequestCtx,
if len(jobs) == 0 {
return true, nil
}
+ if stage == dpv1alpha1.PostReady {
+ if jobs, err = restoreMgr.FreezePostReadyExecutionPlan(reqCtx, r.Client, jobs); err != nil {
+ return false, err
+ }
+ expectedActionCount, err = dprestore.PostReadyActionExpectedJobCount(jobs)
+ if err != nil {
+ return false, err
+ }
+ allActionsFinished, existFailedAction = restoreMgr.AnalysisRestoreActionsWithBackupExpected(
+ stage, backupSet.Backup.Name, actionName, expectedActionCount)
+ isCompleted, err = checkIsCompleted(allActionsFinished, existFailedAction)
+ if isCompleted || err != nil {
+ return isCompleted, err
+ }
+ jobs = restoreMgr.PendingPostReadyJobs(backupSet.Backup.Name, actionName, jobs)
+ if len(jobs) == 0 {
+ return false, intctrlutil.NewErrorf(intctrlutil.ErrorTypeRequeue,
+ "postReady action %s for backup %s has no pending Job but is not complete",
+ actionName, backupSet.Backup.Name)
+ }
+ }
// 3. create jobs
jobs, err = restoreMgr.CreateJobsIfNotExist(reqCtx, r.Client, restoreMgr.Restore, jobs)
if err != nil {
return false, err
}
+ if stage == dpv1alpha1.PostReady {
+ if err = restoreMgr.ResumeNextSerialPostReadyJob(reqCtx, r.Client, jobs); err != nil {
+ return false, err
+ }
+ }
// 4. check if jobs are finished.
allActionsFinished, existFailedAction, err = restoreMgr.CheckJobsDone(stage, actionName, backupSet, jobs)
diff --git a/controllers/dataprotection/restore_controller_test.go b/controllers/dataprotection/restore_controller_test.go
index 2f774fb5953..bf8ab471523 100644
--- a/controllers/dataprotection/restore_controller_test.go
+++ b/controllers/dataprotection/restore_controller_test.go
@@ -20,6 +20,7 @@ along with this program. If not, see .
package dataprotection
import (
+ "encoding/json"
"fmt"
"strconv"
"strings"
@@ -489,6 +490,37 @@ var _ = Describe("Restore Controller test", func() {
_ = testdp.NewFakeCluster(&testCtx)
})
+ expectCommittedFullStage := func(restore *dpv1alpha1.Restore) {
+ Eventually(func(g Gomega) {
+ secrets := &corev1.SecretList{}
+ g.Expect(k8sClient.List(ctx, secrets,
+ client.InNamespace(restore.Namespace),
+ client.MatchingLabels{dprestore.DataProtectionRestoreLabelKey: restore.Name})).Should(Succeed())
+ var payload []byte
+ for i := range secrets.Items {
+ if secrets.Items[i].Type == corev1.SecretType("dataprotection.kubeblocks.io/post-ready-plan") {
+ payload = secrets.Items[i].Data["plan.json"]
+ break
+ }
+ }
+ g.Expect(payload).ShouldNot(BeEmpty())
+ var stage struct {
+ Actions []struct {
+ Order int `json:"order"`
+ BackupName string `json:"backupName"`
+ ActionName string `json:"actionName"`
+ } `json:"actions"`
+ }
+ g.Expect(json.Unmarshal(payload, &stage)).Should(Succeed())
+ g.Expect(stage.Actions).Should(HaveLen(2))
+ g.Expect(stage.Actions[0].Order).Should(Equal(0))
+ g.Expect(stage.Actions[0].ActionName).Should(Equal("postReady-0"))
+ g.Expect(stage.Actions[1].Order).Should(Equal(1))
+ g.Expect(stage.Actions[1].ActionName).Should(Equal("postReady-1"))
+ g.Expect(stage.Actions[1].BackupName).Should(Equal(stage.Actions[0].BackupName))
+ }).Should(Succeed())
+ }
+
It("test post ready actions", func() {
By("remove the prepareData stage for testing post ready actions")
Expect(testapps.ChangeObj(&testCtx, actionSet, func(set *dpv1alpha1.ActionSet) {
@@ -507,6 +539,7 @@ var _ = Describe("Restore Controller test", func() {
Eventually(testapps.List(&testCtx, generics.JobSignature,
client.MatchingLabels{dprestore.DataProtectionRestoreLabelKey: restore.Name},
client.InNamespace(testCtx.DefaultNamespace))).Should(HaveLen(2))
+ expectCommittedFullStage(restore)
checkJobSA(restore, viper.GetString(dptypes.CfgKeyExecWorkerServiceAccountName))
@@ -526,6 +559,82 @@ var _ = Describe("Restore Controller test", func() {
Eventually(testapps.CheckObjExists(&testCtx, client.ObjectKeyFromObject(restore), restore, false)).Should(Succeed())
})
+ It("keeps running persisted postReady jobs after the ActionSet removes them", func() {
+ By("remove the prepareData stage for testing post ready actions")
+ Expect(testapps.ChangeObj(&testCtx, actionSet, func(set *dpv1alpha1.ActionSet) {
+ set.Spec.Restore.PrepareData = nil
+ })).Should(Succeed())
+
+ matchLabels := map[string]string{
+ constant.AppInstanceLabelKey: testdp.ClusterName,
+ }
+ restore := initResourcesAndWaitRestore(true, false, false, "", dpv1alpha1.RestorePhaseRunning,
+ func(f *testdp.MockRestoreFactory) {
+ f.SetConnectCredential(testdp.ClusterName).SetJobActionConfig(matchLabels).SetExecActionConfig(matchLabels)
+ }, nil)
+
+ By("wait for the first postReady step to create its Jobs")
+ Eventually(testapps.List(&testCtx, generics.JobSignature,
+ client.MatchingLabels{dprestore.DataProtectionRestoreLabelKey: restore.Name},
+ client.InNamespace(testCtx.DefaultNamespace))).Should(HaveLen(2))
+ expectCommittedFullStage(restore)
+
+ By("remove postReady from the mutable ActionSet while those Jobs are running")
+ Expect(testapps.ChangeObj(&testCtx, actionSet, func(set *dpv1alpha1.ActionSet) {
+ set.Spec.Restore.PostReady = nil
+ })).Should(Succeed())
+ Consistently(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(restore), func(g Gomega, r *dpv1alpha1.Restore) {
+ g.Expect(r.Status.Phase).Should(Equal(dpv1alpha1.RestorePhaseRunning))
+ }), 2*time.Second, 100*time.Millisecond).Should(Succeed())
+
+ By("complete action zero and continue from the frozen action one")
+ mockRestoreJobsCompleted(restore)
+ Eventually(testapps.List(&testCtx, generics.JobSignature,
+ client.MatchingLabels{dprestore.DataProtectionRestoreLabelKey: restore.Name},
+ client.InNamespace(testCtx.DefaultNamespace))).Should(HaveLen(3))
+ mockRestoreJobsCompleted(restore)
+ Eventually(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(restore), func(g Gomega, r *dpv1alpha1.Restore) {
+ g.Expect(r.Status.Phase).Should(Equal(dpv1alpha1.RestorePhaseCompleted))
+ })).Should(Succeed())
+ })
+
+ It("finishes a committed postReady plan after the ActionSet is deleted", func() {
+ By("remove the prepareData stage for testing post ready actions")
+ Expect(testapps.ChangeObj(&testCtx, actionSet, func(set *dpv1alpha1.ActionSet) {
+ set.Spec.Restore.PrepareData = nil
+ })).Should(Succeed())
+
+ matchLabels := map[string]string{
+ constant.AppInstanceLabelKey: testdp.ClusterName,
+ }
+ restore := initResourcesAndWaitRestore(true, false, false, "", dpv1alpha1.RestorePhaseRunning,
+ func(f *testdp.MockRestoreFactory) {
+ f.SetConnectCredential(testdp.ClusterName).SetJobActionConfig(matchLabels).SetExecActionConfig(matchLabels)
+ }, nil)
+
+ By("wait for the immutable plan and first postReady Jobs")
+ Eventually(testapps.List(&testCtx, generics.JobSignature,
+ client.MatchingLabels{dprestore.DataProtectionRestoreLabelKey: restore.Name},
+ client.InNamespace(testCtx.DefaultNamespace))).Should(HaveLen(2))
+ expectCommittedFullStage(restore)
+
+ By("delete the mutable ActionSet while the committed Jobs are running")
+ Expect(k8sClient.Delete(ctx, actionSet)).Should(Succeed())
+ Consistently(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(restore), func(g Gomega, r *dpv1alpha1.Restore) {
+ g.Expect(r.Status.Phase).Should(Equal(dpv1alpha1.RestorePhaseRunning))
+ }), 2*time.Second, 100*time.Millisecond).Should(Succeed())
+
+ By("complete action zero and create frozen action one without the deleted ActionSet")
+ mockRestoreJobsCompleted(restore)
+ Eventually(testapps.List(&testCtx, generics.JobSignature,
+ client.MatchingLabels{dprestore.DataProtectionRestoreLabelKey: restore.Name},
+ client.InNamespace(testCtx.DefaultNamespace))).Should(HaveLen(3))
+ mockRestoreJobsCompleted(restore)
+ Eventually(testapps.CheckObj(&testCtx, client.ObjectKeyFromObject(restore), func(g Gomega, r *dpv1alpha1.Restore) {
+ g.Expect(r.Status.Phase).Should(Equal(dpv1alpha1.RestorePhaseCompleted))
+ })).Should(Succeed())
+ })
+
It("should complete an existing postReady job when target pod is no longer ready", func() {
By("remove the prepareData stage for testing post ready actions")
Expect(testapps.ChangeObj(&testCtx, actionSet, func(set *dpv1alpha1.ActionSet) {
diff --git a/controllers/dataprotection/utils.go b/controllers/dataprotection/utils.go
index f7b3eb2bbac..4cbea049555 100644
--- a/controllers/dataprotection/utils.go
+++ b/controllers/dataprotection/utils.go
@@ -302,7 +302,7 @@ func getDefaultBackupRepo(ctx context.Context, cli client.Client) (*dpv1alpha1.B
}
type objectList interface {
- *appsv1.StatefulSetList | *batchv1.JobList
+ *appsv1.StatefulSetList | *batchv1.JobList | *corev1.SecretList
client.ObjectList
}
diff --git a/deploy/helm/crds/dataprotection.kubeblocks.io_actionsets.yaml b/deploy/helm/crds/dataprotection.kubeblocks.io_actionsets.yaml
index 15b2de1b464..4196b5ed700 100644
--- a/deploy/helm/crds/dataprotection.kubeblocks.io_actionsets.yaml
+++ b/deploy/helm/crds/dataprotection.kubeblocks.io_actionsets.yaml
@@ -553,6 +553,16 @@ spec:
type: object
type: object
type: array
+ postReadyExecutionPolicy:
+ default: Parallel
+ description: |-
+ Specifies how postReady actions run when they target multiple pods.
+ Parallel preserves the existing behavior. Serial waits for each target
+ action to complete before starting the next one.
+ enum:
+ - Parallel
+ - Serial
+ type: string
prepareData:
description: Specifies the action required to prepare data for
restoration.
diff --git a/docs/developer_docs/api-reference/dataprotection.md b/docs/developer_docs/api-reference/dataprotection.md
index 56e9d7c59a1..1ce3361cc12 100644
--- a/docs/developer_docs/api-reference/dataprotection.md
+++ b/docs/developer_docs/api-reference/dataprotection.md
@@ -4796,6 +4796,27 @@ And only takes effect when the ‘strategy’ is set to ‘Any&rsquo
+
+
baseBackupRequired
bool
diff --git a/pkg/dataprotection/restore/manager.go b/pkg/dataprotection/restore/manager.go
index c456154ac92..d4a1ae753b6 100644
--- a/pkg/dataprotection/restore/manager.go
+++ b/pkg/dataprotection/restore/manager.go
@@ -21,14 +21,18 @@ package restore
import (
"context"
+ "crypto/sha256"
+ "encoding/json"
"fmt"
"sort"
+ "strconv"
"strings"
"time"
vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
+ apiequality "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/runtime"
@@ -48,17 +52,50 @@ import (
)
const (
- restoreManagerContainerName = "restore-manager"
+ restoreManagerContainerName = "restore-manager"
+ postReadyExecutionPolicyAnnotationKey = "dataprotection.kubeblocks.io/post-ready-execution-policy"
+ postReadyTargetIdentityAnnotationKey = "dataprotection.kubeblocks.io/post-ready-target-identity"
+ postReadyTargetPlanAnnotationKey = "dataprotection.kubeblocks.io/post-ready-target-plan"
+ postReadyActionContractAnnotationKey = "dataprotection.kubeblocks.io/post-ready-action-contract"
+ postReadyBackupNameAnnotationKey = "dataprotection.kubeblocks.io/post-ready-backup-name"
+ postReadyActionNameAnnotationKey = "dataprotection.kubeblocks.io/post-ready-action-name"
+ postReadyPlanNameAnnotationKey = "dataprotection.kubeblocks.io/post-ready-plan-name"
+ postReadyPlanDigestAnnotationKey = "dataprotection.kubeblocks.io/post-ready-plan-digest"
+ postReadyPlanRestoreUIDAnnotationKey = "dataprotection.kubeblocks.io/post-ready-restore-uid"
+ postReadyPlanDataKey = "plan.json"
+ postReadyPlanVersion = "v2"
+ postReadyPlanMaxPayloadBytes = 1 << 20
+ postReadyPlanMarkerAnnotationKey = "dataprotection.kubeblocks.io/post-ready-stage-plan"
)
+const postReadyPlanSecretType corev1.SecretType = "dataprotection.kubeblocks.io/post-ready-plan"
+
+type postReadyActionExecutionPlan struct {
+ Order int `json:"order"`
+ BackupName string `json:"backupName"`
+ ActionName string `json:"actionName"`
+ Jobs []batchv1.Job `json:"jobs"`
+}
+
+type postReadyExecutionPlan struct {
+ Version string `json:"version"`
+ RestoreNamespace string `json:"restoreNamespace"`
+ RestoreName string `json:"restoreName"`
+ RestoreUID string `json:"restoreUID"`
+ SourceBackupNamespace string `json:"sourceBackupNamespace"`
+ SourceBackupName string `json:"sourceBackupName"`
+ Actions []postReadyActionExecutionPlan `json:"actions"`
+}
+
type BackupActionSet struct {
Backup *dpv1alpha1.Backup
// set it when the backup relies on incremental backups, such as Incremental backup
AncestorIncrementalBackups []*dpv1alpha1.Backup
// set it when the backup relies on a base backup, such as Continuous backup
- BaseBackup *dpv1alpha1.Backup
- ActionSet *dpv1alpha1.ActionSet
- UseVolumeSnapshot bool
+ BaseBackup *dpv1alpha1.Backup
+ ActionSet *dpv1alpha1.ActionSet
+ UseVolumeSnapshot bool
+ UseDurablePostReadyPlan bool
}
type RestoreManager struct {
@@ -103,6 +140,17 @@ func (r *RestoreManager) GetBackupActionSetByNamespaced(reqCtx intctrlutil.Reque
useVolumeSnapshot := backupMethod.SnapshotVolumes != nil && *backupMethod.SnapshotVolumes
actionSet, err := utils.GetActionSetByName(reqCtx, cli, backup.Status.BackupMethod.ActionSetName)
if err != nil {
+ if apierrors.IsNotFound(err) {
+ _, foundPlan, planErr := r.loadPostReadyExecutionPlanStage(reqCtx, cli)
+ if planErr != nil {
+ return nil, planErr
+ }
+ if foundPlan {
+ return &BackupActionSet{
+ Backup: backup, UseVolumeSnapshot: useVolumeSnapshot, UseDurablePostReadyPlan: true,
+ }, nil
+ }
+ }
return nil, err
}
return &BackupActionSet{Backup: backup, ActionSet: actionSet, UseVolumeSnapshot: useVolumeSnapshot}, nil
@@ -323,6 +371,18 @@ func (r *RestoreManager) SetBackupSets(backupSets ...BackupActionSet) {
// AnalysisRestoreActionsWithBackup analysis the restore actions progress group by backup.
// check if the restore jobs are completed or failed or processing.
func (r *RestoreManager) AnalysisRestoreActionsWithBackup(stage dpv1alpha1.RestoreStage, backupName string, actionName string) (bool, bool) {
+ return r.AnalysisRestoreActionsWithBackupExpected(stage, backupName, actionName, 0)
+}
+
+// AnalysisRestoreActionsWithBackupExpected prevents a partially recorded
+// postReady action from looking complete when its immutable plan contains more
+// Jobs than Restore status has observed so far.
+func (r *RestoreManager) AnalysisRestoreActionsWithBackupExpected(
+ stage dpv1alpha1.RestoreStage,
+ backupName string,
+ actionName string,
+ expectedActionCount int,
+) (bool, bool) {
var (
restoreActionCount int
finishedActionCount int
@@ -350,6 +410,9 @@ func (r *RestoreManager) AnalysisRestoreActionsWithBackup(stage dpv1alpha1.Resto
finishedActionCount += 1
}
}
+ if stage == dpv1alpha1.PostReady && expectedActionCount > 0 {
+ restoreActionCount = expectedActionCount
+ }
allActionsFinished := restoreActionCount > 0 && finishedActionCount == restoreActionCount
return allActionsFinished, existFailedAction
@@ -609,14 +672,24 @@ func (r *RestoreManager) BuildVolumePopulateJob(
}
// GetExistingActionJobs returns jobs already recorded in Restore status for an in-flight action.
-// If any recorded Processing Job is missing, it returns an empty list so callers can follow
-// the original build/create path.
+// PostReady first loads its immutable plan Secret, including completed
+// predecessors, because the plan survives Job GC. Legacy restores retain the
+// status-recorded Job fallback. PrepareData keeps its processing-only path.
func (r *RestoreManager) GetExistingActionJobs(
reqCtx intctrlutil.RequestCtx,
cli client.Client,
stage dpv1alpha1.RestoreStage,
backupName string,
actionName string) ([]*batchv1.Job, error) {
+ if stage == dpv1alpha1.PostReady {
+ jobs, found, err := r.loadPostReadyExecutionPlan(reqCtx, cli, backupName, actionName)
+ if err != nil {
+ return nil, err
+ }
+ if found {
+ return jobs, nil
+ }
+ }
restoreActions := r.Restore.Status.Actions.PrepareData
if stage == dpv1alpha1.PostReady {
restoreActions = r.Restore.Status.Actions.PostReady
@@ -633,10 +706,12 @@ func (r *RestoreManager) GetExistingActionJobs(
action := restoreActions[i]
if action.BackupName != backupName ||
action.Name != actionName ||
- action.Status != dpv1alpha1.RestoreActionProcessing ||
!strings.HasPrefix(action.ObjectKey, jobKeyPrefix) {
continue
}
+ if stage != dpv1alpha1.PostReady && action.Status != dpv1alpha1.RestoreActionProcessing {
+ continue
+ }
jobName := strings.TrimPrefix(action.ObjectKey, jobKeyPrefix)
found := false
for _, namespace := range namespaces {
@@ -665,179 +740,1845 @@ func (r *RestoreManager) GetExistingActionJobs(
return jobs, nil
}
-func (r *RestoreManager) isJobForRestoreAction(job *batchv1.Job) bool {
- if job.Labels[DataProtectionRestoreLabelKey] != r.Restore.Name {
- return false
+// ReconcileOrphanedPostReadyActions keeps persisted in-flight Jobs authoritative
+// when a newer ActionSet removes or shortens postReady. Without this guard, the
+// latest ActionSet loop can skip a still-running Job and mark postReady complete.
+func (r *RestoreManager) ReconcileOrphanedPostReadyActions(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+) (bool, error) {
+ stage, foundStage, err := r.loadPostReadyExecutionPlanStage(reqCtx, cli)
+ if err != nil {
+ return false, err
}
- restoreNamespace := job.Labels[DataProtectionRestoreNamespaceLabelKey]
- if job.Namespace != r.Restore.Namespace {
- return restoreNamespace == r.Restore.Namespace
+ if foundStage {
+ secret := &corev1.Secret{}
+ if err := cli.Get(reqCtx.Ctx, types.NamespacedName{
+ Namespace: r.Restore.Namespace,
+ Name: r.postReadyPlanSecretName(),
+ }, secret); err != nil {
+ return false, err
+ }
+ digest := secret.Annotations[postReadyPlanDigestAnnotationKey]
+ for i := range stage.Actions {
+ action := &stage.Actions[i]
+ jobs := attachPostReadyPlanReference(
+ action.Jobs, secret.Name, digest, string(r.Restore.UID))
+ expectedCount, err := PostReadyActionExpectedJobCount(jobs)
+ if err != nil {
+ return false, err
+ }
+ completed, failed := r.AnalysisRestoreActionsWithBackupExpected(
+ dpv1alpha1.PostReady, action.BackupName, action.ActionName, expectedCount)
+ if failed {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady action %s for backup %s has a terminal failed Job",
+ action.ActionName, action.BackupName))
+ }
+ if completed {
+ continue
+ }
+ pending := r.PendingPostReadyJobs(action.BackupName, action.ActionName, jobs)
+ if len(pending) == 0 {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady action %s for backup %s has no pending Job but is not complete",
+ action.ActionName, action.BackupName))
+ }
+ pending, err = r.CreateJobsIfNotExist(reqCtx, cli, r.Restore, pending)
+ if err != nil {
+ return false, err
+ }
+ if err := r.ResumeNextSerialPostReadyJob(reqCtx, cli, pending); err != nil {
+ return false, err
+ }
+ backupSet := BackupActionSet{Backup: &dpv1alpha1.Backup{
+ ObjectMeta: metav1.ObjectMeta{Name: action.BackupName},
+ }}
+ _, failed, err = r.CheckJobsDone(dpv1alpha1.PostReady, action.ActionName, backupSet, pending)
+ if err != nil {
+ return false, err
+ }
+ if failed {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady action %s for backup %s has a terminal failed Job",
+ action.ActionName, action.BackupName))
+ }
+ completed, _ = r.AnalysisRestoreActionsWithBackupExpected(
+ dpv1alpha1.PostReady, action.BackupName, action.ActionName, expectedCount)
+ if !completed {
+ return false, nil
+ }
+ }
+ return true, nil
}
- return restoreNamespace == "" || restoreNamespace == r.Restore.Namespace
-}
-// BuildPostReadyActionJobs builds the post ready jobs.
-func (r *RestoreManager) BuildPostReadyActionJobs(reqCtx intctrlutil.RequestCtx, cli client.Client, backupSet BackupActionSet, target *dpv1alpha1.BackupStatusTarget, step int) ([]*batchv1.Job, error) {
- readyConfig := r.Restore.Spec.ReadyConfig
- if readyConfig == nil {
- return nil, nil
+ // Before the stage marker exists, retain the legacy recovery path for an
+ // upgrade with already-running frozen Jobs. New reconciles commit the full
+ // stage plan before creating any Job and therefore never enter this path.
+ currentActions := map[string]struct{}{}
+ if r.Restore.Spec.ReadyConfig != nil {
+ for i := range r.PostReadyBackupSets {
+ backupSet := r.PostReadyBackupSets[i]
+ if backupSet.Backup == nil || backupSet.ActionSet == nil || backupSet.ActionSet.Spec.Restore == nil {
+ continue
+ }
+ for step := range backupSet.ActionSet.Spec.Restore.PostReady {
+ currentActions[postReadyActionKey(backupSet.Backup.Name, fmt.Sprintf("%s-%d", dpv1alpha1.PostReady, step))] = struct{}{}
+ }
+ }
}
- if !backupSet.ActionSet.HasPostReadyStage() {
- return nil, nil
+ durablePlanKeys := map[string]struct{}{}
+
+ namespaces := []string{r.Restore.Namespace}
+ if controllerNamespace := viper.GetString(constant.CfgKeyCtrlrMgrNS); controllerNamespace != "" && controllerNamespace != r.Restore.Namespace {
+ namespaces = append(namespaces, controllerNamespace)
}
- backupRepo, err := r.prepareBackupRepo(reqCtx, cli, backupSet)
- if err != nil {
- return nil, err
+ orphaned := map[string][]*batchv1.Job{}
+ for _, namespace := range namespaces {
+ jobList := &batchv1.JobList{}
+ if err := cli.List(reqCtx.Ctx, jobList,
+ client.InNamespace(namespace),
+ client.MatchingLabels{DataProtectionRestoreLabelKey: r.Restore.Name}); err != nil {
+ return false, err
+ }
+ for i := range jobList.Items {
+ job := &jobList.Items[i]
+ if !r.isJobForRestoreAction(job) || !hasPostReadyFrozenContract(job) {
+ continue
+ }
+ backupName, actionName := postReadyActionIdentity(job)
+ if backupName == "" || actionName == "" {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has no recoverable action identity", job.Namespace, job.Name))
+ }
+ key := postReadyActionKey(backupName, actionName)
+ if _, ok := currentActions[key]; ok {
+ continue
+ }
+ if _, ok := durablePlanKeys[key]; ok {
+ continue
+ }
+ orphaned[key] = append(orphaned[key], job.DeepCopy())
+ }
}
- actionSpec := backupSet.ActionSet.Spec.Restore.PostReady[step]
- getTargetPodList := func(labelSelector metav1.LabelSelector, msgKey string) (*corev1.PodList, error) {
- targetPodList, err := utils.GetPodListByLabelSelector(reqCtx, cli, &labelSelector)
+
+ orphanedKeys := make([]string, 0, len(orphaned))
+ for key := range orphaned {
+ orphanedKeys = append(orphanedKeys, key)
+ }
+ sort.Strings(orphanedKeys)
+ for _, key := range orphanedKeys {
+ jobs := orphaned[key]
+ backupName, actionName := splitPostReadyActionKey(key)
+ plan, err := postReadyTargetPlan(jobs[0])
if err != nil {
- return nil, err
+ return false, err
}
- if len(targetPodList.Items) == 0 {
- return nil, intctrlutil.NewErrorf(intctrlutil.ErrorTypeRequeue, "can not found any pod by spec.readyConfig.%s.target.podSelector", msgKey)
+ if len(plan) != len(jobs) {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "in-flight postReady action %s for backup %s is incomplete and no longer exists in the ActionSet",
+ actionName, backupName))
+ }
+ policy, err := postReadyExecutionPolicyForJob(jobs[0])
+ if err != nil {
+ return false, err
+ }
+ contract := postReadyActionContractForJob(jobs[0])
+ identities := map[string]struct{}{}
+ for i := range jobs {
+ jobPlan, err := postReadyTargetPlan(jobs[i])
+ if err != nil {
+ return false, err
+ }
+ jobPolicy, err := postReadyExecutionPolicyForJob(jobs[i])
+ if err != nil {
+ return false, err
+ }
+ identity := postReadyTargetIdentity(jobs[i])
+ index := postReadyJobIndex(jobs[i].Name)
+ if strings.Join(jobPlan, "\x00") != strings.Join(plan, "\x00") ||
+ jobPolicy != policy || postReadyActionContractForJob(jobs[i]) != contract ||
+ index < 0 || index >= len(plan) || identity != plan[index] {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "in-flight postReady action %s for backup %s has an inconsistent frozen contract",
+ actionName, backupName))
+ }
+ if _, ok := identities[identity]; ok {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "in-flight postReady action %s for backup %s has a duplicate frozen target",
+ actionName, backupName))
+ }
+ identities[identity] = struct{}{}
+ }
+ backupSet := BackupActionSet{Backup: &dpv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Name: backupName}}}
+ completed, failed, err := r.CheckJobsDone(dpv1alpha1.PostReady, actionName, backupSet, jobs)
+ if err != nil {
+ return false, err
+ }
+ if failed {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "in-flight postReady action %s for backup %s failed after it was removed from the ActionSet",
+ actionName, backupName))
+ }
+ if !completed {
+ return false, nil
}
- return targetPodList, nil
}
+ return true, nil
+}
- buildJobName := func(index int) string {
- jobName := fmt.Sprintf("restore-post-ready-%s-%s-%d-%d", r.Restore.UID[:8], backupSet.Backup.Name, step, index)
- return cutJobName(jobName)
+func postReadyActionKey(backupName, actionName string) string {
+ return backupName + "\x00" + actionName
+}
+
+func splitPostReadyActionKey(key string) (string, string) {
+ parts := strings.SplitN(key, "\x00", 2)
+ if len(parts) != 2 {
+ return "", ""
}
- jobBuilder := newRestoreJobBuilder(r.Restore, backupSet, backupRepo, dpv1alpha1.PostReady)
- buildJobsForJobAction := func() ([]*batchv1.Job, error) {
- jobAction := r.Restore.Spec.ReadyConfig.JobAction
- if jobAction == nil {
- return nil, intctrlutil.NewFatalError("spec.readyConfig.jobAction can not be empty")
+ return parts[0], parts[1]
+}
+
+func (r *RestoreManager) postReadyPlanSecretName() string {
+ identity := strings.Join([]string{
+ r.Restore.Namespace,
+ r.Restore.Name,
+ string(r.Restore.UID),
+ string(dpv1alpha1.PostReady),
+ }, "\x00")
+ digest := sha256.Sum256([]byte(identity))
+ return fmt.Sprintf("postready-stage-%x", digest[:16])
+}
+
+func postReadyPlanMarkerValue(planName, digest string) string {
+ return planName + "@" + digest
+}
+
+func (r *RestoreManager) postReadyPlanMarker() (string, bool) {
+ if r.Restore.Annotations == nil {
+ return "", false
+ }
+ value, ok := r.Restore.Annotations[postReadyPlanMarkerAnnotationKey]
+ return value, ok
+}
+
+func (r *RestoreManager) ensurePostReadyPlanMarker(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ planName, digest string,
+) error {
+ expected := postReadyPlanMarkerValue(planName, digest)
+ if current, ok := r.postReadyPlanMarker(); ok {
+ if current != expected {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "restore %s/%s has a conflicting postReady execution plan marker",
+ r.Restore.Namespace, r.Restore.Name))
}
- podSelector := jobAction.Target.PodSelector
- if podSelector.LabelSelector == nil {
- return nil, intctrlutil.NewFatalError("spec.readyConfig.jobAction.podSelector.labelSelector can not be empty")
+ return nil
+ }
+ original := r.Restore.DeepCopy()
+ if r.Restore.Annotations == nil {
+ r.Restore.Annotations = map[string]string{}
+ }
+ r.Restore.Annotations[postReadyPlanMarkerAnnotationKey] = expected
+ return cli.Patch(reqCtx.Ctx, r.Restore, client.MergeFrom(original))
+}
+
+func postReadyPlanIdentityForJobs(jobs []*batchv1.Job) (string, string, bool, error) {
+ if len(jobs) == 0 {
+ return "", "", false, nil
+ }
+ backupName, actionName := postReadyActionIdentity(jobs[0])
+ if backupName == "" && actionName == "" {
+ return "", "", false, nil
+ }
+ if backupName == "" || actionName == "" {
+ return "", "", false, intctrlutil.NewFatalError("postReady jobs have an incomplete action identity")
+ }
+ for i := 1; i < len(jobs); i++ {
+ jobBackupName, jobActionName := postReadyActionIdentity(jobs[i])
+ if jobBackupName != backupName || jobActionName != actionName {
+ return "", "", false, intctrlutil.NewFatalError("postReady jobs have inconsistent action identities")
}
- targetPodList, err := getTargetPodList(*podSelector.LabelSelector, "jobAction")
+ }
+ return backupName, actionName, true, nil
+}
+
+func canonicalPostReadyPlanJobs(jobs []*batchv1.Job) ([]batchv1.Job, error) {
+ canonical := make([]batchv1.Job, 0, len(jobs))
+ for i := range jobs {
+ if jobs[i] == nil {
+ return nil, intctrlutil.NewFatalError("postReady execution plan contains a nil Job")
+ }
+ job := jobs[i].DeepCopy()
+ job.TypeMeta = metav1.TypeMeta{APIVersion: batchv1.SchemeGroupVersion.String(), Kind: "Job"}
+ job.ResourceVersion = ""
+ job.UID = ""
+ job.Generation = 0
+ job.CreationTimestamp = metav1.Time{}
+ job.DeletionTimestamp = nil
+ job.DeletionGracePeriodSeconds = nil
+ job.ManagedFields = nil
+ job.OwnerReferences = nil
+ job.Finalizers = []string{dptypes.DataProtectionFinalizerName}
+ job.Status = batchv1.JobStatus{}
+ if job.Annotations != nil {
+ delete(job.Annotations, postReadyPlanNameAnnotationKey)
+ delete(job.Annotations, postReadyPlanDigestAnnotationKey)
+ delete(job.Annotations, postReadyPlanRestoreUIDAnnotationKey)
+ }
+ canonical = append(canonical, *job)
+ }
+ sort.Slice(canonical, func(i, j int) bool {
+ return postReadyJobIndex(canonical[i].Name) < postReadyJobIndex(canonical[j].Name)
+ })
+ var expectedPolicy dpv1alpha1.PostReadyExecutionPolicy
+ var expectedContract string
+ var expectedTargetPlan string
+ for i := range canonical {
+ if postReadyJobIndex(canonical[i].Name) != i {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady execution plan has non-contiguous Job ordinal at %s/%s",
+ canonical[i].Namespace, canonical[i].Name))
+ }
+ policy, err := postReadyExecutionPolicyForJob(&canonical[i])
if err != nil {
return nil, err
}
- sort.Sort(intctrlutil.ByPodName(targetPodList.Items))
- buildJob := func(targetPod *corev1.Pod, sourceTargetPodName string, index int) *batchv1.Job {
- if boolptr.IsSetToTrue(actionSpec.Job.RunOnTargetPodNode) {
- jobBuilder.resetSpecificVolumesAndMounts()
- jobBuilder.setNodeNameToNodeSelector(targetPod.Spec.NodeName)
- // mount the targe pod's volumes when RunOnTargetPodNode is true
- for _, volumeMount := range jobAction.Target.VolumeMounts {
- for _, volume := range targetPod.Spec.Volumes {
- if volume.Name != volumeMount.Name {
- continue
- }
- jobBuilder.addToSpecificVolumesAndMounts(&volume, &volumeMount)
- }
- }
- }
- return jobBuilder.setImage(actionSpec.Job.Image).
- setJobName(buildJobName(index)).
- addCommonEnv(sourceTargetPodName).
- attachBackupRepo().
- setCommand(actionSpec.Job.Command).
- setToleration(targetPod.Spec.Tolerations).
- addTargetPodAndCredentialEnv(targetPod, readyConfig.ConnectionCredential, &target.BackupTarget).
- setServiceAccount(r.WorkerServiceAccount).
- build()
+ plan, err := postReadyTargetPlan(&canonical[i])
+ if err != nil {
+ return nil, err
}
-
- if podSelector.Strategy == dpv1alpha1.PodSelectionStrategyAny {
- targetPod := utils.GetFirstIndexRunningPod(targetPodList)
- if targetPod == nil {
- return nil, fmt.Errorf("can not found any running pod by spec.readyConfig.jobAction.target.podSelector")
- }
- targetPodList.Items = []corev1.Pod{*targetPod}
+ if len(plan) != len(canonical) || plan[i] != postReadyTargetIdentity(&canonical[i]) {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s target identity does not match the complete execution plan",
+ canonical[i].Namespace, canonical[i].Name))
}
- var jobs []*batchv1.Job
- for i := range targetPodList.Items {
- sourceTargetPodName, err := GetSourcePodNameFromTarget(target, jobAction.RequiredPolicyForAllPodSelection, i)
- if err != nil {
- return nil, err
- }
- if target.PodSelector.Strategy == dpv1alpha1.PodSelectionStrategyAll && sourceTargetPodName == "" {
- // no need to recover the volume when the pod selection policy is 'All' and sourceTargetPodName is not found.
- continue
- }
- jobs = append(jobs, buildJob(&targetPodList.Items[i], sourceTargetPodName, i))
+ contract := postReadyActionContractForJob(&canonical[i])
+ if contract == "" {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has no frozen action contract",
+ canonical[i].Namespace, canonical[i].Name))
+ }
+ serializedTargetPlan := canonical[i].Annotations[postReadyTargetPlanAnnotationKey]
+ if i == 0 {
+ expectedPolicy = policy
+ expectedContract = contract
+ expectedTargetPlan = serializedTargetPlan
+ } else if policy != expectedPolicy || contract != expectedContract || serializedTargetPlan != expectedTargetPlan {
+ return nil, intctrlutil.NewFatalError("postReady execution plan contains inconsistent Job contracts")
}
- return jobs, nil
}
+ return canonical, nil
+}
- buildJobsForExecAction := func() ([]*batchv1.Job, error) {
- execAction := r.Restore.Spec.ReadyConfig.ExecAction
- if execAction == nil {
- return nil, intctrlutil.NewFatalError("spec.readyConfig.execAction can not be empty")
+func postReadyPlanDigest(payload []byte) string {
+ return fmt.Sprintf("sha256:%x", sha256.Sum256(payload))
+}
+
+func attachPostReadyPlanReference(jobs []batchv1.Job, planName, digest, restoreUID string) []*batchv1.Job {
+ result := make([]*batchv1.Job, 0, len(jobs))
+ for i := range jobs {
+ job := jobs[i].DeepCopy()
+ if job.Annotations == nil {
+ job.Annotations = map[string]string{}
}
- targetPodList, err := getTargetPodList(execAction.Target.PodSelector, "execAction")
+ job.Annotations[postReadyPlanNameAnnotationKey] = planName
+ job.Annotations[postReadyPlanDigestAnnotationKey] = digest
+ job.Annotations[postReadyPlanRestoreUIDAnnotationKey] = restoreUID
+ result = append(result, job)
+ }
+ return result
+}
+
+func canonicalPostReadyStagePlan(plan postReadyExecutionPlan) (postReadyExecutionPlan, error) {
+ if len(plan.Actions) == 0 {
+ return postReadyExecutionPlan{}, intctrlutil.NewFatalError("postReady stage execution plan has no actions")
+ }
+ seen := map[string]struct{}{}
+ canonicalActions := make([]postReadyActionExecutionPlan, 0, len(plan.Actions))
+ for i := range plan.Actions {
+ action := plan.Actions[i]
+ if action.Order != i || action.BackupName == "" || action.ActionName == "" || len(action.Jobs) == 0 {
+ return postReadyExecutionPlan{}, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage action %d has an invalid ordered identity", i))
+ }
+ key := postReadyActionKey(action.BackupName, action.ActionName)
+ if _, ok := seen[key]; ok {
+ return postReadyExecutionPlan{}, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan has duplicate action %s/%s", action.BackupName, action.ActionName))
+ }
+ seen[key] = struct{}{}
+ jobPointers := make([]*batchv1.Job, 0, len(action.Jobs))
+ for j := range action.Jobs {
+ jobPointers = append(jobPointers, action.Jobs[j].DeepCopy())
+ }
+ canonicalJobs, err := canonicalPostReadyPlanJobs(jobPointers)
if err != nil {
- return nil, err
+ return postReadyExecutionPlan{}, err
}
- var restoreJobs []*batchv1.Job
- for i := range targetPodList.Items {
- containerName := actionSpec.Exec.Container
- if containerName == "" {
- containerName = targetPodList.Items[i].Spec.Containers[0].Name
- }
- args := append([]string{"-n", targetPodList.Items[i].Namespace, "exec", targetPodList.Items[i].Name, "-c", containerName, "--"}, actionSpec.Exec.Command...)
- jobBuilder.setImage(viper.GetString(constant.KBToolsImage)).setCommand([]string{"kubectl"}).setArgs(args).
- setJobName(buildJobName(i)).
- setToleration(targetPodList.Items[i].Spec.Tolerations)
- job := jobBuilder.build()
- // create exec job in kubeblocks namespace for security
- kbInstalledNamespace := viper.GetString(constant.CfgKeyCtrlrMgrNS)
- if kbInstalledNamespace != "" {
- job.Namespace = kbInstalledNamespace
- // use the dedicated ServiceAccount for executing "kubectl exec"
- job.Spec.Template.Spec.ServiceAccountName = viper.GetString(dptypes.CfgKeyExecWorkerServiceAccountName)
+ for j := range canonicalJobs {
+ backupName, actionName := postReadyActionIdentity(&canonicalJobs[j])
+ if backupName != action.BackupName || actionName != action.ActionName {
+ return postReadyExecutionPlan{}, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage action %s/%s contains a Job for another action",
+ action.BackupName, action.ActionName))
}
- job.Labels[DataProtectionRestoreNamespaceLabelKey] = r.Restore.Namespace
- restoreJobs = append(restoreJobs, job)
}
- return restoreJobs, nil
+ canonicalActions = append(canonicalActions, postReadyActionExecutionPlan{
+ Order: action.Order, BackupName: action.BackupName, ActionName: action.ActionName, Jobs: canonicalJobs,
+ })
}
+ plan.Actions = canonicalActions
+ return plan, nil
+}
- if actionSpec.Job != nil {
- return buildJobsForJobAction()
+func (r *RestoreManager) findPostReadyStageAction(
+ plan *postReadyExecutionPlan,
+ backupName, actionName string,
+) (*postReadyActionExecutionPlan, bool) {
+ for i := range plan.Actions {
+ if plan.Actions[i].BackupName == backupName && plan.Actions[i].ActionName == actionName {
+ return &plan.Actions[i], true
+ }
}
- return buildJobsForExecAction()
+ return nil, false
}
-func (r *RestoreManager) createPVCIfNotExist(
+func (r *RestoreManager) loadPostReadyExecutionPlanStage(
reqCtx intctrlutil.RequestCtx,
cli client.Client,
- claimMetadata metav1.ObjectMeta,
- claimSpec corev1.PersistentVolumeClaimSpec) error {
- claimMetadata.Namespace = reqCtx.Req.Namespace
- pvc := &corev1.PersistentVolumeClaim{
- ObjectMeta: claimMetadata,
- Spec: claimSpec,
+ expected ...*postReadyExecutionPlan,
+) (*postReadyExecutionPlan, bool, error) {
+ secretName := r.postReadyPlanSecretName()
+ marker, hasMarker := r.postReadyPlanMarker()
+ secret := &corev1.Secret{}
+ if err := cli.Get(reqCtx.Ctx, types.NamespacedName{Namespace: r.Restore.Namespace, Name: secretName}, secret); err != nil {
+ if apierrors.IsNotFound(err) {
+ if hasMarker {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s is missing", r.Restore.Namespace, secretName))
+ }
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+ if secret.DeletionTimestamp != nil {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s is terminating", secret.Namespace, secret.Name))
+ }
+ if secret.Type != postReadyPlanSecretType || secret.Immutable == nil || !*secret.Immutable {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s is not an immutable plan Secret", secret.Namespace, secret.Name))
+ }
+ controller := metav1.GetControllerOf(secret)
+ if controller == nil ||
+ controller.APIVersion != dpv1alpha1.SchemeGroupVersion.String() ||
+ controller.Kind != dptypes.RestoreKind ||
+ controller.Name != r.Restore.Name ||
+ controller.UID != r.Restore.UID ||
+ controller.Controller == nil || !*controller.Controller ||
+ controller.BlockOwnerDeletion == nil || !*controller.BlockOwnerDeletion ||
+ secret.Annotations[postReadyPlanRestoreUIDAnnotationKey] != string(r.Restore.UID) {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s is not owned by restore %s/%s",
+ secret.Namespace, secret.Name, r.Restore.Namespace, r.Restore.Name))
+ }
+ payload := secret.Data[postReadyPlanDataKey]
+ if len(payload) == 0 {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s has no canonical payload", secret.Namespace, secret.Name))
+ }
+ digest := postReadyPlanDigest(payload)
+ if secret.Annotations == nil || secret.Annotations[postReadyPlanDigestAnnotationKey] != digest {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s has an invalid payload digest", secret.Namespace, secret.Name))
+ }
+ var plan postReadyExecutionPlan
+ if err := json.Unmarshal(payload, &plan); err != nil {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s has an invalid payload: %v", secret.Namespace, secret.Name, err))
+ }
+ if plan.Version != postReadyPlanVersion ||
+ plan.RestoreNamespace != r.Restore.Namespace || plan.RestoreName != r.Restore.Name ||
+ plan.RestoreUID != string(r.Restore.UID) ||
+ plan.SourceBackupNamespace != r.Restore.Spec.Backup.Namespace ||
+ plan.SourceBackupName != r.Restore.Spec.Backup.Name {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s does not match restore identity", secret.Namespace, secret.Name))
+ }
+ canonical, err := canonicalPostReadyStagePlan(plan)
+ if err != nil {
+ return nil, true, err
}
- tmpPVC := &corev1.PersistentVolumeClaim{}
- if err := cli.Get(reqCtx.Ctx, types.NamespacedName{Name: claimMetadata.Name, Namespace: claimMetadata.Namespace}, tmpPVC); err != nil {
- if !apierrors.IsNotFound(err) {
- return err
+ canonicalPayload, err := json.Marshal(canonical)
+ if err != nil {
+ return nil, true, err
+ }
+ if string(canonicalPayload) != string(payload) {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s payload is not canonical", secret.Namespace, secret.Name))
+ }
+ expectedMarker := postReadyPlanMarkerValue(secret.Name, digest)
+ if hasMarker && marker != expectedMarker {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "restore %s/%s postReady stage execution plan marker does not match %s/%s",
+ r.Restore.Namespace, r.Restore.Name, secret.Namespace, secret.Name))
+ }
+ if !hasMarker {
+ if len(expected) != 1 || expected[0] == nil {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s has no committed marker and no complete expected stage",
+ secret.Namespace, secret.Name))
}
- msg := fmt.Sprintf("created pvc %s/%s", pvc.Namespace, pvc.Name)
- r.Recorder.Event(r.Restore, corev1.EventTypeNormal, reasonCreateRestorePVC, msg)
- if err = cli.Create(reqCtx.Ctx, pvc); err != nil {
- return client.IgnoreAlreadyExists(err)
+ expectedCanonical, err := canonicalPostReadyStagePlan(*expected[0])
+ if err != nil {
+ return nil, true, err
+ }
+ expectedPayload, err := json.Marshal(expectedCanonical)
+ if err != nil {
+ return nil, true, err
+ }
+ if string(expectedPayload) != string(payload) {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan %s/%s does not match the complete expected stage",
+ secret.Namespace, secret.Name))
+ }
+ if err := r.migratePostReadyJobsBeforeStageCommit(reqCtx, cli, &canonical, secret.Name, digest); err != nil {
+ return nil, true, err
}
}
- return nil
+ if err := r.ensurePostReadyPlanMarker(reqCtx, cli, secret.Name, digest); err != nil {
+ return nil, true, err
+ }
+ return &canonical, true, nil
}
-// CreateJobsIfNotExist creates the jobs if not exist.
-func (r *RestoreManager) CreateJobsIfNotExist(reqCtx intctrlutil.RequestCtx,
+func (r *RestoreManager) persistPostReadyExecutionPlanStage(
+ reqCtx intctrlutil.RequestCtx,
cli client.Client,
- ownerObj client.Object,
- objs []*batchv1.Job) ([]*batchv1.Job, error) {
- // creates jobs if not exist
- var fetchedJobs []*batchv1.Job
- for i := range objs {
+ actions []postReadyActionExecutionPlan,
+) (*postReadyExecutionPlan, error) {
+ canonical, err := canonicalPostReadyStagePlan(postReadyExecutionPlan{
+ Version: postReadyPlanVersion,
+ RestoreNamespace: r.Restore.Namespace,
+ RestoreName: r.Restore.Name,
+ RestoreUID: string(r.Restore.UID),
+ SourceBackupNamespace: r.Restore.Spec.Backup.Namespace,
+ SourceBackupName: r.Restore.Spec.Backup.Name,
+ Actions: actions,
+ })
+ if err != nil {
+ return nil, err
+ }
+ payload, err := json.Marshal(canonical)
+ if err != nil {
+ return nil, err
+ }
+ if len(payload) > postReadyPlanMaxPayloadBytes {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan payload is %d bytes and exceeds the Secret limit", len(payload)))
+ }
+ digest := postReadyPlanDigest(payload)
+ immutable := true
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: r.Restore.Namespace,
+ Name: r.postReadyPlanSecretName(),
+ Labels: map[string]string{
+ DataProtectionRestoreLabelKey: r.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: r.Restore.Namespace,
+ },
+ Annotations: map[string]string{
+ postReadyPlanDigestAnnotationKey: digest,
+ postReadyPlanRestoreUIDAnnotationKey: string(r.Restore.UID),
+ },
+ },
+ Immutable: &immutable,
+ Type: postReadyPlanSecretType,
+ Data: map[string][]byte{postReadyPlanDataKey: payload},
+ }
+ if err := controllerutil.SetControllerReference(r.Restore, secret, r.Schema); err != nil {
+ return nil, err
+ }
+ if err := cli.Create(reqCtx.Ctx, secret); err != nil {
+ if !apierrors.IsAlreadyExists(err) {
+ return nil, err
+ }
+ persisted, found, loadErr := r.loadPostReadyExecutionPlanStage(reqCtx, cli, &canonical)
+ if loadErr != nil {
+ return nil, loadErr
+ }
+ if !found {
+ return nil, intctrlutil.NewFatalError("postReady stage execution plan disappeared after AlreadyExists")
+ }
+ return persisted, nil
+ }
+ if err := r.migratePostReadyJobsBeforeStageCommit(reqCtx, cli, &canonical, secret.Name, digest); err != nil {
+ return nil, err
+ }
+ if err := r.ensurePostReadyPlanMarker(reqCtx, cli, secret.Name, digest); err != nil {
+ return nil, err
+ }
+ return &canonical, nil
+}
+
+func (r *RestoreManager) loadPostReadyExecutionPlan(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ backupName, actionName string,
+) ([]*batchv1.Job, bool, error) {
+ stage, found, err := r.loadPostReadyExecutionPlanStage(reqCtx, cli)
+ if err != nil || !found {
+ return nil, found, err
+ }
+ action, ok := r.findPostReadyStageAction(stage, backupName, actionName)
+ if !ok {
+ return nil, true, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan does not contain action %s/%s", backupName, actionName))
+ }
+ secretName := r.postReadyPlanSecretName()
+ secret := &corev1.Secret{}
+ if err := cli.Get(reqCtx.Ctx, types.NamespacedName{Namespace: r.Restore.Namespace, Name: secretName}, secret); err != nil {
+ return nil, true, err
+ }
+ digest := secret.Annotations[postReadyPlanDigestAnnotationKey]
+ return attachPostReadyPlanReference(action.Jobs, secret.Name, digest, string(r.Restore.UID)), true, nil
+}
+
+func (r *RestoreManager) persistPostReadyExecutionPlan(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ backupName, actionName string,
+ jobs []*batchv1.Job,
+) ([]*batchv1.Job, error) {
+ canonical, err := canonicalPostReadyPlanJobs(jobs)
+ if err != nil {
+ return nil, err
+ }
+ stage, err := r.persistPostReadyExecutionPlanStage(reqCtx, cli, []postReadyActionExecutionPlan{{
+ Order: 0, BackupName: backupName, ActionName: actionName, Jobs: canonical,
+ }})
+ if err != nil {
+ return nil, err
+ }
+ action, ok := r.findPostReadyStageAction(stage, backupName, actionName)
+ if !ok {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage execution plan does not contain action %s/%s", backupName, actionName))
+ }
+ secretName := r.postReadyPlanSecretName()
+ secret := &corev1.Secret{}
+ if err := cli.Get(reqCtx.Ctx, types.NamespacedName{Namespace: r.Restore.Namespace, Name: secretName}, secret); err != nil {
+ return nil, err
+ }
+ return attachPostReadyPlanReference(
+ action.Jobs, secret.Name, secret.Annotations[postReadyPlanDigestAnnotationKey], string(r.Restore.UID)), nil
+}
+
+func normalizePostReadyJobSpec(job *batchv1.Job, scheme *runtime.Scheme) batchv1.JobSpec {
+ copy := job.DeepCopy()
+ if scheme != nil {
+ scheme.Default(copy)
+ }
+ if copy.Spec.Parallelism != nil && *copy.Spec.Parallelism == 1 {
+ copy.Spec.Parallelism = nil
+ }
+ if copy.Spec.Completions != nil && *copy.Spec.Completions == 1 {
+ copy.Spec.Completions = nil
+ }
+ if copy.Spec.BackoffLimit != nil && *copy.Spec.BackoffLimit == 6 {
+ copy.Spec.BackoffLimit = nil
+ }
+ if copy.Spec.CompletionMode != nil && *copy.Spec.CompletionMode == batchv1.NonIndexedCompletion {
+ copy.Spec.CompletionMode = nil
+ }
+ if copy.Spec.Suspend != nil && !*copy.Spec.Suspend {
+ copy.Spec.Suspend = nil
+ }
+ if copy.Spec.ManualSelector != nil && !*copy.Spec.ManualSelector {
+ copy.Spec.ManualSelector = nil
+ }
+ if isGeneratedPostReadyJobSelector(copy.Spec.Selector, string(copy.UID)) {
+ copy.Spec.Selector = nil
+ }
+ generatedLabels := map[string]string{
+ "batch.kubernetes.io/controller-uid": string(copy.UID),
+ "controller-uid": string(copy.UID),
+ "batch.kubernetes.io/job-name": copy.Name,
+ "job-name": copy.Name,
+ }
+ for key, expected := range generatedLabels {
+ if expected != "" && copy.Spec.Template.Labels[key] == expected {
+ delete(copy.Spec.Template.Labels, key)
+ }
+ }
+ if len(copy.Spec.Template.Labels) == 0 {
+ copy.Spec.Template.Labels = nil
+ }
+ podSpec := ©.Spec.Template.Spec
+ if podSpec.DeprecatedServiceAccount == podSpec.ServiceAccountName {
+ podSpec.DeprecatedServiceAccount = ""
+ }
+ for i := range podSpec.Volumes {
+ normalizePostReadyVolumeDefaults(&podSpec.Volumes[i])
+ }
+ if podSpec.TerminationGracePeriodSeconds != nil && *podSpec.TerminationGracePeriodSeconds == 30 {
+ podSpec.TerminationGracePeriodSeconds = nil
+ }
+ if podSpec.DNSPolicy == corev1.DNSClusterFirst {
+ podSpec.DNSPolicy = ""
+ }
+ if podSpec.SchedulerName == corev1.DefaultSchedulerName {
+ podSpec.SchedulerName = ""
+ }
+ if podSpec.SecurityContext != nil && apiequality.Semantic.DeepEqual(
+ podSpec.SecurityContext, &corev1.PodSecurityContext{}) {
+ podSpec.SecurityContext = nil
+ }
+ for i := range podSpec.InitContainers {
+ normalizePostReadyContainerDefaults(&podSpec.InitContainers[i])
+ }
+ for i := range podSpec.Containers {
+ normalizePostReadyContainerDefaults(&podSpec.Containers[i])
+ }
+ return copy.Spec
+}
+
+func isGeneratedPostReadyJobSelector(selector *metav1.LabelSelector, jobUID string) bool {
+ if selector == nil || jobUID == "" || len(selector.MatchExpressions) != 0 || len(selector.MatchLabels) != 1 {
+ return false
+ }
+ for key, value := range selector.MatchLabels {
+ return (key == "controller-uid" || key == "batch.kubernetes.io/controller-uid") && value == jobUID
+ }
+ return false
+}
+
+func normalizePostReadyContainerDefaults(container *corev1.Container) {
+ if container.TerminationMessagePath == corev1.TerminationMessagePathDefault {
+ container.TerminationMessagePath = ""
+ }
+ if container.TerminationMessagePolicy == corev1.TerminationMessageReadFile {
+ container.TerminationMessagePolicy = ""
+ }
+ imageName := container.Image
+ if slash := strings.LastIndexByte(imageName, '/'); slash >= 0 {
+ imageName = imageName[slash+1:]
+ }
+ defaultPullPolicy := corev1.PullIfNotPresent
+ if !strings.Contains(imageName, "@") {
+ colon := strings.LastIndexByte(imageName, ':')
+ if colon < 0 || imageName[colon+1:] == "latest" {
+ defaultPullPolicy = corev1.PullAlways
+ }
+ }
+ if container.ImagePullPolicy == defaultPullPolicy {
+ container.ImagePullPolicy = ""
+ }
+ for i := range container.Env {
+ if container.Env[i].ValueFrom != nil && container.Env[i].ValueFrom.FieldRef != nil &&
+ container.Env[i].ValueFrom.FieldRef.APIVersion == "v1" {
+ container.Env[i].ValueFrom.FieldRef.APIVersion = ""
+ }
+ }
+}
+
+func normalizePostReadyVolumeDefaults(volume *corev1.Volume) {
+ const defaultMode = int32(0o644)
+ if volume.DownwardAPI != nil {
+ if volume.DownwardAPI.DefaultMode != nil && *volume.DownwardAPI.DefaultMode == defaultMode {
+ volume.DownwardAPI.DefaultMode = nil
+ }
+ for i := range volume.DownwardAPI.Items {
+ if fieldRef := volume.DownwardAPI.Items[i].FieldRef; fieldRef != nil && fieldRef.APIVersion == "v1" {
+ fieldRef.APIVersion = ""
+ }
+ }
+ }
+ if volume.Secret != nil && volume.Secret.DefaultMode != nil && *volume.Secret.DefaultMode == defaultMode {
+ volume.Secret.DefaultMode = nil
+ }
+ if volume.ConfigMap != nil && volume.ConfigMap.DefaultMode != nil && *volume.ConfigMap.DefaultMode == defaultMode {
+ volume.ConfigMap.DefaultMode = nil
+ }
+ if volume.Projected != nil {
+ if volume.Projected.DefaultMode != nil && *volume.Projected.DefaultMode == defaultMode {
+ volume.Projected.DefaultMode = nil
+ }
+ for i := range volume.Projected.Sources {
+ downwardAPI := volume.Projected.Sources[i].DownwardAPI
+ if downwardAPI == nil {
+ continue
+ }
+ for j := range downwardAPI.Items {
+ if fieldRef := downwardAPI.Items[j].FieldRef; fieldRef != nil && fieldRef.APIVersion == "v1" {
+ fieldRef.APIVersion = ""
+ }
+ }
+ }
+ }
+}
+
+func (r *RestoreManager) postReadyJobSpecsEqual(desired, existing *batchv1.Job) bool {
+ desiredSpec := normalizePostReadyJobSpec(desired, r.Schema)
+ existingSpec := normalizePostReadyJobSpec(existing, r.Schema)
+ return apiequality.Semantic.DeepEqual(desiredSpec, existingSpec)
+}
+
+func (r *RestoreManager) migratePostReadyJobsBeforeStageCommit(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ stage *postReadyExecutionPlan,
+ planName, digest string,
+) error {
+ for i := range stage.Actions {
+ desiredJobs := attachPostReadyPlanReference(
+ stage.Actions[i].Jobs, planName, digest, string(r.Restore.UID))
+ for j := range desiredJobs {
+ existing := &batchv1.Job{}
+ if err := cli.Get(reqCtx.Ctx, client.ObjectKeyFromObject(desiredJobs[j]), existing); err != nil {
+ if apierrors.IsNotFound(err) {
+ continue
+ }
+ return err
+ }
+ if existing.DeletionTimestamp != nil || existing.UID == "" || !r.isJobForRestoreAction(existing) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "legacy postReady job %s/%s has no stable restore ownership", existing.Namespace, existing.Name))
+ }
+ desiredBackupName, desiredActionName := postReadyActionIdentity(desiredJobs[j])
+ existingBackupName, existingActionName := postReadyActionIdentity(existing)
+ if existingBackupName != desiredBackupName || existingActionName != desiredActionName {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "legacy postReady job %s/%s action identity does not match the stage plan",
+ existing.Namespace, existing.Name))
+ }
+ controller := metav1.GetControllerOf(existing)
+ if existing.Namespace == r.Restore.Namespace && (controller == nil || controller.UID != r.Restore.UID) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "legacy postReady job %s/%s is not owned by restore %s/%s",
+ existing.Namespace, existing.Name, r.Restore.Namespace, r.Restore.Name))
+ }
+ existingPlanName := existing.Annotations[postReadyPlanNameAnnotationKey]
+ existingDigest := existing.Annotations[postReadyPlanDigestAnnotationKey]
+ existingRestoreUID := existing.Annotations[postReadyPlanRestoreUIDAnnotationKey]
+ if existingPlanName != "" || existingDigest != "" || existingRestoreUID != "" {
+ if existingPlanName != planName || existingDigest != digest || existingRestoreUID != string(r.Restore.UID) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has a conflicting committed stage reference",
+ existing.Namespace, existing.Name))
+ }
+ if err := r.validateExistingRestoreActionJob(desiredJobs[j], existing); err != nil {
+ return err
+ }
+ continue
+ }
+ if !r.postReadyJobSpecsEqual(desiredJobs[j], existing) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "legacy postReady job %s/%s executable spec does not match the stage plan",
+ existing.Namespace, existing.Name))
+ }
+ original := existing.DeepCopy()
+ if existing.Annotations == nil {
+ existing.Annotations = map[string]string{}
+ }
+ existing.Annotations[postReadyPlanNameAnnotationKey] = planName
+ existing.Annotations[postReadyPlanDigestAnnotationKey] = digest
+ existing.Annotations[postReadyPlanRestoreUIDAnnotationKey] = string(r.Restore.UID)
+ controllerutil.AddFinalizer(existing, dptypes.DataProtectionFinalizerName)
+ if err := cli.Patch(reqCtx.Ctx, existing, client.MergeFrom(original)); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func postReadyActionIdentity(job *batchv1.Job) (string, string) {
+ if job.Annotations != nil {
+ if backupName := job.Annotations[postReadyBackupNameAnnotationKey]; backupName != "" {
+ if actionName := job.Annotations[postReadyActionNameAnnotationKey]; actionName != "" {
+ return backupName, actionName
+ }
+ }
+ }
+ backupName := ""
+ for _, container := range job.Spec.Template.Spec.Containers {
+ for _, env := range container.Env {
+ if env.Name == dptypes.DPBackupName {
+ backupName = env.Value
+ break
+ }
+ }
+ if backupName != "" {
+ break
+ }
+ }
+ lastSeparator := strings.LastIndexByte(job.Name, '-')
+ if lastSeparator < 0 {
+ return backupName, ""
+ }
+ stepSeparator := strings.LastIndexByte(job.Name[:lastSeparator], '-')
+ if stepSeparator < 0 || stepSeparator+1 == lastSeparator {
+ return backupName, ""
+ }
+ step := job.Name[stepSeparator+1 : lastSeparator]
+ if _, err := strconv.Atoi(step); err != nil {
+ return backupName, ""
+ }
+ return backupName, fmt.Sprintf("%s-%s", dpv1alpha1.PostReady, step)
+}
+
+func (r *RestoreManager) isJobForRestoreAction(job *batchv1.Job) bool {
+ if job.Labels[DataProtectionRestoreLabelKey] != r.Restore.Name {
+ return false
+ }
+ restoreNamespace := job.Labels[DataProtectionRestoreNamespaceLabelKey]
+ if job.Namespace != r.Restore.Namespace {
+ return restoreNamespace == r.Restore.Namespace
+ }
+ return restoreNamespace == "" || restoreNamespace == r.Restore.Namespace
+}
+
+// BuildPostReadyActionJobs builds the post ready jobs.
+func (r *RestoreManager) BuildPostReadyActionJobs(reqCtx intctrlutil.RequestCtx, cli client.Client, backupSet BackupActionSet, target *dpv1alpha1.BackupStatusTarget, step int) ([]*batchv1.Job, error) {
+ readyConfig := r.Restore.Spec.ReadyConfig
+ if readyConfig == nil {
+ return nil, nil
+ }
+ if !backupSet.ActionSet.HasPostReadyStage() {
+ return nil, nil
+ }
+ backupRepo, err := r.prepareBackupRepo(reqCtx, cli, backupSet)
+ if err != nil {
+ return nil, err
+ }
+ actionSpec := backupSet.ActionSet.Spec.Restore.PostReady[step]
+ getTargetPodList := func(labelSelector metav1.LabelSelector, msgKey string) (*corev1.PodList, error) {
+ targetPodList, err := utils.GetPodListByLabelSelector(reqCtx, cli, &labelSelector)
+ if err != nil {
+ return nil, err
+ }
+ if len(targetPodList.Items) == 0 {
+ return nil, intctrlutil.NewErrorf(intctrlutil.ErrorTypeRequeue, "can not found any pod by spec.readyConfig.%s.target.podSelector", msgKey)
+ }
+ return targetPodList, nil
+ }
+
+ buildJobName := func(index int) string {
+ jobName := fmt.Sprintf("restore-post-ready-%s-%s-%d-%d", r.Restore.UID[:8], backupSet.Backup.Name, step, index)
+ return cutJobName(jobName)
+ }
+ jobBuilder := newRestoreJobBuilder(r.Restore, backupSet, backupRepo, dpv1alpha1.PostReady)
+ buildJobsForJobAction := func() ([]*batchv1.Job, error) {
+ jobAction := r.Restore.Spec.ReadyConfig.JobAction
+ if jobAction == nil {
+ return nil, intctrlutil.NewFatalError("spec.readyConfig.jobAction can not be empty")
+ }
+ podSelector := jobAction.Target.PodSelector
+ if podSelector.LabelSelector == nil {
+ return nil, intctrlutil.NewFatalError("spec.readyConfig.jobAction.podSelector.labelSelector can not be empty")
+ }
+ targetPodList, err := getTargetPodList(*podSelector.LabelSelector, "jobAction")
+ if err != nil {
+ return nil, err
+ }
+ sort.Sort(intctrlutil.ByPodName(targetPodList.Items))
+ frozenTargetByName, hasFrozenPlan, err := r.getFrozenPostReadySourceTargets(
+ reqCtx, cli, types.NamespacedName{Namespace: r.Restore.Namespace, Name: buildJobName(0)})
+ if err != nil {
+ return nil, err
+ }
+ buildJob := func(targetPod *corev1.Pod, sourceTargetPodName string, index int) *batchv1.Job {
+ if boolptr.IsSetToTrue(actionSpec.Job.RunOnTargetPodNode) {
+ jobBuilder.resetSpecificVolumesAndMounts()
+ jobBuilder.setNodeNameToNodeSelector(targetPod.Spec.NodeName)
+ // mount the targe pod's volumes when RunOnTargetPodNode is true
+ for _, volumeMount := range jobAction.Target.VolumeMounts {
+ for _, volume := range targetPod.Spec.Volumes {
+ if volume.Name != volumeMount.Name {
+ continue
+ }
+ jobBuilder.addToSpecificVolumesAndMounts(&volume, &volumeMount)
+ }
+ }
+ }
+ return jobBuilder.setImage(actionSpec.Job.Image).
+ setJobName(buildJobName(index)).
+ addCommonEnv(sourceTargetPodName).
+ attachBackupRepo().
+ setCommand(actionSpec.Job.Command).
+ setToleration(targetPod.Spec.Tolerations).
+ addTargetPodAndCredentialEnv(targetPod, readyConfig.ConnectionCredential, &target.BackupTarget).
+ setServiceAccount(r.WorkerServiceAccount).
+ build()
+ }
+
+ if podSelector.Strategy == dpv1alpha1.PodSelectionStrategyAny && !hasFrozenPlan {
+ targetPod := utils.GetFirstIndexRunningPod(targetPodList)
+ if targetPod == nil {
+ return nil, fmt.Errorf("can not found any running pod by spec.readyConfig.jobAction.target.podSelector")
+ }
+ targetPodList.Items = []corev1.Pod{*targetPod}
+ }
+ var jobs []*batchv1.Job
+ for i := range targetPodList.Items {
+ targetName := types.NamespacedName{
+ Namespace: targetPodList.Items[i].Namespace,
+ Name: targetPodList.Items[i].Name,
+ }.String()
+ frozenTarget, selectedByFrozenPlan := frozenTargetByName[targetName]
+ if hasFrozenPlan && !selectedByFrozenPlan {
+ continue
+ }
+ sourceTargetPodName := frozenTarget.source
+ jobIndex := i
+ if hasFrozenPlan {
+ jobIndex = frozenTarget.ordinal
+ }
+ if !hasFrozenPlan {
+ sourceTargetPodName, err = GetSourcePodNameFromTarget(target, jobAction.RequiredPolicyForAllPodSelection, i)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if target.PodSelector.Strategy == dpv1alpha1.PodSelectionStrategyAll && sourceTargetPodName == "" {
+ // no need to recover the volume when the pod selection policy is 'All' and sourceTargetPodName is not found.
+ continue
+ }
+ job := buildJob(&targetPodList.Items[i], sourceTargetPodName, jobIndex)
+ setPostReadyTargetIdentity(job, &targetPodList.Items[i], sourceTargetPodName)
+ jobs = append(jobs, job)
+ }
+ if hasFrozenPlan && len(jobs) != len(frozenTargetByName) {
+ return nil, intctrlutil.NewErrorf(intctrlutil.ErrorTypeRequeue,
+ "not all frozen postReady target pods are currently available")
+ }
+ return jobs, nil
+ }
+
+ buildJobsForExecAction := func() ([]*batchv1.Job, error) {
+ execAction := r.Restore.Spec.ReadyConfig.ExecAction
+ if execAction == nil {
+ return nil, intctrlutil.NewFatalError("spec.readyConfig.execAction can not be empty")
+ }
+ targetPodList, err := getTargetPodList(execAction.Target.PodSelector, "execAction")
+ if err != nil {
+ return nil, err
+ }
+ sort.Sort(intctrlutil.ByPodName(targetPodList.Items))
+ var restoreJobs []*batchv1.Job
+ for i := range targetPodList.Items {
+ containerName := actionSpec.Exec.Container
+ if containerName == "" {
+ containerName = targetPodList.Items[i].Spec.Containers[0].Name
+ }
+ args := append([]string{"-n", targetPodList.Items[i].Namespace, "exec", targetPodList.Items[i].Name, "-c", containerName, "--"}, actionSpec.Exec.Command...)
+ jobBuilder.setImage(viper.GetString(constant.KBToolsImage)).setCommand([]string{"kubectl"}).setArgs(args).
+ setJobName(buildJobName(i)).
+ setToleration(targetPodList.Items[i].Spec.Tolerations)
+ job := jobBuilder.build()
+ // create exec job in kubeblocks namespace for security
+ kbInstalledNamespace := viper.GetString(constant.CfgKeyCtrlrMgrNS)
+ if kbInstalledNamespace != "" {
+ job.Namespace = kbInstalledNamespace
+ // use the dedicated ServiceAccount for executing "kubectl exec"
+ job.Spec.Template.Spec.ServiceAccountName = viper.GetString(dptypes.CfgKeyExecWorkerServiceAccountName)
+ }
+ job.Labels[DataProtectionRestoreNamespaceLabelKey] = r.Restore.Namespace
+ setPostReadyTargetIdentity(job, &targetPodList.Items[i], "")
+ restoreJobs = append(restoreJobs, job)
+ }
+ return restoreJobs, nil
+ }
+
+ var jobs []*batchv1.Job
+ if actionSpec.Job != nil {
+ jobs, err = buildJobsForJobAction()
+ } else {
+ jobs, err = buildJobsForExecAction()
+ }
+ if err != nil {
+ return nil, err
+ }
+ policy := dpv1alpha1.PostReadyExecutionPolicyParallel
+ if isSerialPostReady(backupSet) {
+ policy = dpv1alpha1.PostReadyExecutionPolicySerial
+ }
+ for i := range jobs {
+ setPostReadyExecutionPolicy(jobs[i], policy)
+ }
+ if err := setPostReadyTargetPlan(jobs); err != nil {
+ return nil, err
+ }
+ if err := r.setPostReadyActionContract(jobs, backupSet, step); err != nil {
+ return nil, err
+ }
+ return jobs, nil
+}
+
+func isSerialPostReady(backupSet BackupActionSet) bool {
+ return backupSet.ActionSet != nil &&
+ backupSet.ActionSet.Spec.Restore != nil &&
+ backupSet.ActionSet.Spec.Restore.PostReadyExecutionPolicy == dpv1alpha1.PostReadyExecutionPolicySerial
+}
+
+func setPostReadyExecutionPolicy(job *batchv1.Job, policy dpv1alpha1.PostReadyExecutionPolicy) {
+ if job.Annotations == nil {
+ job.Annotations = map[string]string{}
+ }
+ job.Annotations[postReadyExecutionPolicyAnnotationKey] = string(policy)
+ if policy == dpv1alpha1.PostReadyExecutionPolicySerial {
+ job.Spec.Suspend = boolptr.True()
+ } else {
+ job.Spec.Suspend = nil
+ }
+}
+
+func setPostReadyTargetIdentity(job *batchv1.Job, pod *corev1.Pod, sourceTargetPodName string) {
+ if job.Annotations == nil {
+ job.Annotations = map[string]string{}
+ }
+ target := types.NamespacedName{
+ Namespace: pod.Namespace,
+ Name: pod.Name,
+ }.String()
+ job.Annotations[postReadyTargetIdentityAnnotationKey] = fmt.Sprintf("%s|source=%s", target, sourceTargetPodName)
+}
+
+func postReadyTargetIdentity(job *batchv1.Job) string {
+ if job.Annotations == nil {
+ return ""
+ }
+ return job.Annotations[postReadyTargetIdentityAnnotationKey]
+}
+
+type postReadyActionContract struct {
+ ReadyConfig *dpv1alpha1.ReadyConfig `json:"readyConfig,omitempty"`
+ PostReady []dpv1alpha1.ActionSpec `json:"postReady,omitempty"`
+ KBToolsImage string `json:"kbToolsImage,omitempty"`
+ WorkerServiceAccount string `json:"workerServiceAccount,omitempty"`
+ ControllerNamespace string `json:"controllerNamespace,omitempty"`
+ ExecWorkerServiceAccount string `json:"execWorkerServiceAccount,omitempty"`
+}
+
+func (r *RestoreManager) setPostReadyActionContract(jobs []*batchv1.Job, backupSet BackupActionSet, step int) error {
+ if backupSet.ActionSet == nil || backupSet.ActionSet.Spec.Restore == nil {
+ return intctrlutil.NewFatalError("postReady action has no ActionSet restore contract")
+ }
+ if step < 0 || step >= len(backupSet.ActionSet.Spec.Restore.PostReady) {
+ return intctrlutil.NewFatalError(fmt.Sprintf("postReady action step %d is out of range", step))
+ }
+ contract := postReadyActionContract{
+ ReadyConfig: r.Restore.Spec.ReadyConfig,
+ PostReady: backupSet.ActionSet.Spec.Restore.PostReady,
+ KBToolsImage: viper.GetString(constant.KBToolsImage),
+ WorkerServiceAccount: r.WorkerServiceAccount,
+ ControllerNamespace: viper.GetString(constant.CfgKeyCtrlrMgrNS),
+ ExecWorkerServiceAccount: viper.GetString(dptypes.CfgKeyExecWorkerServiceAccountName),
+ }
+ serialized, err := json.Marshal(contract)
+ if err != nil {
+ return err
+ }
+ digest := fmt.Sprintf("sha256:%x", sha256.Sum256(serialized))
+ actionName := fmt.Sprintf("%s-%d", dpv1alpha1.PostReady, step)
+ for i := range jobs {
+ if jobs[i].Annotations == nil {
+ jobs[i].Annotations = map[string]string{}
+ }
+ jobs[i].Annotations[postReadyActionContractAnnotationKey] = digest
+ jobs[i].Annotations[postReadyBackupNameAnnotationKey] = backupSet.Backup.Name
+ jobs[i].Annotations[postReadyActionNameAnnotationKey] = actionName
+ }
+ return nil
+}
+
+func postReadyActionContractForJob(job *batchv1.Job) string {
+ if job.Annotations == nil {
+ return ""
+ }
+ return job.Annotations[postReadyActionContractAnnotationKey]
+}
+
+func splitPostReadyTargetIdentity(identity string) (target, source string) {
+ const sourceMarker = "|source="
+ parts := strings.SplitN(identity, sourceMarker, 2)
+ if len(parts) == 1 {
+ return identity, ""
+ }
+ return parts[0], parts[1]
+}
+
+type frozenPostReadyTarget struct {
+ source string
+ ordinal int
+}
+
+// getFrozenPostReadySourceTargets returns the original target-to-source and
+// target-to-ordinal mappings after a partial create. JobAction must use both
+// while rebuilding specs so neither the backup path nor the Job name drifts
+// when the selected target Pod set changes.
+func (r *RestoreManager) getFrozenPostReadySourceTargets(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ firstJobKey types.NamespacedName,
+) (map[string]frozenPostReadyTarget, bool, error) {
+ existing := &batchv1.Job{}
+ if err := cli.Get(reqCtx.Ctx, firstJobKey, existing); err != nil {
+ if apierrors.IsNotFound(err) {
+ return nil, false, nil
+ }
+ return nil, false, err
+ }
+ if !r.isJobForRestoreAction(existing) {
+ return nil, false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "restore job name collision: existing job %s/%s does not belong to restore %s/%s",
+ existing.Namespace, existing.Name, r.Restore.Namespace, r.Restore.Name))
+ }
+ if !hasPostReadyFrozenContract(existing) {
+ return nil, false, nil
+ }
+ if _, err := postReadyExecutionPolicyForJob(existing); err != nil {
+ return nil, false, err
+ }
+ plan, err := postReadyTargetPlan(existing)
+ if err != nil {
+ return nil, false, err
+ }
+ if len(plan) == 0 {
+ return nil, false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has no frozen target plan", existing.Namespace, existing.Name))
+ }
+ targets := make(map[string]frozenPostReadyTarget, len(plan))
+ for ordinal, identity := range plan {
+ target, source := splitPostReadyTargetIdentity(identity)
+ if target == "" {
+ return nil, false, intctrlutil.NewFatalError("postReady frozen target plan has an empty target")
+ }
+ if _, ok := targets[target]; ok {
+ return nil, false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "duplicate postReady frozen target %s", target))
+ }
+ targets[target] = frozenPostReadyTarget{source: source, ordinal: ordinal}
+ }
+ return targets, true, nil
+}
+
+func hasPostReadyFrozenContract(job *batchv1.Job) bool {
+ if job.Annotations == nil {
+ return false
+ }
+ _, hasPolicy := job.Annotations[postReadyExecutionPolicyAnnotationKey]
+ _, hasIdentity := job.Annotations[postReadyTargetIdentityAnnotationKey]
+ _, hasPlan := job.Annotations[postReadyTargetPlanAnnotationKey]
+ return hasPolicy || hasIdentity || hasPlan
+}
+
+func setPostReadyTargetPlan(jobs []*batchv1.Job) error {
+ plan := make([]string, 0, len(jobs))
+ for i := range jobs {
+ identity := postReadyTargetIdentity(jobs[i])
+ if identity == "" {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has empty target identity", jobs[i].Namespace, jobs[i].Name))
+ }
+ plan = append(plan, identity)
+ }
+ serialized, err := json.Marshal(plan)
+ if err != nil {
+ return err
+ }
+ for i := range jobs {
+ jobs[i].Annotations[postReadyTargetPlanAnnotationKey] = string(serialized)
+ }
+ return nil
+}
+
+func postReadyTargetPlan(job *batchv1.Job) ([]string, error) {
+ if job.Annotations == nil || job.Annotations[postReadyTargetPlanAnnotationKey] == "" {
+ return nil, nil
+ }
+ var plan []string
+ if err := json.Unmarshal([]byte(job.Annotations[postReadyTargetPlanAnnotationKey]), &plan); err != nil {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has invalid frozen target plan: %v", job.Namespace, job.Name, err))
+ }
+ if len(plan) == 0 {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has empty frozen target plan", job.Namespace, job.Name))
+ }
+ return plan, nil
+}
+
+func postReadyExecutionPolicyForJob(job *batchv1.Job) (dpv1alpha1.PostReadyExecutionPolicy, error) {
+ if !hasPostReadyFrozenContract(job) {
+ return dpv1alpha1.PostReadyExecutionPolicyParallel, nil
+ }
+ policyValue, hasPolicy := job.Annotations[postReadyExecutionPolicyAnnotationKey]
+ if !hasPolicy || policyValue == "" {
+ return "", intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has a frozen target contract without an execution policy",
+ job.Namespace, job.Name))
+ }
+ identity, hasIdentity := job.Annotations[postReadyTargetIdentityAnnotationKey]
+ if !hasIdentity || identity == "" {
+ return "", intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has a frozen target contract without a target identity",
+ job.Namespace, job.Name))
+ }
+ plan, hasPlan := job.Annotations[postReadyTargetPlanAnnotationKey]
+ if !hasPlan || plan == "" {
+ return "", intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has a frozen target contract without a target plan",
+ job.Namespace, job.Name))
+ }
+ policy := dpv1alpha1.PostReadyExecutionPolicy(policyValue)
+ if policy != dpv1alpha1.PostReadyExecutionPolicyParallel && policy != dpv1alpha1.PostReadyExecutionPolicySerial {
+ return "", intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has invalid frozen execution policy %q",
+ job.Namespace, job.Name, policy))
+ }
+ return policy, nil
+}
+
+func serialPostReadyJobs(jobs []*batchv1.Job) (bool, error) {
+ if len(jobs) == 0 {
+ return false, nil
+ }
+ policy, err := postReadyExecutionPolicyForJob(jobs[0])
+ if err != nil {
+ return false, err
+ }
+ for i := 1; i < len(jobs); i++ {
+ jobPolicy, err := postReadyExecutionPolicyForJob(jobs[i])
+ if err != nil {
+ return false, err
+ }
+ if jobPolicy != policy {
+ return false, intctrlutil.NewFatalError("postReady jobs have inconsistent frozen execution policies")
+ }
+ }
+ return policy == dpv1alpha1.PostReadyExecutionPolicySerial, nil
+}
+
+// PostReadyActionExpectedJobCount returns the immutable plan cardinality when
+// available. This count belongs to the plan domain and does not change as
+// mutable completion status advances.
+func PostReadyActionExpectedJobCount(jobs []*batchv1.Job) (int, error) {
+ if len(jobs) == 0 {
+ return 0, nil
+ }
+ plan, err := postReadyTargetPlan(jobs[0])
+ if err != nil {
+ return 0, err
+ }
+ if len(plan) > 0 {
+ return len(plan), nil
+ }
+ return len(jobs), nil
+}
+
+// EnsurePostReadyStagePlan commits the ordered executable plan for every
+// postReady action before the first Job of the stage can be created.
+func (r *RestoreManager) EnsurePostReadyStagePlan(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+) (bool, error) {
+ if _, hasMarker := r.postReadyPlanMarker(); hasMarker {
+ if _, found, err := r.loadPostReadyExecutionPlanStage(reqCtx, cli); err != nil || found {
+ return found, err
+ }
+ }
+ if r.Restore.Spec.ReadyConfig == nil || len(r.PostReadyBackupSets) == 0 {
+ return false, nil
+ }
+ actions := make([]postReadyActionExecutionPlan, 0)
+ for i := range r.PostReadyBackupSets {
+ backupSet := r.PostReadyBackupSets[i]
+ if backupSet.Backup == nil || backupSet.ActionSet == nil || backupSet.ActionSet.Spec.Restore == nil {
+ return false, intctrlutil.NewFatalError("postReady stage has an incomplete Backup/ActionSet definition")
+ }
+ target := utils.GetBackupStatusTarget(backupSet.Backup, r.Restore.Spec.Backup.SourceTargetName)
+ if target == nil {
+ return false, intctrlutil.NewFatalError("can not found any source targe in backup " + backupSet.Backup.Name)
+ }
+ for step := range backupSet.ActionSet.Spec.Restore.PostReady {
+ actionName := fmt.Sprintf("%s-%d", dpv1alpha1.PostReady, step)
+ jobs, err := r.BuildPostReadyActionJobs(reqCtx, cli, backupSet, target, step)
+ if err != nil {
+ return false, err
+ }
+ jobs, err = r.freezeLegacyPostReadyExecutionPlan(reqCtx, cli, jobs)
+ if err != nil {
+ return false, err
+ }
+ canonical, err := canonicalPostReadyPlanJobs(jobs)
+ if err != nil {
+ return false, err
+ }
+ if len(canonical) == 0 {
+ return false, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady stage action %s/%s has no executable Jobs", backupSet.Backup.Name, actionName))
+ }
+ actions = append(actions, postReadyActionExecutionPlan{
+ Order: len(actions), BackupName: backupSet.Backup.Name, ActionName: actionName, Jobs: canonical,
+ })
+ }
+ }
+ if len(actions) == 0 {
+ return false, nil
+ }
+ _, err := r.persistPostReadyExecutionPlanStage(reqCtx, cli, actions)
+ return err == nil, err
+}
+
+// PendingPostReadyJobs filters Jobs already recorded terminal in mutable
+// Restore status. A garbage-collected Completed or Failed Job is therefore
+// never recreated from the immutable plan.
+func (r *RestoreManager) PendingPostReadyJobs(backupName, actionName string, jobs []*batchv1.Job) []*batchv1.Job {
+ terminal := map[string]struct{}{}
+ for i := range r.Restore.Status.Actions.PostReady {
+ action := r.Restore.Status.Actions.PostReady[i]
+ if action.BackupName == backupName && action.Name == actionName &&
+ (action.Status == dpv1alpha1.RestoreActionCompleted || action.Status == dpv1alpha1.RestoreActionFailed) {
+ terminal[action.ObjectKey] = struct{}{}
+ }
+ }
+ pending := make([]*batchv1.Job, 0, len(jobs))
+ for i := range jobs {
+ if _, ok := terminal[BuildJobKeyForActionStatus(jobs[i].Name)]; ok {
+ continue
+ }
+ pending = append(pending, jobs[i])
+ }
+ return pending
+}
+
+// FreezePostReadyExecutionPlan persists the complete immutable executable plan
+// before any Job is created. Once present, that plan remains authoritative
+// across Job GC and ActionSet or ReadyConfig drift.
+func (r *RestoreManager) FreezePostReadyExecutionPlan(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ jobs []*batchv1.Job,
+) ([]*batchv1.Job, error) {
+ backupName, actionName, hasIdentity, err := postReadyPlanIdentityForJobs(jobs)
+ if err != nil {
+ return nil, err
+ }
+ if hasIdentity {
+ if _, hasMarker := r.postReadyPlanMarker(); hasMarker {
+ persisted, found, err := r.loadPostReadyExecutionPlan(reqCtx, cli, backupName, actionName)
+ if err != nil {
+ return nil, err
+ }
+ if found {
+ return persisted, nil
+ }
+ }
+ }
+
+ frozen, err := r.freezeLegacyPostReadyExecutionPlan(reqCtx, cli, jobs)
+ if err != nil || !hasIdentity {
+ return frozen, err
+ }
+ frozenBackupName, frozenActionName, frozenHasIdentity, err := postReadyPlanIdentityForJobs(frozen)
+ if err != nil {
+ return nil, err
+ }
+ if !frozenHasIdentity || frozenBackupName != backupName || frozenActionName != actionName {
+ return frozen, nil
+ }
+ return r.persistPostReadyExecutionPlan(reqCtx, cli, backupName, actionName, frozen)
+}
+
+// freezeLegacyPostReadyExecutionPlan preserves compatibility with Jobs created
+// before durable plan Secrets existed. It may migrate only a complete or
+// provably equivalent legacy plan; ambiguous legacy state still fails closed.
+func (r *RestoreManager) freezeLegacyPostReadyExecutionPlan(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ jobs []*batchv1.Job,
+) ([]*batchv1.Job, error) {
+ if len(jobs) == 0 {
+ return jobs, nil
+ }
+ policy, err := postReadyExecutionPolicyForJob(jobs[0])
+ if err != nil {
+ return nil, err
+ }
+ allInputJobsPersisted := true
+ for i := range jobs {
+ if jobs[i].ResourceVersion == "" {
+ allInputJobsPersisted = false
+ break
+ }
+ }
+ desiredContract := postReadyActionContractForJob(jobs[0])
+ if desiredContract == "" {
+ if allInputJobsPersisted {
+ // All executable Job specs already exist, so they remain the action
+ // fact source for upgrades from the previous frozen-plan format.
+ return jobs, nil
+ }
+ return nil, intctrlutil.NewFatalError("postReady jobs have no frozen action contract")
+ }
+ for i := 1; i < len(jobs); i++ {
+ if postReadyActionContractForJob(jobs[i]) != desiredContract {
+ return nil, intctrlutil.NewFatalError("postReady jobs have inconsistent frozen action contracts")
+ }
+ }
+ foundExisting := false
+ foundLegacyPartial := false
+ var frozenPlan []string
+ for i := range jobs {
+ existing := &batchv1.Job{}
+ if err := cli.Get(reqCtx.Ctx, client.ObjectKeyFromObject(jobs[i]), existing); err != nil {
+ if apierrors.IsNotFound(err) {
+ continue
+ }
+ return nil, err
+ }
+ if !r.isJobForRestoreAction(existing) {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "restore job name collision: existing job %s/%s does not belong to restore %s/%s",
+ existing.Namespace, existing.Name, r.Restore.Namespace, r.Restore.Name))
+ }
+ if !hasPostReadyFrozenContract(existing) {
+ if allInputJobsPersisted {
+ for j := range jobs {
+ if hasPostReadyFrozenContract(jobs[j]) {
+ return nil, intctrlutil.NewFatalError("legacy and frozen postReady jobs cannot be mixed")
+ }
+ }
+ return jobs, nil
+ }
+ if foundExisting {
+ return nil, intctrlutil.NewFatalError("legacy and frozen postReady jobs cannot be mixed")
+ }
+ if policy != dpv1alpha1.PostReadyExecutionPolicyParallel {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "legacy postReady job %s/%s cannot be migrated to serial execution",
+ existing.Namespace, existing.Name))
+ }
+ if !apiequality.Semantic.DeepDerivative(jobs[i].Spec, existing.Spec) {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "legacy postReady job %s/%s executable action does not match the current ActionSet",
+ existing.Namespace, existing.Name))
+ }
+ foundLegacyPartial = true
+ continue
+ }
+ if foundLegacyPartial {
+ return nil, intctrlutil.NewFatalError("legacy and frozen postReady jobs cannot be mixed")
+ }
+ existingPolicy, err := postReadyExecutionPolicyForJob(existing)
+ if err != nil {
+ return nil, err
+ }
+ if foundExisting && existingPolicy != policy {
+ return nil, intctrlutil.NewFatalError("postReady jobs have inconsistent frozen execution policies")
+ }
+ policy = existingPolicy
+ plan, err := postReadyTargetPlan(existing)
+ if err != nil {
+ return nil, err
+ }
+ if len(plan) == 0 {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has no frozen target plan", existing.Namespace, existing.Name))
+ }
+ if len(frozenPlan) > 0 && strings.Join(plan, "\x00") != strings.Join(frozenPlan, "\x00") {
+ return nil, intctrlutil.NewFatalError("postReady jobs have inconsistent frozen target plans")
+ }
+ existingContract := postReadyActionContractForJob(existing)
+ if existingContract == "" {
+ if !allInputJobsPersisted {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has no complete frozen action contract",
+ existing.Namespace, existing.Name))
+ }
+ } else if existingContract != desiredContract {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s executable action does not match its frozen contract",
+ existing.Namespace, existing.Name))
+ }
+ index := postReadyJobIndex(existing.Name)
+ if index < 0 || index >= len(plan) || postReadyTargetIdentity(existing) != plan[index] {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s target identity does not match its frozen plan",
+ existing.Namespace, existing.Name))
+ }
+ frozenPlan = plan
+ foundExisting = true
+ }
+ if foundLegacyPartial {
+ for i := range jobs {
+ delete(jobs[i].Annotations, postReadyExecutionPolicyAnnotationKey)
+ delete(jobs[i].Annotations, postReadyTargetIdentityAnnotationKey)
+ delete(jobs[i].Annotations, postReadyTargetPlanAnnotationKey)
+ delete(jobs[i].Annotations, postReadyActionContractAnnotationKey)
+ jobs[i].Spec.Suspend = nil
+ }
+ return jobs, nil
+ }
+ if !foundExisting {
+ return jobs, nil
+ }
+
+ jobsByTarget := make(map[string]*batchv1.Job, len(jobs))
+ for i := range jobs {
+ identity := postReadyTargetIdentity(jobs[i])
+ if identity == "" {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has empty target identity", jobs[i].Namespace, jobs[i].Name))
+ }
+ if _, ok := jobsByTarget[identity]; ok {
+ return nil, intctrlutil.NewFatalError(fmt.Sprintf("duplicate postReady target identity %s", identity))
+ }
+ jobsByTarget[identity] = jobs[i]
+ }
+ frozenJobs := make([]*batchv1.Job, 0, len(frozenPlan))
+ for i, identity := range frozenPlan {
+ job, ok := jobsByTarget[identity]
+ if !ok {
+ return nil, intctrlutil.NewErrorf(intctrlutil.ErrorTypeRequeue,
+ "postReady frozen target %s is not currently available", identity)
+ }
+ job = job.DeepCopy()
+ job.Name = postReadyJobNameForIndex(jobs[0].Name, i)
+ setPostReadyExecutionPolicy(job, policy)
+ job.Annotations[postReadyActionContractAnnotationKey] = desiredContract
+ serializedPlan, err := json.Marshal(frozenPlan)
+ if err != nil {
+ return nil, err
+ }
+ job.Annotations[postReadyTargetPlanAnnotationKey] = string(serializedPlan)
+ frozenJobs = append(frozenJobs, job)
+ }
+ return frozenJobs, nil
+}
+
+func (r *RestoreManager) validateExistingRestoreActionJob(desired, existing *batchv1.Job) error {
+ if !r.isJobForRestoreAction(existing) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "restore job name collision: existing job %s/%s does not belong to restore %s/%s",
+ existing.Namespace, existing.Name, r.Restore.Namespace, r.Restore.Name))
+ }
+ desiredIdentity := postReadyTargetIdentity(desired)
+ if desiredIdentity == "" {
+ return nil
+ }
+ if postReadyTargetIdentity(existing) != desiredIdentity {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s target identity does not match desired target",
+ existing.Namespace, existing.Name))
+ }
+ desiredHasPlanReference := desired.Annotations[postReadyPlanNameAnnotationKey] != "" ||
+ desired.Annotations[postReadyPlanDigestAnnotationKey] != "" ||
+ desired.Annotations[postReadyPlanRestoreUIDAnnotationKey] != ""
+ if desiredHasPlanReference {
+ desiredBackupName, hasDesiredBackupName := desired.Annotations[postReadyBackupNameAnnotationKey]
+ desiredActionName, hasDesiredActionName := desired.Annotations[postReadyActionNameAnnotationKey]
+ if !hasDesiredBackupName || desiredBackupName == "" || !hasDesiredActionName || desiredActionName == "" {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s desired committed action identity is incomplete",
+ desired.Namespace, desired.Name))
+ }
+ existingBackupName, hasExistingBackupName := existing.Annotations[postReadyBackupNameAnnotationKey]
+ existingActionName, hasExistingActionName := existing.Annotations[postReadyActionNameAnnotationKey]
+ if !hasExistingBackupName || existingBackupName == "" ||
+ !hasExistingActionName || existingActionName == "" ||
+ existingBackupName != desiredBackupName || existingActionName != desiredActionName {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s committed action identity does not match desired action",
+ existing.Namespace, existing.Name))
+ }
+ } else {
+ desiredBackupName, desiredActionName := postReadyActionIdentity(desired)
+ existingBackupName, existingActionName := postReadyActionIdentity(existing)
+ if (desiredBackupName != "" || desiredActionName != "") &&
+ (existingBackupName != desiredBackupName || existingActionName != desiredActionName) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s action identity does not match desired action",
+ existing.Namespace, existing.Name))
+ }
+ }
+ desiredPolicy, err := postReadyExecutionPolicyForJob(desired)
+ if err != nil {
+ return err
+ }
+ existingPolicy, err := postReadyExecutionPolicyForJob(existing)
+ if err != nil {
+ return err
+ }
+ if existingPolicy != desiredPolicy {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s execution policy does not match desired policy",
+ existing.Namespace, existing.Name))
+ }
+ desiredPlan, err := postReadyTargetPlan(desired)
+ if err != nil {
+ return err
+ }
+ existingPlan, err := postReadyTargetPlan(existing)
+ if err != nil {
+ return err
+ }
+ if len(desiredPlan) == 0 || strings.Join(existingPlan, "\x00") != strings.Join(desiredPlan, "\x00") {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s frozen target plan does not match desired plan",
+ existing.Namespace, existing.Name))
+ }
+ desiredContract := postReadyActionContractForJob(desired)
+ if desiredContract == "" || postReadyActionContractForJob(existing) != desiredContract {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s executable action does not match desired contract",
+ existing.Namespace, existing.Name))
+ }
+ desiredPlanName := desired.Annotations[postReadyPlanNameAnnotationKey]
+ desiredPlanDigest := desired.Annotations[postReadyPlanDigestAnnotationKey]
+ desiredRestoreUID := desired.Annotations[postReadyPlanRestoreUIDAnnotationKey]
+ if desiredPlanName == "" && desiredPlanDigest == "" && desiredRestoreUID == "" {
+ if existing.Annotations[postReadyPlanNameAnnotationKey] != "" ||
+ existing.Annotations[postReadyPlanDigestAnnotationKey] != "" ||
+ existing.Annotations[postReadyPlanRestoreUIDAnnotationKey] != "" {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has an unexpected committed stage reference",
+ existing.Namespace, existing.Name))
+ }
+ if !r.postReadyJobSpecsEqual(desired, existing) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s executable spec does not match its frozen legacy plan",
+ existing.Namespace, existing.Name))
+ }
+ return nil
+ }
+ if desiredPlanName == "" || desiredPlanDigest == "" || desiredRestoreUID == "" {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s desired execution plan reference is incomplete",
+ desired.Namespace, desired.Name))
+ }
+ if existingPlanName := existing.Annotations[postReadyPlanNameAnnotationKey]; existingPlanName != desiredPlanName {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has a missing or different immutable execution plan reference",
+ existing.Namespace, existing.Name))
+ }
+ if existingPlanDigest := existing.Annotations[postReadyPlanDigestAnnotationKey]; existingPlanDigest != desiredPlanDigest {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has a missing or different execution plan digest",
+ existing.Namespace, existing.Name))
+ }
+ if existingRestoreUID := existing.Annotations[postReadyPlanRestoreUIDAnnotationKey]; existingRestoreUID != desiredRestoreUID || existingRestoreUID != string(r.Restore.UID) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s has a missing or different restore UID reference",
+ existing.Namespace, existing.Name))
+ }
+ if !controllerutil.ContainsFinalizer(existing, dptypes.DataProtectionFinalizerName) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s is missing terminal-status protection",
+ existing.Namespace, existing.Name))
+ }
+ if !r.postReadyJobSpecsEqual(desired, existing) {
+ return intctrlutil.NewFatalError(fmt.Sprintf(
+ "postReady job %s/%s executable spec does not match its immutable execution plan",
+ existing.Namespace, existing.Name))
+ }
+ return nil
+}
+
+func postReadyJobNameForIndex(name string, index int) string {
+ separator := strings.LastIndexByte(name, '-')
+ if separator < 0 {
+ return name
+ }
+ return fmt.Sprintf("%s-%d", name[:separator], index)
+}
+
+// ResumeNextSerialPostReadyJob starts at most one suspended postReady job after
+// every preceding job has completed successfully.
+func (r *RestoreManager) ResumeNextSerialPostReadyJob(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ jobs []*batchv1.Job,
+) error {
+ serial, err := serialPostReadyJobs(jobs)
+ if err != nil {
+ return err
+ }
+ if !serial {
+ return nil
+ }
+ sort.Slice(jobs, func(i, j int) bool {
+ return postReadyJobIndex(jobs[i].Name) < postReadyJobIndex(jobs[j].Name)
+ })
+ for i := range jobs {
+ done, _, errMsg := utils.IsJobFinished(jobs[i])
+ if errMsg != "" {
+ return nil
+ }
+ if done {
+ continue
+ }
+ if jobs[i].Spec.Suspend != nil && *jobs[i].Spec.Suspend {
+ updated := jobs[i].DeepCopy()
+ updated.Spec.Suspend = boolptr.False()
+ if err := cli.Patch(reqCtx.Ctx, updated, client.MergeFrom(jobs[i])); err != nil {
+ return err
+ }
+ jobs[i] = updated
+ }
+ return nil
+ }
+ return nil
+}
+
+func postReadyJobIndex(name string) int {
+ separator := strings.LastIndexByte(name, '-')
+ if separator < 0 {
+ return int(^uint(0) >> 1)
+ }
+ index, err := strconv.Atoi(name[separator+1:])
+ if err != nil {
+ return int(^uint(0) >> 1)
+ }
+ return index
+}
+
+func (r *RestoreManager) createPVCIfNotExist(
+ reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ claimMetadata metav1.ObjectMeta,
+ claimSpec corev1.PersistentVolumeClaimSpec) error {
+ claimMetadata.Namespace = reqCtx.Req.Namespace
+ pvc := &corev1.PersistentVolumeClaim{
+ ObjectMeta: claimMetadata,
+ Spec: claimSpec,
+ }
+ tmpPVC := &corev1.PersistentVolumeClaim{}
+ if err := cli.Get(reqCtx.Ctx, types.NamespacedName{Name: claimMetadata.Name, Namespace: claimMetadata.Namespace}, tmpPVC); err != nil {
+ if !apierrors.IsNotFound(err) {
+ return err
+ }
+ msg := fmt.Sprintf("created pvc %s/%s", pvc.Namespace, pvc.Name)
+ r.Recorder.Event(r.Restore, corev1.EventTypeNormal, reasonCreateRestorePVC, msg)
+ if err = cli.Create(reqCtx.Ctx, pvc); err != nil {
+ return client.IgnoreAlreadyExists(err)
+ }
+ }
+ return nil
+}
+
+// CreateJobsIfNotExist creates the jobs if not exist.
+func (r *RestoreManager) CreateJobsIfNotExist(reqCtx intctrlutil.RequestCtx,
+ cli client.Client,
+ ownerObj client.Object,
+ objs []*batchv1.Job) ([]*batchv1.Job, error) {
+ // creates jobs if not exist
+ var fetchedJobs []*batchv1.Job
+ for i := range objs {
if objs[i] == nil {
continue
}
@@ -851,17 +2592,26 @@ func (r *RestoreManager) CreateJobsIfNotExist(reqCtx intctrlutil.RequestCtx,
return nil, err
}
}
- if err = cli.Create(reqCtx.Ctx, objs[i]); err != nil && !apierrors.IsAlreadyExists(err) {
- return nil, err
+ if err = cli.Create(reqCtx.Ctx, objs[i]); err != nil {
+ if !apierrors.IsAlreadyExists(err) {
+ return nil, err
+ }
+ fetchedJob = &batchv1.Job{}
+ if err = cli.Get(reqCtx.Ctx, client.ObjectKeyFromObject(objs[i]), fetchedJob); err != nil {
+ return nil, err
+ }
+ if err = r.validateExistingRestoreActionJob(objs[i], fetchedJob); err != nil {
+ return nil, err
+ }
+ fetchedJobs = append(fetchedJobs, fetchedJob)
+ continue
}
msg := fmt.Sprintf("created job %s/%s", objs[i].Namespace, objs[i].Name)
r.Recorder.Event(r.Restore, corev1.EventTypeNormal, reasonCreateRestoreJob, msg)
fetchedJobs = append(fetchedJobs, objs[i])
} else {
- if !r.isJobForRestoreAction(fetchedJob) {
- err := fmt.Sprintf("restore job name collision: existing job %s/%s does not belong to restore %s/%s",
- fetchedJob.Namespace, fetchedJob.Name, r.Restore.Namespace, r.Restore.Name)
- return nil, intctrlutil.NewFatalError(err)
+ if err = r.validateExistingRestoreActionJob(objs[i], fetchedJob); err != nil {
+ return nil, err
}
fetchedJobs = append(fetchedJobs, fetchedJob)
}
@@ -883,6 +2633,14 @@ func (r *RestoreManager) CheckJobsDone(
if stage == dpv1alpha1.PostReady {
restoreActions = &r.Restore.Status.Actions.PostReady
}
+ serialPostReady := false
+ if stage == dpv1alpha1.PostReady {
+ var err error
+ serialPostReady, err = serialPostReadyJobs(fetchedJobs)
+ if err != nil {
+ return false, false, err
+ }
+ }
// count the number of jobs that are completed, failed,
// or have the normally terminated `restore` container
finishedCount := 0
@@ -914,9 +2672,17 @@ func (r *RestoreManager) CheckJobsDone(
}
if normalTerminated {
finishedCount++
+ if serialPostReady {
+ if err := r.StopManagerContainerByJob(fetchedJobs[i]); err != nil {
+ return false, false, err
+ }
+ }
}
}
}
+ if serialPostReady && existFailedJob {
+ return true, true, nil
+ }
// wait until all `restore` containers are terminated normally or jobs are completed or failed
if finishedCount == len(fetchedJobs) {
for i := range fetchedJobs {
diff --git a/pkg/dataprotection/restore/manager_test.go b/pkg/dataprotection/restore/manager_test.go
index 7efc5d18c3d..58409d2b5a6 100644
--- a/pkg/dataprotection/restore/manager_test.go
+++ b/pkg/dataprotection/restore/manager_test.go
@@ -20,6 +20,8 @@ along with this program. If not, see .
package restore
import (
+ "context"
+ "encoding/json"
"fmt"
"strconv"
"strings"
@@ -31,9 +33,12 @@ import (
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/utils/pointer"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
dpv1alpha1 "github.com/apecloud/kubeblocks/apis/dataprotection/v1alpha1"
"github.com/apecloud/kubeblocks/pkg/constant"
@@ -47,6 +52,82 @@ import (
viper "github.com/apecloud/kubeblocks/pkg/viperx"
)
+type reversePodListClient struct {
+ client.Client
+}
+
+type createAlreadyExistsClient struct {
+ client.Client
+ foreign *batchv1.Job
+}
+
+type failNthCreateClient struct {
+ client.Client
+ n int
+ count int
+}
+
+type failNthJobCreateClient struct {
+ client.Client
+ n int
+ count int
+}
+
+type persistSecretThenErrorClient struct {
+ client.Client
+ failed bool
+}
+
+func (c *failNthCreateClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
+ c.count++
+ if c.count == c.n {
+ return fmt.Errorf("injected create failure at %d", c.n)
+ }
+ return c.Client.Create(ctx, obj, opts...)
+}
+
+func (c *failNthJobCreateClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
+ if _, ok := obj.(*batchv1.Job); ok {
+ c.count++
+ if c.count == c.n {
+ return fmt.Errorf("injected Job create failure at %d", c.n)
+ }
+ }
+ return c.Client.Create(ctx, obj, opts...)
+}
+
+func (c *persistSecretThenErrorClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
+ if _, ok := obj.(*corev1.Secret); ok && !c.failed {
+ c.failed = true
+ if err := c.Client.Create(ctx, obj, opts...); err != nil {
+ return err
+ }
+ return fmt.Errorf("injected lost Secret create response")
+ }
+ return c.Client.Create(ctx, obj, opts...)
+}
+
+func (c createAlreadyExistsClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
+ if err := c.Client.Create(ctx, c.foreign.DeepCopy(), opts...); err != nil {
+ return err
+ }
+ return apierrors.NewAlreadyExists(schema.GroupResource{Group: "batch", Resource: "jobs"}, obj.GetName())
+}
+
+func (c reversePodListClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
+ if err := c.Client.List(ctx, list, opts...); err != nil {
+ return err
+ }
+ pods, ok := list.(*corev1.PodList)
+ if !ok {
+ return nil
+ }
+ for i, j := 0, len(pods.Items)-1; i < j; i, j = i+1, j-1 {
+ pods.Items[i], pods.Items[j] = pods.Items[j], pods.Items[i]
+ }
+ return nil
+}
+
var _ = Describe("RestoreManager Test", func() {
cleanEnv := func() {
@@ -66,6 +147,7 @@ var _ = Describe("RestoreManager Test", func() {
Eventually(testapps.List(&testCtx, generics.BackupSignature, inNS)).Should(HaveLen(0))
testapps.ClearResourcesWithRemoveFinalizerOption(&testCtx, generics.JobSignature, true, inNS)
+ testapps.ClearResourcesWithRemoveFinalizerOption(&testCtx, generics.SecretSignature, true, inNS)
testapps.ClearResourcesWithRemoveFinalizerOption(&testCtx, generics.RestoreSignature, true, inNS)
testapps.ClearResourcesWithRemoveFinalizerOption(&testCtx, generics.PersistentVolumeClaimSignature, true, inNS)
@@ -235,6 +317,32 @@ var _ = Describe("RestoreManager Test", func() {
}
}
+ newDurablePostReadyJobs := func(
+ restoreMGR *RestoreManager,
+ backupName, actionName, image string,
+ ) []*batchv1.Job {
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ jobs := []*batchv1.Job{
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-durable-0", labels),
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-durable-1", labels),
+ }
+ for i := range jobs {
+ jobs[i].Spec.Template.Spec.Containers[0].Image = image
+ jobs[i].Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: fmt.Sprintf("default/pod-%d", i),
+ postReadyActionContractAnnotationKey: "sha256:stable-action-contract",
+ postReadyBackupNameAnnotationKey: backupName,
+ postReadyActionNameAnnotationKey: actionName,
+ }
+ setPostReadyExecutionPolicy(jobs[i], dpv1alpha1.PostReadyExecutionPolicySerial)
+ }
+ Expect(setPostReadyTargetPlan(jobs)).Should(Succeed())
+ return jobs
+ }
+
checkVolumes := func(job *batchv1.Job, volumeName string, exist bool) {
var volumeExist bool
for _, v := range job.Spec.Template.Spec.Volumes {
@@ -350,6 +458,56 @@ var _ = Describe("RestoreManager Test", func() {
Expect(jobs[0].Name).Should(Equal(jobName))
})
+ It("loads completed postReady predecessors with the processing successor", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := "postready-0"
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ jobs := []*batchv1.Job{
+ newRestoreJob(testCtx.DefaultNamespace, "restore-postready-existing-0", labels),
+ newRestoreJob(testCtx.DefaultNamespace, "restore-postready-existing-1", labels),
+ }
+ for i := range jobs {
+ setPostReadyExecutionPolicy(jobs[i], dpv1alpha1.PostReadyExecutionPolicySerial)
+ jobs[i].Annotations[postReadyTargetIdentityAnnotationKey] = fmt.Sprintf("default/pod-%d", i)
+ }
+ Expect(setPostReadyTargetPlan(jobs)).Should(Succeed())
+ for i := range jobs {
+ Expect(k8sClient.Create(reqCtx.Ctx, jobs[i])).Should(Succeed())
+ }
+ testdp.PatchK8sJobStatus(&testCtx, client.ObjectKeyFromObject(jobs[0]), batchv1.JobComplete)
+ restoreMGR.Restore.Status.Actions.PostReady = []dpv1alpha1.RestoreStatusAction{
+ {
+ Name: actionName,
+ ObjectKey: BuildJobKeyForActionStatus(jobs[0].Name),
+ BackupName: backupSet.Backup.Name,
+ Status: dpv1alpha1.RestoreActionCompleted,
+ },
+ {
+ Name: actionName,
+ ObjectKey: BuildJobKeyForActionStatus(jobs[1].Name),
+ BackupName: backupSet.Backup.Name,
+ Status: dpv1alpha1.RestoreActionProcessing,
+ },
+ }
+
+ persisted, err := restoreMGR.GetExistingActionJobs(
+ reqCtx, k8sClient, dpv1alpha1.PostReady, backupSet.Backup.Name, actionName)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(persisted).Should(HaveLen(2))
+ persisted, err = restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, persisted)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(persisted).Should(HaveLen(2))
+ Expect(restoreMGR.ResumeNextSerialPostReadyJob(reqCtx, k8sClient, persisted)).Should(Succeed())
+
+ next := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(jobs[1]), next)).Should(Succeed())
+ Expect(*next.Spec.Suspend).Should(BeFalse())
+ })
+
It("should not use a controller-namespace action job without the Restore namespace label", func() {
controllerNamespace := "kb-system-wrong-restore-ns"
viper.Set(constant.CfgKeyCtrlrMgrNS, controllerNamespace)
@@ -495,12 +653,28 @@ var _ = Describe("RestoreManager Test", func() {
By("test with execAction and expect for creating 2 exec job")
target := utils.GetBackupStatusTarget(backupSet.Backup, restoreMGR.Restore.Spec.Backup.SourceTargetName)
// step 0 is the execAction in actionSet
- jobs, err := restoreMGR.BuildPostReadyActionJobs(reqCtx, k8sClient, *backupSet, target, 0)
+ jobs, err := restoreMGR.BuildPostReadyActionJobs(reqCtx, reversePodListClient{Client: k8sClient}, *backupSet, target, 0)
Expect(err).ShouldNot(HaveOccurred())
// the count of exec jobs should equal to the pods count of cluster
Expect(len(jobs)).Should(Equal(2))
+ for i := range jobs {
+ Expect(jobs[i].Spec.Suspend).Should(BeNil())
+ Expect(jobs[i].Annotations[postReadyExecutionPolicyAnnotationKey]).Should(Equal(string(dpv1alpha1.PostReadyExecutionPolicyParallel)))
+ Expect(jobs[i].Annotations[postReadyActionContractAnnotationKey]).Should(HavePrefix("sha256:"))
+ Expect(jobs[i].Annotations[postReadyBackupNameAnnotationKey]).Should(Equal(backupSet.Backup.Name))
+ Expect(jobs[i].Annotations[postReadyActionNameAnnotationKey]).Should(Equal("postReady-0"))
+ }
+ execActionContract := jobs[0].Annotations[postReadyActionContractAnnotationKey]
+ Expect(jobs[0].Spec.Template.Spec.Containers[0].Args[3] < jobs[1].Spec.Template.Spec.Containers[0].Args[3]).Should(BeTrue())
Expect(jobs[0].Namespace).Should(Equal(kbNamespace))
Expect(jobs[0].Spec.Template.Spec.ServiceAccountName).Should(Equal(execWorkerServiceAccountName))
+ oldToolsImage := viper.GetString(constant.KBToolsImage)
+ viper.Set(constant.KBToolsImage, oldToolsImage+"-changed")
+ mutatedExecJobs, err := restoreMGR.BuildPostReadyActionJobs(reqCtx, k8sClient, *backupSet, target, 0)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(mutatedExecJobs).Should(HaveLen(2))
+ Expect(mutatedExecJobs[0].Annotations[postReadyActionContractAnnotationKey]).ShouldNot(Equal(execActionContract))
+ viper.Set(constant.KBToolsImage, oldToolsImage)
By("test with jobAction and expect for creating 1 job")
// step 0 is the execAction in actionSet
@@ -508,6 +682,9 @@ var _ = Describe("RestoreManager Test", func() {
Expect(err).ShouldNot(HaveOccurred())
// count of job should equal to 1
Expect(len(jobs)).Should(Equal(1))
+ jobActionContract := jobs[0].Annotations[postReadyActionContractAnnotationKey]
+ Expect(jobActionContract).Should(Equal(execActionContract))
+ Expect(jobs[0].Annotations[postReadyActionNameAnnotationKey]).Should(Equal("postReady-1"))
// test timeZone transform
var backupStopTimeEnv string
for _, v := range jobs[0].Spec.Template.Spec.Containers[0].Env {
@@ -518,6 +695,17 @@ var _ = Describe("RestoreManager Test", func() {
}
Expect(backupStopTimeEnv).Should(Equal("2023-01-01 18:00:00"))
checkVolumes(jobs[0], testdp.DataVolumeName, existVolume)
+
+ By("change an executable action and expect the complete action contract to change")
+ backupSet.ActionSet.Spec.Restore.PostReady[1].Job.Image += "-changed"
+ mutatedJobs, err := restoreMGR.BuildPostReadyActionJobs(reqCtx, k8sClient, *backupSet, target, 1)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(mutatedJobs).Should(HaveLen(1))
+ Expect(mutatedJobs[0].Annotations[postReadyActionContractAnnotationKey]).ShouldNot(Equal(jobActionContract))
+ mutatedExecJobs, err = restoreMGR.BuildPostReadyActionJobs(reqCtx, k8sClient, *backupSet, target, 0)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(mutatedExecJobs).Should(HaveLen(2))
+ Expect(mutatedExecJobs[0].Annotations[postReadyActionContractAnnotationKey]).ShouldNot(Equal(execActionContract))
}
It("test with BuildPostReadyActionJobs function and run target pod node", func() {
@@ -532,6 +720,1118 @@ var _ = Describe("RestoreManager Test", func() {
testPostReady(false)
})
+ It("serializes postReady jobs across selected pods", func() {
+ reqCtx := getReqCtx()
+ matchLabels := map[string]string{
+ constant.AppInstanceLabelKey: testdp.ClusterName,
+ }
+ Expect(testapps.ChangeObj(&testCtx, actionSet, func(set *dpv1alpha1.ActionSet) {
+ set.Spec.Restore.PostReadyExecutionPolicy = dpv1alpha1.PostReadyExecutionPolicySerial
+ })).Should(Succeed())
+ oldControllerNamespace := viper.GetString(constant.CfgKeyCtrlrMgrNS)
+ viper.Set(constant.CfgKeyCtrlrMgrNS, testCtx.DefaultNamespace)
+ DeferCleanup(func() { viper.Set(constant.CfgKeyCtrlrMgrNS, oldControllerNamespace) })
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {
+ f.SetConnectCredential(testdp.ClusterName).SetJobActionConfig(matchLabels).SetExecActionConfig(matchLabels)
+ })
+ testdp.NewFakeCluster(&testCtx)
+
+ target := utils.GetBackupStatusTarget(backupSet.Backup, restoreMGR.Restore.Spec.Backup.SourceTargetName)
+ jobs, err := restoreMGR.BuildPostReadyActionJobs(reqCtx, k8sClient, *backupSet, target, 0)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(jobs).Should(HaveLen(2))
+ Expect(jobs[0].Spec.Suspend).ShouldNot(BeNil())
+ Expect(*jobs[0].Spec.Suspend).Should(BeTrue())
+ Expect(jobs[1].Spec.Suspend).ShouldNot(BeNil())
+ Expect(*jobs[1].Spec.Suspend).Should(BeTrue())
+ for i := range jobs {
+ Expect(jobs[i].Annotations[postReadyExecutionPolicyAnnotationKey]).Should(Equal(string(dpv1alpha1.PostReadyExecutionPolicySerial)))
+ }
+ for i := range jobs {
+ for j := range jobs[i].Spec.Template.Spec.Containers {
+ jobs[i].Spec.Template.Spec.Containers[j].Image = "test-image"
+ }
+ }
+
+ jobs, err = restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ jobs, err = restoreMGR.CreateJobsIfNotExist(reqCtx, k8sClient, restoreMGR.Restore, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(restoreMGR.ResumeNextSerialPostReadyJob(reqCtx, k8sClient, jobs)).Should(Succeed())
+ first := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(jobs[0]), first)).Should(Succeed())
+ Expect(*first.Spec.Suspend).Should(BeFalse())
+ second := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(jobs[1]), second)).Should(Succeed())
+ Expect(*second.Spec.Suspend).Should(BeTrue())
+
+ By("keep the frozen serial policy after the ActionSet changes")
+ Expect(testapps.ChangeObj(&testCtx, actionSet, func(set *dpv1alpha1.ActionSet) {
+ set.Spec.Restore.PostReadyExecutionPolicy = dpv1alpha1.PostReadyExecutionPolicyParallel
+ })).Should(Succeed())
+
+ testdp.PatchK8sJobStatus(&testCtx, client.ObjectKeyFromObject(jobs[0]), batchv1.JobComplete)
+ for i := range jobs {
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(jobs[i]), jobs[i])).Should(Succeed())
+ }
+ Expect(restoreMGR.ResumeNextSerialPostReadyJob(reqCtx, k8sClient, jobs)).Should(Succeed())
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(jobs[1]), second)).Should(Succeed())
+ Expect(*second.Spec.Suspend).Should(BeFalse())
+ })
+
+ It("persists the complete plan before the first Job and reuses it after drift", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(frozen).Should(HaveLen(2))
+ planName := frozen[0].Annotations[postReadyPlanNameAnnotationKey]
+ planDigest := frozen[0].Annotations[postReadyPlanDigestAnnotationKey]
+ Expect(planName).ShouldNot(BeEmpty())
+ Expect(planDigest).Should(HavePrefix("sha256:"))
+ planSecret := &corev1.Secret{}
+ Expect(k8sClient.Get(reqCtx.Ctx, types.NamespacedName{
+ Namespace: restoreMGR.Restore.Namespace,
+ Name: planName,
+ }, planSecret)).Should(Succeed())
+ Expect(planSecret.Immutable).ShouldNot(BeNil())
+ Expect(*planSecret.Immutable).Should(BeTrue())
+
+ _, err = restoreMGR.CreateJobsIfNotExist(reqCtx,
+ &failNthJobCreateClient{Client: k8sClient, n: 1}, restoreMGR.Restore, frozen)
+ Expect(err).Should(MatchError(ContainSubstring("injected Job create failure at 1")))
+ for i := range frozen {
+ Expect(apierrors.IsNotFound(k8sClient.Get(reqCtx.Ctx,
+ client.ObjectKeyFromObject(frozen[i]), &batchv1.Job{}))).Should(BeTrue())
+ }
+
+ drifted := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v2")
+ resumed, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, drifted)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(resumed).Should(HaveLen(2))
+ for i := range resumed {
+ Expect(resumed[i].Spec.Template.Spec.Containers[0].Image).Should(Equal("image:v1"))
+ Expect(resumed[i].Annotations[postReadyPlanNameAnnotationKey]).Should(Equal(planName))
+ Expect(resumed[i].Annotations[postReadyPlanDigestAnnotationKey]).Should(Equal(planDigest))
+ }
+ })
+
+ It("commits every BackupSet and action in order before creating the first Job", func() {
+ reqCtx := getReqCtx()
+ matchLabels := map[string]string{constant.AppInstanceLabelKey: testdp.ClusterName}
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {
+ f.SetConnectCredential(testdp.ClusterName).
+ SetJobActionConfig(matchLabels).
+ SetExecActionConfig(matchLabels)
+ })
+ testdp.NewFakeCluster(&testCtx)
+
+ second := *backupSet
+ second.Backup = backupSet.Backup.DeepCopy()
+ second.Backup.Name += "-second"
+ second.Backup.Status.Path += "-second"
+ restoreMGR.PostReadyBackupSets = []BackupActionSet{*backupSet, second}
+
+ found, err := restoreMGR.EnsurePostReadyStagePlan(reqCtx, k8sClient)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(found).Should(BeTrue())
+ jobList := &batchv1.JobList{}
+ Expect(k8sClient.List(reqCtx.Ctx, jobList,
+ client.InNamespace(restoreMGR.Restore.Namespace),
+ client.MatchingLabels{DataProtectionRestoreLabelKey: restoreMGR.Restore.Name})).Should(Succeed())
+ Expect(jobList.Items).Should(BeEmpty())
+
+ stage, found, err := restoreMGR.loadPostReadyExecutionPlanStage(reqCtx, k8sClient)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(found).Should(BeTrue())
+ Expect(stage.Actions).Should(HaveLen(4))
+ Expect(stage.Actions).Should(ConsistOf(
+ HaveField("Order", 0), HaveField("Order", 1), HaveField("Order", 2), HaveField("Order", 3)))
+ Expect([]string{
+ postReadyActionKey(stage.Actions[0].BackupName, stage.Actions[0].ActionName),
+ postReadyActionKey(stage.Actions[1].BackupName, stage.Actions[1].ActionName),
+ postReadyActionKey(stage.Actions[2].BackupName, stage.Actions[2].ActionName),
+ postReadyActionKey(stage.Actions[3].BackupName, stage.Actions[3].ActionName),
+ }).Should(Equal([]string{
+ postReadyActionKey(backupSet.Backup.Name, "postReady-0"),
+ postReadyActionKey(backupSet.Backup.Name, "postReady-1"),
+ postReadyActionKey(second.Backup.Name, "postReady-0"),
+ postReadyActionKey(second.Backup.Name, "postReady-1"),
+ }))
+ })
+
+ It("rejects a canonical subset Secret before the stage marker is committed", func() {
+ reqCtx := getReqCtx()
+ matchLabels := map[string]string{constant.AppInstanceLabelKey: testdp.ClusterName}
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {
+ f.SetConnectCredential(testdp.ClusterName).
+ SetJobActionConfig(matchLabels).
+ SetExecActionConfig(matchLabels)
+ })
+ testdp.NewFakeCluster(&testCtx)
+
+ second := *backupSet
+ second.Backup = backupSet.Backup.DeepCopy()
+ second.Backup.Name += "-second"
+ second.Backup.Status.Path += "-second"
+ restoreMGR.PostReadyBackupSets = []BackupActionSet{*backupSet, second}
+
+ found, err := restoreMGR.EnsurePostReadyStagePlan(reqCtx, k8sClient)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(found).Should(BeTrue())
+ secretKey := types.NamespacedName{
+ Namespace: restoreMGR.Restore.Namespace,
+ Name: restoreMGR.postReadyPlanSecretName(),
+ }
+ secret := &corev1.Secret{}
+ Expect(k8sClient.Get(reqCtx.Ctx, secretKey, secret)).Should(Succeed())
+ Expect(k8sClient.Delete(reqCtx.Ctx, secret)).Should(Succeed())
+ Eventually(func() bool {
+ return apierrors.IsNotFound(k8sClient.Get(reqCtx.Ctx, secretKey, &corev1.Secret{}))
+ }).Should(BeTrue())
+
+ originalRestore := restoreMGR.Restore.DeepCopy()
+ delete(restoreMGR.Restore.Annotations, postReadyPlanMarkerAnnotationKey)
+ Expect(k8sClient.Patch(reqCtx.Ctx, restoreMGR.Restore,
+ client.MergeFrom(originalRestore))).Should(Succeed())
+
+ var subset postReadyExecutionPlan
+ Expect(json.Unmarshal(secret.Data[postReadyPlanDataKey], &subset)).Should(Succeed())
+ Expect(subset.Actions).Should(HaveLen(4))
+ subset.Actions = subset.Actions[:2]
+ payload, err := json.Marshal(subset)
+ Expect(err).ShouldNot(HaveOccurred())
+ forged := secret.DeepCopy()
+ forged.ResourceVersion = ""
+ forged.UID = ""
+ forged.CreationTimestamp = metav1.Time{}
+ forged.DeletionTimestamp = nil
+ forged.ManagedFields = nil
+ forged.Data[postReadyPlanDataKey] = payload
+ forged.Annotations[postReadyPlanDigestAnnotationKey] = postReadyPlanDigest(payload)
+ Expect(k8sClient.Create(reqCtx.Ctx, forged)).Should(Succeed())
+
+ found, err = restoreMGR.EnsurePostReadyStagePlan(reqCtx, k8sClient)
+ Expect(found).Should(BeFalse())
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("does not match the complete expected stage"))
+ _, hasMarker := restoreMGR.postReadyPlanMarker()
+ Expect(hasMarker).Should(BeFalse())
+ })
+
+ It("creates no Job when the plan create response is lost", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+
+ _, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx,
+ &persistSecretThenErrorClient{Client: k8sClient}, jobs)
+ Expect(err).Should(MatchError(ContainSubstring("injected lost Secret create response")))
+ jobList := &batchv1.JobList{}
+ Expect(k8sClient.List(reqCtx.Ctx, jobList, client.InNamespace(restoreMGR.Restore.Namespace),
+ client.MatchingLabels{DataProtectionRestoreLabelKey: restoreMGR.Restore.Name})).Should(Succeed())
+ Expect(jobList.Items).Should(BeEmpty())
+
+ resumed, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(resumed).Should(HaveLen(2))
+ Expect(resumed[0].Annotations[postReadyPlanDigestAnnotationKey]).Should(HavePrefix("sha256:"))
+ })
+
+ It("keeps completed ordinals outside the immutable plan and does not recreate a GCed Job", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ planDigest := frozen[0].Annotations[postReadyPlanDigestAnnotationKey]
+ created, err := restoreMGR.CreateJobsIfNotExist(reqCtx, k8sClient, restoreMGR.Restore, frozen)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(created).Should(HaveLen(2))
+
+ SetRestoreStatusAction(&restoreMGR.Restore.Status.Actions.PostReady, dpv1alpha1.RestoreStatusAction{
+ Name: actionName,
+ BackupName: backupSet.Backup.Name,
+ ObjectKey: BuildJobKeyForActionStatus(created[0].Name),
+ Status: dpv1alpha1.RestoreActionCompleted,
+ })
+ background := metav1.DeletePropagationBackground
+ zero := int64(0)
+ Expect(k8sClient.Delete(reqCtx.Ctx, created[0],
+ &client.DeleteOptions{PropagationPolicy: &background, GracePeriodSeconds: &zero})).Should(Succeed())
+
+ loaded, err := restoreMGR.GetExistingActionJobs(reqCtx, k8sClient, dpv1alpha1.PostReady,
+ backupSet.Backup.Name, actionName)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(loaded).Should(HaveLen(2))
+ Expect(loaded[0].Annotations[postReadyPlanDigestAnnotationKey]).Should(Equal(planDigest))
+ pending := restoreMGR.PendingPostReadyJobs(backupSet.Backup.Name, actionName, loaded)
+ Expect(pending).Should(HaveLen(1))
+ Expect(pending[0].Name).Should(Equal(created[1].Name))
+ _, err = restoreMGR.CreateJobsIfNotExist(reqCtx, k8sClient, restoreMGR.Restore, pending)
+ Expect(err).ShouldNot(HaveOccurred())
+ protected := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(created[0]), protected)).Should(Succeed())
+ Expect(protected.DeletionTimestamp).ShouldNot(BeNil())
+ Expect(protected.Finalizers).Should(ContainElement(dptypes.DataProtectionFinalizerName))
+
+ By("release terminal-status protection only after the Restore fact is durable")
+ original := protected.DeepCopy()
+ controllerutil.RemoveFinalizer(protected, dptypes.DataProtectionFinalizerName)
+ Expect(k8sClient.Patch(reqCtx.Ctx, protected, client.MergeFrom(original))).Should(Succeed())
+ Eventually(func() bool {
+ return apierrors.IsNotFound(k8sClient.Get(reqCtx.Ctx,
+ client.ObjectKeyFromObject(created[0]), &batchv1.Job{}))
+ }).Should(BeTrue())
+
+ secret := &corev1.Secret{}
+ Expect(k8sClient.Get(reqCtx.Ctx, types.NamespacedName{
+ Namespace: restoreMGR.Restore.Namespace,
+ Name: loaded[0].Annotations[postReadyPlanNameAnnotationKey],
+ }, secret)).Should(Succeed())
+ Expect(secret.Annotations[postReadyPlanDigestAnnotationKey]).Should(Equal(planDigest))
+ })
+
+ It("does not replay a failed Job after garbage collection", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ _, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ SetRestoreStatusAction(&restoreMGR.Restore.Status.Actions.PostReady, dpv1alpha1.RestoreStatusAction{
+ Name: actionName,
+ BackupName: backupSet.Backup.Name,
+ ObjectKey: BuildJobKeyForActionStatus(jobs[0].Name),
+ Status: dpv1alpha1.RestoreActionFailed,
+ })
+
+ completed, err := restoreMGR.ReconcileOrphanedPostReadyActions(reqCtx, k8sClient)
+ Expect(completed).Should(BeFalse())
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("terminal failed Job"))
+ jobList := &batchv1.JobList{}
+ Expect(k8sClient.List(reqCtx.Ctx, jobList,
+ client.InNamespace(restoreMGR.Restore.Namespace),
+ client.MatchingLabels{DataProtectionRestoreLabelKey: restoreMGR.Restore.Name})).Should(Succeed())
+ Expect(jobList.Items).Should(BeEmpty())
+ })
+
+ It("re-observes a terminal Job when the Restore status patch response is lost", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")[:1]
+ Expect(setPostReadyTargetPlan(jobs)).Should(Succeed())
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ created, err := restoreMGR.CreateJobsIfNotExist(reqCtx, k8sClient, restoreMGR.Restore, frozen)
+ Expect(err).ShouldNot(HaveOccurred())
+ testdp.PatchK8sJobStatus(&testCtx, client.ObjectKeyFromObject(created[0]), batchv1.JobComplete)
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(created[0]), created[0])).Should(Succeed())
+
+ completed, failed, err := restoreMGR.CheckJobsDone(
+ dpv1alpha1.PostReady, actionName, *backupSet, created)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(completed).Should(BeTrue())
+ Expect(failed).Should(BeFalse())
+
+ originalRestore := restoreMGR.OriginalRestore.DeepCopy()
+ freshManager := NewRestoreManager(originalRestore, nil, restoreMGR.Schema, k8sClient)
+ freshJob := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(created[0]), freshJob)).Should(Succeed())
+ Expect(freshJob.Finalizers).Should(ContainElement(dptypes.DataProtectionFinalizerName))
+ completed, failed, err = freshManager.CheckJobsDone(
+ dpv1alpha1.PostReady, actionName, *backupSet, []*batchv1.Job{freshJob})
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(completed).Should(BeTrue())
+ Expect(failed).Should(BeFalse())
+ Expect(freshManager.Restore.Status.Actions.PostReady).Should(ContainElement(
+ HaveField("Status", dpv1alpha1.RestoreActionCompleted)))
+ })
+
+ It("fails closed when the durable plan payload is corrupted", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ secret := &corev1.Secret{}
+ secretKey := types.NamespacedName{
+ Namespace: restoreMGR.Restore.Namespace,
+ Name: frozen[0].Annotations[postReadyPlanNameAnnotationKey],
+ }
+ Expect(k8sClient.Get(reqCtx.Ctx, secretKey, secret)).Should(Succeed())
+ Expect(k8sClient.Delete(reqCtx.Ctx, secret)).Should(Succeed())
+ Eventually(func() bool {
+ return apierrors.IsNotFound(k8sClient.Get(reqCtx.Ctx, secretKey, &corev1.Secret{}))
+ }).Should(BeTrue())
+ corrupted := secret.DeepCopy()
+ corrupted.ResourceVersion = ""
+ corrupted.UID = ""
+ corrupted.CreationTimestamp = metav1.Time{}
+ corrupted.Data[postReadyPlanDataKey] = []byte(`{"version":"v1","jobs":[]}`)
+ Expect(k8sClient.Create(reqCtx.Ctx, corrupted)).Should(Succeed())
+
+ _, err = restoreMGR.GetExistingActionJobs(reqCtx, k8sClient, dpv1alpha1.PostReady,
+ backupSet.Backup.Name, actionName)
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("invalid payload digest"))
+ })
+
+ It("rejects untrusted, foreign, empty, and partial stage plans", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ secretKey := types.NamespacedName{
+ Namespace: restoreMGR.Restore.Namespace,
+ Name: frozen[0].Annotations[postReadyPlanNameAnnotationKey],
+ }
+ base := &corev1.Secret{}
+ Expect(k8sClient.Get(reqCtx.Ctx, secretKey, base)).Should(Succeed())
+
+ mutatePayload := func(secret *corev1.Secret, mutate func(*postReadyExecutionPlan)) {
+ var plan postReadyExecutionPlan
+ Expect(json.Unmarshal(secret.Data[postReadyPlanDataKey], &plan)).Should(Succeed())
+ mutate(&plan)
+ payload, err := json.Marshal(plan)
+ Expect(err).ShouldNot(HaveOccurred())
+ secret.Data[postReadyPlanDataKey] = payload
+ secret.Annotations[postReadyPlanDigestAnnotationKey] = postReadyPlanDigest(payload)
+ }
+ cases := []struct {
+ name string
+ mutate func(*corev1.Secret)
+ }{
+ {name: "wrong owner", mutate: func(secret *corev1.Secret) {
+ secret.OwnerReferences = nil
+ }},
+ {name: "same UID wrong owner name", mutate: func(secret *corev1.Secret) {
+ secret.OwnerReferences[0].Name = "foreign-restore"
+ }},
+ {name: "same UID wrong owner kind", mutate: func(secret *corev1.Secret) {
+ secret.OwnerReferences[0].Kind = "Backup"
+ }},
+ {name: "same UID wrong owner API", mutate: func(secret *corev1.Secret) {
+ secret.OwnerReferences[0].APIVersion = "v1"
+ }},
+ {name: "same UID non-controller owner", mutate: func(secret *corev1.Secret) {
+ secret.OwnerReferences[0].Controller = pointer.Bool(false)
+ }},
+ {name: "same UID non-blocking owner", mutate: func(secret *corev1.Secret) {
+ secret.OwnerReferences[0].BlockOwnerDeletion = pointer.Bool(false)
+ }},
+ {name: "wrong restore UID", mutate: func(secret *corev1.Secret) {
+ secret.Annotations[postReadyPlanRestoreUIDAnnotationKey] = "foreign-restore"
+ }},
+ {name: "wrong digest", mutate: func(secret *corev1.Secret) {
+ secret.Annotations[postReadyPlanDigestAnnotationKey] = "sha256:forged"
+ }},
+ {name: "empty shell", mutate: func(secret *corev1.Secret) {
+ secret.Data[postReadyPlanDataKey] = nil
+ secret.Annotations[postReadyPlanDigestAnnotationKey] = postReadyPlanDigest(nil)
+ }},
+ {name: "foreign source backup", mutate: func(secret *corev1.Secret) {
+ mutatePayload(secret, func(plan *postReadyExecutionPlan) {
+ plan.SourceBackupName = "foreign-backup"
+ })
+ }},
+ {name: "partial action zero", mutate: func(secret *corev1.Secret) {
+ mutatePayload(secret, func(plan *postReadyExecutionPlan) {
+ plan.Actions[0].Jobs = nil
+ })
+ }},
+ {name: "no actions", mutate: func(secret *corev1.Secret) {
+ mutatePayload(secret, func(plan *postReadyExecutionPlan) {
+ plan.Actions = nil
+ })
+ }},
+ }
+ for _, tc := range cases {
+ By(tc.name)
+ current := &corev1.Secret{}
+ Expect(k8sClient.Get(reqCtx.Ctx, secretKey, current)).Should(Succeed())
+ Expect(k8sClient.Delete(reqCtx.Ctx, current)).Should(Succeed())
+ Eventually(func() bool {
+ return apierrors.IsNotFound(k8sClient.Get(reqCtx.Ctx, secretKey, &corev1.Secret{}))
+ }).Should(BeTrue())
+ forged := base.DeepCopy()
+ forged.ResourceVersion = ""
+ forged.UID = ""
+ forged.CreationTimestamp = metav1.Time{}
+ forged.DeletionTimestamp = nil
+ forged.ManagedFields = nil
+ tc.mutate(forged)
+ Expect(k8sClient.Create(reqCtx.Ctx, forged)).Should(Succeed())
+ _, found, err := restoreMGR.loadPostReadyExecutionPlanStage(reqCtx, k8sClient)
+ Expect(found).Should(BeTrue())
+ Expect(err).Should(HaveOccurred())
+ }
+ })
+
+ It("fails closed when a committed durable plan is missing", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ marker, ok := restoreMGR.postReadyPlanMarker()
+ Expect(ok).Should(BeTrue())
+ Expect(marker).Should(ContainSubstring(frozen[0].Annotations[postReadyPlanNameAnnotationKey]))
+ secret := &corev1.Secret{}
+ secretKey := types.NamespacedName{
+ Namespace: restoreMGR.Restore.Namespace,
+ Name: frozen[0].Annotations[postReadyPlanNameAnnotationKey],
+ }
+ Expect(k8sClient.Get(reqCtx.Ctx, secretKey, secret)).Should(Succeed())
+ background := metav1.DeletePropagationBackground
+ zero := int64(0)
+ Expect(k8sClient.Delete(reqCtx.Ctx, secret,
+ &client.DeleteOptions{PropagationPolicy: &background, GracePeriodSeconds: &zero})).Should(Succeed())
+ Eventually(func() bool {
+ return apierrors.IsNotFound(k8sClient.Get(reqCtx.Ctx, secretKey, &corev1.Secret{}))
+ }).Should(BeTrue())
+
+ _, err = restoreMGR.GetExistingActionJobs(reqCtx, k8sClient, dpv1alpha1.PostReady,
+ backupSet.Backup.Name, actionName)
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("is missing"))
+ })
+
+ It("fails closed while the committed stage plan Secret is terminating", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ secretKey := types.NamespacedName{
+ Namespace: restoreMGR.Restore.Namespace,
+ Name: frozen[0].Annotations[postReadyPlanNameAnnotationKey],
+ }
+ secret := &corev1.Secret{}
+ Expect(k8sClient.Get(reqCtx.Ctx, secretKey, secret)).Should(Succeed())
+ original := secret.DeepCopy()
+ controllerutil.AddFinalizer(secret, "dataprotection.kubeblocks.io/test-hold")
+ Expect(k8sClient.Patch(reqCtx.Ctx, secret, client.MergeFrom(original))).Should(Succeed())
+ Expect(k8sClient.Delete(reqCtx.Ctx, secret)).Should(Succeed())
+ Eventually(func(g Gomega) {
+ terminating := &corev1.Secret{}
+ g.Expect(k8sClient.Get(reqCtx.Ctx, secretKey, terminating)).Should(Succeed())
+ g.Expect(terminating.DeletionTimestamp).ShouldNot(BeNil())
+ }).Should(Succeed())
+
+ _, found, err := restoreMGR.loadPostReadyExecutionPlanStage(reqCtx, k8sClient)
+ Expect(found).Should(BeTrue())
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("is terminating"))
+ })
+
+ It("freezes target membership and serial policy across a partial creation retry", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, _ := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ newTargetJob := func(index int, target string, policy dpv1alpha1.PostReadyExecutionPolicy) *batchv1.Job {
+ job := newRestoreJob(testCtx.DefaultNamespace, fmt.Sprintf("restore-post-ready-partial-%d", index), labels)
+ job.Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: target,
+ postReadyActionContractAnnotationKey: "sha256:stable-action-contract",
+ }
+ setPostReadyExecutionPolicy(job, policy)
+ return job
+ }
+
+ oldPlan := []*batchv1.Job{
+ newTargetJob(0, "default/pod-b", dpv1alpha1.PostReadyExecutionPolicySerial),
+ newTargetJob(1, "default/pod-c", dpv1alpha1.PostReadyExecutionPolicySerial),
+ }
+ Expect(setPostReadyTargetPlan(oldPlan)).Should(Succeed())
+ failingClient := &failNthCreateClient{Client: k8sClient, n: 2}
+ created, err := restoreMGR.CreateJobsIfNotExist(reqCtx, failingClient, restoreMGR.Restore, oldPlan)
+ Expect(err).Should(HaveOccurred())
+ Expect(created).Should(BeNil())
+ firstAfterFailure := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(oldPlan[0]), firstAfterFailure)).Should(Succeed())
+ Expect(*firstAfterFailure.Spec.Suspend).Should(BeTrue())
+ Expect(apierrors.IsNotFound(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(oldPlan[1]), &batchv1.Job{}))).Should(BeTrue())
+
+ missingTarget := []*batchv1.Job{
+ newTargetJob(0, "default/pod-a", dpv1alpha1.PostReadyExecutionPolicyParallel),
+ newTargetJob(1, "default/pod-b", dpv1alpha1.PostReadyExecutionPolicyParallel),
+ }
+ Expect(setPostReadyTargetPlan(missingTarget)).Should(Succeed())
+ _, err = restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, missingTarget)
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("frozen target default/pod-c is not currently available"))
+
+ current := []*batchv1.Job{
+ newTargetJob(0, "default/pod-a", dpv1alpha1.PostReadyExecutionPolicyParallel),
+ newTargetJob(1, "default/pod-b", dpv1alpha1.PostReadyExecutionPolicyParallel),
+ newTargetJob(2, "default/pod-c", dpv1alpha1.PostReadyExecutionPolicyParallel),
+ }
+ Expect(setPostReadyTargetPlan(current)).Should(Succeed())
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, current)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(frozen).Should(HaveLen(2))
+ for i, target := range []string{"default/pod-b", "default/pod-c"} {
+ Expect(frozen[i].Name).Should(Equal(fmt.Sprintf("restore-post-ready-partial-%d", i)))
+ Expect(postReadyTargetIdentity(frozen[i])).Should(Equal(target))
+ Expect(frozen[i].Annotations[postReadyExecutionPolicyAnnotationKey]).Should(Equal(string(dpv1alpha1.PostReadyExecutionPolicySerial)))
+ Expect(*frozen[i].Spec.Suspend).Should(BeTrue())
+ }
+
+ frozen, err = restoreMGR.CreateJobsIfNotExist(reqCtx, k8sClient, restoreMGR.Restore, frozen)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(restoreMGR.ResumeNextSerialPostReadyJob(reqCtx, k8sClient, frozen)).Should(Succeed())
+ first := &batchv1.Job{}
+ second := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(frozen[0]), first)).Should(Succeed())
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(frozen[1]), second)).Should(Succeed())
+ Expect(*first.Spec.Suspend).Should(BeFalse())
+ Expect(*second.Spec.Suspend).Should(BeTrue())
+ })
+
+ It("rejects executable action drift across a frozen partial creation retry", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, _ := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ newFrozenJob := func(index int, contract, image string) *batchv1.Job {
+ job := newRestoreJob(testCtx.DefaultNamespace, fmt.Sprintf("restore-post-ready-drift-%d", index), labels)
+ job.Spec.Template.Spec.Containers[0].Image = image
+ job.Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: fmt.Sprintf("default/pod-%d", index),
+ postReadyActionContractAnnotationKey: contract,
+ }
+ setPostReadyExecutionPolicy(job, dpv1alpha1.PostReadyExecutionPolicyParallel)
+ return job
+ }
+
+ initial := []*batchv1.Job{
+ newFrozenJob(0, "sha256:initial", "image:v1"),
+ newFrozenJob(1, "sha256:initial", "image:v1"),
+ }
+ Expect(setPostReadyTargetPlan(initial)).Should(Succeed())
+ _, err := restoreMGR.CreateJobsIfNotExist(reqCtx,
+ &failNthCreateClient{Client: k8sClient, n: 2}, restoreMGR.Restore, initial)
+ Expect(err).Should(HaveOccurred())
+
+ mutated := []*batchv1.Job{
+ newFrozenJob(0, "sha256:mutated", "image:v2"),
+ newFrozenJob(1, "sha256:mutated", "image:v2"),
+ }
+ Expect(setPostReadyTargetPlan(mutated)).Should(Succeed())
+ _, err = restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, mutated)
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("executable action does not match its frozen contract"))
+ })
+
+ It("continues an unchanged legacy Parallel action after partial creation", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, _ := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ desired := []*batchv1.Job{
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-legacy-partial-0", labels),
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-legacy-partial-1", labels),
+ }
+ for i := range desired {
+ desired[i].Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: fmt.Sprintf("default/pod-%d", i),
+ postReadyActionContractAnnotationKey: "sha256:current-action",
+ }
+ setPostReadyExecutionPolicy(desired[i], dpv1alpha1.PostReadyExecutionPolicyParallel)
+ }
+ Expect(setPostReadyTargetPlan(desired)).Should(Succeed())
+
+ legacyFirst := desired[0].DeepCopy()
+ legacyFirst.Annotations = nil
+ legacyFirst.Spec.Suspend = nil
+ Expect(k8sClient.Create(reqCtx.Ctx, legacyFirst)).Should(Succeed())
+ testdp.PatchK8sJobStatus(&testCtx, client.ObjectKeyFromObject(legacyFirst), batchv1.JobComplete)
+ persistedLegacy := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(legacyFirst), persistedLegacy)).Should(Succeed())
+ legacyUID := persistedLegacy.UID
+
+ continued, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, desired)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(continued).Should(HaveLen(2))
+ for i := range continued {
+ Expect(continued[i].Annotations).ShouldNot(HaveKey(postReadyExecutionPolicyAnnotationKey))
+ Expect(continued[i].Annotations).ShouldNot(HaveKey(postReadyTargetIdentityAnnotationKey))
+ Expect(continued[i].Annotations).ShouldNot(HaveKey(postReadyTargetPlanAnnotationKey))
+ Expect(continued[i].Annotations).ShouldNot(HaveKey(postReadyActionContractAnnotationKey))
+ Expect(continued[i].Spec.Suspend).Should(BeNil())
+ }
+ continued, err = restoreMGR.CreateJobsIfNotExist(reqCtx, k8sClient, restoreMGR.Restore, continued)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(continued).Should(HaveLen(2))
+ Expect(continued[0].UID).Should(Equal(legacyUID))
+ done, condition, _ := utils.IsJobFinished(continued[0])
+ Expect(done).Should(BeTrue())
+ Expect(condition).Should(Equal(batchv1.JobComplete))
+ })
+
+ It("rejects executable drift while recovering a legacy Parallel partial creation", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, _ := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ desired := []*batchv1.Job{
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-legacy-drift-0", labels),
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-legacy-drift-1", labels),
+ }
+ for i := range desired {
+ desired[i].Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: fmt.Sprintf("default/pod-%d", i),
+ postReadyActionContractAnnotationKey: "sha256:current-action",
+ }
+ setPostReadyExecutionPolicy(desired[i], dpv1alpha1.PostReadyExecutionPolicyParallel)
+ }
+ Expect(setPostReadyTargetPlan(desired)).Should(Succeed())
+
+ legacyFirst := desired[0].DeepCopy()
+ legacyFirst.Annotations = nil
+ legacyFirst.Spec.Suspend = nil
+ legacyFirst.Spec.Template.Spec.Containers[0].Image = "legacy:image"
+ Expect(k8sClient.Create(reqCtx.Ctx, legacyFirst)).Should(Succeed())
+
+ continued, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, desired)
+ Expect(err).Should(HaveOccurred())
+ Expect(continued).Should(BeNil())
+ Expect(err.Error()).Should(ContainSubstring("executable action does not match the current ActionSet"))
+ })
+
+ It("waits for persisted postReady jobs after their ActionSet step is removed", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ Expect(backupSet.ActionSet.Spec.Restore.PostReady).Should(HaveLen(2))
+ backupSet.ActionSet.Spec.Restore.PostReady = backupSet.ActionSet.Spec.Restore.PostReady[:1]
+ restoreMGR.PostReadyBackupSets = []BackupActionSet{*backupSet}
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ actionName := fmt.Sprintf("%s-1", dpv1alpha1.PostReady)
+ jobs := []*batchv1.Job{
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-removed-0", labels),
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-removed-1", labels),
+ }
+ for i := range jobs {
+ jobs[i].Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: fmt.Sprintf("default/pod-%d", i),
+ postReadyActionContractAnnotationKey: "sha256:removed-action",
+ postReadyBackupNameAnnotationKey: backupSet.Backup.Name,
+ postReadyActionNameAnnotationKey: actionName,
+ }
+ setPostReadyExecutionPolicy(jobs[i], dpv1alpha1.PostReadyExecutionPolicyParallel)
+ }
+ Expect(setPostReadyTargetPlan(jobs)).Should(Succeed())
+ for i := range jobs {
+ Expect(k8sClient.Create(reqCtx.Ctx, jobs[i])).Should(Succeed())
+ }
+
+ completed, err := restoreMGR.ReconcileOrphanedPostReadyActions(reqCtx, k8sClient)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(completed).Should(BeFalse())
+ Expect(restoreMGR.Restore.Status.Actions.PostReady).Should(HaveLen(2))
+
+ for i := range jobs {
+ testdp.PatchK8sJobStatus(&testCtx, client.ObjectKeyFromObject(jobs[i]), batchv1.JobComplete)
+ }
+ completed, err = restoreMGR.ReconcileOrphanedPostReadyActions(reqCtx, k8sClient)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(completed).Should(BeTrue())
+ for i := range restoreMGR.Restore.Status.Actions.PostReady {
+ Expect(restoreMGR.Restore.Status.Actions.PostReady[i].Status).Should(Equal(dpv1alpha1.RestoreActionCompleted))
+ }
+ })
+
+ It("recovers a durable orphaned plan when no Job was ever created", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-1", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(frozen).Should(HaveLen(2))
+
+ restoreMGR.PostReadyBackupSets = nil
+ restoreMGR.Restore.Spec.ReadyConfig = nil
+ completed, err := restoreMGR.ReconcileOrphanedPostReadyActions(reqCtx, k8sClient)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(completed).Should(BeFalse())
+ for i := range frozen {
+ persisted := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(frozen[i]), persisted)).Should(Succeed())
+ Expect(persisted.Annotations[postReadyPlanDigestAnnotationKey]).Should(
+ Equal(frozen[i].Annotations[postReadyPlanDigestAnnotationKey]))
+ }
+ Expect(restoreMGR.Restore.Status.Actions.PostReady).Should(HaveLen(2))
+ })
+
+ It("recovers an orphaned frozen-plan Job written before action-contract annotations", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ Expect(backupSet.ActionSet.Spec.Restore.PostReady).Should(HaveLen(2))
+ backupSet.ActionSet.Spec.Restore.PostReady = backupSet.ActionSet.Spec.Restore.PostReady[:1]
+ restoreMGR.PostReadyBackupSets = []BackupActionSet{*backupSet}
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ job := newRestoreJob(testCtx.DefaultNamespace,
+ fmt.Sprintf("restore-post-ready-%s-%s-1-0", restoreMGR.Restore.UID[:8], backupSet.Backup.Name), labels)
+ job.Spec.Template.Spec.Containers[0].Env = append(job.Spec.Template.Spec.Containers[0].Env,
+ corev1.EnvVar{Name: dptypes.DPBackupName, Value: backupSet.Backup.Name})
+ job.Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: "default/pod-0",
+ }
+ setPostReadyExecutionPolicy(job, dpv1alpha1.PostReadyExecutionPolicyParallel)
+ Expect(setPostReadyTargetPlan([]*batchv1.Job{job})).Should(Succeed())
+ Expect(k8sClient.Create(reqCtx.Ctx, job)).Should(Succeed())
+
+ completed, err := restoreMGR.ReconcileOrphanedPostReadyActions(reqCtx, k8sClient)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(completed).Should(BeFalse())
+ Expect(restoreMGR.Restore.Status.Actions.PostReady).Should(HaveLen(1))
+ Expect(restoreMGR.Restore.Status.Actions.PostReady[0].Name).Should(Equal("postReady-1"))
+ })
+
+ It("waits for persisted postReady jobs after readyConfig is removed", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ job := newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-ready-config-removed-0", labels)
+ job.Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: "default/pod-0",
+ postReadyActionContractAnnotationKey: "sha256:removed-ready-config",
+ postReadyBackupNameAnnotationKey: backupSet.Backup.Name,
+ postReadyActionNameAnnotationKey: actionName,
+ }
+ setPostReadyExecutionPolicy(job, dpv1alpha1.PostReadyExecutionPolicyParallel)
+ Expect(setPostReadyTargetPlan([]*batchv1.Job{job})).Should(Succeed())
+ Expect(k8sClient.Create(reqCtx.Ctx, job)).Should(Succeed())
+ restoreMGR.Restore.Spec.ReadyConfig = nil
+
+ completed, err := restoreMGR.ReconcileOrphanedPostReadyActions(reqCtx, k8sClient)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(completed).Should(BeFalse())
+ Expect(restoreMGR.Restore.Status.Actions.PostReady).Should(HaveLen(1))
+ Expect(restoreMGR.Restore.Status.Actions.PostReady[0].Status).Should(Equal(dpv1alpha1.RestoreActionProcessing))
+ })
+
+ It("preserves the OneToOne source mapping through the real Job builder after target insertion", func() {
+ reqCtx := getReqCtx()
+ oldToolsImage := viper.GetString(constant.KBToolsImage)
+ viper.Set(constant.KBToolsImage, "kubeblocks-tools")
+ DeferCleanup(func() { viper.Set(constant.KBToolsImage, oldToolsImage) })
+ matchLabels := map[string]string{constant.AppInstanceLabelKey: testdp.ClusterName}
+ Expect(testapps.ChangeObj(&testCtx, actionSet, func(set *dpv1alpha1.ActionSet) {
+ set.Spec.Restore.PostReadyExecutionPolicy = dpv1alpha1.PostReadyExecutionPolicySerial
+ })).Should(Succeed())
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {
+ f.SetConnectCredential(testdp.ClusterName).SetJobActionConfig(matchLabels)
+ })
+ restoreMGR.Restore.Spec.ReadyConfig.JobAction.RequiredPolicyForAllPodSelection =
+ &dpv1alpha1.RequiredPolicyForAllPodSelection{DataRestorePolicy: dpv1alpha1.OneToOneRestorePolicy}
+ restoreMGR.Restore.Spec.ReadyConfig.JobAction.Target.PodSelector.Strategy =
+ dpv1alpha1.PodSelectionStrategyAll
+ testdp.NewFakeCluster(&testCtx)
+ target := utils.GetBackupStatusTarget(backupSet.Backup, restoreMGR.Restore.Spec.Backup.SourceTargetName)
+ target.PodSelector.Strategy = dpv1alpha1.PodSelectionStrategyAll
+ target.SelectedTargetPods = []string{"source-b", "source-c"}
+ backupSet.Backup.Status.Path = "/repo/default/test-backup"
+
+ initial, err := restoreMGR.BuildPostReadyActionJobs(reqCtx, k8sClient, *backupSet, target, 1)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(initial).Should(HaveLen(2))
+ initialIdentities := []string{postReadyTargetIdentity(initial[0]), postReadyTargetIdentity(initial[1])}
+ Expect(initialIdentities[0]).Should(ContainSubstring("|source=source-b"))
+ Expect(initialIdentities[1]).Should(ContainSubstring("|source=source-c"))
+ created, err := restoreMGR.CreateJobsIfNotExist(reqCtx,
+ &failNthCreateClient{Client: k8sClient, n: 2}, restoreMGR.Restore, initial)
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("injected create failure at 2"))
+ Expect(created).Should(BeNil())
+ persistedFirst := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(initial[0]), persistedFirst)).Should(Succeed())
+ Expect(persistedFirst.Annotations).Should(HaveKey(postReadyTargetPlanAnnotationKey))
+
+ inserted := testapps.NewPodFactory(testCtx.DefaultNamespace, "aaa-inserted-target").
+ AddAppInstanceLabel(testdp.ClusterName).
+ AddAppComponentLabel(testdp.ComponentName).
+ AddContainer(corev1.Container{Name: testdp.ContainerName, Image: testapps.ApeCloudMySQLImage}).
+ Create(&testCtx).GetObject()
+ Expect(testapps.ChangeObjStatus(&testCtx, inserted, func() {
+ inserted.Status.Phase = corev1.PodRunning
+ })).Should(Succeed())
+ frozenSources, hasFrozenPlan, err := restoreMGR.getFrozenPostReadySourceTargets(
+ reqCtx, k8sClient, client.ObjectKeyFromObject(initial[0]))
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(hasFrozenPlan).Should(BeTrue())
+ Expect(frozenSources).Should(HaveLen(2))
+
+ rebuilt, err := restoreMGR.BuildPostReadyActionJobs(reqCtx, k8sClient, *backupSet, target, 1)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(rebuilt).Should(HaveLen(2))
+ Expect(rebuilt[0].Name).Should(Equal(initial[0].Name))
+ Expect(rebuilt[1].Name).Should(Equal(initial[1].Name))
+ rebuilt, err = restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, rebuilt)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(rebuilt).Should(HaveLen(2))
+ Expect(postReadyTargetIdentity(rebuilt[0])).Should(Equal(initialIdentities[0]))
+ Expect(postReadyTargetIdentity(rebuilt[1])).Should(Equal(initialIdentities[1]))
+
+ targetRelativePaths := make([]string, 0, len(rebuilt))
+ for i := range rebuilt {
+ for _, env := range rebuilt[i].Spec.Template.Spec.Containers[0].Env {
+ if env.Name == dptypes.DPTargetRelativePath {
+ targetRelativePaths = append(targetRelativePaths, env.Value)
+ }
+ }
+ }
+ Expect(targetRelativePaths).Should(Equal([]string{"source-b", "source-c"}))
+
+ rebuilt, err = restoreMGR.CreateJobsIfNotExist(reqCtx, k8sClient, restoreMGR.Restore, rebuilt)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(rebuilt).Should(HaveLen(2))
+ Expect(restoreMGR.ResumeNextSerialPostReadyJob(reqCtx, k8sClient, rebuilt)).Should(Succeed())
+ persistedJobs := &batchv1.JobList{}
+ Expect(k8sClient.List(reqCtx.Ctx, persistedJobs,
+ client.InNamespace(initial[0].Namespace),
+ client.MatchingLabels{DataProtectionRestoreLabelKey: restoreMGR.Restore.Name})).Should(Succeed())
+ Expect(persistedJobs.Items).Should(HaveLen(2))
+ Expect([]string{persistedJobs.Items[0].Name, persistedJobs.Items[1].Name}).Should(
+ ConsistOf(initial[0].Name, initial[1].Name))
+ for i := range initial {
+ persisted := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(initial[i]), persisted)).Should(Succeed())
+ Expect(postReadyTargetIdentity(persisted)).Should(Equal(initialIdentities[i]))
+ Expect(*persisted.Spec.Suspend).Should(Equal(i != 0))
+ }
+ })
+
+ It("does not resume a serial job after a failed predecessor", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, _ := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ jobs := []*batchv1.Job{
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-failed-0", labels),
+ newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-failed-1", labels),
+ }
+ for i := range jobs {
+ setPostReadyExecutionPolicy(jobs[i], dpv1alpha1.PostReadyExecutionPolicySerial)
+ jobs[i].Annotations[postReadyTargetIdentityAnnotationKey] = fmt.Sprintf("default/pod-%d", i)
+ }
+ Expect(setPostReadyTargetPlan(jobs)).Should(Succeed())
+ for i := range jobs {
+ Expect(k8sClient.Create(reqCtx.Ctx, jobs[i])).Should(Succeed())
+ }
+ testdp.PatchK8sJobStatus(&testCtx, client.ObjectKeyFromObject(jobs[0]), batchv1.JobFailed)
+ for i := range jobs {
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(jobs[i]), jobs[i])).Should(Succeed())
+ }
+
+ Expect(restoreMGR.ResumeNextSerialPostReadyJob(reqCtx, k8sClient, jobs)).Should(Succeed())
+ second := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(jobs[1]), second)).Should(Succeed())
+ Expect(*second.Spec.Suspend).Should(BeTrue())
+ })
+
+ It("refetches and rejects a foreign Job after an AlreadyExists create race", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, _ := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ labels := map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ }
+ desired := newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-race-0", labels)
+ desired.Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: "default/pod-0",
+ postReadyActionContractAnnotationKey: "sha256:stable-action-contract",
+ }
+ setPostReadyExecutionPolicy(desired, dpv1alpha1.PostReadyExecutionPolicySerial)
+ Expect(setPostReadyTargetPlan([]*batchv1.Job{desired})).Should(Succeed())
+
+ foreign := desired.DeepCopy()
+ foreign.Labels[DataProtectionRestoreLabelKey] = "another-restore"
+ jobs, err := restoreMGR.CreateJobsIfNotExist(reqCtx,
+ createAlreadyExistsClient{Client: k8sClient, foreign: foreign}, restoreMGR.Restore, []*batchv1.Job{desired})
+ Expect(err).Should(HaveOccurred())
+ Expect(jobs).Should(BeNil())
+ Expect(err.Error()).Should(ContainSubstring("does not belong to restore"))
+ })
+
+ It("rejects an existing Job whose executable spec differs from the durable plan", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+ forged := frozen[0].DeepCopy()
+ forged.Spec.Template.Spec.Containers[0].Image = "image:forged"
+ Expect(k8sClient.Create(reqCtx.Ctx, forged)).Should(Succeed())
+
+ _, err = restoreMGR.CreateJobsIfNotExist(reqCtx, k8sClient, restoreMGR.Restore, frozen)
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("executable spec does not match its immutable execution plan"))
+ Expect(apierrors.IsNotFound(k8sClient.Get(reqCtx.Ctx,
+ client.ObjectKeyFromObject(frozen[1]), &batchv1.Job{}))).Should(BeTrue())
+ })
+
+ It("rejects missing committed references and executable superset drift", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, backupSet := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ actionName := fmt.Sprintf("%s-0", dpv1alpha1.PostReady)
+ jobs := newDurablePostReadyJobs(restoreMGR, backupSet.Backup.Name, actionName, "image:v1")
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, jobs)
+ Expect(err).ShouldNot(HaveOccurred())
+
+ cases := []struct {
+ name string
+ mutate func(*batchv1.Job)
+ }{
+ {name: "missing plan name", mutate: func(job *batchv1.Job) {
+ delete(job.Annotations, postReadyPlanNameAnnotationKey)
+ }},
+ {name: "missing plan digest", mutate: func(job *batchv1.Job) {
+ delete(job.Annotations, postReadyPlanDigestAnnotationKey)
+ }},
+ {name: "missing restore UID", mutate: func(job *batchv1.Job) {
+ delete(job.Annotations, postReadyPlanRestoreUIDAnnotationKey)
+ }},
+ {name: "missing committed backup identity", mutate: func(job *batchv1.Job) {
+ delete(job.Annotations, postReadyBackupNameAnnotationKey)
+ }},
+ {name: "missing committed action identity", mutate: func(job *batchv1.Job) {
+ delete(job.Annotations, postReadyActionNameAnnotationKey)
+ }},
+ {name: "wrong backup and missing action identity", mutate: func(job *batchv1.Job) {
+ job.Annotations[postReadyBackupNameAnnotationKey] = "foreign-backup"
+ delete(job.Annotations, postReadyActionNameAnnotationKey)
+ }},
+ {name: "missing terminal finalizer", mutate: func(job *batchv1.Job) {
+ controllerutil.RemoveFinalizer(job, dptypes.DataProtectionFinalizerName)
+ }},
+ {name: "extra init container", mutate: func(job *batchv1.Job) {
+ job.Spec.Template.Spec.InitContainers = append(job.Spec.Template.Spec.InitContainers,
+ corev1.Container{Name: "forged-init", Image: "busybox:1.36"})
+ }},
+ {name: "extra volume", mutate: func(job *batchv1.Job) {
+ job.Spec.Template.Spec.Volumes = append(job.Spec.Template.Spec.Volumes,
+ corev1.Volume{Name: "forged-volume", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}})
+ }},
+ {name: "extra environment", mutate: func(job *batchv1.Job) {
+ job.Spec.Template.Spec.Containers[0].Env = append(job.Spec.Template.Spec.Containers[0].Env,
+ corev1.EnvVar{Name: "FORGED", Value: "true"})
+ }},
+ {name: "manual selector", mutate: func(job *batchv1.Job) {
+ job.Spec.ManualSelector = pointer.Bool(true)
+ }},
+ {name: "forged selector", mutate: func(job *batchv1.Job) {
+ job.Spec.Selector = &metav1.LabelSelector{MatchLabels: map[string]string{"forged": "true"}}
+ if job.Spec.Template.Labels == nil {
+ job.Spec.Template.Labels = map[string]string{}
+ }
+ job.Spec.Template.Labels["forged"] = "true"
+ }},
+ }
+ for _, tc := range cases {
+ By(tc.name)
+ existing := frozen[0].DeepCopy()
+ tc.mutate(existing)
+ Expect(restoreMGR.validateExistingRestoreActionJob(frozen[0], existing)).Should(HaveOccurred())
+ }
+ })
+
+ It("keeps a fully persisted legacy postReady action on the Parallel path", func() {
+ reqCtx := getReqCtx()
+ restoreMGR, _ := initResources(reqCtx, 0, false, func(f *testdp.MockRestoreFactory) {})
+ legacy := newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-legacy-0", map[string]string{
+ DataProtectionRestoreLabelKey: restoreMGR.Restore.Name,
+ DataProtectionRestoreNamespaceLabelKey: restoreMGR.Restore.Namespace,
+ })
+ Expect(k8sClient.Create(reqCtx.Ctx, legacy)).Should(Succeed())
+ persisted := &batchv1.Job{}
+ Expect(k8sClient.Get(reqCtx.Ctx, client.ObjectKeyFromObject(legacy), persisted)).Should(Succeed())
+ Expect(persisted.ResourceVersion).ShouldNot(BeEmpty())
+
+ frozen, err := restoreMGR.FreezePostReadyExecutionPlan(reqCtx, k8sClient, []*batchv1.Job{persisted})
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(frozen).Should(HaveLen(1))
+ Expect(frozen[0].Annotations).ShouldNot(HaveKey(postReadyExecutionPolicyAnnotationKey))
+ Expect(frozen[0].Annotations).ShouldNot(HaveKey(postReadyTargetIdentityAnnotationKey))
+ Expect(frozen[0].Annotations).ShouldNot(HaveKey(postReadyTargetPlanAnnotationKey))
+ serial, err := serialPostReadyJobs(frozen)
+ Expect(err).ShouldNot(HaveOccurred())
+ Expect(serial).Should(BeFalse())
+ Expect(frozen[0].Spec.Suspend).ShouldNot(BeNil())
+ Expect(*frozen[0].Spec.Suspend).Should(BeFalse())
+ })
+
+ It("rejects a frozen target contract without an execution policy", func() {
+ job := newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-missing-policy-0", nil)
+ job.Annotations = map[string]string{
+ postReadyTargetIdentityAnnotationKey: "default/pod-0",
+ postReadyTargetPlanAnnotationKey: `["default/pod-0"]`,
+ }
+
+ serial, err := serialPostReadyJobs([]*batchv1.Job{job})
+ Expect(err).Should(HaveOccurred())
+ Expect(err.Error()).Should(ContainSubstring("frozen target contract without an execution policy"))
+ Expect(serial).Should(BeFalse())
+ })
+
+ It("rejects a frozen target contract whose three keys have empty values", func() {
+ job := newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-empty-contract-0", nil)
+ job.Annotations = map[string]string{
+ postReadyExecutionPolicyAnnotationKey: "",
+ postReadyTargetIdentityAnnotationKey: "",
+ postReadyTargetPlanAnnotationKey: "",
+ }
+
+ serial, err := serialPostReadyJobs([]*batchv1.Job{job})
+ Expect(err).Should(HaveOccurred())
+ Expect(serial).Should(BeFalse())
+ })
+
+ It("rejects a single present frozen target key with an empty value", func() {
+ job := newRestoreJob(testCtx.DefaultNamespace, "restore-post-ready-empty-identity-0", nil)
+ job.Annotations = map[string]string{postReadyTargetIdentityAnnotationKey: ""}
+
+ serial, err := serialPostReadyJobs([]*batchv1.Job{job})
+ Expect(err).Should(HaveOccurred())
+ Expect(serial).Should(BeFalse())
+ })
+
Context("BuildContinuousRestoreManager", func() {
const (
continuousBackupStartTime = "2023-01-01T09:00:00Z"
diff --git a/pkg/dataprotection/restore/utils.go b/pkg/dataprotection/restore/utils.go
index 13e503ed306..fdd4e814afc 100644
--- a/pkg/dataprotection/restore/utils.go
+++ b/pkg/dataprotection/restore/utils.go
@@ -240,6 +240,12 @@ func ValidateAndInitRestoreMGR(reqCtx intctrlutil.RequestCtx,
if err != nil {
return err
}
+ if backupSet.UseDurablePostReadyPlan {
+ // Reaching a committed postReady plan proves that prepareData already
+ // converged. If the mutable ActionSet is later deleted, the immutable
+ // plan remains the sole executable source for the unfinished stage.
+ return nil
+ }
// validate restore parameters
if backupSet.ActionSet != nil {
diff --git a/pkg/dataprotection/restore/utils_test.go b/pkg/dataprotection/restore/utils_test.go
index 6ce44c95b7d..f07ac6430b7 100644
--- a/pkg/dataprotection/restore/utils_test.go
+++ b/pkg/dataprotection/restore/utils_test.go
@@ -21,6 +21,8 @@ package restore
import (
"context"
+ "encoding/json"
+ "strconv"
"testing"
"time"
@@ -29,6 +31,7 @@ import (
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"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -319,6 +322,154 @@ func TestValidateAndInitRestoreMGRFullBackup(t *testing.T) {
assert.Error(t, ValidateAndInitRestoreMGR(reqCtx, cli, mgr))
}
+func TestValidateAndInitRestoreMGRUsesPersistedPlanAfterActionSetDeletion(t *testing.T) {
+ scheme := runtime.NewScheme()
+ assert.NoError(t, dpv1alpha1.AddToScheme(scheme))
+ assert.NoError(t, corev1.AddToScheme(scheme))
+ assert.NoError(t, batchv1.AddToScheme(scheme))
+
+ backup := &dpv1alpha1.Backup{
+ ObjectMeta: metav1.ObjectMeta{Name: "backup", Namespace: "ns"},
+ Status: dpv1alpha1.BackupStatus{
+ Phase: dpv1alpha1.BackupPhaseCompleted,
+ BackupMethod: &dpv1alpha1.BackupMethod{
+ Name: "full",
+ ActionSetName: "deleted-action-set",
+ },
+ },
+ }
+ restoreObj := &dpv1alpha1.Restore{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "restore",
+ Namespace: "ns",
+ UID: types.UID("restore-uid"),
+ },
+ Spec: dpv1alpha1.RestoreSpec{Backup: dpv1alpha1.BackupRef{Name: "backup", Namespace: "ns"}},
+ }
+ mgr := NewRestoreManager(restoreObj, nil, scheme, nil)
+ job := &batchv1.Job{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "restore-post-ready-0",
+ Namespace: "ns",
+ Finalizers: []string{dptypes.DataProtectionFinalizerName},
+ Labels: map[string]string{
+ DataProtectionRestoreLabelKey: "restore",
+ DataProtectionRestoreNamespaceLabelKey: "ns",
+ },
+ Annotations: map[string]string{
+ postReadyTargetIdentityAnnotationKey: "ns/pod-0",
+ postReadyActionContractAnnotationKey: "sha256:contract",
+ postReadyBackupNameAnnotationKey: "backup",
+ postReadyActionNameAnnotationKey: "postReady-0",
+ },
+ },
+ Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{
+ RestartPolicy: corev1.RestartPolicyNever,
+ Containers: []corev1.Container{{Name: Restore, Image: "busybox:1.36"}},
+ }}},
+ }
+ setPostReadyExecutionPolicy(job, dpv1alpha1.PostReadyExecutionPolicySerial)
+ assert.NoError(t, setPostReadyTargetPlan([]*batchv1.Job{job}))
+ stage, err := canonicalPostReadyStagePlan(postReadyExecutionPlan{
+ Version: postReadyPlanVersion,
+ RestoreNamespace: "ns",
+ RestoreName: "restore",
+ RestoreUID: "restore-uid",
+ SourceBackupNamespace: "ns",
+ SourceBackupName: "backup",
+ Actions: []postReadyActionExecutionPlan{{
+ Order: 0, BackupName: "backup", ActionName: "postReady-0", Jobs: []batchv1.Job{*job},
+ }},
+ })
+ assert.NoError(t, err)
+ payload, err := json.Marshal(stage)
+ assert.NoError(t, err)
+ digest := postReadyPlanDigest(payload)
+ planName := mgr.postReadyPlanSecretName()
+ restoreObj.Annotations = map[string]string{
+ postReadyPlanMarkerAnnotationKey: postReadyPlanMarkerValue(planName, digest),
+ }
+ immutable := true
+ controller := true
+ planSecret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: planName,
+ Namespace: "ns",
+ Labels: map[string]string{
+ DataProtectionRestoreLabelKey: "restore",
+ },
+ Annotations: map[string]string{
+ postReadyPlanRestoreUIDAnnotationKey: "restore-uid",
+ postReadyPlanDigestAnnotationKey: digest,
+ },
+ OwnerReferences: []metav1.OwnerReference{{
+ APIVersion: dpv1alpha1.SchemeGroupVersion.String(),
+ Kind: "Restore",
+ Name: "restore",
+ UID: types.UID("restore-uid"),
+ Controller: &controller,
+ BlockOwnerDeletion: &controller,
+ }},
+ },
+ Immutable: &immutable,
+ Type: postReadyPlanSecretType,
+ Data: map[string][]byte{postReadyPlanDataKey: payload},
+ }
+ cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(backup, planSecret).Build()
+ reqCtx := intctrlutil.RequestCtx{
+ Ctx: context.Background(),
+ Req: ctrl.Request{NamespacedName: client.ObjectKey{Namespace: "ns", Name: "restore"}},
+ }
+ mgr = NewRestoreManager(restoreObj, nil, scheme, cli)
+
+ assert.NoError(t, ValidateAndInitRestoreMGR(reqCtx, cli, mgr))
+ assert.Empty(t, mgr.PrepareDataBackupSets)
+ assert.Empty(t, mgr.PostReadyBackupSets)
+}
+
+func TestValidateAndInitRestoreMGRRejectsUntrustedPlanShellAfterActionSetDeletion(t *testing.T) {
+ scheme := runtime.NewScheme()
+ assert.NoError(t, dpv1alpha1.AddToScheme(scheme))
+ assert.NoError(t, corev1.AddToScheme(scheme))
+
+ backup := &dpv1alpha1.Backup{
+ ObjectMeta: metav1.ObjectMeta{Name: "backup", Namespace: "ns"},
+ Status: dpv1alpha1.BackupStatus{
+ Phase: dpv1alpha1.BackupPhaseCompleted,
+ BackupMethod: &dpv1alpha1.BackupMethod{
+ Name: "full", ActionSetName: "deleted-action-set",
+ },
+ },
+ }
+ restoreObj := &dpv1alpha1.Restore{
+ ObjectMeta: metav1.ObjectMeta{Name: "restore", Namespace: "ns", UID: types.UID("restore-uid")},
+ Spec: dpv1alpha1.RestoreSpec{Backup: dpv1alpha1.BackupRef{Name: "backup", Namespace: "ns"}},
+ }
+ immutable := true
+ foreign := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "foreign-postready-plan",
+ Namespace: "ns",
+ Labels: map[string]string{DataProtectionRestoreLabelKey: "restore"},
+ Annotations: map[string]string{
+ postReadyPlanRestoreUIDAnnotationKey: "restore-uid",
+ },
+ },
+ Immutable: &immutable,
+ Type: postReadyPlanSecretType,
+ }
+ cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(backup, foreign).Build()
+ reqCtx := intctrlutil.RequestCtx{
+ Ctx: context.Background(),
+ Req: ctrl.Request{NamespacedName: client.ObjectKey{Namespace: "ns", Name: "restore"}},
+ }
+ mgr := NewRestoreManager(restoreObj, nil, scheme, cli)
+
+ err := ValidateAndInitRestoreMGR(reqCtx, cli, mgr)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "deleted-action-set")
+}
+
func TestRestoreManagerStopsManagerContainer(t *testing.T) {
scheme := runtime.NewScheme()
assert.NoError(t, corev1.AddToScheme(scheme))
@@ -345,3 +496,99 @@ func TestRestoreManagerStopsManagerContainer(t *testing.T) {
assert.NoError(t, mgr.StopManagerContainer(got))
assert.NoError(t, mgr.StopManagerContainerByJob(job))
}
+
+func TestSerialPostReadyNormalExitHandsOffToNextJob(t *testing.T) {
+ scheme := runtime.NewScheme()
+ assert.NoError(t, corev1.AddToScheme(scheme))
+ assert.NoError(t, batchv1.AddToScheme(scheme))
+
+ jobs := []*batchv1.Job{
+ {ObjectMeta: metav1.ObjectMeta{Name: "restore-post-ready-0", Namespace: "ns"}},
+ {ObjectMeta: metav1.ObjectMeta{Name: "restore-post-ready-1", Namespace: "ns"}},
+ }
+ for i := range jobs {
+ jobs[i].Annotations = map[string]string{postReadyTargetIdentityAnnotationKey: "ns/pod-" + strconv.Itoa(i)}
+ setPostReadyExecutionPolicy(jobs[i], dpv1alpha1.PostReadyExecutionPolicySerial)
+ }
+ assert.NoError(t, setPostReadyTargetPlan(jobs))
+ jobs[0].Spec.Suspend = func() *bool { v := false; return &v }()
+ pod := &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "restore-post-ready-0-pod",
+ Namespace: "ns",
+ Labels: map[string]string{"job-name": jobs[0].Name},
+ },
+ Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{
+ Name: Restore,
+ State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 0}},
+ }}},
+ }
+ cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(jobs[0], jobs[1], pod).Build()
+ mgr := &RestoreManager{
+ Restore: &dpv1alpha1.Restore{},
+ Client: cli,
+ }
+ backupSet := BackupActionSet{Backup: &dpv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Name: "backup"}}}
+
+ allDone, failed, err := mgr.CheckJobsDone(dpv1alpha1.PostReady, "postready-0", backupSet, jobs)
+ assert.NoError(t, err)
+ assert.False(t, allDone)
+ assert.False(t, failed)
+ gotPod := &corev1.Pod{}
+ assert.NoError(t, cli.Get(context.Background(), client.ObjectKeyFromObject(pod), gotPod))
+ assert.Equal(t, "true", gotPod.Annotations[DataProtectionStopRestoreManagerAnnotationKey])
+
+ jobs[0].Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}}
+ reqCtx := intctrlutil.RequestCtx{Ctx: context.Background()}
+ assert.NoError(t, mgr.ResumeNextSerialPostReadyJob(reqCtx, cli, jobs))
+ second := &batchv1.Job{}
+ assert.NoError(t, cli.Get(context.Background(), client.ObjectKeyFromObject(jobs[1]), second))
+ assert.NotNil(t, second.Spec.Suspend)
+ assert.False(t, *second.Spec.Suspend)
+}
+
+func TestSerialPostReadyUsesNumericOrderForElevenJobs(t *testing.T) {
+ scheme := runtime.NewScheme()
+ assert.NoError(t, batchv1.AddToScheme(scheme))
+ jobs := make([]*batchv1.Job, 11)
+ for i := range jobs {
+ jobs[i] = &batchv1.Job{ObjectMeta: metav1.ObjectMeta{
+ Name: "restore-post-ready-" + strconv.Itoa(i),
+ Namespace: "ns",
+ Annotations: map[string]string{postReadyTargetIdentityAnnotationKey: "ns/pod-" + strconv.Itoa(i)},
+ }}
+ setPostReadyExecutionPolicy(jobs[i], dpv1alpha1.PostReadyExecutionPolicySerial)
+ if i < 3 {
+ jobs[i].Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}}
+ }
+ }
+ assert.NoError(t, setPostReadyTargetPlan(jobs))
+ objects := make([]client.Object, 0, len(jobs))
+ for i := range jobs {
+ objects = append(objects, jobs[i])
+ }
+ cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
+ shuffled := []*batchv1.Job{jobs[10], jobs[4], jobs[2], jobs[8], jobs[0], jobs[6], jobs[1], jobs[9], jobs[5], jobs[3], jobs[7]}
+ assert.NoError(t, (&RestoreManager{}).ResumeNextSerialPostReadyJob(intctrlutil.RequestCtx{Ctx: context.Background()}, cli, shuffled))
+ for i := 3; i < len(jobs); i++ {
+ got := &batchv1.Job{}
+ assert.NoError(t, cli.Get(context.Background(), client.ObjectKeyFromObject(jobs[i]), got))
+ assert.NotNil(t, got.Spec.Suspend)
+ assert.Equal(t, i != 3, *got.Spec.Suspend)
+ }
+}
+
+func TestSerialPostReadyRejectsInvalidAndMixedFrozenPolicies(t *testing.T) {
+ invalid := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "invalid", Annotations: map[string]string{
+ postReadyExecutionPolicyAnnotationKey: "Unknown",
+ }}}
+ _, err := serialPostReadyJobs([]*batchv1.Job{invalid})
+ assert.Error(t, err)
+
+ parallel := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "parallel"}}
+ serial := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: "serial"}}
+ setPostReadyExecutionPolicy(parallel, dpv1alpha1.PostReadyExecutionPolicyParallel)
+ setPostReadyExecutionPolicy(serial, dpv1alpha1.PostReadyExecutionPolicySerial)
+ _, err = serialPostReadyJobs([]*batchv1.Job{parallel, serial})
+ assert.Error(t, err)
+}
|