Add in-place restore pre-flight check: PVC must be bound to the backed-up PV

An in-place restore onto a different volume than the one backed up is
unsafe: an incremental (CBT) restore computes deltas against a different
volume lineage, and even a full restore would patch and write into an
unrelated volume. Verify the existing PVC is bound and still bound to
the PV recorded at backup time before any side effect, on both the CSI
data mover path (using the backed-up PVC's volume name) and the file
system path (using the PVC-to-PV mapping from the backup volume info).

The PV comparison is skipped for namespace-mapped restores, where the
target PVC is necessarily bound to a different PV (the documented
cross-namespace clone-and-restore workflow).

Signed-off-by: chlins <chlins.zhang@gmail.com>
This commit is contained in:
chlins
2026-09-03 13:59:42 +08:00
parent efc69c61aa
commit fa717d4e48
10 changed files with 170 additions and 27 deletions
+3 -4
View File
@@ -234,11 +234,10 @@ func (p *pvcRestoreItemAction) executeWithDataMove(logger *logrus.Entry, input *
var volumeSnapshot *snapshotv1api.VolumeSnapshot
restoreType := input.Restore.Spec.ExistingVolumeDataPolicy
if pvcExists {
if existingPVC.Status.Phase != corev1api.ClaimBound {
return nil, errors.New("ExistingVolumeDataPolicy is in-place restore, but the existing PVC is not bound.")
}
// Pre-flight checks must pass before any side effect on the existing PVC/PV.
if err := inplace.CheckPVCBoundToBackedUpPV(existingPVC, pvcFromBackup.Spec.VolumeName, pvcFromBackup.Namespace); err != nil {
return nil, errors.WithStack(err)
}
if err := inplace.CheckPVCNotInUse(ctx, p.crClient, existingPVC, input.Restore.UID); err != nil {
return nil, errors.WithStack(err)
}
+20 -11
View File
@@ -742,8 +742,8 @@ func TestExecuteInplaceRestore(t *testing.T) {
}
// TestExecuteInplaceRestorePreflight verifies the RIA fails the item without
// side effects when the pre-flight check fails. The in-use semantics are
// covered by the pkg/restore/inplace unit tests.
// side effects when a pre-flight check fails. The check semantics themselves
// are covered by the pkg/restore/inplace unit tests.
func TestExecuteInplaceRestorePreflight(t *testing.T) {
newPodUsingPVC := func(phase corev1api.PodPhase) *corev1api.Pod {
pod := builder.ForPod("velero", "consumer-pod").
@@ -754,17 +754,25 @@ func TestExecuteInplaceRestorePreflight(t *testing.T) {
}
tests := []struct {
name string
pod *corev1api.Pod
expectBlock bool
name string
pod *corev1api.Pod
backedUpPVName string
expectBlock string
}{
{
name: "no pod, restore proceeds",
name: "checks pass, restore proceeds",
backedUpPVName: "testPV",
},
{
name: "active pod blocks the restore",
pod: newPodUsingPVC(corev1api.PodRunning),
expectBlock: true,
name: "active pod blocks the restore",
pod: newPodUsingPVC(corev1api.PodRunning),
backedUpPVName: "testPV",
expectBlock: "consumer-pod",
},
{
name: "PVC bound to a different PV blocks the restore",
backedUpPVName: "backupPV",
expectBlock: "was bound to PV backupPV at backup time",
},
}
@@ -778,6 +786,7 @@ func TestExecuteInplaceRestorePreflight(t *testing.T) {
restore := builder.ForRestore("velero", "testRestore").Backup("testBackup").
ObjectMeta(builder.WithUID("uid")).ExistingVolumeDataPolicy("full").Result()
pvcFromBackup := builder.ForPersistentVolumeClaim("velero", "testPVC").
VolumeName(tc.backedUpPVName).
ObjectMeta(builder.WithAnnotations(
velerov1api.VolumeSnapshotLabel, "vsName",
velerov1api.DataUploadNameAnnotation, "velero/testDU",
@@ -817,10 +826,10 @@ func TestExecuteInplaceRestorePreflight(t *testing.T) {
dataDownloadList := new(velerov2alpha1.DataDownloadList)
require.NoError(t, pvcRIA.crClient.List(t.Context(), dataDownloadList, &crclient.ListOptions{}))
if tc.expectBlock {
if tc.expectBlock != "" {
require.Error(t, err)
require.Contains(t, err.Error(), "pre-flight check failed")
require.Contains(t, err.Error(), "consumer-pod")
require.Contains(t, err.Error(), tc.expectBlock)
// No side effects: PVC untouched with the original volumeName,
// PV reclaim policy not patched, no DataDownload created.
require.NoError(t, getErr)
+20
View File
@@ -129,3 +129,23 @@ func gatedByThisRestore(pod *corev1api.Pod, restoreUID types.UID) bool {
// is still closed.
return true
}
// CheckPVCBoundToBackedUpPV verifies the existing PVC is still bound to the
// same PV it was bound to at backup time. An in-place restore onto a
// different volume is unsafe: an incremental (CBT) restore computes deltas
// against a different volume lineage, and even a full restore would patch and
// write into a volume unrelated to the backup. The PV comparison is skipped
// when the PVC is restored into a different namespace, where it is necessarily
// bound to a different PV (the documented cross-namespace clone-and-restore
// workflow), and when the backed-up PV name is unknown.
func CheckPVCBoundToBackedUpPV(existingPVC *corev1api.PersistentVolumeClaim, backedUpPVName, sourceNamespace string) error {
if existingPVC.Status.Phase != corev1api.ClaimBound {
return errors.Errorf("in-place restore pre-flight check failed, skipping volume data restore: PVC %s/%s is not bound (phase %s)",
existingPVC.Namespace, existingPVC.Name, existingPVC.Status.Phase)
}
if existingPVC.Namespace != sourceNamespace || backedUpPVName == "" || existingPVC.Spec.VolumeName == backedUpPVName {
return nil
}
return errors.Errorf("in-place restore pre-flight check failed, skipping volume data restore: PVC %s/%s is bound to PV %s, but was bound to PV %s at backup time",
existingPVC.Namespace, existingPVC.Name, existingPVC.Spec.VolumeName, backedUpPVName)
}
+63
View File
@@ -200,3 +200,66 @@ func TestCheckPVCNotInUse(t *testing.T) {
})
}
}
func TestCheckPVCBoundToBackedUpPV(t *testing.T) {
pvc := func(namespace, pvName string, phase corev1api.PersistentVolumeClaimPhase) *corev1api.PersistentVolumeClaim {
return &corev1api.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-1", Namespace: namespace},
Spec: corev1api.PersistentVolumeClaimSpec{VolumeName: pvName},
Status: corev1api.PersistentVolumeClaimStatus{Phase: phase},
}
}
tests := []struct {
name string
existingPVC *corev1api.PersistentVolumeClaim
backedUpPVName string
expectError string
}{
{
name: "bound to the backed-up PV, check passes",
existingPVC: pvc("default", "pv-1", corev1api.ClaimBound),
backedUpPVName: "pv-1",
},
{
name: "bound to a different PV, check fails",
existingPVC: pvc("default", "pv-other", corev1api.ClaimBound),
backedUpPVName: "pv-1",
expectError: "is bound to PV pv-other, but was bound to PV pv-1 at backup time",
},
{
name: "PVC not bound, check fails",
existingPVC: pvc("default", "", corev1api.ClaimPending),
backedUpPVName: "pv-1",
expectError: "is not bound (phase Pending)",
},
{
name: "different PV in a different namespace, check passes",
existingPVC: pvc("mapped-ns", "pv-other", corev1api.ClaimBound),
backedUpPVName: "pv-1",
},
{
name: "backed-up PV name unknown, check passes",
existingPVC: pvc("default", "pv-other", corev1api.ClaimBound),
backedUpPVName: "",
},
{
name: "different namespace but PVC not bound, check still fails",
existingPVC: pvc("mapped-ns", "", corev1api.ClaimLost),
backedUpPVName: "pv-1",
expectError: "is not bound (phase Lost)",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := CheckPVCBoundToBackedUpPV(tc.existingPVC, tc.backedUpPVName, "default")
if tc.expectError == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectError)
})
}
}
+6 -5
View File
@@ -2287,11 +2287,12 @@ func restorePodVolumeBackups(ctx *restoreContext, createdObj *unstructured.Unstr
}
data := podvolume.RestoreData{
Restore: ctx.restore,
Pod: pod,
PodVolumeBackups: ctx.podVolumeBackups,
SourceNamespace: originalNamespace,
BackupLocation: ctx.backup.Spec.StorageLocation,
Restore: ctx.restore,
Pod: pod,
PodVolumeBackups: ctx.podVolumeBackups,
SourceNamespace: originalNamespace,
BackupLocation: ctx.backup.Spec.StorageLocation,
BackupVolumeInfos: ctx.backupVolumeInfoMap,
}
if errs := ctx.podVolumeRestorer.RestorePodVolumes(data, ctx.restoreVolumeInfoTracker); errs != nil {
ctx.log.WithError(kubeerrs.NewAggregate(errs)).Error("unable to successfully complete pod volume restores of pod's volumes")