From 016868ecd34c8c4ed888f28c6ed78a268b733570 Mon Sep 17 00:00:00 2001 From: Mayank <33252549+mynktl@users.noreply.github.com> Date: Fri, 3 Apr 2020 20:31:34 +0530 Subject: [PATCH] add restore item action to update PVC selected-node annotation (#2377) * New RestoreItemAction for PVC with annotation `volume.kubernetes.io/selected-node` Signed-off-by: mayank --- changelogs/unreleased/2377-mynktl | 1 + pkg/builder/node_builder.go | 47 +++++ pkg/cmd/server/plugin/plugin.go | 16 ++ pkg/restore/change_pvc_node_selector.go | 155 +++++++++++++++++ pkg/restore/change_pvc_node_selector_test.go | 172 +++++++++++++++++++ site/docs/master/restore-reference.md | 30 +++- 6 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/2377-mynktl create mode 100644 pkg/builder/node_builder.go create mode 100644 pkg/restore/change_pvc_node_selector.go create mode 100644 pkg/restore/change_pvc_node_selector_test.go diff --git a/changelogs/unreleased/2377-mynktl b/changelogs/unreleased/2377-mynktl new file mode 100644 index 000000000..2b8f5f38c --- /dev/null +++ b/changelogs/unreleased/2377-mynktl @@ -0,0 +1 @@ +Adding new restoreItemAction for PVC to update the selected-node annotation diff --git a/pkg/builder/node_builder.go b/pkg/builder/node_builder.go new file mode 100644 index 000000000..52e2c1e2e --- /dev/null +++ b/pkg/builder/node_builder.go @@ -0,0 +1,47 @@ +/* +Copyright 2020 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 ( + corev1api "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NodeBuilder builds Node objects. +type NodeBuilder struct { + object *corev1api.Node +} + +// ForNode is the constructor for a NodeBuilder. +func ForNode(name string) *NodeBuilder { + return &NodeBuilder{ + object: &corev1api.Node{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1api.SchemeGroupVersion.String(), + Kind: "Node", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + }, + } +} + +// Result returns the built Node. +func (b *NodeBuilder) Result() *corev1api.Node { + return b.object +} diff --git a/pkg/cmd/server/plugin/plugin.go b/pkg/cmd/server/plugin/plugin.go index 697697dac..e6c3a2877 100644 --- a/pkg/cmd/server/plugin/plugin.go +++ b/pkg/cmd/server/plugin/plugin.go @@ -50,6 +50,7 @@ func NewCommand(f client.Factory) *cobra.Command { RegisterRestoreItemAction("velero.io/role-bindings", newRoleBindingItemAction). RegisterRestoreItemAction("velero.io/cluster-role-bindings", newClusterRoleBindingItemAction). RegisterRestoreItemAction("velero.io/crd-preserve-fields", newCRDV1PreserveUnknownFieldsItemAction). + RegisterRestoreItemAction("velero.io/change-pvc-node-selector", newChangePVCNodeSelectorItemAction(f)). Serve() }, } @@ -162,3 +163,18 @@ func newRoleBindingItemAction(logger logrus.FieldLogger) (interface{}, error) { func newClusterRoleBindingItemAction(logger logrus.FieldLogger) (interface{}, error) { return restore.NewClusterRoleBindingAction(logger), nil } + +func newChangePVCNodeSelectorItemAction(f client.Factory) veleroplugin.HandlerInitializer { + return func(logger logrus.FieldLogger) (interface{}, error) { + client, err := f.KubeClient() + if err != nil { + return nil, err + } + + return restore.NewChangePVCNodeSelectorAction( + logger, + client.CoreV1().ConfigMaps(f.Namespace()), + client.CoreV1().Nodes(), + ), nil + } +} diff --git a/pkg/restore/change_pvc_node_selector.go b/pkg/restore/change_pvc_node_selector.go new file mode 100644 index 000000000..cbc7f8936 --- /dev/null +++ b/pkg/restore/change_pvc_node_selector.go @@ -0,0 +1,155 @@ +/* +Copyright 2020 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" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + + "github.com/vmware-tanzu/velero/pkg/plugin/framework" + "github.com/vmware-tanzu/velero/pkg/plugin/velero" +) + +// ChangePVCNodeSelectorAction updates/reset PVC's node selector +// if a mapping is found in the plugin's config map. +type ChangePVCNodeSelectorAction struct { + logger logrus.FieldLogger + configMapClient corev1client.ConfigMapInterface + nodeClient corev1client.NodeInterface +} + +// NewChangePVCNodeSelectorAction is the constructor for ChangePVCNodeSelectorAction. +func NewChangePVCNodeSelectorAction( + logger logrus.FieldLogger, + configMapClient corev1client.ConfigMapInterface, + nodeClient corev1client.NodeInterface, +) *ChangePVCNodeSelectorAction { + return &ChangePVCNodeSelectorAction{ + logger: logger, + configMapClient: configMapClient, + nodeClient: nodeClient, + } +} + +// AppliesTo returns the resources that ChangePVCNodeSelectorAction should be run for +func (p *ChangePVCNodeSelectorAction) AppliesTo() (velero.ResourceSelector, error) { + return velero.ResourceSelector{ + IncludedResources: []string{"persistentvolumeclaims"}, + }, nil +} + +// Execute updates the pvc's selected-node annotation: +// a) if node mapping found in the config map for the plugin +// b) if node mentioned in annotation doesn't exist +func (p *ChangePVCNodeSelectorAction) Execute(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + p.logger.Info("Executing ChangePVCNodeSelectorAction") + defer p.logger.Info("Done executing ChangePVCNodeSelectorAction") + + typeAcc, err := meta.TypeAccessor(input.Item) + if err != nil { + return &velero.RestoreItemActionExecuteOutput{}, err + } + + metadata, err := meta.Accessor(input.Item) + if err != nil { + return &velero.RestoreItemActionExecuteOutput{}, err + } + + annotations := metadata.GetAnnotations() + if annotations == nil { + return velero.NewRestoreItemActionExecuteOutput(input.Item), nil + } + + log := p.logger.WithFields(map[string]interface{}{ + "kind": typeAcc.GetKind(), + "namespace": metadata.GetNamespace(), + "name": metadata.GetName(), + }) + + // let's check if PVC has annotation of the selected node + node, ok := annotations["volume.kubernetes.io/selected-node"] + if !ok { + log.Debug("PVC doesn't have node selector") + return velero.NewRestoreItemActionExecuteOutput(input.Item), nil + } + + // fetch node mapping from configMap + newNode, err := getNewNodeFromConfigMap(p.configMapClient, node) + if err != nil { + return nil, err + } + + if len(newNode) != 0 { + // set node selector + // We assume that node exist for node-mapping + annotations["volume.kubernetes.io/selected-node"] = newNode + metadata.SetAnnotations(annotations) + log.Infof("Updating selected-node to %s from %s", newNode, node) + return velero.NewRestoreItemActionExecuteOutput(input.Item), nil + } + + // configMap doesn't have node-mapping + // Let's check if node exists or not + exists, err := isNodeExist(p.nodeClient, node) + if err != nil { + return nil, errors.Wrapf(err, "error checking node %s existence", node) + } + + if !exists { + log.Infof("Clearing selected-node because node named %s does not exist", node) + delete(annotations, "volume.kubernetes.io/selected-node") + if len(annotations) == 0 { + metadata.SetAnnotations(nil) + } else { + metadata.SetAnnotations(annotations) + } + } + + return velero.NewRestoreItemActionExecuteOutput(input.Item), nil +} + +func getNewNodeFromConfigMap(client corev1client.ConfigMapInterface, node string) (string, error) { + // fetch node mapping from configMap + config, err := getPluginConfig(framework.PluginKindRestoreItemAction, "velero.io/change-pvc-node", client) + if err != nil { + return "", err + } + + if config == nil { + // there is no node mapping defined for change-pvc-node + // so we will return empty new node + return "", nil + } + + return config.Data[node], nil +} + +// isNodeExist check if node resource exist or not +func isNodeExist(nodeClient corev1client.NodeInterface, name string) (bool, error) { + _, err := nodeClient.Get(name, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/pkg/restore/change_pvc_node_selector_test.go b/pkg/restore/change_pvc_node_selector_test.go new file mode 100644 index 000000000..cb8d9e625 --- /dev/null +++ b/pkg/restore/change_pvc_node_selector_test.go @@ -0,0 +1,172 @@ +/* +Copyright 2020 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 ( + "fmt" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1api "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + + "github.com/vmware-tanzu/velero/pkg/builder" + "github.com/vmware-tanzu/velero/pkg/plugin/velero" +) + +// TestChangePVCNodeSelectorActionExecute runs the ChangePVCNodeSelectorAction's Execute +// method and validates that the item's PVC 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 TestChangePVCNodeSelectorActionExecute(t *testing.T) { + tests := []struct { + name string + pvc *corev1api.PersistentVolumeClaim + configMap *corev1api.ConfigMap + node *corev1api.Node + want *corev1api.PersistentVolumeClaim + wantErr error + }{ + { + name: "a valid mapping for a persistent volume claim is applied correctly", + pvc: builder.ForPersistentVolumeClaim("source-ns", "pvc-1"). + ObjectMeta( + builder.WithAnnotations("volume.kubernetes.io/selected-node", "source-node"), + ).Result(), + configMap: builder.ForConfigMap("velero", "change-pvc-node"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-pvc-node", "RestoreItemAction")). + Data("source-node", "dest-node"). + Result(), + want: builder.ForPersistentVolumeClaim("source-ns", "pvc-1"). + ObjectMeta( + builder.WithAnnotations("volume.kubernetes.io/selected-node", "dest-node"), + ).Result(), + }, + { + name: "when no config map exists for the plugin and node doesn't exist, the item is returned without node selector", + pvc: builder.ForPersistentVolumeClaim("source-ns", "pvc-1"). + ObjectMeta( + builder.WithAnnotations("volume.kubernetes.io/selected-node", "source-node"), + ).Result(), + configMap: builder.ForConfigMap("velero", "change-pvc-node"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/some-other-plugin", "RestoreItemAction")). + Data("source-noed", "dest-node"). + Result(), + want: builder.ForPersistentVolumeClaim("source-ns", "pvc-1").Result(), + }, + { + name: "when no node-mappings exist in the plugin config map and selected-node doesn't exist, the item is returned without node selector", + pvc: builder.ForPersistentVolumeClaim("source-ns", "pvc-1"). + ObjectMeta( + builder.WithAnnotations("volume.kubernetes.io/selected-node", "source-node"), + ).Result(), + configMap: builder.ForConfigMap("velero", "change-pvc-node"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-pvc-node", "RestoreItemAction")). + Result(), + want: builder.ForPersistentVolumeClaim("source-ns", "pvc-1").Result(), + }, + { + name: "when no node-mappings exist in the plugin config map and selected-node exist, the item is returned as-is", + pvc: builder.ForPersistentVolumeClaim("source-ns", "pvc-1"). + ObjectMeta( + builder.WithAnnotations("volume.kubernetes.io/selected-node", "source-node"), + ).Result(), + configMap: builder.ForConfigMap("velero", "change-pvc-node"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-pvc-node", "RestoreItemAction")). + Result(), + // MAYANK TODO + node: builder.ForNode("source-node").Result(), + want: builder.ForPersistentVolumeClaim("source-ns", "pvc-1"). + ObjectMeta( + builder.WithAnnotations("volume.kubernetes.io/selected-node", "source-node"), + ).Result(), + }, + { + name: "when persistent volume claim has no node selector, the item is returned as-is", + pvc: builder.ForPersistentVolumeClaim("source-ns", "pvc-1").Result(), + configMap: builder.ForConfigMap("velero", "change-pvc-node"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-pvc-node", "RestoreItemAction")). + Data("source-node", "dest-node"). + Result(), + want: builder.ForPersistentVolumeClaim("source-ns", "pvc-1").Result(), + }, + { + name: "when persistent volume claim's node-selector has no mapping in the config map, the item is returned without node selector", + pvc: builder.ForPersistentVolumeClaim("source-ns", "pvc-1"). + ObjectMeta( + builder.WithAnnotations("volume.kubernetes.io/selected-node", "source-node"), + ).Result(), + configMap: builder.ForConfigMap("velero", "change-pvc-node"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-pvc-node", "RestoreItemAction")). + Data("source-node-1", "dest-node"). + Result(), + want: builder.ForPersistentVolumeClaim("source-ns", "pvc-1").Result(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clientset := fake.NewSimpleClientset() + a := NewChangePVCNodeSelectorAction( + logrus.StandardLogger(), + clientset.CoreV1().ConfigMaps("velero"), + clientset.CoreV1().Nodes(), + ) + + // set up test data + if tc.configMap != nil { + _, err := clientset.CoreV1().ConfigMaps(tc.configMap.Namespace).Create(tc.configMap) + require.NoError(t, err) + } + + if tc.node != nil { + _, err := clientset.CoreV1().Nodes().Create(tc.node) + require.NoError(t, err) + } + unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.pvc) + 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: + fmt.Printf("got +%v\n", res.UpdatedItem) + assert.NoError(t, err) + + wantUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.want) + fmt.Printf("exptected +%v\n", wantUnstructured) + require.NoError(t, err) + + assert.Equal(t, &unstructured.Unstructured{Object: wantUnstructured}, res.UpdatedItem) + } + }) + } +} diff --git a/site/docs/master/restore-reference.md b/site/docs/master/restore-reference.md index 43c40a287..9ea192074 100644 --- a/site/docs/master/restore-reference.md +++ b/site/docs/master/restore-reference.md @@ -50,8 +50,7 @@ metadata: # ConfigMap. labels: # this value-less label identifies the ConfigMap as - # config for a plugin (i.e. the built-in change storage - # class restore item action plugin) + # config for a plugin (i.e. the built-in restore item action plugin) velero.io/plugin-config: "" # this label identifies the name and kind of plugin # that this ConfigMap is for. @@ -62,3 +61,30 @@ data: # class name. : ``` + +## Changing PVC selected-node + +Velero can update the selected-node annotation of persistent volume claim during restores, if selected-node doesn't exist in the cluster then it will remove the selected-node annotation from PersistentVolumeClaim. To configure a node mapping, create a config map in the Velero namespace like the following: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + # any name can be used; Velero uses the labels (below) + # to identify it rather than the name + name: change-pvc-node-selector-config + # must be in the velero namespace + namespace: velero + # the below labels should be used verbatim in your + # ConfigMap. + labels: + # this value-less label identifies the ConfigMap as + # config for a plugin (i.e. the built-in restore item action plugin) + velero.io/plugin-config: "" + # this label identifies the name and kind of plugin + # that this ConfigMap is for. + velero.io/change-pvc-node-selector: RestoreItemAction +data: + # add 1+ key-value pairs here, where the key is the old + # node name and the value is the new node name. + :