add restore item action to change PVC/PV storage class name (#1621)

* add restore item action to change PVC/PV storage class name

Signed-off-by: Steve Kriss <krisss@vmware.com>

* code review

Signed-off-by: Steve Kriss <krisss@vmware.com>

* change existing plugin names to lowercase/hyphenated

Signed-off-by: Steve Kriss <krisss@vmware.com>

* add validation for new storage class name

Signed-off-by: Steve Kriss <krisss@vmware.com>

* add test cases

Signed-off-by: Steve Kriss <krisss@vmware.com>

* changelog

Signed-off-by: Steve Kriss <krisss@vmware.com>

* fix imports

Signed-off-by: Steve Kriss <krisss@vmware.com>

* update plugin names to be more consistent

Signed-off-by: Steve Kriss <krisss@vmware.com>

* update unit tests to use pkg/test object constructors

Signed-off-by: Steve Kriss <krisss@vmware.com>
This commit is contained in:
Steve Kriss
2019-07-15 12:19:38 -06:00
committed by Adnan Abdulhussein
parent 63964fc6f9
commit 1bb167ef90
5 changed files with 399 additions and 4 deletions

View File

@@ -0,0 +1 @@
add plugin for updating PV & PVC storage classes on restore based on a config map

View File

@@ -46,14 +46,15 @@ func NewCommand(f client.Factory) *cobra.Command {
RegisterVolumeSnapshotter("velero.io/gcp", newGcpVolumeSnapshotter).
RegisterBackupItemAction("velero.io/pv", newPVBackupItemAction).
RegisterBackupItemAction("velero.io/pod", newPodBackupItemAction).
RegisterBackupItemAction("velero.io/serviceaccount", newServiceAccountBackupItemAction(f)).
RegisterBackupItemAction("velero.io/service-account", newServiceAccountBackupItemAction(f)).
RegisterRestoreItemAction("velero.io/job", newJobRestoreItemAction).
RegisterRestoreItemAction("velero.io/pod", newPodRestoreItemAction).
RegisterRestoreItemAction("velero.io/restic", newResticRestoreItemAction(f)).
RegisterRestoreItemAction("velero.io/service", newServiceRestoreItemAction).
RegisterRestoreItemAction("velero.io/serviceaccount", newServiceAccountRestoreItemAction).
RegisterRestoreItemAction("velero.io/addPVCFromPod", newAddPVCFromPodRestoreItemAction).
RegisterRestoreItemAction("velero.io/addPVFromPVC", newAddPVFromPVCRestoreItemAction).
RegisterRestoreItemAction("velero.io/service-account", newServiceAccountRestoreItemAction).
RegisterRestoreItemAction("velero.io/add-pvc-from-pod", newAddPVCFromPodRestoreItemAction).
RegisterRestoreItemAction("velero.io/add-pv-from-pvc", newAddPVFromPVCRestoreItemAction).
RegisterRestoreItemAction("velero.io/change-storage-class", newChangeStorageClassRestoreItemAction(f)).
Serve()
},
}
@@ -154,3 +155,18 @@ func newAddPVCFromPodRestoreItemAction(logger logrus.FieldLogger) (interface{},
func newAddPVFromPVCRestoreItemAction(logger logrus.FieldLogger) (interface{}, error) {
return restore.NewAddPVFromPVCAction(logger), nil
}
func newChangeStorageClassRestoreItemAction(f client.Factory) veleroplugin.HandlerInitializer {
return func(logger logrus.FieldLogger) (interface{}, error) {
client, err := f.KubeClient()
if err != nil {
return nil, err
}
return restore.NewChangeStorageClassAction(
logger,
client.CoreV1().ConfigMaps(f.Namespace()),
client.StorageV1().StorageClasses(),
), nil
}
}

View File

@@ -0,0 +1,117 @@
/*
Copyright 2019 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 restore
import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
storagev1client "k8s.io/client-go/kubernetes/typed/storage/v1"
"github.com/heptio/velero/pkg/plugin/framework"
"github.com/heptio/velero/pkg/plugin/velero"
)
// ChangeStorageClassAction updates a PV or PVC's storage class name
// if a mapping is found in the plugin's config map.
type ChangeStorageClassAction struct {
logger logrus.FieldLogger
configMapClient corev1client.ConfigMapInterface
storageClassClient storagev1client.StorageClassInterface
}
// NewChangeStorageClassAction is the constructor for ChangeStorageClassAction.
func NewChangeStorageClassAction(
logger logrus.FieldLogger,
configMapClient corev1client.ConfigMapInterface,
storageClassClient storagev1client.StorageClassInterface,
) *ChangeStorageClassAction {
return &ChangeStorageClassAction{
logger: logger,
configMapClient: configMapClient,
storageClassClient: storageClassClient,
}
}
// AppliesTo returns the resources that ChangeStorageClassAction should
// be run for.
func (a *ChangeStorageClassAction) AppliesTo() (velero.ResourceSelector, error) {
return velero.ResourceSelector{
IncludedResources: []string{"persistentvolumeclaims", "persistentvolumes"},
}, nil
}
// Execute updates the item's spec.storageClassName if a mapping is found
// in the config map for the plugin.
func (a *ChangeStorageClassAction) Execute(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
a.logger.Info("Executing ChangeStorageClassAction")
defer a.logger.Info("Done executing ChangeStorageClassAction")
a.logger.Debug("Getting plugin config")
config, err := getPluginConfig(framework.PluginKindRestoreItemAction, "velero.io/change-storage-class", a.configMapClient)
if err != nil {
return nil, err
}
if config == nil || len(config.Data) == 0 {
a.logger.Debug("No storage class mappings found")
return velero.NewRestoreItemActionExecuteOutput(input.Item), nil
}
obj, ok := input.Item.(*unstructured.Unstructured)
if !ok {
return nil, errors.Errorf("object was of unexpected type %T", input.Item)
}
log := a.logger.WithFields(map[string]interface{}{
"kind": obj.GetKind(),
"namespace": obj.GetNamespace(),
"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")
}
if storageClass == "" {
log.Debug("Item has no storage class specified")
return velero.NewRestoreItemActionExecuteOutput(input.Item), nil
}
newStorageClass, ok := config.Data[storageClass]
if !ok {
log.Debugf("No mapping found for storage class %s", storageClass)
return velero.NewRestoreItemActionExecuteOutput(input.Item), nil
}
// validate that new storage class exists
if _, err := a.storageClassClient.Get(newStorageClass, metav1.GetOptions{}); err != nil {
return nil, 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
}

View File

@@ -0,0 +1,188 @@
/*
Copyright 2019 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 restore
import (
"testing"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1api "k8s.io/api/core/v1"
storagev1api "k8s.io/api/storage/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
"github.com/heptio/velero/pkg/plugin/velero"
"github.com/heptio/velero/pkg/test"
)
// TestChangeStorageClassActionExecute runs the ChangeStorageClassAction's Execute
// method and validates that the item's storage class is modified (or not) as expected.
// Validation is done by comparing the result of the Execute method to the test case's
// desired result.
func TestChangeStorageClassActionExecute(t *testing.T) {
tests := []struct {
name string
pvOrPVC interface{}
configMap *corev1api.ConfigMap
storageClass *storagev1api.StorageClass
want interface{}
wantErr error
}{
{
name: "a valid mapping for a persistent volume is applied correctly",
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
test.WithConfigMapData("storageclass-1", "storageclass-2"),
),
storageClass: test.NewStorageClass("storageclass-2"),
want: test.NewPV("pv-1", test.WithStorageClassName("storageclass-2")),
},
{
name: "a valid mapping for a persistent volume claim is applied correctly",
pvOrPVC: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-1")),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
test.WithConfigMapData("storageclass-1", "storageclass-2"),
),
storageClass: test.NewStorageClass("storageclass-2"),
want: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-2")),
},
{
name: "when no config map exists for the plugin, the item is returned as-is",
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/some-other-plugin", "RestoreItemAction"),
test.WithConfigMapData("storageclass-1", "storageclass-2"),
),
want: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
},
{
name: "when no storage class mappings exist in the plugin config map, the item is returned as-is",
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
),
want: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
},
{
name: "when persistent volume has no storage class, the item is returned as-is",
pvOrPVC: test.NewPV("pv-1"),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
test.WithConfigMapData("storageclass-1", "storageclass-2"),
),
want: test.NewPV("pv-1"),
},
{
name: "when persistent volume claim has no storage class, the item is returned as-is",
pvOrPVC: test.NewPVC("velero", "pvc-1"),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
test.WithConfigMapData("storageclass-1", "storageclass-2"),
),
want: test.NewPVC("velero", "pvc-1"),
},
{
name: "when persistent volume's storage class has no mapping in the config map, the item is returned as-is",
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
test.WithConfigMapData("storageclass-3", "storageclass-4"),
),
want: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
},
{
name: "when persistent volume claim's storage class has no mapping in the config map, the item is returned as-is",
pvOrPVC: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-1")),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
test.WithConfigMapData("storageclass-3", "storageclass-4"),
),
want: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-1")),
},
{
name: "when persistent volume's storage class is mapped to a nonexistent storage class, an error is returned",
pvOrPVC: test.NewPV("pv-1", test.WithStorageClassName("storageclass-1")),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
test.WithConfigMapData("storageclass-1", "nonexistent-storage-class"),
),
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: test.NewPVC("velero", "pvc-1", test.WithStorageClassName("storageclass-1")),
configMap: test.NewConfigMap("velero", "change-storage-class",
test.WithLabels("velero.io/plugin-config", "true", "velero.io/change-storage-class", "RestoreItemAction"),
test.WithConfigMapData("storageclass-1", "nonexistent-storage-class"),
),
wantErr: errors.New("error getting storage class nonexistent-storage-class from API: storageclasses.storage.k8s.io \"nonexistent-storage-class\" not found"),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
clientset := fake.NewSimpleClientset()
a := NewChangeStorageClassAction(
logrus.StandardLogger(),
clientset.CoreV1().ConfigMaps("velero"),
clientset.StorageV1().StorageClasses(),
)
// set up test data
if tc.configMap != nil {
_, err := clientset.CoreV1().ConfigMaps(tc.configMap.Namespace).Create(tc.configMap)
require.NoError(t, err)
}
if tc.storageClass != nil {
_, err := clientset.StorageV1().StorageClasses().Create(tc.storageClass)
require.NoError(t, err)
}
unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.pvOrPVC)
require.NoError(t, err)
input := &velero.RestoreItemActionExecuteInput{
Item: &unstructured.Unstructured{
Object: unstructuredMap,
},
}
// execute method under test
res, err := a.Execute(input)
// validate for both error and non-error cases
switch {
case tc.wantErr != nil:
assert.EqualError(t, err, tc.wantErr.Error())
default:
assert.NoError(t, err)
wantUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.want)
require.NoError(t, err)
assert.Equal(t, &unstructured.Unstructured{Object: wantUnstructured}, res.UpdatedItem)
}
})
}
}

View File

@@ -21,6 +21,7 @@ import (
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
@@ -249,6 +250,38 @@ func NewNamespace(name string, opts ...ObjectOpts) *corev1.Namespace {
return obj
}
func NewConfigMap(ns, name string, opts ...ObjectOpts) *corev1.ConfigMap {
obj := &corev1.ConfigMap{
TypeMeta: metav1.TypeMeta{
Kind: "ConfigMap",
APIVersion: "v1",
},
ObjectMeta: objectMeta(ns, name),
}
for _, opt := range opts {
opt(obj)
}
return obj
}
func NewStorageClass(name string, opts ...ObjectOpts) *storagev1.StorageClass {
obj := &storagev1.StorageClass{
TypeMeta: metav1.TypeMeta{
Kind: "StorageClass",
APIVersion: "storage/v1",
},
ObjectMeta: objectMeta("", name),
}
for _, opt := range opts {
opt(obj)
}
return obj
}
// VolumeOpts exists because corev1.Volume does not implement metav1.Object
type VolumeOpts func(*corev1.Volume)
@@ -418,6 +451,23 @@ func WithPVName(name string) func(obj metav1.Object) {
}
}
// WithStorageClassName is a functional option for persistent volumes or
// persistent volume claims that sets the specified storage class name.
// It panics if the object is not a persistent volume or persistent volume
// claim.
func WithStorageClassName(name string) func(obj metav1.Object) {
return func(obj metav1.Object) {
switch obj.(type) {
case *corev1.PersistentVolume:
obj.(*corev1.PersistentVolume).Spec.StorageClassName = name
case *corev1.PersistentVolumeClaim:
obj.(*corev1.PersistentVolumeClaim).Spec.StorageClassName = &name
default:
panic("WithStorageClassName is only valid for persistent volumes and persistent volume claims")
}
}
}
// WithVolume is a functional option for pods that sets the specified
// volume on the pod's Spec. It panics if the object is not a pod.
func WithVolume(volume *corev1.Volume) func(obj metav1.Object) {
@@ -445,3 +495,26 @@ func WithCSISource(driverName string) func(vol *corev1.Volume) {
vol.VolumeSource.CSI = &corev1.CSIVolumeSource{Driver: driverName}
}
}
// WithConfigMapData is a functional option for config maps that puts the specified
// values in the Data field. It panics if the object is not a config map.
func WithConfigMapData(vals ...string) func(obj metav1.Object) {
return func(obj metav1.Object) {
cm, ok := obj.(*corev1.ConfigMap)
if !ok {
panic("WithConfigMapData is only valid for config maps")
}
if cm.Data == nil {
cm.Data = make(map[string]string)
}
if len(vals)%2 != 0 {
vals = append(vals, "")
}
for i := 0; i < len(vals); i += 2 {
cm.Data[vals[i]] = vals[i+1]
}
}
}