diff --git a/changelogs/unreleased/10331-chlins b/changelogs/unreleased/10331-chlins new file mode 100644 index 000000000..b4bb1b1e3 --- /dev/null +++ b/changelogs/unreleased/10331-chlins @@ -0,0 +1 @@ +Preserve PVC selected-node annotation via carrier annotation for in-place restore diff --git a/design/volume-data-inplace-restore/volume-data-inplace-restore.md b/design/volume-data-inplace-restore/volume-data-inplace-restore.md index 664f5a654..b178b9314 100644 --- a/design/volume-data-inplace-restore/volume-data-inplace-restore.md +++ b/design/volume-data-inplace-restore/volume-data-inplace-restore.md @@ -212,7 +212,9 @@ Users must manage the lifecycle of their workloads before starting the restore. When performing an in-place restore, Velero deletes the existing target PVC and recreates it. For StorageClasses using the `WaitForFirstConsumer` volume binding mode, this recreation resets the scheduling lifecycle. Even though Velero adds a selector to the PVC spec to ensure it binds exclusively to the original PV, a scheduling issue can still occur. If the target PVC loses its node affinity, the Kubernetes Scheduler might schedule the recreated business Pod to a different availability zone. Because the original PV is physically constrained to its original zone, the Pod will fail to mount the volume and remain stuck in the `ContainerCreating` state with an attachment error. **Solution**: -During the PVC Restore Item Action (RIA), Velero must extract the `volume.kubernetes.io/selected-node` annotation from the original PVC. When Velero recreates the target PVC, it must inject this annotation back into the PVC spec. +During the PVC CSI Restore Item Action (RIA), right before deleting the existing PVC, Velero extracts the `volume.kubernetes.io/selected-node` annotation from that PVC and carries it on the PVC to be restored via a Velero-internal carrier annotation (`restore.velero.io/inplace-restore-selected-node`). After all Restore Item Actions have run, the restore engine translates the carrier back to the `volume.kubernetes.io/selected-node` annotation and strips the carrier so it never lands on the cluster. + +A carrier annotation is used instead of the Kubernetes annotation directly because the generic PVC RIA unconditionally strips the `selected-node` annotation during restore, and the execution order of Restore Item Actions is not a documented contract. With the carrier, the behavior is independent of the RIA execution order: the Kubernetes annotation is stripped by default on every path (including when the target PVC does not exist and Velero falls back to provisioning a new PVC), and preservation only happens when the PVC CSI RIA explicitly captured a value from the existing PVC. By preserving the `selected-node` annotation, the Kubernetes Scheduler is forced to schedule the recreated business Pod to the original node/zone, ensuring it successfully mounts the restored PV. ### Namespace Mapping @@ -260,10 +262,8 @@ This section outlines the step-by-step control path and data path workflows for **Control Path** -PVC RIA: -- Preserve the `volume.kubernetes.io/selected-node` annotation to ensure correct scheduling during target PVC recreation. - PVC CSI RIA: +- Capture the `volume.kubernetes.io/selected-node` annotation from the existing PVC into the Velero-internal carrier annotation before deleting the PVC, so the restore engine can re-apply it to the recreated target PVC (see [Handling Cross-Zone Scheduling](#handling-cross-zone-scheduling-waitforfirstconsumer)). - Create a snapshot of the existing `PVC` to serve as the baseline for CBT delta calculations. - Patch the existing PV's reclaim policy to `Retain`. - Delete the existing PVC. @@ -308,10 +308,8 @@ The workflow is identical to the **In-place Incremental Restore for CSI Snapshot **Control Path** -PVC RIA: -- Preserve the `volume.kubernetes.io/selected-node` annotation to ensure correct scheduling during target PVC recreation. - PVC CSI RIA: +- Capture the `volume.kubernetes.io/selected-node` annotation from the existing PVC into the Velero-internal carrier annotation before deleting the PVC, so the restore engine can re-apply it to the recreated target PVC (see [Handling Cross-Zone Scheduling](#handling-cross-zone-scheduling-waitforfirstconsumer)). - Create a snapshot of the existing `PVC` to serve as the baseline for CBT delta calculations. - Patch the existing `PV` to set its `persistentVolumeReclaimPolicy` to `Retain`. - Delete the existing `PVC`. diff --git a/pkg/apis/velero/v1/labels_annotations.go b/pkg/apis/velero/v1/labels_annotations.go index b34f05ed9..5636ecd36 100644 --- a/pkg/apis/velero/v1/labels_annotations.go +++ b/pkg/apis/velero/v1/labels_annotations.go @@ -174,6 +174,17 @@ const ( // Notice: SkipRestore on the Execute output takes precedence. If SkipRestore is true, the // annotation is never inspected and AdditionalItems are not processed. MustIncludeAdditionalItemRestoreAnnotation = "restore.velero.io/must-include-additional-items" + + // InplaceRestoreSelectedNodeAnnotation is a Velero-internal carrier annotation set by the + // PVC CSI RestoreItemAction during an in-place volume data restore. It carries the + // "volume.kubernetes.io/selected-node" value captured from the existing PVC right before + // that PVC is deleted, so the restore engine can re-apply it to the recreated target PVC + // after all RestoreItemActions have run. This keeps the recreated PVC (and the workload + // Pod, for WaitForFirstConsumer StorageClasses) scheduled to the original node/zone. + // The annotation is always translated and stripped by the restore engine; it never lands + // on the cluster. Using a carrier annotation avoids any dependency on the execution order + // of RestoreItemActions. + InplaceRestoreSelectedNodeAnnotation = "restore.velero.io/inplace-restore-selected-node" // SkippedNoCSIPVAnnotation - Velero checks this annotation on processed PVC to // find out if the snapshot was skipped b/c the PV is not provisioned via CSI SkippedNoCSIPVAnnotation = "backup.velero.io/skipped-no-csi-pv" diff --git a/pkg/restore/actions/csi/pvc_action.go b/pkg/restore/actions/csi/pvc_action.go index ef914558a..9498e63bd 100644 --- a/pkg/restore/actions/csi/pvc_action.go +++ b/pkg/restore/actions/csi/pvc_action.go @@ -597,14 +597,19 @@ func (p *pvcRestoreItemAction) prepareForInplaceRestore(ctx context.Context, log return nil, errors.New("ExistingVolumeDataPolicy is in-place restore, but the existing PVC is not bound.") } - // set the "selected-node" annotation to target PVC to make sure the target pod is scheduled to the same node + // Capture the "selected-node" annotation from the existing PVC before it is deleted below, + // and carry it on the target PVC via a Velero-internal carrier annotation. The restore + // engine translates the carrier back to the Kubernetes "selected-node" annotation after + // all RestoreItemActions have run, so the recreated target PVC keeps the same scheduling + // constraint regardless of the order in which RestoreItemActions execute (the generic PVC + // RIA unconditionally strips the Kubernetes annotation). selectedNode, exists := existingPVC.Annotations[kube.KubeAnnSelectedNode] if exists { - logger.Infof("Setting %q annotation to %q for target PVC to keep the same selected node as the existing PVC", kube.KubeAnnSelectedNode, existingPVC.Annotations[kube.KubeAnnSelectedNode]) + logger.Infof("Carrying %q annotation with value %q for target PVC to keep the same selected node as the existing PVC", kube.KubeAnnSelectedNode, selectedNode) if targetPVC.Annotations == nil { targetPVC.Annotations = map[string]string{} } - targetPVC.Annotations[kube.KubeAnnSelectedNode] = selectedNode + targetPVC.Annotations[velerov1api.InplaceRestoreSelectedNodeAnnotation] = selectedNode } var err error diff --git a/pkg/restore/actions/csi/pvc_action_test.go b/pkg/restore/actions/csi/pvc_action_test.go index 48ef18fc9..d6350652b 100644 --- a/pkg/restore/actions/csi/pvc_action_test.go +++ b/pkg/restore/actions/csi/pvc_action_test.go @@ -28,6 +28,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1api "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -596,6 +597,128 @@ func TestExecute(t *testing.T) { } } +// TestPrepareForInplaceRestoreSelectedNode verifies that prepareForInplaceRestore captures +// the selected-node annotation from the existing PVC into the Velero-internal carrier +// annotation (not the Kubernetes annotation) on the target PVC, before deleting the PVC. +func TestPrepareForInplaceRestoreSelectedNode(t *testing.T) { + tests := []struct { + name string + existingPVC *corev1api.PersistentVolumeClaim + expectedCarrier string + expectCarrierSet bool + expectKubeAnnoSet bool + }{ + { + name: "existing PVC with selected-node sets carrier annotation only", + existingPVC: builder.ForPersistentVolumeClaim("ns-1", "pvc-1"). + ObjectMeta(builder.WithAnnotations(AnnSelectedNode, "node-1")). + VolumeName("pv-1"). + Phase(corev1api.ClaimBound).Result(), + expectedCarrier: "node-1", + expectCarrierSet: true, + expectKubeAnnoSet: false, + }, + { + name: "existing PVC without selected-node sets neither annotation", + existingPVC: builder.ForPersistentVolumeClaim("ns-1", "pvc-1"). + VolumeName("pv-1"). + Phase(corev1api.ClaimBound).Result(), + expectCarrierSet: false, + expectKubeAnnoSet: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pv := builder.ForPersistentVolume("pv-1").Result() + kubeClient := fake.NewSimpleClientset(tc.existingPVC, pv) + pvcRIA := pvcRestoreItemAction{ + log: logrus.New(), + crClient: velerotest.NewFakeControllerRuntimeClient(t, pv), + kubeClient: kubeClient, + } + + targetPVC := builder.ForPersistentVolumeClaim("ns-1", "pvc-1").Result() + returnedPV, err := pvcRIA.prepareForInplaceRestore( + t.Context(), logrus.New().WithField("test", tc.name), + targetPVC, tc.existingPVC, time.Minute) + require.NoError(t, err) + require.Equal(t, "pv-1", returnedPV.Name) + + carrier, carrierOK := targetPVC.Annotations[velerov1api.InplaceRestoreSelectedNodeAnnotation] + require.Equal(t, tc.expectCarrierSet, carrierOK) + if tc.expectCarrierSet { + require.Equal(t, tc.expectedCarrier, carrier) + } + _, kubeAnnoOK := targetPVC.Annotations[AnnSelectedNode] + require.Equal(t, tc.expectKubeAnnoSet, kubeAnnoOK) + }) + } +} + +// TestExecuteInplaceRestore exercises the public Execute() entry for an in-place restore +// with an existing PVC: the carrier annotation must be emitted on the returned item, the +// Kubernetes selected-node annotation must not be set by this RIA, the existing PVC must be +// deleted, and a DataDownload with the in-place restoreType must be created. +func TestExecuteInplaceRestore(t *testing.T) { + existingPVC := builder.ForPersistentVolumeClaim("velero", "testPVC"). + ObjectMeta(builder.WithAnnotations(AnnSelectedNode, "node-1")). + VolumeName("testPV"). + Phase(corev1api.ClaimBound).Result() + existingPV := builder.ForPersistentVolume("testPV").Result() + backup := builder.ForBackup("velero", "testBackup").SnapshotMoveData(true).Result() + restore := builder.ForRestore("velero", "testRestore").Backup("testBackup"). + ObjectMeta(builder.WithUID("uid")).ExistingVolumeDataPolicy("full").Result() + pvcFromBackup := builder.ForPersistentVolumeClaim("velero", "testPVC"). + ObjectMeta(builder.WithAnnotations( + velerov1api.VolumeSnapshotLabel, "vsName", + velerov1api.DataUploadNameAnnotation, "velero/testDU", + )).Result() + dataUploadResult := builder.ForConfigMap("velero", "testCM").Data("uid", "{}"). + ObjectMeta(builder.WithLabels( + velerov1api.RestoreUIDLabel, "uid", + velerov1api.PVCNamespaceNameLabel, "velero.testPVC", + velerov1api.ResourceUsageLabel, label.GetValidName(string(velerov1api.VeleroResourceUsageDataUploadResult)), + )).Result() + + pvcRIA := pvcRestoreItemAction{ + log: logrus.New(), + crClient: velerotest.NewFakeControllerRuntimeClient(t, existingPVC, existingPV, backup, dataUploadResult), + kubeClient: fake.NewSimpleClientset(existingPVC, existingPV), + } + + pvcMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(pvcFromBackup.DeepCopy()) + require.NoError(t, err) + pvcFromBackupMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(pvcFromBackup) + require.NoError(t, err) + + output, err := pvcRIA.Execute(&velero.RestoreItemActionExecuteInput{ + Item: &unstructured.Unstructured{Object: pvcMap}, + ItemFromBackup: &unstructured.Unstructured{Object: pvcFromBackupMap}, + Restore: restore, + }) + require.NoError(t, err) + + updatedPVC := new(corev1api.PersistentVolumeClaim) + require.NoError(t, runtime.DefaultUnstructuredConverter.FromUnstructured( + output.UpdatedItem.UnstructuredContent(), updatedPVC)) + + // Carrier annotation carries the captured value; the Kubernetes annotation is not set by this RIA. + require.Equal(t, "node-1", updatedPVC.Annotations[velerov1api.InplaceRestoreSelectedNodeAnnotation]) + require.NotContains(t, updatedPVC.Annotations, AnnSelectedNode) + + // The existing PVC is deleted so the exposer can bind a temporary PVC to the PV. + _, err = pvcRIA.kubeClient.CoreV1().PersistentVolumeClaims("velero").Get(t.Context(), "testPVC", metav1.GetOptions{}) + require.True(t, apierrors.IsNotFound(err)) + + // A DataDownload with the in-place restoreType referencing the existing PV is created. + dataDownloadList := new(velerov2alpha1.DataDownloadList) + require.NoError(t, pvcRIA.crClient.List(t.Context(), dataDownloadList, &crclient.ListOptions{})) + require.Len(t, dataDownloadList.Items, 1) + require.Equal(t, "full", dataDownloadList.Items[0].Spec.RestoreType) + require.Equal(t, "testPV", dataDownloadList.Items[0].Spec.TargetVolume.PV) +} + func TestPVCAppliesTo(t *testing.T) { p := pvcRestoreItemAction{ log: logrus.StandardLogger(), diff --git a/pkg/restore/restore.go b/pkg/restore/restore.go index f3be181ca..aec181a97 100644 --- a/pkg/restore/restore.go +++ b/pkg/restore/restore.go @@ -1636,6 +1636,19 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso return warnings, errs, itemExists } + // Strip any pre-existing Velero-internal in-place restore carrier annotation coming from + // the backup metadata before RestoreItemActions run. The carrier is only trusted when it + // is set by a RestoreItemAction (the PVC CSI RIA) during this restore; a stale carrier + // baked into the backup must not be translated into the Kubernetes "selected-node" + // annotation, which could pin a newly provisioned PVC to a stale node. + if annotations := obj.GetAnnotations(); annotations != nil { + if _, present := annotations[velerov1api.InplaceRestoreSelectedNodeAnnotation]; present { + restoreLogger.Infof("Removing pre-existing %q annotation from backup metadata", velerov1api.InplaceRestoreSelectedNodeAnnotation) + delete(annotations, velerov1api.InplaceRestoreSelectedNodeAnnotation) + obj.SetAnnotations(annotations) + } + } + restoreLogger.Infof("restore status includes excludes: %+v", ctx.resourceStatusIncludesExcludes) for _, action := range ctx.getApplicableActions(groupResource, namespace) { @@ -1768,6 +1781,23 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso } } + // Translate the Velero-internal carrier annotation (set by the PVC CSI RestoreItemAction + // during an in-place volume data restore) back to the Kubernetes "selected-node" annotation. + // This runs after all RestoreItemActions so the result does not depend on the order in which + // the actions executed: the generic PVC RIA unconditionally strips the Kubernetes annotation, + // while the carrier annotation passes through untouched. The carrier itself is always + // stripped so it never lands on the cluster. + if annotations := obj.GetAnnotations(); annotations != nil { + if selectedNode, present := annotations[velerov1api.InplaceRestoreSelectedNodeAnnotation]; present { + if selectedNode != "" { + restoreLogger.Infof("Restoring %q annotation with value %q from in-place restore carrier annotation", kube.KubeAnnSelectedNode, selectedNode) + annotations[kube.KubeAnnSelectedNode] = selectedNode + } + delete(annotations, velerov1api.InplaceRestoreSelectedNodeAnnotation) + obj.SetAnnotations(annotations) + } + } + // This comes after running item actions because we have built-in actions that restore // a PVC's associated PV (if applicable). As part of the PV being restored, the 'pvsToProvision' // set may be inserted into, and this needs to happen *before* running the following block of logic. diff --git a/pkg/restore/restore_test.go b/pkg/restore/restore_test.go index fdb6f20c4..5c75fff42 100644 --- a/pkg/restore/restore_test.go +++ b/pkg/restore/restore_test.go @@ -44,6 +44,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/dynamic" + k8sfake "k8s.io/client-go/kubernetes/fake" kubetesting "k8s.io/client-go/testing" "github.com/vmware-tanzu/velero/internal/volume" @@ -60,6 +61,7 @@ import ( vsv1 "github.com/vmware-tanzu/velero/pkg/plugin/velero/volumesnapshotter/v1" "github.com/vmware-tanzu/velero/pkg/podvolume" uploadermocks "github.com/vmware-tanzu/velero/pkg/podvolume/mocks" + riav1 "github.com/vmware-tanzu/velero/pkg/restore/actions" "github.com/vmware-tanzu/velero/pkg/test" "github.com/vmware-tanzu/velero/pkg/types" "github.com/vmware-tanzu/velero/pkg/util/kube" @@ -2852,6 +2854,185 @@ func TestRestoreMustIncludeAdditionalItems(t *testing.T) { }) } +// TestRestoreInplaceSelectedNodeCarrierAnnotation verifies the engine translates the +// Velero-internal in-place restore carrier annotation into the Kubernetes selected-node +// annotation after all RestoreItemActions have run, and always strips the carrier. +func TestRestoreInplaceSelectedNodeCarrierAnnotation(t *testing.T) { + t.Run("carrier annotation is translated to selected-node and stripped", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.PVCs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("persistentvolumeclaims", builder.ForPersistentVolumeClaim("ns-1", "pvc-1").Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + // Simulates the PVC CSI RIA setting the carrier during an in-place restore. + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.InplaceRestoreSelectedNodeAnnotation] = "node-1" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{UpdatedItem: item}, nil + }, + }, + // The real generic PVC RIA (velero.io/pvc), which unconditionally strips the + // Kubernetes selected-node annotation. Running it after the carrier-setting + // action proves the carrier survives the real strip regardless of action order. + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + clientset := k8sfake.NewSimpleClientset() + return riav1.NewPVCAction( + h.log, + clientset.CoreV1().ConfigMaps("velero"), + clientset.CoreV1().Nodes(), + ).Execute(input) + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + + got, err := h.DynamicClient.Resource(test.PVCs().GVR()).Namespace("ns-1").Get(t.Context(), "pvc-1", metav1.GetOptions{}) + require.NoError(t, err) + annotations := got.GetAnnotations() + assert.Equal(t, "node-1", annotations["volume.kubernetes.io/selected-node"]) + assert.NotContains(t, annotations, velerov1api.InplaceRestoreSelectedNodeAnnotation) + }) + + t.Run("empty carrier annotation is stripped without setting selected-node", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.PVCs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("persistentvolumeclaims", builder.ForPersistentVolumeClaim("ns-1", "pvc-1").Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[velerov1api.InplaceRestoreSelectedNodeAnnotation] = "" + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{UpdatedItem: item}, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + + got, err := h.DynamicClient.Resource(test.PVCs().GVR()).Namespace("ns-1").Get(t.Context(), "pvc-1", metav1.GetOptions{}) + require.NoError(t, err) + annotations := got.GetAnnotations() + assert.NotContains(t, annotations, "volume.kubernetes.io/selected-node") + assert.NotContains(t, annotations, velerov1api.InplaceRestoreSelectedNodeAnnotation) + }) + + t.Run("no carrier annotation leaves selected-node stripped (PVC-absent fallback)", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.PVCs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("persistentvolumeclaims", builder.ForPersistentVolumeClaim("ns-1", "pvc-1"). + ObjectMeta(builder.WithAnnotations("volume.kubernetes.io/selected-node", "stale-node")).Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + []riav2.RestoreItemAction{ + // Simulates the generic PVC RIA stripping the annotation; no action sets the + // carrier (as when the target PVC does not exist and Velero falls back to + // provisioning a new PVC). + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + annotations := item.GetAnnotations() + delete(annotations, "volume.kubernetes.io/selected-node") + item.SetAnnotations(annotations) + return &velero.RestoreItemActionExecuteOutput{UpdatedItem: item}, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + + got, err := h.DynamicClient.Resource(test.PVCs().GVR()).Namespace("ns-1").Get(t.Context(), "pvc-1", metav1.GetOptions{}) + require.NoError(t, err) + assert.NotContains(t, got.GetAnnotations(), "volume.kubernetes.io/selected-node") + }) + + t.Run("carrier annotation baked into backup metadata is not trusted when no action sets it", func(t *testing.T) { + h := newHarness(t) + h.AddItems(t, test.PVCs()) + + data := &Request{ + Log: h.log, + Restore: defaultRestore().Result(), + Backup: defaultBackup().Result(), + BackupReader: test.NewTarWriter(t). + AddItems("persistentvolumeclaims", builder.ForPersistentVolumeClaim("ns-1", "pvc-1"). + ObjectMeta(builder.WithAnnotations(velerov1api.InplaceRestoreSelectedNodeAnnotation, "stale-node")).Result()). + Done(), + } + warnings, errs := h.restorer.Restore( + data, + // No action sets the carrier during this restore (as in the PVC-absent fallback + // path where a new PVC is dynamically provisioned), so the carrier from the + // backup metadata must be stripped and never translated into selected-node. + []riav2.RestoreItemAction{ + &pluggableAction{ + executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + item := input.Item.(*unstructured.Unstructured) + // The stale carrier from the backup must already be gone before + // RestoreItemActions execute. + assert.NotContains(t, item.GetAnnotations(), velerov1api.InplaceRestoreSelectedNodeAnnotation) + return &velero.RestoreItemActionExecuteOutput{UpdatedItem: item}, nil + }, + }, + }, + nil, + ) + + assertEmptyResults(t, warnings, errs) + + got, err := h.DynamicClient.Resource(test.PVCs().GVR()).Namespace("ns-1").Get(t.Context(), "pvc-1", metav1.GetOptions{}) + require.NoError(t, err) + annotations := got.GetAnnotations() + assert.NotContains(t, annotations, "volume.kubernetes.io/selected-node") + assert.NotContains(t, annotations, velerov1api.InplaceRestoreSelectedNodeAnnotation) + }) +} + // TestShouldRestore runs the ShouldRestore function for various permutations of // existing/nonexisting/being-deleted PVs, PVCs, and namespaces, and verifies the // result/error matches expectations.