Files
velero/pkg/backup/actions/csi/pvc_action_test.go
Shubham Pampattiwar dc3da29f3e Apply volume policies to VolumeGroupSnapshot PVC filtering
VolumeGroupSnapshots were querying all PVCs with matching labels
directly from the cluster without respecting volume policies. This
caused errors when labeled PVCs included both CSI and non-CSI volumes,
or volumes from different CSI drivers that were excluded by policies.

This change filters PVCs by volume policy before VGS grouping,
ensuring only PVCs that should be snapshotted are included in the
group. A warning is logged when PVCs are excluded from VGS due to
volume policy.

Fixes #9344

Signed-off-by: Shubham Pampattiwar <spampatt@redhat.com>
2025-11-18 15:40:30 -08:00

1962 lines
58 KiB
Go

/*
Copyright the Velero contributors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package csi
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/vmware-tanzu/velero/pkg/kuberesource"
volumegroupsnapshotv1beta1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumegroupsnapshot/v1beta1"
"github.com/stretchr/testify/assert"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/utils/pointer"
"github.com/vmware-tanzu/velero/pkg/label"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
snapshotv1api "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
corev1api "k8s.io/api/core/v1"
storagev1api "k8s.io/api/storage/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/wait"
crclient "sigs.k8s.io/controller-runtime/pkg/client"
"github.com/vmware-tanzu/velero/pkg/apis/velero/shared"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
velerov2alpha1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v2alpha1"
"github.com/vmware-tanzu/velero/pkg/builder"
factorymocks "github.com/vmware-tanzu/velero/pkg/client/mocks"
"github.com/vmware-tanzu/velero/pkg/plugin/velero"
velerotest "github.com/vmware-tanzu/velero/pkg/test"
)
const testDriver = "csi.example.com"
// errorInjectingClient is a wrapper around a normal client that injects an error
// when a specific resource type (VolumeSnapshot) is created.
type errorInjectingClient struct {
crclient.Client
}
// Create overrides the embedded client's Create method.
func (c *errorInjectingClient) Create(ctx context.Context, obj crclient.Object, opts ...crclient.CreateOption) error {
// Check if the object being created is a VolumeSnapshot.
if _, ok := obj.(*snapshotv1api.VolumeSnapshot); ok {
// If it is, return our injected error instead of proceeding.
return errors.New("injected error on create")
}
// For all other object types, call the original, embedded Create method.
return c.Client.Create(ctx, obj, opts...)
}
func TestExecute(t *testing.T) {
boolTrue := true
tests := []struct {
name string
backup *velerov1api.Backup
pvc *corev1api.PersistentVolumeClaim
pv *corev1api.PersistentVolume
sc *storagev1api.StorageClass
vsClass *snapshotv1api.VolumeSnapshotClass
operationID string
expectedErr error
expectErr bool // Use bool for cases where we just need to check for any error
expectedBackup *velerov1api.Backup
expectedDataUpload *velerov2alpha1.DataUpload
expectedPVC *corev1api.PersistentVolumeClaim
resourcePolicy *corev1api.ConfigMap
failVSCreate bool
skipVSReadyUpdate bool // New flag to control VS readiness
}{
{
name: "Skip PVC BIA when backup is in finalizing phase",
backup: builder.ForBackup("velero", "test").Phase(velerov1api.BackupPhaseFinalizing).Result(),
},
{
name: "Fail when creating volumesnapshot returns error",
backup: builder.ForBackup("velero", "test").CSISnapshotTimeout(1 * time.Minute).Result(),
pvc: builder.ForPersistentVolumeClaim("velero", "testPVC").VolumeName("testPV").StorageClass("testSC").Phase(corev1api.ClaimBound).Result(),
pv: builder.ForPersistentVolume("testPV").CSI("hostpath", "testVolume").Result(),
sc: builder.ForStorageClass("testSC").Provisioner("hostpath").Result(),
vsClass: builder.ForVolumeSnapshotClass("testVSClass").Driver("hostpath").ObjectMeta(builder.WithLabels(velerov1api.VolumeSnapshotClassSelectorLabel, "")).Result(),
failVSCreate: true,
expectedErr: errors.New("error creating volume snapshot: injected error on create"),
},
{
name: "Fail when waiting for VolumeSnapshot to be ready times out",
backup: builder.ForBackup("velero", "test").CSISnapshotTimeout(20 * time.Millisecond).Result(), // Short timeout
pvc: builder.ForPersistentVolumeClaim("velero", "testPVC").VolumeName("testPV").StorageClass("testSC").Phase(corev1api.ClaimBound).Result(),
pv: builder.ForPersistentVolume("testPV").CSI("hostpath", "testVolume").Result(),
sc: builder.ForStorageClass("testSC").Provisioner("hostpath").Result(),
vsClass: builder.ForVolumeSnapshotClass("testVSClass").Driver("hostpath").ObjectMeta(builder.WithLabels(velerov1api.VolumeSnapshotClassSelectorLabel, "")).Result(),
skipVSReadyUpdate: true, // This will cause the timeout
expectErr: true, // Expect an error, but the exact message can vary
},
{
name: "Test SnapshotMoveData",
backup: builder.ForBackup("velero", "test").SnapshotMoveData(true).CSISnapshotTimeout(1 * time.Minute).Result(),
pvc: builder.ForPersistentVolumeClaim("velero", "testPVC").VolumeName("testPV").StorageClass("testSC").Phase(corev1api.ClaimBound).Result(),
pv: builder.ForPersistentVolume("testPV").CSI("hostpath", "testVolume").Result(),
sc: builder.ForStorageClass("testSC").Provisioner("hostpath").Result(),
vsClass: builder.ForVolumeSnapshotClass("testVSClass").Driver("hostpath").ObjectMeta(builder.WithLabels(velerov1api.VolumeSnapshotClassSelectorLabel, "")).Result(),
operationID: ".",
expectedDataUpload: &velerov2alpha1.DataUpload{
TypeMeta: metav1.TypeMeta{
Kind: "DataUpload",
APIVersion: velerov2alpha1.SchemeGroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-",
Namespace: "velero",
Labels: map[string]string{
velerov1api.BackupNameLabel: "test",
velerov1api.BackupUIDLabel: "",
velerov1api.PVCUIDLabel: "",
velerov1api.AsyncOperationIDLabel: "du-.",
},
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: "velero.io/v1",
Kind: "Backup",
Name: "test",
UID: "",
Controller: &boolTrue,
},
},
},
Spec: velerov2alpha1.DataUploadSpec{
SnapshotType: velerov2alpha1.SnapshotTypeCSI,
CSISnapshot: &velerov2alpha1.CSISnapshotSpec{
VolumeSnapshot: "",
StorageClass: "testSC",
SnapshotClass: "testVSClass",
},
SourcePVC: "testPVC",
SourceNamespace: "velero",
OperationTimeout: metav1.Duration{Duration: 1 * time.Minute},
},
},
},
{
name: "Verify PVC is modified as expected",
backup: builder.ForBackup("velero", "test").SnapshotMoveData(true).CSISnapshotTimeout(1 * time.Minute).Result(),
pvc: builder.ForPersistentVolumeClaim("velero", "testPVC").VolumeName("testPV").StorageClass("testSC").Phase(corev1api.ClaimBound).Result(),
pv: builder.ForPersistentVolume("testPV").CSI("hostpath", "testVolume").Result(),
sc: builder.ForStorageClass("testSC").Provisioner("hostpath").Result(),
vsClass: builder.ForVolumeSnapshotClass("tescVSClass").Driver("hostpath").ObjectMeta(builder.WithLabels(velerov1api.VolumeSnapshotClassSelectorLabel, "")).Result(),
operationID: ".",
expectedPVC: builder.ForPersistentVolumeClaim("velero", "testPVC").
ObjectMeta(builder.WithAnnotations(velerov1api.MustIncludeAdditionalItemAnnotation, "true", velerov1api.DataUploadNameAnnotation, "velero/"),
builder.WithLabels(velerov1api.BackupNameLabel, "test")).
VolumeName("testPV").StorageClass("testSC").Phase(corev1api.ClaimBound).Result(),
},
{
name: "Test ResourcePolicy",
backup: builder.ForBackup("velero", "test").ResourcePolicies("resourcePolicy").SnapshotVolumes(false).CSISnapshotTimeout(time.Duration(3600) * time.Second).Result(),
resourcePolicy: builder.ForConfigMap("velero", "resourcePolicy").Data("policy", "{\"version\":\"v1\", \"volumePolicies\":[{\"conditions\":{\"csi\": {}},\"action\":{\"type\":\"snapshot\"}}]}").Result(),
pvc: builder.ForPersistentVolumeClaim("velero", "testPVC").VolumeName("testPV").StorageClass("testSC").Phase(corev1api.ClaimBound).Result(),
pv: builder.ForPersistentVolume("testPV").CSI("hostpath", "testVolume").Result(),
sc: builder.ForStorageClass("testSC").Provisioner("hostpath").Result(),
vsClass: builder.ForVolumeSnapshotClass("tescVSClass").Driver("hostpath").ObjectMeta(builder.WithLabels(velerov1api.VolumeSnapshotClassSelectorLabel, "")).Result(),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
logger := logrus.New()
logger.Level = logrus.DebugLevel
objects := make([]runtime.Object, 0)
if tc.pvc != nil {
objects = append(objects, tc.pvc)
}
if tc.pv != nil {
objects = append(objects, tc.pv)
}
if tc.sc != nil {
objects = append(objects, tc.sc)
}
if tc.vsClass != nil {
objects = append(objects, tc.vsClass)
}
if tc.resourcePolicy != nil {
objects = append(objects, tc.resourcePolicy)
}
var crClient crclient.Client
if tc.failVSCreate {
realFakeClient := velerotest.NewFakeControllerRuntimeClient(t, objects...)
crClient = &errorInjectingClient{Client: realFakeClient}
} else {
crClient = velerotest.NewFakeControllerRuntimeClient(t, objects...)
}
pvcBIA := pvcBackupItemAction{
log: logger,
crClient: crClient,
}
pvcMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&tc.pvc)
require.NoError(t, err)
if tc.pvc != nil && !tc.failVSCreate && !tc.skipVSReadyUpdate {
go func() {
var vsList snapshotv1api.VolumeSnapshotList
err := wait.PollUntilContextTimeout(t.Context(), 1*time.Second, 10*time.Second, true, func(ctx context.Context) (bool, error) {
err = pvcBIA.crClient.List(ctx, &vsList, &crclient.ListOptions{Namespace: tc.pvc.Namespace})
require.NoError(t, err)
if err != nil || len(vsList.Items) == 0 {
return false, err
}
return true, nil
})
require.NoError(t, err)
vscName := "testVSC"
readyToUse := true
vsList.Items[0].Status = &snapshotv1api.VolumeSnapshotStatus{
BoundVolumeSnapshotContentName: &vscName,
ReadyToUse: &readyToUse,
}
err = pvcBIA.crClient.Update(t.Context(), &vsList.Items[0])
require.NoError(t, err)
handleName := "testHandle"
vsc := builder.ForVolumeSnapshotContent("testVSC").Status(&snapshotv1api.VolumeSnapshotContentStatus{SnapshotHandle: &handleName}).Result()
err = pvcBIA.crClient.Create(t.Context(), vsc)
require.NoError(t, err)
}()
}
resultUnstructed, _, _, _, err := pvcBIA.Execute(&unstructured.Unstructured{Object: pvcMap}, tc.backup)
if tc.expectedErr != nil {
require.EqualError(t, err, tc.expectedErr.Error())
} else if tc.expectErr {
require.Error(t, err)
// On timeout failure, check that the cleanup logic was called
if tc.skipVSReadyUpdate {
vsList := new(snapshotv1api.VolumeSnapshotList)
errList := crClient.List(t.Context(), vsList, &crclient.ListOptions{Namespace: tc.pvc.Namespace})
require.NoError(t, errList)
require.Empty(t, vsList.Items, "VolumeSnapshot should have been cleaned up after readiness check failed")
}
} else {
require.NoError(t, err)
}
if tc.expectedDataUpload != nil {
dataUploadList := new(velerov2alpha1.DataUploadList)
err := crClient.List(t.Context(), dataUploadList, &crclient.ListOptions{LabelSelector: labels.SelectorFromSet(map[string]string{velerov1api.BackupNameLabel: tc.backup.Name})})
require.NoError(t, err)
require.Len(t, dataUploadList.Items, 1)
require.True(t, cmp.Equal(tc.expectedDataUpload, &dataUploadList.Items[0], cmpopts.IgnoreFields(velerov2alpha1.DataUpload{}, "ResourceVersion", "Name", "Spec.CSISnapshot.VolumeSnapshot")))
}
if tc.expectedPVC != nil {
resultPVC := new(corev1api.PersistentVolumeClaim)
runtime.DefaultUnstructuredConverter.FromUnstructured(resultUnstructed.UnstructuredContent(), resultPVC)
require.True(t, cmp.Equal(tc.expectedPVC, resultPVC, cmpopts.IgnoreFields(corev1api.PersistentVolumeClaim{}, "ResourceVersion", "Annotations", "Labels")))
}
})
}
}
func TestProgress(t *testing.T) {
currentTime := time.Now()
tests := []struct {
name string
backup *velerov1api.Backup
dataUpload *velerov2alpha1.DataUpload
operationID string
expectedErr string
expectedProgress velero.OperationProgress
}{
{
name: "DataUpload cannot be found",
backup: builder.ForBackup("velero", "test").Result(),
operationID: "testing",
expectedErr: "not found DataUpload for operationID testing",
},
{
name: "DataUpload is found",
backup: builder.ForBackup("velero", "test").Result(),
dataUpload: &velerov2alpha1.DataUpload{
TypeMeta: metav1.TypeMeta{
Kind: "DataUpload",
APIVersion: "v2alpha1",
},
ObjectMeta: metav1.ObjectMeta{
Namespace: "velero",
Name: "testing",
Labels: map[string]string{
velerov1api.AsyncOperationIDLabel: "testing",
},
},
Status: velerov2alpha1.DataUploadStatus{
Phase: velerov2alpha1.DataUploadPhaseFailed,
Progress: shared.DataMoveOperationProgress{
BytesDone: 1000,
TotalBytes: 1000,
},
StartTimestamp: &metav1.Time{Time: currentTime},
CompletionTimestamp: &metav1.Time{Time: currentTime},
Message: "Testing error",
},
},
operationID: "testing",
expectedProgress: velero.OperationProgress{
Completed: true,
Err: "Testing error",
NCompleted: 1000,
NTotal: 1000,
OperationUnits: "Bytes",
Description: "Failed",
Started: currentTime,
Updated: currentTime,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
crClient := velerotest.NewFakeControllerRuntimeClient(t)
logger := logrus.New()
pvcBIA := pvcBackupItemAction{
log: logger,
crClient: crClient,
}
if tc.dataUpload != nil {
err := crClient.Create(t.Context(), tc.dataUpload)
require.NoError(t, err)
}
progress, err := pvcBIA.Progress(tc.operationID, tc.backup)
if tc.expectedErr != "" {
require.Equal(t, tc.expectedErr, err.Error())
}
require.True(t, cmp.Equal(tc.expectedProgress, progress, cmpopts.IgnoreFields(velero.OperationProgress{}, "Started", "Updated")))
})
}
}
func TestCancel(t *testing.T) {
tests := []struct {
name string
backup *velerov1api.Backup
dataUpload velerov2alpha1.DataUpload
operationID string
expectedErr error
expectedDataUpload velerov2alpha1.DataUpload
}{
{
name: "Cancel DataUpload",
backup: builder.ForBackup("velero", "test").Result(),
dataUpload: velerov2alpha1.DataUpload{
TypeMeta: metav1.TypeMeta{
Kind: "DataUpload",
APIVersion: velerov2alpha1.SchemeGroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Namespace: "velero",
Name: "testing",
Labels: map[string]string{
velerov1api.AsyncOperationIDLabel: "testing",
},
},
},
operationID: "testing",
expectedDataUpload: velerov2alpha1.DataUpload{
TypeMeta: metav1.TypeMeta{
Kind: "DataUpload",
APIVersion: velerov2alpha1.SchemeGroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Namespace: "velero",
Name: "testing",
Labels: map[string]string{
velerov1api.AsyncOperationIDLabel: "testing",
},
},
Spec: velerov2alpha1.DataUploadSpec{
Cancel: true,
},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
crClient := velerotest.NewFakeControllerRuntimeClient(t)
logger := logrus.New()
pvcBIA := pvcBackupItemAction{
log: logger,
crClient: crClient,
}
err := crClient.Create(t.Context(), &tc.dataUpload)
require.NoError(t, err)
err = pvcBIA.Cancel(tc.operationID, tc.backup)
require.NoError(t, err)
du := new(velerov2alpha1.DataUpload)
err = crClient.Get(t.Context(), crclient.ObjectKey{Namespace: tc.dataUpload.Namespace, Name: tc.dataUpload.Name}, du)
require.NoError(t, err)
require.True(t, cmp.Equal(tc.expectedDataUpload, *du, cmpopts.IgnoreFields(velerov2alpha1.DataUpload{}, "ResourceVersion")))
})
}
}
func TestPVCAppliesTo(t *testing.T) {
p := pvcBackupItemAction{
log: logrus.StandardLogger(),
}
selector, err := p.AppliesTo()
require.NoError(t, err)
require.Equal(
t,
velero.ResourceSelector{
IncludedResources: []string{"persistentvolumeclaims"},
},
selector,
)
}
func TestNewPVCBackupItemAction(t *testing.T) {
logger := logrus.StandardLogger()
crClient := velerotest.NewFakeControllerRuntimeClient(t)
f := &factorymocks.Factory{}
f.On("KubebuilderClient").Return(nil, fmt.Errorf(""))
plugin := NewPvcBackupItemAction(f)
_, err := plugin(logger)
require.Error(t, err)
f1 := &factorymocks.Factory{}
f1.On("KubebuilderClient").Return(crClient, nil)
plugin1 := NewPvcBackupItemAction(f1)
_, err1 := plugin1(logger)
require.NoError(t, err1)
}
func TestListGroupedPVCs(t *testing.T) {
tests := []struct {
name string
namespace string
labelKey string
groupValue string
pvcs []corev1api.PersistentVolumeClaim
expectCount int
expectError bool
}{
{
name: "Match single PVC with label",
namespace: "ns1",
labelKey: "vgs-key",
groupValue: "group-a",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "pvc1",
Namespace: "ns1",
Labels: map[string]string{
"vgs-key": "group-a",
},
},
},
},
expectCount: 1,
},
{
name: "No matching PVCs",
namespace: "ns1",
labelKey: "vgs-key",
groupValue: "group-b",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "pvc1",
Namespace: "ns1",
Labels: map[string]string{
"vgs-key": "group-a",
},
},
},
},
expectCount: 0,
},
{
name: "Match multiple PVCs",
namespace: "ns1",
labelKey: "vgs-key",
groupValue: "group-a",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "pvc1",
Namespace: "ns1",
Labels: map[string]string{"vgs-key": "group-a"},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "pvc2",
Namespace: "ns1",
Labels: map[string]string{"vgs-key": "group-a"},
},
},
},
expectCount: 2,
},
{
name: "Different namespace",
namespace: "ns2",
labelKey: "vgs-key",
groupValue: "group-a",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "pvc1",
Namespace: "ns1",
Labels: map[string]string{"vgs-key": "group-a"},
},
},
},
expectCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var objs []runtime.Object
for i := range tt.pvcs {
objs = append(objs, &tt.pvcs[i])
}
client := velerotest.NewFakeControllerRuntimeClient(t, objs...)
action := &pvcBackupItemAction{
log: logrus.New(),
crClient: client,
}
result, err := action.listGroupedPVCs(t.Context(), tt.namespace, tt.labelKey, tt.groupValue)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Len(t, result, tt.expectCount)
}
})
}
}
func TestFilterPVCsByVolumePolicy(t *testing.T) {
tests := []struct {
name string
pvcs []corev1api.PersistentVolumeClaim
pvs []corev1api.PersistentVolume
volumePolicyStr string
expectCount int
expectError bool
}{
{
name: "All PVCs should be included when no volume policy",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-1", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{
VolumeName: "pv-1",
StorageClassName: pointer.String("sc-1"),
},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-2", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{
VolumeName: "pv-2",
StorageClassName: pointer.String("sc-1"),
},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
},
pvs: []corev1api.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "csi-driver-1"},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-2"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "csi-driver-1"},
},
},
},
},
expectCount: 2,
},
{
name: "Filter out NFS PVC by volume policy",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-csi", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{
VolumeName: "pv-csi",
StorageClassName: pointer.String("sc-1"),
},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-nfs", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{
VolumeName: "pv-nfs",
StorageClassName: pointer.String("sc-nfs"),
},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
},
pvs: []corev1api.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-csi"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "csi-driver"},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-nfs"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
NFS: &corev1api.NFSVolumeSource{
Server: "nfs-server",
Path: "/export",
},
},
},
},
},
volumePolicyStr: `
version: v1
volumePolicies:
- conditions:
nfs: {}
action:
type: skip
`,
expectCount: 1,
},
{
name: "All PVCs filtered out by volume policy",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-nfs-1", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{
VolumeName: "pv-nfs-1",
StorageClassName: pointer.String("sc-nfs"),
},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-nfs-2", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{
VolumeName: "pv-nfs-2",
StorageClassName: pointer.String("sc-nfs"),
},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
},
pvs: []corev1api.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-nfs-1"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
NFS: &corev1api.NFSVolumeSource{
Server: "nfs-server",
Path: "/export/1",
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-nfs-2"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
NFS: &corev1api.NFSVolumeSource{
Server: "nfs-server",
Path: "/export/2",
},
},
},
},
},
volumePolicyStr: `
version: v1
volumePolicies:
- conditions:
nfs: {}
action:
type: skip
`,
expectCount: 0,
},
{
name: "Filter out non-CSI PVCs from mixed driver group",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{
Name: "pvc-linstor",
Namespace: "ns-1",
Labels: map[string]string{"app.kubernetes.io/instance": "myapp"},
},
Spec: corev1api.PersistentVolumeClaimSpec{
VolumeName: "pv-linstor",
StorageClassName: pointer.String("sc-linstor"),
},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "pvc-nfs",
Namespace: "ns-1",
Labels: map[string]string{"app.kubernetes.io/instance": "myapp"},
},
Spec: corev1api.PersistentVolumeClaimSpec{
VolumeName: "pv-nfs",
StorageClassName: pointer.String("sc-nfs"),
},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
},
pvs: []corev1api.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-linstor"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "linstor.csi.linbit.com"},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-nfs"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
NFS: &corev1api.NFSVolumeSource{
Server: "nfs-server",
Path: "/export",
},
},
},
},
},
volumePolicyStr: `
version: v1
volumePolicies:
- conditions:
nfs: {}
action:
type: skip
`,
expectCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
objs := []runtime.Object{}
for i := range tt.pvs {
objs = append(objs, &tt.pvs[i])
}
client := velerotest.NewFakeControllerRuntimeClient(t, objs...)
backup := &velerov1api.Backup{
ObjectMeta: metav1.ObjectMeta{
Name: "test-backup",
Namespace: "velero",
},
Spec: velerov1api.BackupSpec{},
}
// Add volume policy ConfigMap if specified
if tt.volumePolicyStr != "" {
cm := &corev1api.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "volume-policy",
Namespace: "velero",
},
Data: map[string]string{
"volume-policy": tt.volumePolicyStr,
},
}
require.NoError(t, client.Create(context.Background(), cm))
backup.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{
Kind: "ConfigMap",
Name: "volume-policy",
}
}
action := &pvcBackupItemAction{
log: velerotest.NewLogger(),
crClient: client,
}
result, err := action.filterPVCsByVolumePolicy(tt.pvcs, backup)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Len(t, result, tt.expectCount)
// For mixed driver scenarios, verify filtered result can determine single CSI driver
if tt.name == "Filter out non-CSI PVCs from mixed driver group" && len(result) > 0 {
driver, err := action.determineCSIDriver(result)
require.NoError(t, err, "After filtering, determineCSIDriver should not fail with multiple drivers error")
require.Equal(t, "linstor.csi.linbit.com", driver, "Should have the Linstor driver after filtering out NFS")
}
}
})
}
}
func TestDetermineCSIDriver(t *testing.T) {
tests := []struct {
name string
pvcs []corev1api.PersistentVolumeClaim
pvs []corev1api.PersistentVolume
expectError bool
expectedDriver string
}{
{
name: "Single PVC with CSI PV",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-1", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{VolumeName: "pv-1"},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
},
pvs: []corev1api.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "csi-driver"},
},
},
},
},
expectedDriver: "csi-driver",
},
{
name: "Multiple PVCs with same CSI driver",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-1", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{VolumeName: "pv-1"},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-2", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{VolumeName: "pv-2"},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
},
pvs: []corev1api.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "csi-driver"},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-2"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "csi-driver"},
},
},
},
},
expectedDriver: "csi-driver",
},
{
name: "PV not CSI provisioned",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-1", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{VolumeName: "pv-1"},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
},
pvs: []corev1api.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
Spec: corev1api.PersistentVolumeSpec{},
},
},
expectError: true,
},
{
name: "Multiple PVCs with different CSI drivers",
pvcs: []corev1api.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-1", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{VolumeName: "pv-1"},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pvc-2", Namespace: "ns-1"},
Spec: corev1api.PersistentVolumeClaimSpec{VolumeName: "pv-2"},
Status: corev1api.PersistentVolumeClaimStatus{Phase: corev1api.ClaimBound},
},
},
pvs: []corev1api.PersistentVolume{
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-1"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "csi-driver-1"},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{Name: "pv-2"},
Spec: corev1api.PersistentVolumeSpec{
PersistentVolumeSource: corev1api.PersistentVolumeSource{
CSI: &corev1api.CSIPersistentVolumeSource{Driver: "csi-driver-2"},
},
},
},
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var initObjs []runtime.Object
for i := range tt.pvcs {
pvc := tt.pvcs[i]
initObjs = append(initObjs, &pvc)
}
for i := range tt.pvs {
pv := tt.pvs[i]
initObjs = append(initObjs, &pv)
}
client := velerotest.NewFakeControllerRuntimeClient(t, initObjs...)
action := &pvcBackupItemAction{
log: logrus.New(),
crClient: client,
}
driver, err := action.determineCSIDriver(tt.pvcs)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expectedDriver, driver)
}
})
}
}
func TestDetermineVGSClass(t *testing.T) {
tests := []struct {
name string
backup *velerov1api.Backup
pvc *corev1api.PersistentVolumeClaim
existingVGSClass []volumegroupsnapshotv1beta1.VolumeGroupSnapshotClass
expectError bool
expectResult string
}{
{
name: "PVC annotation override",
pvc: &corev1api.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
velerov1api.VolumeGroupSnapshotClassAnnotationPVC: "pvc-class",
},
},
},
backup: &velerov1api.Backup{},
expectResult: "pvc-class",
},
{
name: "Backup annotation override",
pvc: &corev1api.PersistentVolumeClaim{},
backup: &velerov1api.Backup{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
fmt.Sprintf("%s%s", velerov1api.VolumeGroupSnapshotClassAnnotationBackupPrefix, testDriver): "backup-class",
},
},
},
expectResult: "backup-class",
},
{
name: "Default label-based match",
pvc: &corev1api.PersistentVolumeClaim{},
backup: &velerov1api.Backup{},
existingVGSClass: []volumegroupsnapshotv1beta1.VolumeGroupSnapshotClass{
{
ObjectMeta: metav1.ObjectMeta{
Name: "default-class",
Labels: map[string]string{velerov1api.VolumeGroupSnapshotClassDefaultLabel: "true"},
},
Driver: testDriver,
},
},
expectResult: "default-class",
},
{
name: "No matching VGS class",
pvc: &corev1api.PersistentVolumeClaim{},
backup: &velerov1api.Backup{},
expectError: true,
},
{
name: "Multiple matching VGS classes",
pvc: &corev1api.PersistentVolumeClaim{},
backup: &velerov1api.Backup{},
existingVGSClass: []volumegroupsnapshotv1beta1.VolumeGroupSnapshotClass{
{
ObjectMeta: metav1.ObjectMeta{
Name: "class1",
Labels: map[string]string{velerov1api.VolumeGroupSnapshotClassDefaultLabel: "true"},
},
Driver: testDriver,
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "class2",
Labels: map[string]string{velerov1api.VolumeGroupSnapshotClassDefaultLabel: "true"},
},
Driver: testDriver,
},
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var initObjs []runtime.Object
for _, vgsClass := range tt.existingVGSClass {
vgsClassCopy := vgsClass
initObjs = append(initObjs, &vgsClassCopy)
}
client := velerotest.NewFakeControllerRuntimeClient(t, initObjs...)
logger := logrus.New()
require.NoError(t, volumegroupsnapshotv1beta1.AddToScheme(client.Scheme()))
action := &pvcBackupItemAction{crClient: client, log: logger}
result, err := action.determineVGSClass(t.Context(), testDriver, tt.backup, tt.pvc)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expectResult, result)
}
})
}
}
func TestCreateVolumeGroupSnapshot(t *testing.T) {
testNamespace := "test-ns"
testLabelKey := "velero.io/test-vgs-label"
testLabelValue := "group-1"
testVGSClass := "test-class"
testBackup := &velerov1api.Backup{
ObjectMeta: metav1.ObjectMeta{
Name: "test-backup",
UID: "test-uid",
},
}
testPVC := corev1api.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pvc",
Namespace: testNamespace,
Labels: map[string]string{
testLabelKey: testLabelValue,
},
},
}
crClient := velerotest.NewFakeControllerRuntimeClient(t)
log := logrus.New()
action := &pvcBackupItemAction{
log: log,
crClient: crClient,
}
vgs, err := action.createVolumeGroupSnapshot(t.Context(), testBackup, testPVC, testLabelKey, testLabelValue, testVGSClass)
require.NoError(t, err)
require.NotNil(t, vgs)
// Verify VGS fields
assert.Equal(t, testNamespace, vgs.Namespace)
assert.NotEmpty(t, vgs.GenerateName)
assert.Equal(t, testVGSClass, *vgs.Spec.VolumeGroupSnapshotClassName)
assert.NotNil(t, vgs.Spec.Source.Selector)
assert.Equal(t, testLabelValue, vgs.Spec.Source.Selector.MatchLabels[testLabelKey])
assert.Equal(t, testLabelValue, vgs.Labels[testLabelKey])
assert.Equal(t, label.GetValidName(testBackup.Name), vgs.Labels[velerov1api.BackupNameLabel])
assert.Equal(t, string(testBackup.UID), vgs.Labels[velerov1api.BackupUIDLabel])
// Check that it exists in fake client
retrieved := &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{}
err = crClient.Get(t.Context(), crclient.ObjectKey{Name: vgs.Name, Namespace: vgs.Namespace}, retrieved)
require.NoError(t, err)
}
func TestWaitForVGSAssociatedVS(t *testing.T) {
vgs := &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-vgs",
Namespace: "test-ns",
UID: types.UID("1234-5678-uuid"),
},
}
makeVS := func(name string, hasStatus bool, hasVGSName bool, owned bool, pvcName string) *snapshotv1api.VolumeSnapshot {
var refs []metav1.OwnerReference
if owned {
refs = []metav1.OwnerReference{
{
APIVersion: "groupsnapshot.storage.k8s.io/v1beta1",
Kind: "VolumeGroupSnapshot",
Name: vgs.Name,
UID: vgs.UID,
},
}
}
vs := &snapshotv1api.VolumeSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: vgs.Namespace,
OwnerReferences: refs,
},
Spec: snapshotv1api.VolumeSnapshotSpec{
Source: snapshotv1api.VolumeSnapshotSource{
PersistentVolumeClaimName: pointer.String(pvcName),
},
},
}
if hasStatus {
vs.Status = &snapshotv1api.VolumeSnapshotStatus{}
if hasVGSName {
vs.Status.VolumeGroupSnapshotName = pointer.String(vgs.Name)
}
}
return vs
}
makePVC := func(name string) corev1api.PersistentVolumeClaim {
return corev1api.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: vgs.Namespace,
},
}
}
tests := []struct {
name string
vsList []*snapshotv1api.VolumeSnapshot
groupedPVCs []corev1api.PersistentVolumeClaim
expectErr bool
expectVSMap int
}{
{
name: "all owned VS have VGS name",
vsList: []*snapshotv1api.VolumeSnapshot{
makeVS("vs1", true, true, true, "pvc1"),
makeVS("vs2", true, true, true, "pvc2"),
},
groupedPVCs: []corev1api.PersistentVolumeClaim{
makePVC("pvc1"),
makePVC("pvc2"),
},
expectErr: false,
expectVSMap: 2,
},
{
name: "one owned VS missing VGS name",
vsList: []*snapshotv1api.VolumeSnapshot{
makeVS("vs1", true, true, true, "pvc1"),
makeVS("vs2", true, false, true, "pvc2"),
},
groupedPVCs: []corev1api.PersistentVolumeClaim{
makePVC("pvc1"),
makePVC("pvc2"),
},
expectErr: true,
},
{
name: "owned VS has no status",
vsList: []*snapshotv1api.VolumeSnapshot{
makeVS("vs1", false, false, true, "pvc1"),
},
groupedPVCs: []corev1api.PersistentVolumeClaim{
makePVC("pvc1"),
},
expectErr: true,
},
{
name: "unrelated VS ignored",
vsList: []*snapshotv1api.VolumeSnapshot{
makeVS("vs1", true, true, false, "pvc1"),
},
groupedPVCs: []corev1api.PersistentVolumeClaim{
makePVC("pvc1"),
},
expectErr: true,
},
{
name: "no owned VS present",
vsList: []*snapshotv1api.VolumeSnapshot{
makeVS("vs1", true, true, false, "pvc1"),
},
groupedPVCs: []corev1api.PersistentVolumeClaim{
makePVC("pvc1"),
},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var objs []runtime.Object
objs = append(objs, vgs)
for _, vs := range tt.vsList {
objs = append(objs, vs)
}
for _, pvc := range tt.groupedPVCs {
objs = append(objs, &pvc)
}
client := velerotest.NewFakeControllerRuntimeClient(t, objs...)
action := &pvcBackupItemAction{
log: velerotest.NewLogger(),
crClient: client,
}
vsMap, err := action.waitForVGSAssociatedVS(t.Context(), tt.groupedPVCs, vgs, 2*time.Second)
if tt.expectErr {
if err == nil {
t.Errorf("expected error but got nil")
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(vsMap) != tt.expectVSMap {
t.Errorf("expected vsMap length %d, got %d", tt.expectVSMap, len(vsMap))
}
}
})
}
}
func TestUpdateVGSCreatedVS(t *testing.T) {
backup := &velerov1api.Backup{
ObjectMeta: metav1.ObjectMeta{
Name: "backup-1",
UID: "backup-uid-123",
},
}
vgs := &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-vgs",
Namespace: "ns",
UID: "vgs-uid-123",
},
}
makeVS := func(name string, withVGSOwner bool, vgsNamePtr *string, pvcName string) *snapshotv1api.VolumeSnapshot {
var refs []metav1.OwnerReference
if withVGSOwner {
refs = []metav1.OwnerReference{
{
APIVersion: "groupsnapshot.storage.k8s.io/v1beta1",
Kind: "VolumeGroupSnapshot",
Name: vgs.Name,
UID: vgs.UID,
},
}
}
return &snapshotv1api.VolumeSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: vgs.Namespace,
OwnerReferences: refs,
Finalizers: []string{
VolumeSnapshotFinalizerGroupProtection,
VolumeSnapshotFinalizerSourceProtection,
},
},
Status: &snapshotv1api.VolumeSnapshotStatus{
ReadyToUse: pointer.Bool(true),
VolumeGroupSnapshotName: vgsNamePtr,
},
Spec: snapshotv1api.VolumeSnapshotSpec{
Source: snapshotv1api.VolumeSnapshotSource{
PersistentVolumeClaimName: pointer.String(pvcName),
},
},
}
}
tests := []struct {
name string
vs *snapshotv1api.VolumeSnapshot
expectOwnerCleared bool
expectFinalizersCleared bool
expectLabelPatched bool
}{
{
name: "should update owned VS",
vs: makeVS("vs-owned", true, pointer.String(vgs.Name), "pvc-1"),
expectOwnerCleared: true,
expectFinalizersCleared: true,
expectLabelPatched: true,
},
{
name: "should skip VS not owned by VGS",
vs: makeVS("vs-unowned", false, nil, "pvc-1"),
expectOwnerCleared: false,
expectFinalizersCleared: false,
expectLabelPatched: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := velerotest.NewFakeControllerRuntimeClient(t, vgs, tt.vs)
action := &pvcBackupItemAction{
log: velerotest.NewLogger(),
crClient: client,
}
// Build vsMap using the PVC name from the VS
vsMap := map[string]*snapshotv1api.VolumeSnapshot{
*tt.vs.Spec.Source.PersistentVolumeClaimName: tt.vs,
}
err := action.updateVGSCreatedVS(t.Context(), vsMap, vgs, backup)
require.NoError(t, err)
// Fetch updated VS
updated := &snapshotv1api.VolumeSnapshot{}
err = client.Get(t.Context(), crclient.ObjectKey{Name: tt.vs.Name, Namespace: tt.vs.Namespace}, updated)
require.NoError(t, err)
if tt.expectOwnerCleared {
assert.Empty(t, updated.OwnerReferences, "expected ownerReferences to be cleared")
} else {
assert.Equal(t, tt.vs.OwnerReferences, updated.OwnerReferences, "expected ownerReferences to remain unchanged")
}
if tt.expectFinalizersCleared {
assert.Empty(t, updated.Finalizers, "expected finalizers to be cleared")
} else {
assert.Equal(t, tt.vs.Finalizers, updated.Finalizers, "expected finalizers to remain unchanged")
}
if tt.expectLabelPatched {
assert.Equal(t, "backup-1", updated.Labels[velerov1api.BackupNameLabel])
assert.Equal(t, "backup-uid-123", updated.Labels[velerov1api.BackupUIDLabel])
} else {
assert.Nil(t, updated.Labels, "expected no labels to be patched")
}
})
}
}
func TestPatchVGSCDeletionPolicy(t *testing.T) {
tests := []struct {
name string
initialPolicy snapshotv1api.DeletionPolicy
expectedPolicy snapshotv1api.DeletionPolicy
expectPatch bool
expectErr bool
}{
{
name: "patches Delete to Retain",
initialPolicy: snapshotv1api.VolumeSnapshotContentDelete,
expectedPolicy: snapshotv1api.VolumeSnapshotContentRetain,
expectPatch: true,
},
{
name: "no patch if already Retain",
initialPolicy: snapshotv1api.VolumeSnapshotContentRetain,
expectedPolicy: snapshotv1api.VolumeSnapshotContentRetain,
expectPatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
vgsc := &volumegroupsnapshotv1beta1.VolumeGroupSnapshotContent{
ObjectMeta: metav1.ObjectMeta{Name: "test-vgsc"},
Spec: volumegroupsnapshotv1beta1.VolumeGroupSnapshotContentSpec{
DeletionPolicy: tt.initialPolicy,
},
}
vgs := &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-vgs",
Namespace: "ns",
},
Status: &volumegroupsnapshotv1beta1.VolumeGroupSnapshotStatus{
BoundVolumeGroupSnapshotContentName: pointer.String("test-vgsc"),
},
}
client := velerotest.NewFakeControllerRuntimeClient(t, vgs, vgsc)
action := &pvcBackupItemAction{
log: velerotest.NewLogger(),
crClient: client,
}
err := action.patchVGSCDeletionPolicy(t.Context(), vgs)
if tt.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
updated := &volumegroupsnapshotv1beta1.VolumeGroupSnapshotContent{}
err = client.Get(t.Context(), crclient.ObjectKey{Name: "test-vgsc"}, updated)
require.NoError(t, err)
require.Equal(t, tt.expectedPolicy, updated.Spec.DeletionPolicy)
})
}
}
func TestDeleteVGSAndVGSC(t *testing.T) {
makeVGS := func(name, namespace string, boundVGSCName *string) *volumegroupsnapshotv1beta1.VolumeGroupSnapshot {
return &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Status: &volumegroupsnapshotv1beta1.VolumeGroupSnapshotStatus{
BoundVolumeGroupSnapshotContentName: boundVGSCName,
},
}
}
makeVGSC := func(name string) *volumegroupsnapshotv1beta1.VolumeGroupSnapshotContent {
return &volumegroupsnapshotv1beta1.VolumeGroupSnapshotContent{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
}
tests := []struct {
name string
vgs *volumegroupsnapshotv1beta1.VolumeGroupSnapshot
existingVGSC *volumegroupsnapshotv1beta1.VolumeGroupSnapshotContent
expectVGSCDelete bool
expectVGSDelete bool
}{
{
name: "deletes both VGSC and VGS",
vgs: makeVGS("test-vgs", "ns", pointer.String("test-vgsc")),
existingVGSC: makeVGSC("test-vgsc"),
expectVGSCDelete: true,
expectVGSDelete: true,
},
{
name: "VGSC not found, still deletes VGS",
vgs: makeVGS("test-vgs", "ns", pointer.String("missing-vgsc")),
existingVGSC: nil,
expectVGSCDelete: false,
expectVGSDelete: true,
},
{
name: "no BoundVGSCName set, only deletes VGS",
vgs: makeVGS("test-vgs", "ns", nil),
existingVGSC: nil,
expectVGSCDelete: false,
expectVGSDelete: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var objs []runtime.Object
objs = append(objs, tt.vgs)
if tt.existingVGSC != nil {
objs = append(objs, tt.existingVGSC)
}
client := velerotest.NewFakeControllerRuntimeClient(t, objs...)
action := &pvcBackupItemAction{
log: velerotest.NewLogger(),
crClient: client,
}
err := action.deleteVGSAndVGSC(t.Context(), tt.vgs)
require.NoError(t, err)
// Check VGSC is deleted
if tt.expectVGSCDelete {
got := &volumegroupsnapshotv1beta1.VolumeGroupSnapshotContent{}
err = client.Get(t.Context(), crclient.ObjectKey{Name: "test-vgsc"}, got)
assert.True(t, apierrors.IsNotFound(err), "expected VGSC to be deleted")
}
// Check VGS is deleted
gotVGS := &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{}
err = client.Get(t.Context(), crclient.ObjectKey{Name: "test-vgs", Namespace: "ns"}, gotVGS)
assert.True(t, apierrors.IsNotFound(err), "expected VGS to be deleted")
})
}
}
func TestFindExistingVSForBackup(t *testing.T) {
backupUID := types.UID("backup-uid-123")
backupName := "backup-1"
pvcName := "pvc-1"
namespace := "ns"
makeVS := func(name, pvc string, match bool) *snapshotv1api.VolumeSnapshot {
labels := map[string]string{}
if match {
labels[velerov1api.BackupNameLabel] = label.GetValidName(backupName)
labels[velerov1api.BackupUIDLabel] = string(backupUID)
}
return &snapshotv1api.VolumeSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Labels: labels,
},
Spec: snapshotv1api.VolumeSnapshotSpec{
Source: snapshotv1api.VolumeSnapshotSource{
PersistentVolumeClaimName: pointer.String(pvc),
},
},
}
}
tests := []struct {
name string
vsList []*snapshotv1api.VolumeSnapshot
expectName string
expectNil bool
}{
{
name: "should find matching VS",
vsList: []*snapshotv1api.VolumeSnapshot{
makeVS("vs-match", pvcName, true),
},
expectName: "vs-match",
expectNil: false,
},
{
name: "should skip VS with non-matching labels",
vsList: []*snapshotv1api.VolumeSnapshot{
makeVS("vs-nolabel", pvcName, false),
},
expectNil: true,
},
{
name: "should skip VS with different PVC name",
vsList: []*snapshotv1api.VolumeSnapshot{
makeVS("vs-other-pvc", "other-pvc", true),
},
expectNil: true,
},
{
name: "should return nil if VS list is empty",
vsList: []*snapshotv1api.VolumeSnapshot{},
expectNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var objs []runtime.Object
for _, vs := range tt.vsList {
objs = append(objs, vs)
}
client := velerotest.NewFakeControllerRuntimeClient(t, objs...)
action := &pvcBackupItemAction{
log: velerotest.NewLogger(),
crClient: client,
}
vs, err := action.findExistingVSForBackup(t.Context(), backupUID, backupName, pvcName, namespace)
require.NoError(t, err)
if tt.expectNil {
assert.Nil(t, vs)
} else {
require.NotNil(t, vs)
assert.Equal(t, tt.expectName, vs.Name)
}
})
}
}
func TestWaitForVGSCBinding(t *testing.T) {
makeVGS := func(name string, withStatus bool) *volumegroupsnapshotv1beta1.VolumeGroupSnapshot {
vgs := &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "ns",
},
}
if withStatus {
contentName := "vgsc-123"
vgs.Status = &volumegroupsnapshotv1beta1.VolumeGroupSnapshotStatus{
BoundVolumeGroupSnapshotContentName: &contentName,
}
}
return vgs
}
tests := []struct {
name string
vgs *volumegroupsnapshotv1beta1.VolumeGroupSnapshot
expectErr bool
}{
{
name: "status is already bound",
vgs: makeVGS("vgs1", true),
expectErr: false,
},
{
name: "status is nil",
vgs: makeVGS("vgs2", false),
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := velerotest.NewFakeControllerRuntimeClient(t, tt.vgs.DeepCopy())
action := &pvcBackupItemAction{
log: velerotest.NewLogger(),
crClient: client,
}
err := action.waitForVGSCBinding(t.Context(), tt.vgs, 1*time.Second)
if tt.expectErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.NotNil(t, tt.vgs.Status)
require.NotNil(t, tt.vgs.Status.BoundVolumeGroupSnapshotContentName)
require.Equal(t, "vgsc-123", *tt.vgs.Status.BoundVolumeGroupSnapshotContentName)
}
})
}
}
func TestGetVGSByLabels(t *testing.T) {
labelKey := "velero.io/backup-name"
labelVal := "backup-123"
testLabels := map[string]string{labelKey: labelVal}
makeVGS := func(name string, labels map[string]string) *volumegroupsnapshotv1beta1.VolumeGroupSnapshot {
return &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "test-ns",
Labels: labels,
},
}
}
tests := []struct {
name string
vgsObjects []runtime.Object
expectError string
expectName string
}{
{
name: "exactly one matching VGS",
vgsObjects: []runtime.Object{
makeVGS("vgs1", testLabels),
},
expectName: "vgs1",
},
{
name: "no matching VGS",
vgsObjects: []runtime.Object{},
expectError: "no VolumeGroupSnapshot found matching labels",
},
{
name: "multiple matching VGS",
vgsObjects: []runtime.Object{
makeVGS("vgs1", testLabels),
makeVGS("vgs2", testLabels),
},
expectError: "multiple VolumeGroupSnapshots found matching labels",
},
{
name: "client list error",
vgsObjects: []runtime.Object{},
expectError: "failed to list VolumeGroupSnapshots by labels",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var client crclient.Client
if tt.name == "client list error" {
// Inject a client that always errors on List
client = &failingClient{}
} else {
client = velerotest.NewFakeControllerRuntimeClient(t, tt.vgsObjects...)
}
action := &pvcBackupItemAction{
log: velerotest.NewLogger(),
crClient: client,
}
vgs, err := action.getVGSByLabels(t.Context(), "test-ns", testLabels)
if tt.expectError != "" {
if err == nil || !strings.Contains(err.Error(), tt.expectError) {
t.Errorf("expected error containing '%s', got: %v", tt.expectError, err)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if vgs == nil || vgs.Name != tt.expectName {
t.Errorf("expected VGS name %s, got %v", tt.expectName, vgs)
}
}
})
}
}
// failingClient is a dummy client that fails on List
type failingClient struct {
crclient.Client
}
func (f *failingClient) List(ctx context.Context, list crclient.ObjectList, opts ...crclient.ListOption) error {
return fmt.Errorf("simulated list error")
}
func TestHasOwnerReference(t *testing.T) {
vgs := &volumegroupsnapshotv1beta1.VolumeGroupSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "test-vgs",
Namespace: "test-ns",
UID: types.UID("1234-uid"),
},
}
tests := []struct {
name string
ownerRef metav1.OwnerReference
expect bool
}{
{
name: "match kind, apiversion, uid",
ownerRef: metav1.OwnerReference{
Kind: kuberesource.VGSKind,
APIVersion: volumegroupsnapshotv1beta1.GroupName + "/" + volumegroupsnapshotv1beta1.SchemeGroupVersion.Version,
UID: vgs.UID,
},
expect: true,
},
{
name: "mismatch kind",
ownerRef: metav1.OwnerReference{
Kind: "other-kind",
APIVersion: volumegroupsnapshotv1beta1.GroupName + "/" + volumegroupsnapshotv1beta1.SchemeGroupVersion.Version,
UID: vgs.UID,
},
expect: false,
},
{
name: "mismatch apiversion",
ownerRef: metav1.OwnerReference{
Kind: kuberesource.VGSKind,
APIVersion: "wrong.group/v1",
UID: vgs.UID,
},
expect: false,
},
{
name: "mismatch uid",
ownerRef: metav1.OwnerReference{
Kind: kuberesource.VGSKind,
APIVersion: volumegroupsnapshotv1beta1.GroupName + "/" + volumegroupsnapshotv1beta1.SchemeGroupVersion.Version,
UID: "wrong-uid",
},
expect: false,
},
{
name: "no owner references",
ownerRef: metav1.OwnerReference{},
expect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obj := &metav1.ObjectMeta{
Name: "dummy",
Namespace: "test-ns",
}
if tt.name != "no owner references" {
obj.OwnerReferences = []metav1.OwnerReference{tt.ownerRef}
}
found := hasOwnerReference(obj, vgs)
assert.Equal(t, tt.expect, found)
})
}
}
func TestPVCRequestSize(t *testing.T) {
logger := logrus.New()
tests := []struct {
name string
pvcInitial *corev1api.PersistentVolumeClaim // Use full PVC to allow for nil Requests
restoreSize string
expectedSize string
}{
{
name: "UpdateRequired: PVC request is lower than restore size",
pvcInitial: func() *corev1api.PersistentVolumeClaim {
pvc := builder.ForPersistentVolumeClaim("velero", "testPVC").Result()
pvc.Spec.Resources.Requests = corev1api.ResourceList{
corev1api.ResourceStorage: resource.MustParse("1Gi"),
}
return pvc
}(),
restoreSize: "2Gi",
expectedSize: "2Gi",
},
{
name: "NoUpdateRequired: PVC request is larger than restore size",
pvcInitial: func() *corev1api.PersistentVolumeClaim {
pvc := builder.ForPersistentVolumeClaim("velero", "testPVC").Result()
pvc.Spec.Resources.Requests = corev1api.ResourceList{
corev1api.ResourceStorage: resource.MustParse("3Gi"),
}
return pvc
}(),
restoreSize: "2Gi",
expectedSize: "3Gi",
},
{
name: "PVC has no initial storage request",
pvcInitial: func() *corev1api.PersistentVolumeClaim {
pvc := builder.ForPersistentVolumeClaim("velero", "testPVC").Result()
pvc.Spec.Resources.Requests = corev1api.ResourceList{} // Empty request list
return pvc
}(),
restoreSize: "2Gi",
expectedSize: "2Gi",
},
{
name: "PVC has no initial Resources.Requests map",
pvcInitial: func() *corev1api.PersistentVolumeClaim {
pvc := builder.ForPersistentVolumeClaim("velero", "testPVC").Result()
pvc.Spec.Resources.Requests = nil // This will trigger the line to be covered
return pvc
}(),
restoreSize: "2Gi",
expectedSize: "2Gi",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create a VolumeSnapshotContent with restore size
rsQty := resource.MustParse(tc.restoreSize)
vsc := &snapshotv1api.VolumeSnapshotContent{
ObjectMeta: metav1.ObjectMeta{
Name: "testVSC",
},
Status: &snapshotv1api.VolumeSnapshotContentStatus{
RestoreSize: pointer.Int64(rsQty.Value()),
},
}
// Call the function under test
pvc := tc.pvcInitial
setPVCRequestSizeToVSRestoreSize(pvc, vsc, logger)
// Verify that the PVC storage request is updated as expected.
updatedSize := pvc.Spec.Resources.Requests[corev1api.ResourceStorage]
expected := resource.MustParse(tc.expectedSize)
// Corrected line below:
require.Equal(t, 0, expected.Cmp(updatedSize), "Expected size %s, but got %s", expected.String(), updatedSize.String())
})
}
}