diff --git a/changelogs/unreleased/5540-wenterjoy b/changelogs/unreleased/5540-wenterjoy new file mode 100644 index 000000000..f8551f934 --- /dev/null +++ b/changelogs/unreleased/5540-wenterjoy @@ -0,0 +1,4 @@ +add new RestoreItemAction of "velero.io/change-image-name" to handle the issue mentioned at #5519 + + + diff --git a/pkg/cmd/server/plugin/plugin.go b/pkg/cmd/server/plugin/plugin.go index bb2c1c600..010db9c5f 100644 --- a/pkg/cmd/server/plugin/plugin.go +++ b/pkg/cmd/server/plugin/plugin.go @@ -52,6 +52,7 @@ func NewCommand(f client.Factory) *cobra.Command { RegisterRestoreItemAction("velero.io/add-pvc-from-pod", newAddPVCFromPodRestoreItemAction). RegisterRestoreItemAction("velero.io/add-pv-from-pvc", newAddPVFromPVCRestoreItemAction). RegisterRestoreItemAction("velero.io/change-storage-class", newChangeStorageClassRestoreItemAction(f)). + RegisterRestoreItemAction("velero.io/change-image-name", newChangeImageNameRestoreItemAction(f)). RegisterRestoreItemAction("velero.io/role-bindings", newRoleBindingItemAction). RegisterRestoreItemAction("velero.io/cluster-role-bindings", newClusterRoleBindingItemAction). RegisterRestoreItemAction("velero.io/crd-preserve-fields", newCRDV1PreserveUnknownFieldsItemAction). @@ -190,6 +191,19 @@ func newChangeStorageClassRestoreItemAction(f client.Factory) plugincommon.Handl } } +func newChangeImageNameRestoreItemAction(f client.Factory) plugincommon.HandlerInitializer { + return func(logger logrus.FieldLogger) (interface{}, error) { + client, err := f.KubeClient() + if err != nil { + return nil, err + } + + return restore.NewChangeImageNameAction( + logger, + client.CoreV1().ConfigMaps(f.Namespace()), + ), nil + } +} func newRoleBindingItemAction(logger logrus.FieldLogger) (interface{}, error) { return restore.NewRoleBindingAction(logger), nil } diff --git a/pkg/restore/change_image_name_action.go b/pkg/restore/change_image_name_action.go new file mode 100644 index 000000000..755cedded --- /dev/null +++ b/pkg/restore/change_image_name_action.go @@ -0,0 +1,213 @@ +/* +Copyright 2022 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 ( + "context" + "fmt" + "strings" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + 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" + + "github.com/vmware-tanzu/velero/pkg/plugin/framework/common" + "github.com/vmware-tanzu/velero/pkg/plugin/velero" +) + +const ( + DELIMITER_VALUE = "," +) + +// ChangeImageNameAction updates a deployment or Pod's image name +// if a mapping is found in the plugin's config map. +type ChangeImageNameAction struct { + logger logrus.FieldLogger + configMapClient corev1client.ConfigMapInterface +} + +// NewChangeImageNameAction is the constructor for ChangeImageNameAction. +func NewChangeImageNameAction( + logger logrus.FieldLogger, + configMapClient corev1client.ConfigMapInterface, +) *ChangeImageNameAction { + return &ChangeImageNameAction{ + logger: logger, + configMapClient: configMapClient, + } +} + +// AppliesTo returns the resources that ChangeImageNameAction should +// be run for. +func (a *ChangeImageNameAction) AppliesTo() (velero.ResourceSelector, error) { + return velero.ResourceSelector{ + IncludedResources: []string{"deployments", "statefulsets", "daemonsets", "replicasets", "replicationcontrollers", "jobs", "cronjobs", "pods"}, + }, nil +} + +// Execute updates the item's spec.containers' image if a mapping is found +// in the config map for the plugin. +func (a *ChangeImageNameAction) Execute(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) { + a.logger.Info("Executing ChangeImageNameAction") + defer a.logger.Info("Done executing ChangeImageNameAction") + + opts := metav1.ListOptions{ + LabelSelector: fmt.Sprintf("velero.io/plugin-config,%s=%s", "velero.io/change-image-name", common.PluginKindRestoreItemAction), + } + + list, err := a.configMapClient.List(context.TODO(), opts) + if err != nil { + return nil, errors.WithStack(err) + } + + if len(list.Items) == 0 { + return &velero.RestoreItemActionExecuteOutput{ + UpdatedItem: input.Item, + }, nil + } + + if len(list.Items) > 1 { + var items []string + for _, item := range list.Items { + items = append(items, item.Name) + } + return nil, errors.Errorf("found more than one ConfigMap matching label selector %q: %v", opts.LabelSelector, items) + } + + config := &list.Items[0] + if len(config.Data) == 0 { + a.logger.Info("No image name 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) + } + if obj.GetKind() == "Pod" { + + err = a.replaceImageName(obj, config, "spec", "containers") + if err != nil { + a.logger.Infof("replace image name meet error: %v", err) + return nil, errors.Wrap(err, "error getting item's spec.containers") + } + + err = a.replaceImageName(obj, config, "spec", "initContainers") + if err != nil { + a.logger.Infof("replace image name meet error: %v", err) + return nil, errors.Wrap(err, "error getting item's spec.containers") + } + + } else if obj.GetKind() == "CronJob" { + //handle containers + err = a.replaceImageName(obj, config, "spec", "jobTemplate", "spec", "template", "spec", "containers") + if err != nil { + a.logger.Infof("replace image name meet error: %v", err) + return nil, errors.Wrap(err, "error getting item's spec.containers") + } + //handle initContainers + err = a.replaceImageName(obj, config, "spec", "jobTemplate", "spec", "template", "spec", "initContainers") + if err != nil { + a.logger.Infof("replace image name meet error: %v", err) + return nil, errors.Wrap(err, "error getting item's spec.containers") + } + + } else { + //handle containers + err = a.replaceImageName(obj, config, "spec", "template", "spec", "containers") + if err != nil { + a.logger.Infof("replace image name meet error: %v", err) + return nil, errors.Wrap(err, "error getting item's spec.containers") + } + + //handle initContainers + err = a.replaceImageName(obj, config, "spec", "template", "spec", "initContainers") + if err != nil { + a.logger.Infof("replace image name meet error: %v", err) + return nil, errors.Wrap(err, "error getting item's spec.containers") + } + } + return velero.NewRestoreItemActionExecuteOutput(obj), nil +} + +func (a *ChangeImageNameAction) replaceImageName(obj *unstructured.Unstructured, config *corev1.ConfigMap, filed ...string) error { + + log := a.logger.WithFields(map[string]interface{}{ + "kind": obj.GetKind(), + "namespace": obj.GetNamespace(), + "name": obj.GetName(), + }) + needUpdateObj := false + containers, _, err := unstructured.NestedSlice(obj.UnstructuredContent(), filed...) + if err != nil { + a.logger.Infof("UnstructuredConverter meet error: %v", err) + return errors.Wrap(err, "error getting item's spec.containers") + } + if len(containers) == 0 { + return nil + } + for i, container := range containers { + a.logger.Infoln("container:", container) + if image, ok := container.(map[string]interface{})["image"]; ok { + imageName := image.(string) + if exists, newImageName, err := a.isImageReplaceRuleExist(log, imageName, config); exists && err == nil { + needUpdateObj = true + a.logger.Infof("Updating item's image from %s to %s", imageName, newImageName) + container.(map[string]interface{})["image"] = newImageName + containers[i] = container + } + } + } + if needUpdateObj { + if err := unstructured.SetNestedField(obj.UnstructuredContent(), containers, filed...); err != nil { + return errors.Wrap(err, "unable to set item's initContainer image") + } + } + return nil +} + +func (a *ChangeImageNameAction) isImageReplaceRuleExist(log *logrus.Entry, oldImageName string, cm *corev1.ConfigMap) (exists bool, newImageName string, err error) { + if oldImageName == "" { + log.Infoln("Item has no old image name specified") + return false, "", nil + } + log.Debug("oldImageName: ", oldImageName) + + //how to use: "" + //for current implementation the value can only be "," + //e.x: in case your old image name is 1.1.1.1:5000/abc:test + //"case1":"1.1.1.1:5000,2.2.2.2:3000" + //"case2":"5000,3000" + //"case3":"abc:test,edf:test" + //"case4":"1.1.1.1:5000/abc:test,2.2.2.2:3000/edf:test" + for _, row := range cm.Data { + if !strings.Contains(row, DELIMITER_VALUE) { + continue + } + if strings.Contains(oldImageName, strings.TrimSpace(row[0:strings.Index(row, DELIMITER_VALUE)])) && len(row[strings.Index(row, DELIMITER_VALUE):]) > len(DELIMITER_VALUE) { + log.Infoln("match specific case:", row) + oldImagePart := strings.TrimSpace(row[0:strings.Index(row, DELIMITER_VALUE)]) + newImagePart := strings.TrimSpace(row[strings.Index(row, DELIMITER_VALUE)+len(DELIMITER_VALUE):]) + newImageName = strings.Replace(oldImageName, oldImagePart, newImagePart, -1) + return true, newImageName, nil + } + } + return false, "", errors.Errorf("No mapping rule found for image: %s", oldImageName) +} diff --git a/pkg/restore/change_image_name_action_test.go b/pkg/restore/change_image_name_action_test.go new file mode 100644 index 000000000..da9ae3ce5 --- /dev/null +++ b/pkg/restore/change_image_name_action_test.go @@ -0,0 +1,182 @@ +/* +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 ( + "context" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + corev1api "k8s.io/api/core/v1" + v1 "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" + "k8s.io/client-go/kubernetes/fake" + + "github.com/vmware-tanzu/velero/pkg/builder" + "github.com/vmware-tanzu/velero/pkg/plugin/velero" +) + +// TestChangeImageNameActionExecute runs the ChangeImageNameAction's Execute +// method and validates that the item's image name 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 TestChangeImageRepositoryActionExecute(t *testing.T) { + tests := []struct { + name string + podOrObj interface{} + configMap *corev1api.ConfigMap + freshedImageName string + imageNameSlice []string + want interface{} + wantErr error + }{ + { + name: "a valid mapping with spaces for a new image repository is applied correctly", + podOrObj: builder.ForPod("default", "pod1").ObjectMeta(). + Containers(&v1.Container{ + Name: "container1", + Image: "1.1.1.1:5000/abc:test", + }).Result(), + configMap: builder.ForConfigMap("velero", "change-image-name"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-image-name", "RestoreItemAction")). + Data("case1", "1.1.1.1:5000 , 2.2.2.2:3000"). + Result(), + freshedImageName: "2.2.2.2:3000/abc:test", + want: "2.2.2.2:3000/abc:test", + }, + + { + name: "a valid mapping for a new image repository is applied correctly", + podOrObj: builder.ForPod("default", "pod1").ObjectMeta(). + Containers(&v1.Container{ + Name: "container2", + Image: "1.1.1.1:5000/abc:test", + }).Result(), + configMap: builder.ForConfigMap("velero", "change-image-name"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-image-name", "RestoreItemAction")). + Data("specific", "1.1.1.1:5000,2.2.2.2:3000"). + Result(), + freshedImageName: "2.2.2.2:3000/abc:test", + want: "2.2.2.2:3000/abc:test", + }, + + { + name: "a valid mapping for a new image name is applied correctly", + podOrObj: builder.ForPod("default", "pod1").ObjectMeta(). + Containers(&v1.Container{ + Name: "container3", + Image: "1.1.1.1:5000/abc:test", + }).Result(), + configMap: builder.ForConfigMap("velero", "change-image-name"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-image-name", "RestoreItemAction")). + Data("specific", "abc:test,myproject:latest"). + Result(), + freshedImageName: "1.1.1.1:5000/myproject:latest", + want: "1.1.1.1:5000/myproject:latest", + }, + + { + name: "a valid mapping for a new image repository port is applied correctly", + podOrObj: builder.ForPod("default", "pod1").ObjectMeta(). + Containers(&v1.Container{ + Name: "container4", + Image: "1.1.1.1:5000/abc:test", + }).Result(), + configMap: builder.ForConfigMap("velero", "change-image-name"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-image-name", "RestoreItemAction")). + Data("specific", "5000,3333"). + Result(), + freshedImageName: "1.1.1.1:5000/abc:test", + want: "1.1.1.1:3333/abc:test", + }, + + { + name: "a valid mapping for a new image tag is applied correctly", + podOrObj: builder.ForPod("default", "pod1").ObjectMeta(). + Containers(&v1.Container{ + Name: "container5", + Image: "1.1.1.1:5000/abc:test", + }).Result(), + configMap: builder.ForConfigMap("velero", "change-image-name"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-image-name", "RestoreItemAction")). + Data("specific", "test,latest"). + Result(), + freshedImageName: "1.1.1.1:5000/abc:test", + want: "1.1.1.1:5000/abc:latest", + }, + + { + name: "image name contains more than one part that matching the replacing words.", + podOrObj: builder.ForPod("default", "pod1").ObjectMeta(). + Containers(&v1.Container{ + Name: "container6", + Image: "dev/image1:dev", + }).Result(), + configMap: builder.ForConfigMap("velero", "change-image-name"). + ObjectMeta(builder.WithLabels("velero.io/plugin-config", "true", "velero.io/change-image-name", "RestoreItemAction")). + Data("specific", "dev/,test/"). + Result(), + freshedImageName: "dev/image1:dev", + want: "test/image1:dev", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clientset := fake.NewSimpleClientset() + a := NewChangeImageNameAction( + logrus.StandardLogger(), + clientset.CoreV1().ConfigMaps("velero"), + ) + + // set up test data + if tc.configMap != nil { + _, err := clientset.CoreV1().ConfigMaps(tc.configMap.Namespace).Create(context.TODO(), tc.configMap, metav1.CreateOptions{}) + require.NoError(t, err) + } + + unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.podOrObj) + 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) + pod := new(corev1.Pod) + err = runtime.DefaultUnstructuredConverter.FromUnstructured(res.UpdatedItem.UnstructuredContent(), pod) + require.NoError(t, err) + assert.Equal(t, pod.Spec.Containers[0].Image, tc.want) + } + }) + } +} diff --git a/site/content/docs/main/restore-reference.md b/site/content/docs/main/restore-reference.md index 31a3e7960..0c71259b2 100644 --- a/site/content/docs/main/restore-reference.md +++ b/site/content/docs/main/restore-reference.md @@ -169,6 +169,49 @@ data: # class name. : ``` +### Changing Pod/Deployment/StatefulSet/DaemonSet/ReplicaSet/ReplicationController/Job/CronJob Image Repositories +Velero can change the image name of pod/deployment/statefulsets/daemonset/replicaset/replicationcontroller/job/cronjob during restores. To configure a image name 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-image-name-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-image-name: RestoreItemAction +data: + # add 1+ key-value pairs here, where the key can be any + # words that ConfigMap accepts. + # the value should be: + # "" + # for current implementation the can only be "," + # e.x: in case your old image name is 1.1.1.1:5000/abc:test + "case1":"1.1.1.1:5000,2.2.2.2:3000" + "case2":"5000,3000" + "case3":"abc:test,edf:test" + "case5":"test,latest" + "case4":"1.1.1.1:5000/abc:test,2.2.2.2:3000/edf:test" + # Please note that image name may contain more than one part that + # matching the replacing words. + # e.x:in case your old image names are: + # dev/image1:dev and dev/image2:dev + # you want change to: + # test/image1:dev and test/image2:dev + # the suggested replacing rule is: + "case5":"dev/,test/" + # this will avoid unexpected replacement to the second "dev". +``` ### Changing PVC selected-node