diff --git a/changelogs/unreleased/4375-Box-Cube b/changelogs/unreleased/4375-Box-Cube new file mode 100644 index 0000000000..fd1b1b231e --- /dev/null +++ b/changelogs/unreleased/4375-Box-Cube @@ -0,0 +1 @@ +Fix statefulsets volumeClaimTemplates storageClassName when use Changing PV/PVC Storage Classes \ No newline at end of file diff --git a/pkg/builder/statefulset_builder.go b/pkg/builder/statefulset_builder.go new file mode 100644 index 0000000000..0edcd0455e --- /dev/null +++ b/pkg/builder/statefulset_builder.go @@ -0,0 +1,62 @@ +/* +Copyright 2021 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 builder + +import ( + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// StatefulSetBuilder builds StatefulSet objects. +type StatefulSetBuilder struct { + object *appsv1.StatefulSet +} + +// ForStatefulSet is the constructor for a StatefulSetBuilder. +func ForStatefulSet(ns, name string) *StatefulSetBuilder { + return &StatefulSetBuilder{ + object: &appsv1.StatefulSet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: appsv1.SchemeGroupVersion.String(), + Kind: "StatefulSet", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: name, + }, + Spec: appsv1.StatefulSetSpec{ + VolumeClaimTemplates: []corev1.PersistentVolumeClaim{}, + }, + }, + } +} + +// Result returns the built StatefulSet. +func (b *StatefulSetBuilder) Result() *appsv1.StatefulSet { + return b.object +} + +// StorageClass sets the StatefulSet's VolumeClaimTemplates storage class name. +func (b *StatefulSetBuilder) StorageClass(names ...string) *StatefulSetBuilder { + for _, name := range names { + nameTmp := name + b.object.Spec.VolumeClaimTemplates = append(b.object.Spec.VolumeClaimTemplates, + corev1.PersistentVolumeClaim{Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: &nameTmp}}) + } + return b +} diff --git a/pkg/builder/storage_class_builder.go b/pkg/builder/storage_class_builder.go index 9945433325..8ffe4afd11 100644 --- a/pkg/builder/storage_class_builder.go +++ b/pkg/builder/storage_class_builder.go @@ -23,7 +23,8 @@ import ( // StorageClassBuilder builds StorageClass objects. type StorageClassBuilder struct { - object *storagev1api.StorageClass + object *storagev1api.StorageClass + objectSlice []*storagev1api.StorageClass } // ForStorageClass is the constructor for a StorageClassBuilder. @@ -54,3 +55,29 @@ func (b *StorageClassBuilder) ObjectMeta(opts ...ObjectMetaOpt) *StorageClassBui return b } + +// ForStorageClassSlice is the constructor for a storageClassSlice in StorageClassBuilder. +func ForStorageClassSlice(names ...string) *StorageClassBuilder { + var storageClassSlice []*storagev1api.StorageClass + for _, name := range names { + storageClass := &storagev1api.StorageClass{ + TypeMeta: metav1.TypeMeta{ + APIVersion: storagev1api.SchemeGroupVersion.String(), + Kind: "StorageClass", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + storageClassSlice = append(storageClassSlice, storageClass) + } + + return &StorageClassBuilder{ + objectSlice: storageClassSlice, + } +} + +// SliceResult returns the built StorageClass slice. +func (b *StorageClassBuilder) SliceResult() []*storagev1api.StorageClass { + return b.objectSlice +} diff --git a/pkg/restore/change_storageclass_action.go b/pkg/restore/change_storageclass_action.go index effa83dbcb..5714a1a7f3 100644 --- a/pkg/restore/change_storageclass_action.go +++ b/pkg/restore/change_storageclass_action.go @@ -21,8 +21,11 @@ import ( "github.com/pkg/errors" "github.com/sirupsen/logrus" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" storagev1client "k8s.io/client-go/kubernetes/typed/storage/v1" @@ -55,7 +58,7 @@ func NewChangeStorageClassAction( // be run for. func (a *ChangeStorageClassAction) AppliesTo() (velero.ResourceSelector, error) { return velero.ResourceSelector{ - IncludedResources: []string{"persistentvolumeclaims", "persistentvolumes"}, + IncludedResources: []string{"persistentvolumeclaims", "persistentvolumes", "statefulsets"}, }, nil } @@ -87,33 +90,72 @@ func (a *ChangeStorageClassAction) Execute(input *velero.RestoreItemActionExecut "name": obj.GetName(), }) - // use the unstructured helpers here since this code is for both PVs and PVCs, and the - // field names are the same for both types. - storageClass, _, err := unstructured.NestedString(obj.UnstructuredContent(), "spec", "storageClassName") - if err != nil { - return nil, errors.Wrap(err, "error getting item's spec.storageClassName") + // change StatefulSet volumeClaimTemplates storageClassName + if obj.GetKind() == "StatefulSet" { + sts := new(appsv1.StatefulSet) + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), sts); err != nil { + return nil, err + } + + if len(sts.Spec.VolumeClaimTemplates) > 0 { + for index, pvc := range sts.Spec.VolumeClaimTemplates { + exists, newStorageClass, err := a.isStorageClassExist(log, *pvc.Spec.StorageClassName, config) + if err != nil { + return nil, err + } else if !exists { + continue + } + + log.Infof("Updating item's storage class name to %s", newStorageClass) + sts.Spec.VolumeClaimTemplates[index].Spec.StorageClassName = &newStorageClass + } + + newObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(sts) + if err != nil { + return nil, errors.Wrap(err, "convert obj to StatefulSet failed") + } + obj.Object = newObj + } + } else { + // use the unstructured helpers here since this code is for both PVs and PVCs, and the + // field names are the same for both types. + storageClass, _, err := unstructured.NestedString(obj.UnstructuredContent(), "spec", "storageClassName") + if err != nil { + return nil, errors.Wrap(err, "error getting item's spec.storageClassName") + } + + exists, newStorageClass, err := a.isStorageClassExist(log, storageClass, config) + if err != nil { + return nil, err + } else if !exists { + return velero.NewRestoreItemActionExecuteOutput(input.Item), nil + } + + log.Infof("Updating item's storage class name to %s", newStorageClass) + + if err := unstructured.SetNestedField(obj.UnstructuredContent(), newStorageClass, "spec", "storageClassName"); err != nil { + return nil, errors.Wrap(err, "unable to set item's spec.storageClassName") + } } + return velero.NewRestoreItemActionExecuteOutput(obj), nil +} + +func (a *ChangeStorageClassAction) isStorageClassExist(log *logrus.Entry, storageClass string, cm *corev1.ConfigMap) (exists bool, newStorageClass string, err error) { if storageClass == "" { log.Debug("Item has no storage class specified") - return velero.NewRestoreItemActionExecuteOutput(input.Item), nil + return false, "", nil } - newStorageClass, ok := config.Data[storageClass] + newStorageClass, ok := cm.Data[storageClass] if !ok { log.Debugf("No mapping found for storage class %s", storageClass) - return velero.NewRestoreItemActionExecuteOutput(input.Item), nil + return false, "", nil } // validate that new storage class exists if _, err := a.storageClassClient.Get(context.TODO(), newStorageClass, metav1.GetOptions{}); err != nil { - return nil, errors.Wrapf(err, "error getting storage class %s from API", newStorageClass) + return false, "", errors.Wrapf(err, "error getting storage class %s from API", newStorageClass) } - log.Infof("Updating item's storage class name to %s", newStorageClass) - - if err := unstructured.SetNestedField(obj.UnstructuredContent(), newStorageClass, "spec", "storageClassName"); err != nil { - return nil, errors.Wrap(err, "unable to set item's spec.storageClassName") - } - - return velero.NewRestoreItemActionExecuteOutput(obj), nil + return true, newStorageClass, nil } diff --git a/pkg/restore/change_storageclass_action_test.go b/pkg/restore/change_storageclass_action_test.go index 9e6005851d..65de052db2 100644 --- a/pkg/restore/change_storageclass_action_test.go +++ b/pkg/restore/change_storageclass_action_test.go @@ -41,16 +41,17 @@ import ( // desired result. func TestChangeStorageClassActionExecute(t *testing.T) { tests := []struct { - name string - pvOrPVC interface{} - configMap *corev1api.ConfigMap - storageClass *storagev1api.StorageClass - want interface{} - wantErr error + name string + pvOrPvcOrSTS interface{} + configMap *corev1api.ConfigMap + storageClass *storagev1api.StorageClass + storageClassSlice []*storagev1api.StorageClass + want interface{} + wantErr error }{ { - name: "a valid mapping for a persistent volume is applied correctly", - pvOrPVC: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), + name: "a valid mapping for a persistent volume is applied correctly", + pvOrPvcOrSTS: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Data("storageclass-1", "storageclass-2"). @@ -59,8 +60,8 @@ func TestChangeStorageClassActionExecute(t *testing.T) { want: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-2").Result(), }, { - name: "a valid mapping for a persistent volume claim is applied correctly", - pvOrPVC: builder.ForPersistentVolumeClaim("velero", "pvc-1").StorageClass("storageclass-1").Result(), + name: "a valid mapping for a persistent volume claim is applied correctly", + pvOrPvcOrSTS: builder.ForPersistentVolumeClaim("velero", "pvc-1").StorageClass("storageclass-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Data("storageclass-1", "storageclass-2"). @@ -69,8 +70,8 @@ func TestChangeStorageClassActionExecute(t *testing.T) { want: builder.ForPersistentVolumeClaim("velero", "pvc-1").StorageClass("storageclass-2").Result(), }, { - name: "when no config map exists for the plugin, the item is returned as-is", - pvOrPVC: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), + name: "when no config map exists for the plugin, the item is returned as-is", + pvOrPvcOrSTS: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/some-other-plugin", "RestoreItemAction")). Data("storageclass-1", "storageclass-2"). @@ -78,16 +79,16 @@ func TestChangeStorageClassActionExecute(t *testing.T) { want: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), }, { - name: "when no storage class mappings exist in the plugin config map, the item is returned as-is", - pvOrPVC: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), + name: "when no storage class mappings exist in the plugin config map, the item is returned as-is", + pvOrPvcOrSTS: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Result(), want: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), }, { - name: "when persistent volume has no storage class, the item is returned as-is", - pvOrPVC: builder.ForPersistentVolume("pv-1").Result(), + name: "when persistent volume has no storage class, the item is returned as-is", + pvOrPvcOrSTS: builder.ForPersistentVolume("pv-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Data("storageclass-1", "storageclass-2"). @@ -95,8 +96,8 @@ func TestChangeStorageClassActionExecute(t *testing.T) { want: builder.ForPersistentVolume("pv-1").Result(), }, { - name: "when persistent volume claim has no storage class, the item is returned as-is", - pvOrPVC: builder.ForPersistentVolumeClaim("velero", "pvc-1").Result(), + name: "when persistent volume claim has no storage class, the item is returned as-is", + pvOrPvcOrSTS: builder.ForPersistentVolumeClaim("velero", "pvc-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Data("storageclass-1", "storageclass-2"). @@ -104,8 +105,8 @@ func TestChangeStorageClassActionExecute(t *testing.T) { want: builder.ForPersistentVolumeClaim("velero", "pvc-1").Result(), }, { - name: "when persistent volume's storage class has no mapping in the config map, the item is returned as-is", - pvOrPVC: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), + name: "when persistent volume's storage class has no mapping in the config map, the item is returned as-is", + pvOrPvcOrSTS: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Data("storageclass-3", "storageclass-4"). @@ -113,8 +114,8 @@ func TestChangeStorageClassActionExecute(t *testing.T) { want: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), }, { - name: "when persistent volume claim's storage class has no mapping in the config map, the item is returned as-is", - pvOrPVC: builder.ForPersistentVolumeClaim("velero", "pvc-1").StorageClass("storageclass-1").Result(), + name: "when persistent volume claim's storage class has no mapping in the config map, the item is returned as-is", + pvOrPvcOrSTS: builder.ForPersistentVolumeClaim("velero", "pvc-1").StorageClass("storageclass-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Data("storageclass-3", "storageclass-4"). @@ -122,8 +123,8 @@ func TestChangeStorageClassActionExecute(t *testing.T) { want: builder.ForPersistentVolumeClaim("velero", "pvc-1").StorageClass("storageclass-1").Result(), }, { - name: "when persistent volume's storage class is mapped to a nonexistent storage class, an error is returned", - pvOrPVC: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), + name: "when persistent volume's storage class is mapped to a nonexistent storage class, an error is returned", + pvOrPvcOrSTS: builder.ForPersistentVolume("pv-1").StorageClass("storageclass-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Data("storageclass-1", "nonexistent-storage-class"). @@ -131,8 +132,81 @@ func TestChangeStorageClassActionExecute(t *testing.T) { wantErr: errors.New("error getting storage class nonexistent-storage-class from API: storageclasses.storage.k8s.io \"nonexistent-storage-class\" not found"), }, { - name: "when persistent volume claim's storage class is mapped to a nonexistent storage class, an error is returned", - pvOrPVC: builder.ForPersistentVolumeClaim("velero", "pvc-1").StorageClass("storageclass-1").Result(), + name: "when persistent volume claim's storage class is mapped to a nonexistent storage class, an error is returned", + pvOrPvcOrSTS: builder.ForPersistentVolumeClaim("velero", "pvc-1").StorageClass("storageclass-1").Result(), + configMap: builder.ForConfigMap("velero", "change-storage-classs"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). + Data("storageclass-1", "nonexistent-storage-class"). + Result(), + wantErr: errors.New("error getting storage class nonexistent-storage-class from API: storageclasses.storage.k8s.io \"nonexistent-storage-class\" not found"), + }, + { + name: "when statefulset's VolumeClaimTemplates has only one pvc, a valid mapping for a statefulset is applied correctly", + pvOrPvcOrSTS: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1").Result(), + configMap: builder.ForConfigMap("velero", "change-storage-classs"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). + Data("storageclass-1", "storageclass-2"). + Result(), + storageClass: builder.ForStorageClass("storageclass-2").Result(), + want: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-2").Result(), + }, + { + name: "when statefulset's VolumeClaimTemplates has more than one same pvc's storageClassName, a valid mapping for a statefulset is applied correctly", + pvOrPvcOrSTS: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1", "storageclass-1").Result(), + configMap: builder.ForConfigMap("velero", "change-storage-classs"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). + Data("storageclass-1", "storageclass-2", "storageclass-3", "storageclass-4"). + Result(), + storageClass: builder.ForStorageClass("storageclass-2").Result(), + want: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-2", "storageclass-2").Result(), + }, + { + name: "when statefulset's VolumeClaimTemplates has more than one different pvc's storageClassName, a valid mapping for a statefulset is applied correctly", + pvOrPvcOrSTS: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1", "storageclass-2", "storageclass-3").Result(), + configMap: builder.ForConfigMap("velero", "change-storage-classs"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). + Data("storageclass-1", "storageclass-a", "storageclass-2", "storageclass-b", "storageclass-3", "storageclass-c"). + Result(), + storageClassSlice: builder.ForStorageClassSlice("storageclass-a", "storageclass-b", "storageclass-c").SliceResult(), + want: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-a", "storageclass-b", "storageclass-c").Result(), + }, + { + name: "when no config map exists for the plugin, the statefulset item is returned as-is", + pvOrPvcOrSTS: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1").Result(), + configMap: builder.ForConfigMap("velero", "change-storage-classs"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/some-other-plugin", "RestoreItemAction")). + Data("storageclass-1", "storageclass-2"). + Result(), + want: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1").Result(), + }, + { + name: "when no storage class mappings exist in the plugin config map, the statefulset item is returned as-is", + pvOrPvcOrSTS: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1").Result(), + configMap: builder.ForConfigMap("velero", "change-storage-classs"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). + Result(), + want: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1").Result(), + }, + { + name: "when persistent volume claim has no storage class, the statefulset item is returned as-is", + pvOrPvcOrSTS: builder.ForStatefulSet("velero", "sts-1").Result(), + configMap: builder.ForConfigMap("velero", "change-storage-classs"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). + Result(), + want: builder.ForStatefulSet("velero", "sts-1").Result(), + }, + { + name: "when statefulset's storage class has no mapping in the config map, the item is returned as-is", + pvOrPvcOrSTS: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1").Result(), + configMap: builder.ForConfigMap("velero", "change-storage-classs"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). + Data("storageclass-3", "storageclass-4"). + Result(), + want: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1").Result(), + }, + { + name: "when statefulset's storage class is mapped to a nonexistent storage class, an error is returned", + pvOrPvcOrSTS: builder.ForStatefulSet("velero", "sts-1").StorageClass("storageclass-1").Result(), configMap: builder.ForConfigMap("velero", "change-storage-classs"). ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction")). Data("storageclass-1", "nonexistent-storage-class"). @@ -161,7 +235,14 @@ func TestChangeStorageClassActionExecute(t *testing.T) { require.NoError(t, err) } - unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.pvOrPVC) + if tc.storageClassSlice != nil { + for _, storageClass := range tc.storageClassSlice { + _, err := clientset.StorageV1().StorageClasses().Create(context.TODO(), storageClass, metav1.CreateOptions{}) + require.NoError(t, err) + } + } + + unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.pvOrPvcOrSTS) require.NoError(t, err) input := &velero.RestoreItemActionExecuteInput{