From 7298a4eda0ccf85e6e7f1a5a986f894a1fe39e2d Mon Sep 17 00:00:00 2001 From: Steve Kriss Date: Fri, 29 Mar 2019 15:11:34 -0600 Subject: [PATCH] allow restic restore helper image to be specified via ConfigMap (#1311) * allow restic restore helper image to be specified via ConfigMap Signed-off-by: Steve Kriss --- changelogs/unreleased/1311-skriss | 1 + docs/restic.md | 34 ++++++- pkg/cmd/server/plugin/plugin.go | 13 ++- pkg/restore/restic_restore_action.go | 112 ++++++++++++++++++---- pkg/restore/restic_restore_action_test.go | 82 ++++++++++++++++ 5 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 changelogs/unreleased/1311-skriss create mode 100644 pkg/restore/restic_restore_action_test.go diff --git a/changelogs/unreleased/1311-skriss b/changelogs/unreleased/1311-skriss new file mode 100644 index 000000000..39b586546 --- /dev/null +++ b/changelogs/unreleased/1311-skriss @@ -0,0 +1 @@ +Allow restic restore helper image name to be optionally specified via ConfigMap diff --git a/docs/restic.md b/docs/restic.md index 690b4c373..34391d758 100644 --- a/docs/restic.md +++ b/docs/restic.md @@ -126,7 +126,39 @@ You're now ready to use Velero with restic. common encryption key for all restic repositories created by Velero. **This means that anyone who has access to your bucket can decrypt your restic backup data**. Make sure that you limit access to the restic bucket appropriately. We plan to implement full Velero backup encryption, including securing the restic encryption keys, in -a future release. +a future release. + +## Customize Restore Helper Image + +Velero uses a helper init container when performing a restic restore. By default, the image for this container is `gcr.io/heptio-images/velero-restic-restore-helper:`, +where `VERSION` matches the version/tag of the main Velero image. You can customize the image that is used for this helper by creating a ConfigMap in the Velero namespace with +the alternate image. The ConfigMap must look 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: restic-restore-action-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 restic restore + # item action plugin) + velero.io/plugin-config: "" + # this label identifies the name and kind of plugin + # that this ConfigMap is for. + velero.io/restic: RestoreItemAction +data: + # "image" is the only configurable key. The value can either + # include a tag or not; if the tag is *not* included, the + # tag from the main Velero image will automatically be used. + image: myregistry.io/my-custom-helper-image[:OPTIONAL_TAG] +``` ## Troubleshooting diff --git a/pkg/cmd/server/plugin/plugin.go b/pkg/cmd/server/plugin/plugin.go index cdec1f383..f73f0336e 100644 --- a/pkg/cmd/server/plugin/plugin.go +++ b/pkg/cmd/server/plugin/plugin.go @@ -49,7 +49,7 @@ func NewCommand(f client.Factory) *cobra.Command { RegisterBackupItemAction("serviceaccount", newServiceAccountBackupItemAction(f)). RegisterRestoreItemAction("job", newJobRestoreItemAction). RegisterRestoreItemAction("pod", newPodRestoreItemAction). - RegisterRestoreItemAction("restic", newResticRestoreItemAction). + RegisterRestoreItemAction("restic", newResticRestoreItemAction(f)). RegisterRestoreItemAction("service", newServiceRestoreItemAction). RegisterRestoreItemAction("serviceaccount", newServiceAccountRestoreItemAction). RegisterRestoreItemAction("addPVCFromPod", newAddPVCFromPodRestoreItemAction). @@ -128,8 +128,15 @@ func newPodRestoreItemAction(logger logrus.FieldLogger) (interface{}, error) { return restore.NewPodAction(logger), nil } -func newResticRestoreItemAction(logger logrus.FieldLogger) (interface{}, error) { - return restore.NewResticRestoreAction(logger), nil +func newResticRestoreItemAction(f client.Factory) veleroplugin.HandlerInitializer { + return func(logger logrus.FieldLogger) (interface{}, error) { + client, err := f.KubeClient() + if err != nil { + return nil, err + } + + return restore.NewResticRestoreAction(logger, client.CoreV1().ConfigMaps(f.Namespace())), nil + } } func newServiceRestoreItemAction(logger logrus.FieldLogger) (interface{}, error) { diff --git a/pkg/restore/restic_restore_action.go b/pkg/restore/restic_restore_action.go index 2f0250c7a..051b2c0c0 100644 --- a/pkg/restore/restic_restore_action.go +++ b/pkg/restore/restic_restore_action.go @@ -1,5 +1,5 @@ /* -Copyright 2018 the Velero contributors. +Copyright 2018, 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. @@ -18,41 +18,37 @@ package restore import ( "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" "k8s.io/apimachinery/pkg/runtime" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "github.com/heptio/velero/pkg/buildinfo" + "github.com/heptio/velero/pkg/plugin/framework" "github.com/heptio/velero/pkg/plugin/velero" "github.com/heptio/velero/pkg/restic" "github.com/heptio/velero/pkg/util/kube" ) +const defaultImageBase = "gcr.io/heptio-images/velero-restic-restore-helper" + type ResticRestoreAction struct { - logger logrus.FieldLogger - initContainerImage string + logger logrus.FieldLogger + client corev1client.ConfigMapInterface } -func NewResticRestoreAction(logger logrus.FieldLogger) *ResticRestoreAction { +func NewResticRestoreAction(logger logrus.FieldLogger, client corev1client.ConfigMapInterface) *ResticRestoreAction { return &ResticRestoreAction{ - logger: logger, - initContainerImage: initContainerImage(), + logger: logger, + client: client, } } -func initContainerImage() string { - tag := buildinfo.Version - if tag == "" { - tag = "latest" - } - - // TODO allow full image URL to be overriden via CLI flag. - return fmt.Sprintf("gcr.io/heptio-images/velero-restic-restore-helper:%s", tag) -} - func (a *ResticRestoreAction) AppliesTo() (velero.ResourceSelector, error) { return velero.ResourceSelector{ IncludedResources: []string{"pods"}, @@ -78,9 +74,20 @@ func (a *ResticRestoreAction) Execute(input *velero.RestoreItemActionExecuteInpu log.Info("Restic snapshot ID annotations found") + // TODO we might want/need to get plugin config at the top of this method at some point; for now, wait + // until we know we're doing a restore before getting config. + log.Debugf("Getting plugin config") + config, err := getPluginConfig(framework.PluginKindRestoreItemAction, "velero.io/restic", a.client) + if err != nil { + return nil, err + } + + image := getImage(log, config) + log.Infof("Using image %q", image) + initContainer := corev1.Container{ Name: restic.InitContainer, - Image: a.initContainerImage, + Image: image, Args: []string{string(input.Restore.UID)}, Env: []corev1.EnvVar{ { @@ -110,7 +117,7 @@ func (a *ResticRestoreAction) Execute(input *velero.RestoreItemActionExecuteInpu initContainer.VolumeMounts = append(initContainer.VolumeMounts, mount) } - if len(pod.Spec.InitContainers) == 0 || pod.Spec.InitContainers[0].Name != "restic-wait" { + if len(pod.Spec.InitContainers) == 0 || pod.Spec.InitContainers[0].Name != restic.InitContainer { pod.Spec.InitContainers = append([]corev1.Container{initContainer}, pod.Spec.InitContainers...) } else { pod.Spec.InitContainers[0] = initContainer @@ -123,3 +130,72 @@ func (a *ResticRestoreAction) Execute(input *velero.RestoreItemActionExecuteInpu return velero.NewRestoreItemActionExecuteOutput(&unstructured.Unstructured{Object: res}), nil } + +func getImage(log logrus.FieldLogger, config *corev1.ConfigMap) string { + if config == nil { + log.Debug("No config found for plugin") + return initContainerImage(defaultImageBase) + } + + image := config.Data["image"] + if image == "" { + log.Debugf("No custom image configured") + return initContainerImage(defaultImageBase) + } + + log = log.WithField("image", image) + + parts := strings.Split(image, ":") + switch { + case len(parts) == 1: + // tag-less image name: add tag + log.Debugf("Plugin config contains image name without tag. Adding tag.") + return initContainerImage(image) + case len(parts) == 2: + // tagged image name + log.Debugf("Plugin config contains image name with tag") + return image + default: + // unrecognized + log.Warnf("Plugin config contains unparseable image name") + return initContainerImage(defaultImageBase) + } +} + +// TODO eventually this can move to pkg/plugin/framework since it'll be used across multiple +// plugins. +func getPluginConfig(kind framework.PluginKind, name string, client corev1client.ConfigMapInterface) (*corev1.ConfigMap, error) { + opts := metav1.ListOptions{ + // velero.io/plugin-config: true + // velero.io/restic: RestoreItemAction + LabelSelector: fmt.Sprintf("velero.io/plugin-config,%s=%s", name, kind), + } + + list, err := client.List(opts) + if err != nil { + return nil, errors.WithStack(err) + } + + if len(list.Items) == 0 { + return nil, 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) + } + + return &list.Items[0], nil +} + +func initContainerImage(imageBase string) string { + tag := buildinfo.Version + if tag == "" { + tag = "latest" + } + + return fmt.Sprintf("%s:%s", imageBase, tag) +} diff --git a/pkg/restore/restic_restore_action_test.go b/pkg/restore/restic_restore_action_test.go new file mode 100644 index 000000000..4318771dd --- /dev/null +++ b/pkg/restore/restic_restore_action_test.go @@ -0,0 +1,82 @@ +/* +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 ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + + "github.com/heptio/velero/pkg/buildinfo" + velerotest "github.com/heptio/velero/pkg/util/test" +) + +func TestGetImage(t *testing.T) { + configMapWithData := func(key, val string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + Data: map[string]string{ + key: val, + }, + } + } + + originalVersion := buildinfo.Version + buildinfo.Version = "buildinfo-version" + defer func() { + buildinfo.Version = originalVersion + }() + + tests := []struct { + name string + configMap *corev1.ConfigMap + want string + }{ + { + name: "nil config map returns default image with buildinfo.Version as tag", + configMap: nil, + want: fmt.Sprintf("%s:%s", defaultImageBase, buildinfo.Version), + }, + { + name: "config map without 'image' key returns default image with buildinfo.Version as tag", + configMap: configMapWithData("non-matching-key", "val"), + want: fmt.Sprintf("%s:%s", defaultImageBase, buildinfo.Version), + }, + { + name: "config map with invalid data in 'image' key returns default image with buildinfo.Version as tag", + configMap: configMapWithData("image", "not:valid:image"), + want: fmt.Sprintf("%s:%s", defaultImageBase, buildinfo.Version), + }, + { + name: "config map with untagged image returns image with buildinfo.Version as tag", + configMap: configMapWithData("image", "myregistry.io/my-image"), + want: fmt.Sprintf("%s:%s", "myregistry.io/my-image", buildinfo.Version), + }, + { + name: "config map with tagged image returns tagged image", + configMap: configMapWithData("image", "myregistry.io/my-image:my-tag"), + want: "myregistry.io/my-image:my-tag", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, getImage(velerotest.NewLogger(), test.configMap)) + }) + } +}