diff --git a/go.mod b/go.mod index 662dc379..79761544 100644 --- a/go.mod +++ b/go.mod @@ -12,12 +12,14 @@ require ( github.com/onsi/gomega v1.14.0 github.com/operator-framework/operator-lib v0.3.0 github.com/prometheus/client_golang v1.11.0 + github.com/sergi/go-diff v1.1.0 github.com/sirupsen/logrus v1.8.1 github.com/spf13/afero v1.2.2 github.com/spf13/cobra v1.1.3 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.7.0 gomodules.xyz/jsonpatch/v2 v2.2.0 + gomodules.xyz/jsonpatch/v3 v3.0.1 helm.sh/helm/v3 v3.6.2 k8s.io/api v0.22.1 k8s.io/apiextensions-apiserver v0.22.1 diff --git a/go.sum b/go.sum index d0491ed3..77a0d1a1 100644 --- a/go.sum +++ b/go.sum @@ -1263,6 +1263,10 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T gomodules.xyz/jsonpatch/v2 v2.1.0/go.mod h1:IhYNNY4jnS53ZnfE4PAmpKtDpTCj1JFXc+3mwe7XcUU= gomodules.xyz/jsonpatch/v2 v2.2.0 h1:4pT439QV83L+G9FkcCriY6EkpcK6r6bK+A5FBUMI7qY= gomodules.xyz/jsonpatch/v2 v2.2.0/go.mod h1:WXp+iVDkoLQqPudfQ9GBlwB2eZ5DKOnjQZCYdOS8GPY= +gomodules.xyz/jsonpatch/v3 v3.0.1 h1:Te7hKxV52TKCbNYq3t84tzKav3xhThdvSsSp/W89IyI= +gomodules.xyz/jsonpatch/v3 v3.0.1/go.mod h1:CBhndykehEwTOlEfnsfJwvkFQbSN8YZFr9M+cIHAJto= +gomodules.xyz/orderedmap v0.1.0 h1:fM/+TGh/O1KkqGR5xjTKg6bU8OKBkg7p0Y+x/J9m8Os= +gomodules.xyz/orderedmap v0.1.0/go.mod h1:g9/TPUCm1t2gwD3j3zfV8uylyYhVdCNSi+xCEIu7yTU= google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= diff --git a/internal/cmd/helm-operator/run/cmd.go b/internal/cmd/helm-operator/run/cmd.go new file mode 100644 index 00000000..5119d511 --- /dev/null +++ b/internal/cmd/helm-operator/run/cmd.go @@ -0,0 +1,221 @@ +// Copyright 2020 The Operator-SDK Authors +// +// 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 run + +import ( + "errors" + "flag" + "fmt" + "os" + "runtime" + "strings" + + "github.com/operator-framework/helm-operator-plugins/internal/flags" + "github.com/operator-framework/helm-operator-plugins/internal/legacy/controller" + "github.com/operator-framework/helm-operator-plugins/internal/legacy/release" + watches "github.com/operator-framework/helm-operator-plugins/internal/legacy/watches" + "github.com/operator-framework/helm-operator-plugins/internal/metrics" + "github.com/operator-framework/helm-operator-plugins/internal/version" + helmmgr "github.com/operator-framework/helm-operator-plugins/pkg/manager" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/controller-runtime/pkg/healthz" + logf "sigs.k8s.io/controller-runtime/pkg/log" + zapf "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/manager/signals" + crmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +var log = logf.Log.WithName("cmd") + +func printVersion() { + log.Info("Version", + "Go Version", runtime.Version(), + "GOOS", runtime.GOOS, + "GOARCH", runtime.GOARCH, + "helm-operator", version.GitVersion, + "commit", version.GitCommit) +} + +func NewCmd() *cobra.Command { + f := &flags.Flags{} + zapfs := flag.NewFlagSet("zap", flag.ExitOnError) + opts := &zapf.Options{} + opts.BindFlags(zapfs) + + cmd := &cobra.Command{ + Use: "run", + Short: "Run the operator", + Run: func(cmd *cobra.Command, _ []string) { + logf.SetLogger(zapf.New(zapf.UseFlagOptions(opts))) + run(cmd, f) + }, + } + + f.AddTo(cmd.Flags()) + cmd.Flags().AddGoFlagSet(zapfs) + return cmd +} + +func run(cmd *cobra.Command, f *flags.Flags) { + printVersion() + metrics.RegisterBuildInfo(crmetrics.Registry) + + // Load config options from the config at f.ManagerConfigPath. + // These options will not override those set by flags. + var ( + options manager.Options + err error + ) + if f.ManagerConfigPath != "" { + cfgLoader := ctrl.ConfigFile().AtPath(f.ManagerConfigPath) + if options, err = options.AndFrom(cfgLoader); err != nil { + log.Error(err, "Unable to load the manager config file") + os.Exit(1) + } + } + exitIfUnsupported(options) + + cfg, err := config.GetConfig() + if err != nil { + log.Error(err, "Failed to get config.") + os.Exit(1) + } + + // TODO(2.0.0): remove + // Deprecated: OPERATOR_NAME environment variable is an artifact of the + // legacy operator-sdk project scaffolding. Flag `--leader-election-id` + // should be used instead. + if operatorName, found := os.LookupEnv("OPERATOR_NAME"); found { + log.Info("Environment variable OPERATOR_NAME has been deprecated, use --leader-election-id instead.") + if cmd.Flags().Changed("leader-election-id") { + log.Info("Ignoring OPERATOR_NAME environment variable since --leader-election-id is set") + } else if options.LeaderElectionID == "" { + // Only set leader election ID using OPERATOR_NAME if unset everywhere else, + // since this env var is deprecated. + options.LeaderElectionID = operatorName + } + } + + //TODO(2.0.0): remove the following checks. they are required just because of the flags deprecation + if cmd.Flags().Changed("leader-elect") && cmd.Flags().Changed("enable-leader-election") { + log.Error(errors.New("only one of --leader-elect and --enable-leader-election may be set"), "invalid flags usage") + os.Exit(1) + } + + if cmd.Flags().Changed("metrics-addr") && cmd.Flags().Changed("metrics-bind-address") { + log.Error(errors.New("only one of --metrics-addr and --metrics-bind-address may be set"), "invalid flags usage") + os.Exit(1) + } + + // Set default manager options + options = f.ToManagerOptions(options) + + if options.NewClient == nil { + options.NewClient = helmmgr.NewCachingClientFunc() + } + namespace, found := os.LookupEnv(helmmgr.WatchNamespaceEnvVar) + log = log.WithValues("Namespace", namespace) + if found { + log.V(1).Info(fmt.Sprintf("Setting namespace with value in %s", helmmgr.WatchNamespaceEnvVar)) + if namespace == metav1.NamespaceAll { + log.Info("Watching all namespaces.") + options.Namespace = metav1.NamespaceAll + } else { + if strings.Contains(namespace, ",") { + log.Info("Watching multiple namespaces.") + options.NewCache = cache.MultiNamespacedCacheBuilder(strings.Split(namespace, ",")) + } else { + log.Info("Watching single namespace.") + options.Namespace = namespace + } + } + } else if options.Namespace == "" { + log.Info(fmt.Sprintf("Watch namespaces not configured by environment variable %s or file. "+ + "Watching all namespaces.", helmmgr.WatchNamespaceEnvVar)) + options.Namespace = metav1.NamespaceAll + } + + mgr, err := manager.New(cfg, options) + if err != nil { + log.Error(err, "Failed to create a new manager") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + log.Error(err, "Unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + log.Error(err, "Unable to set up ready check") + os.Exit(1) + } + + ws, err := watches.Load(f.WatchesFile) + if err != nil { + log.Error(err, "Failed to create new manager factories.") + os.Exit(1) + } + + for _, w := range ws { + // Register the controller with the factory. + err := controller.Add(mgr, controller.WatchOptions{ + Namespace: namespace, + GVK: w.GroupVersionKind, + ManagerFactory: release.NewManagerFactory(mgr, w.ChartDir), + ReconcilePeriod: f.ReconcilePeriod, + WatchDependentResources: *w.WatchDependentResources, + OverrideValues: w.OverrideValues, + MaxConcurrentReconciles: f.MaxConcurrentReconciles, + Selector: w.Selector, + }) + if err != nil { + log.Error(err, "Failed to add manager factory to controller.") + os.Exit(1) + } + } + + // Start the Cmd + if err = mgr.Start(signals.SetupSignalHandler()); err != nil { + log.Error(err, "Manager exited non-zero.") + os.Exit(1) + } + +} + +// exitIfUnsupported prints an error containing unsupported field names and exits +// if any of those fields are not their default values. +func exitIfUnsupported(options manager.Options) { + var keys []string + // The below options are webhook-specific, which is not supported by ansible. + if options.CertDir != "" { + keys = append(keys, "certDir") + } + if options.Host != "" { + keys = append(keys, "host") + } + if options.Port != 0 { + keys = append(keys, "port") + } + + if len(keys) > 0 { + log.Error(fmt.Errorf("%s set in manager options", strings.Join(keys, ", ")), "unsupported fields") + os.Exit(1) + } +} diff --git a/internal/cmd/run/cmd.go b/internal/cmd/hybrid-operator/run/cmd.go similarity index 100% rename from internal/cmd/run/cmd.go rename to internal/cmd/hybrid-operator/run/cmd.go diff --git a/internal/legacy/controller/controller.go b/internal/legacy/controller/controller.go new file mode 100644 index 00000000..4ce24840 --- /dev/null +++ b/internal/legacy/controller/controller.go @@ -0,0 +1,209 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 controller + +import ( + "fmt" + "reflect" + "strings" + "sync" + "time" + + rpb "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/releaseutil" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/controller" + + crthandler "sigs.k8s.io/controller-runtime/pkg/handler" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" + ctrlpredicate "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/source" + "sigs.k8s.io/yaml" + + "github.com/operator-framework/helm-operator-plugins/internal/legacy/release" + "github.com/operator-framework/helm-operator-plugins/pkg/sdk/controllerutil" + libhandler "github.com/operator-framework/operator-lib/handler" + "github.com/operator-framework/operator-lib/predicate" +) + +var log = logf.Log.WithName("helm.controller") + +// WatchOptions contains the necessary values to create a new controller that +// manages helm releases in a particular namespace based on a GVK watch. +type WatchOptions struct { + Namespace string + GVK schema.GroupVersionKind + ManagerFactory release.ManagerFactory + ReconcilePeriod time.Duration + WatchDependentResources bool + OverrideValues map[string]string + MaxConcurrentReconciles int + Selector metav1.LabelSelector +} + +// Add creates a new helm operator controller and adds it to the manager +func Add(mgr manager.Manager, options WatchOptions) error { + controllerName := fmt.Sprintf("%v-controller", strings.ToLower(options.GVK.Kind)) + + r := &HelmOperatorReconciler{ + Client: mgr.GetClient(), + EventRecorder: mgr.GetEventRecorderFor(controllerName), + GVK: options.GVK, + ManagerFactory: options.ManagerFactory, + ReconcilePeriod: options.ReconcilePeriod, + OverrideValues: options.OverrideValues, + } + + // Register the GVK with the schema + mgr.GetScheme().AddKnownTypeWithName(options.GVK, &unstructured.Unstructured{}) + metav1.AddToGroupVersion(mgr.GetScheme(), options.GVK.GroupVersion()) + + c, err := controller.New(controllerName, mgr, controller.Options{ + Reconciler: r, + MaxConcurrentReconciles: options.MaxConcurrentReconciles, + }) + if err != nil { + return err + } + + o := &unstructured.Unstructured{} + o.SetGroupVersionKind(options.GVK) + + var preds []ctrlpredicate.Predicate + p, err := parsePredicateSelector(options.Selector) + + if err != nil { + return err + } + + if p != nil { + preds = append(preds, p) + } + + if err := c.Watch(&source.Kind{Type: o}, &libhandler.InstrumentedEnqueueRequestForObject{}, preds...); err != nil { + return err + } + + if options.WatchDependentResources { + watchDependentResources(mgr, r, c) + } + + log.Info("Watching resource", "apiVersion", options.GVK.GroupVersion(), "kind", + options.GVK.Kind, "namespace", options.Namespace, "reconcilePeriod", options.ReconcilePeriod.String()) + return nil +} + +// parsePredicateSelector parses the selector in the WatchOptions and creates a predicate +// that is used to filter resources based on the specified selector +func parsePredicateSelector(selector metav1.LabelSelector) (ctrlpredicate.Predicate, error) { + // If a selector has been specified in watches.yaml, add it to the watch's predicates. + if !reflect.ValueOf(selector).IsZero() { + p, err := ctrlpredicate.LabelSelectorPredicate(selector) + if err != nil { + return nil, fmt.Errorf("error constructing predicate from watches selector: %v", err) + } + return p, nil + } + return nil, nil +} + +// watchDependentResources adds a release hook function to the HelmOperatorReconciler +// that adds watches for resources in released Helm charts. +func watchDependentResources(mgr manager.Manager, r *HelmOperatorReconciler, c controller.Controller) { + owner := &unstructured.Unstructured{} + owner.SetGroupVersionKind(r.GVK) + + var m sync.RWMutex + watches := map[schema.GroupVersionKind]struct{}{} + releaseHook := func(release *rpb.Release) error { + resources := releaseutil.SplitManifests(release.Manifest) + for _, resource := range resources { + var u unstructured.Unstructured + if err := yaml.Unmarshal([]byte(resource), &u); err != nil { + return err + } + + gvk := u.GroupVersionKind() + if gvk.Empty() { + continue + } + + var setWatchOnResource = func(dependent runtime.Object) error { + unstructuredObj := dependent.(*unstructured.Unstructured) + gvkDependent := unstructuredObj.GroupVersionKind() + if gvkDependent.Empty() { + return nil + } + + m.RLock() + _, ok := watches[gvkDependent] + m.RUnlock() + if ok { + return nil + } + + restMapper := mgr.GetRESTMapper() + useOwnerRef, err := controllerutil.SupportsOwnerReference(restMapper, owner, unstructuredObj) + if err != nil { + return err + } + + if useOwnerRef { // Setup watch using owner references. + err = c.Watch(&source.Kind{Type: unstructuredObj}, &crthandler.EnqueueRequestForOwner{OwnerType: owner}, + predicate.DependentPredicate{}) + if err != nil { + return err + } + } else { // Setup watch using annotations. + err = c.Watch(&source.Kind{Type: unstructuredObj}, &libhandler.EnqueueRequestForAnnotation{Type: gvkDependent.GroupKind()}, + predicate.DependentPredicate{}) + if err != nil { + return err + } + } + m.Lock() + watches[gvkDependent] = struct{}{} + m.Unlock() + log.Info("Watching dependent resource", "ownerApiVersion", r.GVK.GroupVersion(), + "ownerKind", r.GVK.Kind, "apiVersion", gvkDependent.GroupVersion(), "kind", gvkDependent.Kind) + return nil + } + + // List is not actually a resource and therefore cannot have a + // watch on it. The watch will be on the kinds listed in the list + // and will therefore need to be handled individually. + listGVK := schema.GroupVersionKind{Group: "", Version: "v1", Kind: "List"} + if gvk == listGVK { + errListItem := u.EachListItem(func(obj runtime.Object) error { + return setWatchOnResource(obj) + }) + if errListItem != nil { + return errListItem + } + } else { + err := setWatchOnResource(&u) + if err != nil { + return err + } + } + } + return nil + } + r.releaseHook = releaseHook +} diff --git a/internal/legacy/controller/controller_test.go b/internal/legacy/controller/controller_test.go new file mode 100644 index 00000000..968fdef1 --- /dev/null +++ b/internal/legacy/controller/controller_test.go @@ -0,0 +1,39 @@ +// Copyright 2021 The Operator-SDK Authors +// +// 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 controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestFilterPredicate(t *testing.T) { + matchLabelPass := make(map[string]string) + matchLabelPass["testKey"] = "testValue" + selectorPass := metav1.LabelSelector{ + MatchLabels: matchLabelPass, + } + noSelector := metav1.LabelSelector{} + + passPredicate, err := parsePredicateSelector(selectorPass) + assert.Equal(t, nil, err, "Verify that no error is thrown on a valid populated selector") + assert.NotEqual(t, nil, passPredicate, "Verify that a predicate is constructed using a valid selector") + + nilPredicate, err := parsePredicateSelector(noSelector) + assert.Equal(t, nil, err, "Verify that no error is thrown on a valid unpopulated selector") + assert.Equal(t, nil, nilPredicate, "Verify correct parsing of an unpopulated selector") +} diff --git a/internal/legacy/controller/doc.go b/internal/legacy/controller/doc.go new file mode 100644 index 00000000..e466a250 --- /dev/null +++ b/internal/legacy/controller/doc.go @@ -0,0 +1,18 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 controller provides functions for creating and registering a Helm +// controller with a `controller-runtime` manager. It also provides a Helm +// reconciler implementation that can be used to create a Helm-based operator. +package controller diff --git a/internal/legacy/controller/reconcile.go b/internal/legacy/controller/reconcile.go new file mode 100644 index 00000000..cba0fed4 --- /dev/null +++ b/internal/legacy/controller/reconcile.go @@ -0,0 +1,439 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 controller + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + rpb "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/operator-framework/helm-operator-plugins/internal/legacy/helm/diff" + "github.com/operator-framework/helm-operator-plugins/internal/legacy/helm/types" + "github.com/operator-framework/helm-operator-plugins/internal/legacy/release" +) + +// blank assignment to verify that HelmOperatorReconciler implements reconcile.Reconciler +var _ reconcile.Reconciler = &HelmOperatorReconciler{} + +// ReleaseHookFunc defines a function signature for release hooks. +type ReleaseHookFunc func(*rpb.Release) error + +// HelmOperatorReconciler reconciles custom resources as Helm releases. +type HelmOperatorReconciler struct { + Client client.Client + EventRecorder record.EventRecorder + GVK schema.GroupVersionKind + ManagerFactory release.ManagerFactory + ReconcilePeriod time.Duration + OverrideValues map[string]string + releaseHook ReleaseHookFunc +} + +const ( + // uninstallFinalizer is added to CRs so they are cleaned up after uninstalling a release. + uninstallFinalizer = "helm.sdk.operatorframework.io/uninstall-release" + // Deprecated: use uninstallFinalizer. This will be removed in operator-sdk v2.0.0. + uninstallFinalizerLegacy = "uninstall-helm-release" + + helmUpgradeForceAnnotation = "helm.sdk.operatorframework.io/upgrade-force" + helmUninstallWaitAnnotation = "helm.sdk.operatorframework.io/uninstall-wait" +) + +// Reconcile reconciles the requested resource by installing, updating, or +// uninstalling a Helm release based on the resource's current state. If no +// release changes are necessary, Reconcile will create or patch the underlying +// resources to match the expected release manifest. + +func (r HelmOperatorReconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { //nolint:gocyclo + o := &unstructured.Unstructured{} + o.SetGroupVersionKind(r.GVK) + o.SetNamespace(request.Namespace) + o.SetName(request.Name) + log := log.WithValues( + "namespace", o.GetNamespace(), + "name", o.GetName(), + "apiVersion", o.GetAPIVersion(), + "kind", o.GetKind(), + ) + log.V(1).Info("Reconciling") + + err := r.Client.Get(ctx, request.NamespacedName, o) + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + if err != nil { + log.Error(err, "Failed to lookup resource") + return reconcile.Result{}, err + } + + manager, err := r.ManagerFactory.NewManager(o, r.OverrideValues) + if err != nil { + log.Error(err, "Failed to get release manager") + return reconcile.Result{}, err + } + + status := types.StatusFor(o) + log = log.WithValues("release", manager.ReleaseName()) + + if o.GetDeletionTimestamp() != nil { + if !(controllerutil.ContainsFinalizer(o, uninstallFinalizer) || + controllerutil.ContainsFinalizer(o, uninstallFinalizerLegacy)) { + + log.Info("Resource is terminated, skipping reconciliation") + return reconcile.Result{}, nil + } + + uninstalledRelease, err := manager.UninstallRelease(ctx) + if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { + log.Error(err, "Failed to uninstall release") + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionReleaseFailed, + Status: types.StatusTrue, + Reason: types.ReasonUninstallError, + Message: err.Error(), + }) + if err := r.updateResourceStatus(ctx, o, status); err != nil { + log.Error(err, "Failed to update status after uninstall release failure") + } + return reconcile.Result{}, err + } + status.RemoveCondition(types.ConditionReleaseFailed) + + wait := hasAnnotation(helmUninstallWaitAnnotation, o) + if errors.Is(err, driver.ErrReleaseNotFound) { + log.Info("Release not found") + } else { + log.Info("Uninstalled release") + if log.V(0).Enabled() && uninstalledRelease != nil { + fmt.Println(diff.Generate(uninstalledRelease.Manifest, "")) + } + if !wait { + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionDeployed, + Status: types.StatusFalse, + Reason: types.ReasonUninstallSuccessful, + }) + status.DeployedRelease = nil + } + } + if wait { + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionDeployed, + Status: types.StatusFalse, + Reason: types.ReasonUninstallSuccessful, + Message: "Waiting until all resources are deleted.", + }) + } + if err := r.updateResourceStatus(ctx, o, status); err != nil { + log.Info("Failed to update CR status") + return reconcile.Result{}, err + } + + if wait && status.DeployedRelease != nil && status.DeployedRelease.Manifest != "" { + log.Info("Uninstall wait") + isAllResourcesDeleted, err := manager.CleanupRelease(ctx, status.DeployedRelease.Manifest) + if err != nil { + log.Error(err, "Failed to cleanup release") + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionReleaseFailed, + Status: types.StatusTrue, + Reason: types.ReasonUninstallError, + Message: err.Error(), + }) + _ = r.updateResourceStatus(ctx, o, status) + return reconcile.Result{}, err + } + if !isAllResourcesDeleted { + log.Info("Waiting until all resources are deleted") + return reconcile.Result{RequeueAfter: r.ReconcilePeriod}, nil + } + status.RemoveCondition(types.ConditionReleaseFailed) + } + + log.Info("Removing finalizer") + controllerutil.RemoveFinalizer(o, uninstallFinalizer) + controllerutil.RemoveFinalizer(o, uninstallFinalizerLegacy) + if err := r.updateResource(ctx, o); err != nil { + log.Info("Failed to remove CR uninstall finalizer") + return reconcile.Result{}, err + } + + // Since the client is hitting a cache, waiting for the + // deletion here will guarantee that the next reconciliation + // will see that the CR has been deleted and that there's + // nothing left to do. + if err := r.waitForDeletion(ctx, o); err != nil { + log.Info("Failed waiting for CR deletion") + return reconcile.Result{}, err + } + + return reconcile.Result{}, nil + } + + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionInitialized, + Status: types.StatusTrue, + }) + + if err := manager.Sync(ctx); err != nil { + log.Error(err, "Failed to sync release") + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionIrreconcilable, + Status: types.StatusTrue, + Reason: types.ReasonReconcileError, + Message: err.Error(), + }) + if err := r.updateResourceStatus(ctx, o, status); err != nil { + log.Error(err, "Failed to update status after sync release failure") + } + return reconcile.Result{}, err + } + status.RemoveCondition(types.ConditionIrreconcilable) + + if !manager.IsInstalled() { + for k, v := range r.OverrideValues { + r.EventRecorder.Eventf(o, "Warning", "OverrideValuesInUse", + "Chart value %q overridden to %q by operator's watches.yaml", k, v) + } + installedRelease, err := manager.InstallRelease(ctx) + if err != nil { + log.Error(err, "Release failed") + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionReleaseFailed, + Status: types.StatusTrue, + Reason: types.ReasonInstallError, + Message: err.Error(), + }) + if err := r.updateResourceStatus(ctx, o, status); err != nil { + log.Error(err, "Failed to update status after install release failure") + } + return reconcile.Result{}, err + } + status.RemoveCondition(types.ConditionReleaseFailed) + + log.V(1).Info("Adding finalizer", "finalizer", uninstallFinalizer) + controllerutil.AddFinalizer(o, uninstallFinalizer) + if err := r.updateResource(ctx, o); err != nil { + log.Info("Failed to add CR uninstall finalizer") + return reconcile.Result{}, err + } + + if r.releaseHook != nil { + if err := r.releaseHook(installedRelease); err != nil { + log.Error(err, "Failed to run release hook") + return reconcile.Result{}, err + } + } + + log.Info("Installed release") + if log.V(0).Enabled() { + fmt.Println(diff.Generate("", installedRelease.Manifest)) + } + log.V(1).Info("Config values", "values", installedRelease.Config) + message := "" + if installedRelease.Info != nil { + message = installedRelease.Info.Notes + } + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionDeployed, + Status: types.StatusTrue, + Reason: types.ReasonInstallSuccessful, + Message: message, + }) + status.DeployedRelease = &types.HelmAppRelease{ + Name: installedRelease.Name, + Manifest: installedRelease.Manifest, + } + err = r.updateResourceStatus(ctx, o, status) + return reconcile.Result{RequeueAfter: r.ReconcilePeriod}, err + } + + if !(controllerutil.ContainsFinalizer(o, uninstallFinalizer) || + controllerutil.ContainsFinalizer(o, uninstallFinalizerLegacy)) { + + log.V(1).Info("Adding finalizer", "finalizer", uninstallFinalizer) + controllerutil.AddFinalizer(o, uninstallFinalizer) + if err := r.updateResource(ctx, o); err != nil { + log.Info("Failed to add CR uninstall finalizer") + return reconcile.Result{}, err + } + } + + if manager.IsUpgradeRequired() { + for k, v := range r.OverrideValues { + r.EventRecorder.Eventf(o, "Warning", "OverrideValuesInUse", + "Chart value %q overridden to %q by operator's watches.yaml", k, v) + } + force := hasAnnotation(helmUpgradeForceAnnotation, o) + previousRelease, upgradedRelease, err := manager.UpgradeRelease(ctx, release.ForceUpgrade(force)) + if err != nil { + log.Error(err, "Release failed") + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionReleaseFailed, + Status: types.StatusTrue, + Reason: types.ReasonUpgradeError, + Message: err.Error(), + }) + if err := r.updateResourceStatus(ctx, o, status); err != nil { + log.Error(err, "Failed to update status after sync release failure") + } + return reconcile.Result{}, err + } + status.RemoveCondition(types.ConditionReleaseFailed) + + if r.releaseHook != nil { + if err := r.releaseHook(upgradedRelease); err != nil { + log.Error(err, "Failed to run release hook") + return reconcile.Result{}, err + } + } + + log.Info("Upgraded release", "force", force) + if log.V(0).Enabled() { + fmt.Println(diff.Generate(previousRelease.Manifest, upgradedRelease.Manifest)) + } + log.V(1).Info("Config values", "values", upgradedRelease.Config) + message := "" + if upgradedRelease.Info != nil { + message = upgradedRelease.Info.Notes + } + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionDeployed, + Status: types.StatusTrue, + Reason: types.ReasonUpgradeSuccessful, + Message: message, + }) + status.DeployedRelease = &types.HelmAppRelease{ + Name: upgradedRelease.Name, + Manifest: upgradedRelease.Manifest, + } + err = r.updateResourceStatus(ctx, o, status) + return reconcile.Result{RequeueAfter: r.ReconcilePeriod}, err + } + + // If a change is made to the CR spec that causes a release failure, a + // ConditionReleaseFailed is added to the status conditions. If that change + // is then reverted to its previous state, the operator will stop + // attempting the release and will resume reconciling. In this case, we + // need to remove the ConditionReleaseFailed because the failing release is + // no longer being attempted. + status.RemoveCondition(types.ConditionReleaseFailed) + + expectedRelease, err := manager.ReconcileRelease(ctx) + if err != nil { + log.Error(err, "Failed to reconcile release") + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionIrreconcilable, + Status: types.StatusTrue, + Reason: types.ReasonReconcileError, + Message: err.Error(), + }) + if err := r.updateResourceStatus(ctx, o, status); err != nil { + log.Error(err, "Failed to update status after reconcile release failure") + } + return reconcile.Result{}, err + } + status.RemoveCondition(types.ConditionIrreconcilable) + + if r.releaseHook != nil { + if err := r.releaseHook(expectedRelease); err != nil { + log.Error(err, "Failed to run release hook") + return reconcile.Result{}, err + } + } + + log.Info("Reconciled release") + reason := types.ReasonUpgradeSuccessful + if expectedRelease.Version == 1 { + reason = types.ReasonInstallSuccessful + } + message := "" + if expectedRelease.Info != nil { + message = expectedRelease.Info.Notes + } + status.SetCondition(types.HelmAppCondition{ + Type: types.ConditionDeployed, + Status: types.StatusTrue, + Reason: reason, + Message: message, + }) + status.DeployedRelease = &types.HelmAppRelease{ + Name: expectedRelease.Name, + Manifest: expectedRelease.Manifest, + } + err = r.updateResourceStatus(ctx, o, status) + return reconcile.Result{RequeueAfter: r.ReconcilePeriod}, err +} + +// returns the boolean representation of the annotation string +// will return false if annotation is not set +func hasAnnotation(anno string, o *unstructured.Unstructured) bool { + boolStr := o.GetAnnotations()[anno] + if boolStr == "" { + return false + } + value := false + if i, err := strconv.ParseBool(boolStr); err != nil { + log.Info("Could not parse annotation as a boolean", + "annotation", anno, "value informed", boolStr) + } else { + value = i + } + return value +} + +func (r HelmOperatorReconciler) updateResource(ctx context.Context, o client.Object) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + return r.Client.Update(ctx, o) + }) +} + +func (r HelmOperatorReconciler) updateResourceStatus(ctx context.Context, o *unstructured.Unstructured, status *types.HelmAppStatus) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + o.Object["status"] = status + return r.Client.Status().Update(ctx, o) + }) +} + +func (r HelmOperatorReconciler) waitForDeletion(ctx context.Context, o client.Object) error { + key := client.ObjectKeyFromObject(o) + + tctx, cancel := context.WithTimeout(ctx, time.Second*5) + defer cancel() + return wait.PollImmediateUntil(time.Millisecond*10, func() (bool, error) { + err := r.Client.Get(tctx, key, o) + if apierrors.IsNotFound(err) { + return true, nil + } + if err != nil { + return false, err + } + return false, nil + }, tctx.Done()) +} diff --git a/internal/legacy/controller/reconcile_test.go b/internal/legacy/controller/reconcile_test.go new file mode 100644 index 00000000..188e936f --- /dev/null +++ b/internal/legacy/controller/reconcile_test.go @@ -0,0 +1,142 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestHasAnnotation(t *testing.T) { + upgradeForceTests := []struct { + input map[string]interface{} + expectedVal bool + expectedOut string + name string + }{ + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/upgrade-force": "True", + }, + expectedVal: true, + name: "upgrade force base case true", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/upgrade-force": "False", + }, + expectedVal: false, + name: "upgrade force base case false", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/upgrade-force": "1", + }, + expectedVal: true, + name: "upgrade force true as int", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/upgrade-force": "0", + }, + expectedVal: false, + name: "upgrade force false as int", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/wrong-annotation": "true", + }, + expectedVal: false, + name: "upgrade force annotation not set", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/upgrade-force": "invalid", + }, + expectedVal: false, + name: "upgrade force invalid value", + }, + } + + for _, test := range upgradeForceTests { + assert.Equal(t, test.expectedVal, hasAnnotation(helmUpgradeForceAnnotation, annotations(test.input)), test.name) + } + + uninstallWaitTests := []struct { + input map[string]interface{} + expectedVal bool + expectedOut string + name string + }{ + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/uninstall-wait": "True", + }, + expectedVal: true, + name: "uninstall wait base case true", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/uninstall-wait": "False", + }, + expectedVal: false, + name: "uninstall wait base case false", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/uninstall-wait": "1", + }, + expectedVal: true, + name: "uninstall wait true as int", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/uninstall-wait": "0", + }, + expectedVal: false, + name: "uninstall wait false as int", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/wrong-annotation": "true", + }, + expectedVal: false, + name: "uninstall wait annotation not set", + }, + { + input: map[string]interface{}{ + "helm.sdk.operatorframework.io/uninstall-wait": "invalid", + }, + expectedVal: false, + name: "uninstall wait invalid value", + }, + } + + for _, test := range uninstallWaitTests { + assert.Equal(t, test.expectedVal, hasAnnotation(helmUninstallWaitAnnotation, annotations(test.input)), test.name) + } +} + +func annotations(m map[string]interface{}) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": m, + }, + }, + } +} diff --git a/internal/legacy/helm/client/client.go b/internal/legacy/helm/client/client.go new file mode 100644 index 00000000..dbca5a3c --- /dev/null +++ b/internal/legacy/helm/client/client.go @@ -0,0 +1,177 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 client + +import ( + "errors" + "io" + "strings" + + "github.com/operator-framework/helm-operator-plugins/pkg/sdk/controllerutil" + "github.com/operator-framework/operator-lib/handler" + "helm.sh/helm/v3/pkg/kube" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/discovery" + cached "k8s.io/client-go/discovery/cached" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +var _ genericclioptions.RESTClientGetter = &restClientGetter{} + +type restClientGetter struct { + restConfig *rest.Config + discoveryClient discovery.CachedDiscoveryInterface + restMapper meta.RESTMapper + namespaceConfig clientcmd.ClientConfig +} + +func (c *restClientGetter) ToRESTConfig() (*rest.Config, error) { + return c.restConfig, nil +} + +func (c *restClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + return c.discoveryClient, nil +} + +func (c *restClientGetter) ToRESTMapper() (meta.RESTMapper, error) { + return c.restMapper, nil +} + +func (c *restClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { + return c.namespaceConfig +} + +var _ clientcmd.ClientConfig = &namespaceClientConfig{} + +type namespaceClientConfig struct { + namespace string +} + +func (c namespaceClientConfig) RawConfig() (clientcmdapi.Config, error) { + return clientcmdapi.Config{}, nil +} + +func (c namespaceClientConfig) ClientConfig() (*rest.Config, error) { + return nil, nil +} + +func (c namespaceClientConfig) Namespace() (string, bool, error) { + return c.namespace, false, nil +} + +func (c namespaceClientConfig) ConfigAccess() clientcmd.ConfigAccess { + return nil +} + +func NewRESTClientGetter(mgr manager.Manager, ns string) (genericclioptions.RESTClientGetter, error) { + cfg := mgr.GetConfig() + dc, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + return nil, err + } + cdc := cached.NewMemCacheClient(dc) + rm := mgr.GetRESTMapper() + + return &restClientGetter{ + restConfig: cfg, + discoveryClient: cdc, + restMapper: rm, + namespaceConfig: &namespaceClientConfig{ns}, + }, nil +} + +var _ kube.Interface = &ownerRefInjectingClient{} + +func NewOwnerRefInjectingClient(base kube.Client, restMapper meta.RESTMapper, + cr *unstructured.Unstructured) (kube.Interface, error) { + + if cr != nil { + if cr.GetObjectKind() != nil { + if cr.GetObjectKind().GroupVersionKind().Empty() || cr.GetName() == "" || cr.GetUID() == "" { + var err = errors.New("owner resource is invalid") + return nil, err + } + } + } + return &ownerRefInjectingClient{ + Client: base, + restMapper: restMapper, + owner: cr, + }, nil +} + +type ownerRefInjectingClient struct { + kube.Client + restMapper meta.RESTMapper + owner *unstructured.Unstructured +} + +func (c *ownerRefInjectingClient) Build(reader io.Reader, validate bool) (kube.ResourceList, error) { + resourceList, err := c.Client.Build(reader, validate) + if err != nil { + return resourceList, err + } + err = resourceList.Visit(func(r *resource.Info, err error) error { + if err != nil { + return err + } + objMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(r.Object) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: objMap} + useOwnerRef, err := controllerutil.SupportsOwnerReference(c.restMapper, c.owner, u) + if err != nil { + return err + } + + // If the resource contains the Helm resource-policy keep annotation, then do not add + // the owner reference. So when the CR is deleted, Kubernetes won't GCs the resource. + if useOwnerRef && !containsResourcePolicyKeep(u.GetAnnotations()) { + ownerRef := metav1.NewControllerRef(c.owner, c.owner.GroupVersionKind()) + u.SetOwnerReferences([]metav1.OwnerReference{*ownerRef}) + } else { + err := handler.SetOwnerAnnotations(u, c.owner) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return resourceList, nil +} + +func containsResourcePolicyKeep(annotations map[string]string) bool { + if annotations == nil { + return false + } + resourcePolicyType, ok := annotations[kube.ResourcePolicyAnno] + if !ok { + return false + } + resourcePolicyType = strings.ToLower(strings.TrimSpace(resourcePolicyType)) + return resourcePolicyType == kube.KeepPolicy +} diff --git a/internal/legacy/helm/client/client_test.go b/internal/legacy/helm/client/client_test.go new file mode 100644 index 00000000..c223da33 --- /dev/null +++ b/internal/legacy/helm/client/client_test.go @@ -0,0 +1,79 @@ +// Copyright 2021 The Operator-SDK Authors +// +// 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 client + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "helm.sh/helm/v3/pkg/kube" +) + +func TestContainsResourcePolicyKeep(t *testing.T) { + tests := []struct { + input map[string]string + expectedVal bool + expectedOut string + name string + }{ + { + input: map[string]string{ + kube.ResourcePolicyAnno: kube.KeepPolicy, + }, + expectedVal: true, + name: "base case true", + }, + { + input: map[string]string{ + "not-" + kube.ResourcePolicyAnno: kube.KeepPolicy, + }, + expectedVal: false, + name: "base case annotation false", + }, + { + input: map[string]string{ + kube.ResourcePolicyAnno: "not-" + kube.KeepPolicy, + }, + expectedVal: false, + name: "base case value false", + }, + { + input: map[string]string{ + kube.ResourcePolicyAnno: strings.ToUpper(kube.KeepPolicy), + }, + expectedVal: true, + name: "true with upper case", + }, + { + input: map[string]string{ + kube.ResourcePolicyAnno: " " + kube.KeepPolicy + " ", + }, + expectedVal: true, + name: "true with spaces", + }, + { + input: map[string]string{ + kube.ResourcePolicyAnno: " " + strings.ToUpper(kube.KeepPolicy) + " ", + }, + expectedVal: true, + name: "true with upper case and spaces", + }, + } + + for _, test := range tests { + assert.Equal(t, test.expectedVal, containsResourcePolicyKeep(test.input), test.name) + } +} diff --git a/internal/legacy/helm/client/doc.go b/internal/legacy/helm/client/doc.go new file mode 100644 index 00000000..93e885bc --- /dev/null +++ b/internal/legacy/helm/client/doc.go @@ -0,0 +1,17 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 client provides helper functions for API clients used by the helm +// operator. +package client diff --git a/internal/legacy/helm/diff/diff.go b/internal/legacy/helm/diff/diff.go new file mode 100644 index 00000000..6a833fdf --- /dev/null +++ b/internal/legacy/helm/diff/diff.go @@ -0,0 +1,61 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 diff + +import ( + "bytes" + "regexp" + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" +) + +// Generate generates a diff between a and b, in color. +func Generate(a, b string) string { + dmp := diffmatchpatch.New() + + wSrc, wDst, warray := dmp.DiffLinesToRunes(a, b) + diffs := dmp.DiffMainRunes(wSrc, wDst, false) + diffs = dmp.DiffCharsToLines(diffs, warray) + var buff bytes.Buffer + for _, diff := range diffs { + text := diff.Text + + switch diff.Type { + case diffmatchpatch.DiffInsert: + _, _ = buff.WriteString("\x1b[32m") + _, _ = buff.WriteString(prefixLines(text, "+")) + _, _ = buff.WriteString("\x1b[0m") + case diffmatchpatch.DiffDelete: + _, _ = buff.WriteString("\x1b[31m") + _, _ = buff.WriteString(prefixLines(text, "-")) + _, _ = buff.WriteString("\x1b[0m") + case diffmatchpatch.DiffEqual: + _, _ = buff.WriteString(prefixLines(text, " ")) + } + } + return buff.String() +} + +func prefixLines(s, prefix string) string { + var buf bytes.Buffer + lines := strings.Split(s, "\n") + ls := regexp.MustCompile("^") + for _, line := range lines[:len(lines)-1] { + buf.WriteString(ls.ReplaceAllString(line, prefix)) + buf.WriteString("\n") + } + return buf.String() +} diff --git a/internal/legacy/helm/types/doc.go b/internal/legacy/helm/types/doc.go new file mode 100644 index 00000000..18255ab3 --- /dev/null +++ b/internal/legacy/helm/types/doc.go @@ -0,0 +1,17 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 types contains types used by various components of the Helm +// operator +package types diff --git a/internal/legacy/helm/types/types.go b/internal/legacy/helm/types/types.go new file mode 100644 index 00000000..b816b57b --- /dev/null +++ b/internal/legacy/helm/types/types.go @@ -0,0 +1,146 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 types + +import ( + "encoding/json" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +type HelmAppList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []HelmApp `json:"items"` +} + +type HelmApp struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec HelmAppSpec `json:"spec"` + Status HelmAppStatus `json:"status,omitempty"` +} + +type HelmAppSpec map[string]interface{} + +type HelmAppConditionType string +type ConditionStatus string +type HelmAppConditionReason string + +type HelmAppCondition struct { + Type HelmAppConditionType `json:"type"` + Status ConditionStatus `json:"status"` + Reason HelmAppConditionReason `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + + LastTransitionTime metav1.Time `json:"lastTransitionTime,omitempty"` +} + +type HelmAppRelease struct { + Name string `json:"name,omitempty"` + Manifest string `json:"manifest,omitempty"` +} + +const ( + ConditionInitialized HelmAppConditionType = "Initialized" + ConditionDeployed HelmAppConditionType = "Deployed" + ConditionReleaseFailed HelmAppConditionType = "ReleaseFailed" + ConditionIrreconcilable HelmAppConditionType = "Irreconcilable" + + StatusTrue ConditionStatus = "True" + StatusFalse ConditionStatus = "False" + StatusUnknown ConditionStatus = "Unknown" + + ReasonInstallSuccessful HelmAppConditionReason = "InstallSuccessful" + ReasonUpgradeSuccessful HelmAppConditionReason = "UpgradeSuccessful" + ReasonUninstallSuccessful HelmAppConditionReason = "UninstallSuccessful" + ReasonInstallError HelmAppConditionReason = "InstallError" + ReasonUpgradeError HelmAppConditionReason = "UpgradeError" + ReasonReconcileError HelmAppConditionReason = "ReconcileError" + ReasonUninstallError HelmAppConditionReason = "UninstallError" +) + +type HelmAppStatus struct { + Conditions []HelmAppCondition `json:"conditions"` + DeployedRelease *HelmAppRelease `json:"deployedRelease,omitempty"` +} + +func (s *HelmAppStatus) ToMap() (map[string]interface{}, error) { + var out map[string]interface{} + jsonObj, err := json.Marshal(&s) + if err != nil { + return nil, err + } + if err := json.Unmarshal(jsonObj, &out); err != nil { + return nil, err + } + return out, nil +} + +// SetCondition sets a condition on the status object. If the condition already +// exists, it will be replaced. SetCondition does not update the resource in +// the cluster. +func (s *HelmAppStatus) SetCondition(condition HelmAppCondition) *HelmAppStatus { + now := metav1.Now() + for i := range s.Conditions { + if s.Conditions[i].Type == condition.Type { + if s.Conditions[i].Status != condition.Status { + condition.LastTransitionTime = now + } else { + condition.LastTransitionTime = s.Conditions[i].LastTransitionTime + } + s.Conditions[i] = condition + return s + } + } + + // If the condition does not exist, + // initialize the lastTransitionTime + condition.LastTransitionTime = now + s.Conditions = append(s.Conditions, condition) + return s +} + +// RemoveCondition removes the condition with the passed condition type from +// the status object. If the condition is not already present, the returned +// status object is returned unchanged. RemoveCondition does not update the +// resource in the cluster. +func (s *HelmAppStatus) RemoveCondition(conditionType HelmAppConditionType) *HelmAppStatus { + for i := range s.Conditions { + if s.Conditions[i].Type == conditionType { + s.Conditions = append(s.Conditions[:i], s.Conditions[i+1:]...) + return s + } + } + return s +} + +// StatusFor safely returns a typed status block from a custom resource. +func StatusFor(cr *unstructured.Unstructured) *HelmAppStatus { + switch s := cr.Object["status"].(type) { + case *HelmAppStatus: + return s + case map[string]interface{}: + var status *HelmAppStatus + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(s, &status); err != nil { + return &HelmAppStatus{} + } + return status + default: + return &HelmAppStatus{} + } +} diff --git a/internal/legacy/helm/types/types_test.go b/internal/legacy/helm/types/types_test.go new file mode 100644 index 00000000..cd8e84ca --- /dev/null +++ b/internal/legacy/helm/types/types_test.go @@ -0,0 +1,135 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + testNamespaceName = "helm-test" +) + +var now = metav1.Now() + +func TestSetCondition(t *testing.T) { + message := "uninstall was successful" + newStatus, err := newTestStatus().SetCondition(HelmAppCondition{ + Type: ConditionDeployed, + Status: StatusFalse, + Reason: ReasonUninstallSuccessful, + Message: message, + }).ToMap() + assert.NoError(t, err) + + resource := newTestResource() + resource.Object["status"] = newStatus + actual := StatusFor(resource) + + assert.Equal(t, ConditionDeployed, actual.Conditions[0].Type) + assert.Equal(t, StatusFalse, actual.Conditions[0].Status) + assert.Equal(t, ReasonUninstallSuccessful, actual.Conditions[0].Reason) + assert.Equal(t, message, actual.Conditions[0].Message) + assert.NotEqual(t, metav1.Now(), actual.Conditions[0].LastTransitionTime) +} +func TestRemoveCondition(t *testing.T) { + newStatus, err := newTestStatus().RemoveCondition(ConditionDeployed).ToMap() + assert.NoError(t, err) + + resource := newTestResource() + resource.Object["status"] = newStatus + actual := StatusFor(resource) + + assert.Empty(t, actual.Conditions) +} + +func TestStatusForEmpty(t *testing.T) { + status := StatusFor(newTestResource()) + + assert.Equal(t, &HelmAppStatus{}, status) +} + +func TestStatusForFilled(t *testing.T) { + expectedResource := newTestResource() + expectedResource.Object["status"] = newTestStatus() + status := StatusFor(expectedResource) + + assert.EqualValues(t, newTestStatus(), status) +} + +func TestStatusForFilledRaw(t *testing.T) { + expectedResource := newTestResource() + expectedResource.Object["status"] = newTestStatusRaw() + status := StatusFor(expectedResource) + + assert.Equal(t, ConditionDeployed, status.Conditions[0].Type) + assert.Equal(t, StatusTrue, status.Conditions[0].Status) + assert.Equal(t, ReasonInstallSuccessful, status.Conditions[0].Reason) + assert.Equal(t, "some message", status.Conditions[0].Message) + assert.NotEqual(t, metav1.Now(), status.Conditions[0].LastTransitionTime) + assert.Equal(t, "SomeRelease", status.DeployedRelease.Name) +} + +func newTestResource() *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "Character", + "apiVersion": "stable.nicolerenee.io", + "metadata": map[string]interface{}{ + "name": "dory", + "namespace": testNamespaceName, + }, + "spec": map[string]interface{}{ + "Name": "Dory", + "From": "Finding Nemo", + "By": "Disney", + }, + }, + } +} + +func newTestStatus() *HelmAppStatus { + return &HelmAppStatus{ + Conditions: []HelmAppCondition{ + { + Type: ConditionDeployed, + Status: StatusTrue, + Reason: ReasonInstallSuccessful, + Message: "some message", + LastTransitionTime: now, + }, + }, + DeployedRelease: &HelmAppRelease{Name: "SomeRelease"}, + } +} + +func newTestStatusRaw() map[string]interface{} { + return map[string]interface{}{ + "conditions": []map[string]interface{}{ + { + "type": "Deployed", + "status": "True", + "reason": "InstallSuccessful", + "message": "some message", + "lastTransitionTime": now.UTC(), + }, + }, + "deployedRelease": map[string]interface{}{"name": "SomeRelease"}, + } +} diff --git a/internal/legacy/manifestutil/resource_policy.go b/internal/legacy/manifestutil/resource_policy.go new file mode 100644 index 00000000..320d9f4d --- /dev/null +++ b/internal/legacy/manifestutil/resource_policy.go @@ -0,0 +1,44 @@ +/* +Copyright 2021 The Operator-SDK Authors. +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 manifestutil + +import ( + "strings" + + "helm.sh/helm/v3/pkg/kube" + "helm.sh/helm/v3/pkg/releaseutil" +) + +// Source from https://github.com/helm/helm/blob/v3.4.2/pkg/action/resource_policy.go +func FilterManifestsToKeep(manifests []releaseutil.Manifest) (keep, remaining []releaseutil.Manifest) { + for _, m := range manifests { + if m.Head.Metadata == nil || m.Head.Metadata.Annotations == nil || len(m.Head.Metadata.Annotations) == 0 { + remaining = append(remaining, m) + continue + } + + resourcePolicyType, ok := m.Head.Metadata.Annotations[kube.ResourcePolicyAnno] + if !ok { + remaining = append(remaining, m) + continue + } + + resourcePolicyType = strings.ToLower(strings.TrimSpace(resourcePolicyType)) + if resourcePolicyType == kube.KeepPolicy { + keep = append(keep, m) + } + + } + return keep, remaining +} diff --git a/internal/legacy/release/doc.go b/internal/legacy/release/doc.go new file mode 100644 index 00000000..76c4c344 --- /dev/null +++ b/internal/legacy/release/doc.go @@ -0,0 +1,18 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 release provides interfaces and default implementations for a Helm +// release manager, which is used by the Helm controller and reconciler to +// manage Helm releases in a cluster based on watched custom resources. +package release diff --git a/internal/legacy/release/manager.go b/internal/legacy/release/manager.go new file mode 100644 index 00000000..6baa26fd --- /dev/null +++ b/internal/legacy/release/manager.go @@ -0,0 +1,424 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 release + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + jsonpatch "gomodules.xyz/jsonpatch/v3" + "helm.sh/helm/v3/pkg/action" + cpb "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/kube" + rpb "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/releaseutil" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + apitypes "k8s.io/apimachinery/pkg/types" + apiutilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/discovery" + + "github.com/operator-framework/helm-operator-plugins/internal/legacy/helm/types" + "github.com/operator-framework/helm-operator-plugins/internal/legacy/manifestutil" +) + +// Manager manages a Helm release. It can install, upgrade, reconcile, +// and uninstall a release. +type Manager interface { + ReleaseName() string + IsInstalled() bool + IsUpgradeRequired() bool + Sync(context.Context) error + InstallRelease(context.Context, ...InstallOption) (*rpb.Release, error) + UpgradeRelease(context.Context, ...UpgradeOption) (*rpb.Release, *rpb.Release, error) + ReconcileRelease(context.Context) (*rpb.Release, error) + UninstallRelease(context.Context, ...UninstallOption) (*rpb.Release, error) + CleanupRelease(context.Context, string) (bool, error) +} + +type manager struct { + actionConfig *action.Configuration + storageBackend *storage.Storage + kubeClient kube.Interface + + releaseName string + namespace string + + values map[string]interface{} + status *types.HelmAppStatus + + isInstalled bool + isUpgradeRequired bool + deployedRelease *rpb.Release + chart *cpb.Chart +} + +type InstallOption func(*action.Install) error +type UpgradeOption func(*action.Upgrade) error +type UninstallOption func(*action.Uninstall) error + +// ReleaseName returns the name of the release. +func (m manager) ReleaseName() string { + return m.releaseName +} + +func (m manager) IsInstalled() bool { + return m.isInstalled +} + +func (m manager) IsUpgradeRequired() bool { + return m.isUpgradeRequired +} + +// Sync ensures the Helm storage backend is in sync with the status of the +// custom resource. +func (m *manager) Sync(ctx context.Context) error { + // Get release history for this release name + releases, err := m.storageBackend.History(m.releaseName) + if err != nil && !notFoundErr(err) { + return fmt.Errorf("failed to retrieve release history: %w", err) + } + + // Cleanup non-deployed release versions. If all release versions are + // non-deployed, this will ensure that failed installations are correctly + // retried. + for _, rel := range releases { + if rel.Info != nil && rel.Info.Status != rpb.StatusDeployed { + _, err := m.storageBackend.Delete(rel.Name, rel.Version) + if err != nil && !notFoundErr(err) { + return fmt.Errorf("failed to delete stale release version: %w", err) + } + } + } + + // Load the most recently deployed release from the storage backend. + deployedRelease, err := m.getDeployedRelease() + if errors.Is(err, driver.ErrReleaseNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("failed to get deployed release: %w", err) + } + m.deployedRelease = deployedRelease + m.isInstalled = true + + // Get the next candidate release to determine if an upgrade is necessary. + candidateRelease, err := m.getCandidateRelease(m.namespace, m.releaseName, m.chart, m.values) + if err != nil { + return fmt.Errorf("failed to get candidate release: %w", err) + } + if deployedRelease.Manifest != candidateRelease.Manifest { + m.isUpgradeRequired = true + } + + return nil +} + +func notFoundErr(err error) bool { + return err != nil && strings.Contains(err.Error(), "not found") +} + +func (m manager) getDeployedRelease() (*rpb.Release, error) { + deployedRelease, err := m.storageBackend.Deployed(m.releaseName) + if err != nil { + if strings.Contains(err.Error(), "has no deployed releases") { + return nil, driver.ErrReleaseNotFound + } + return nil, err + } + return deployedRelease, nil +} + +func (m manager) getCandidateRelease(namespace, name string, chart *cpb.Chart, + values map[string]interface{}) (*rpb.Release, error) { + upgrade := action.NewUpgrade(m.actionConfig) + upgrade.Namespace = namespace + upgrade.DryRun = true + return upgrade.Run(name, chart, values) +} + +// InstallRelease performs a Helm release install. +func (m manager) InstallRelease(ctx context.Context, opts ...InstallOption) (*rpb.Release, error) { + install := action.NewInstall(m.actionConfig) + install.ReleaseName = m.releaseName + install.Namespace = m.namespace + for _, o := range opts { + if err := o(install); err != nil { + return nil, fmt.Errorf("failed to apply install option: %w", err) + } + } + + installedRelease, err := install.Run(m.chart, m.values) + if err != nil { + // Workaround for helm/helm#3338 + if installedRelease != nil { + uninstall := action.NewUninstall(m.actionConfig) + _, uninstallErr := uninstall.Run(m.releaseName) + + // In certain cases, InstallRelease will return a partial release in + // the response even when it doesn't record the release in its release + // store (e.g. when there is an error rendering the release manifest). + // In that case the rollback will fail with a not found error because + // there was nothing to rollback. + // + // Only log a message about a rollback failure if the failure was caused + // by something other than the release not being found. + if uninstallErr != nil && !notFoundErr(uninstallErr) { + return nil, fmt.Errorf("failed installation (%s) and failed rollback: %w", err, uninstallErr) + } + } + return nil, fmt.Errorf("failed to install release: %w", err) + } + return installedRelease, nil +} + +func ForceUpgrade(force bool) UpgradeOption { + return func(u *action.Upgrade) error { + u.Force = force + return nil + } +} + +// UpgradeRelease performs a Helm release upgrade. +func (m manager) UpgradeRelease(ctx context.Context, opts ...UpgradeOption) (*rpb.Release, *rpb.Release, error) { + upgrade := action.NewUpgrade(m.actionConfig) + upgrade.Namespace = m.namespace + for _, o := range opts { + if err := o(upgrade); err != nil { + return nil, nil, fmt.Errorf("failed to apply upgrade option: %w", err) + } + } + + upgradedRelease, err := upgrade.Run(m.releaseName, m.chart, m.values) + if err != nil { + // Workaround for helm/helm#3338 + if upgradedRelease != nil { + rollback := action.NewRollback(m.actionConfig) + rollback.Force = true + + // As of Helm 2.13, if UpgradeRelease returns a non-nil release, that + // means the release was also recorded in the release store. + // Therefore, we should perform the rollback when we have a non-nil + // release. Any rollback error here would be unexpected, so always + // log both the upgrade and rollback errors. + rollbackErr := rollback.Run(m.releaseName) + if rollbackErr != nil { + return nil, nil, fmt.Errorf("failed upgrade (%s) and failed rollback: %w", err, rollbackErr) + } + } + return nil, nil, fmt.Errorf("failed to upgrade release: %w", err) + } + return m.deployedRelease, upgradedRelease, err +} + +// ReconcileRelease creates or patches resources as necessary to match the +// deployed release's manifest. +func (m manager) ReconcileRelease(ctx context.Context) (*rpb.Release, error) { + err := reconcileRelease(ctx, m.kubeClient, m.deployedRelease.Manifest) + return m.deployedRelease, err +} + +func reconcileRelease(_ context.Context, kubeClient kube.Interface, expectedManifest string) error { + expectedInfos, err := kubeClient.Build(bytes.NewBufferString(expectedManifest), false) + if err != nil { + return err + } + return expectedInfos.Visit(func(expected *resource.Info, err error) error { + if err != nil { + return fmt.Errorf("visit error: %w", err) + } + + helper := resource.NewHelper(expected.Client, expected.Mapping) + existing, err := helper.Get(expected.Namespace, expected.Name) + if apierrors.IsNotFound(err) { + if _, err := helper.Create(expected.Namespace, true, expected.Object); err != nil { + return fmt.Errorf("create error: %s", err) + } + return nil + } else if err != nil { + return fmt.Errorf("could not get object: %w", err) + } + + // Replicate helm's patch creation, which will create a Three-Way-Merge patch for + // native kubernetes Objects and fall back to a JSON merge patch for unstructured Objects such as CRDs + // We also extend the JSON merge patch by ignoring "remove" operations for fields added by kubernetes + // Reference in the helm source code: + // https://github.com/helm/helm/blob/1c9b54ad7f62a5ce12f87c3ae55136ca20f09c98/pkg/kube/client.go#L392 + patch, patchType, err := createPatch(existing, expected) + if err != nil { + return fmt.Errorf("error creating patch: %w", err) + } + + if patch == nil { + // nothing to do + return nil + } + + _, err = helper.Patch(expected.Namespace, expected.Name, patchType, patch, + &metav1.PatchOptions{}) + if err != nil { + return fmt.Errorf("patch error: %w", err) + } + return nil + }) +} + +func createPatch(existing runtime.Object, expected *resource.Info) ([]byte, apitypes.PatchType, error) { + existingJSON, err := json.Marshal(existing) + if err != nil { + return nil, apitypes.StrategicMergePatchType, err + } + expectedJSON, err := json.Marshal(expected.Object) + if err != nil { + return nil, apitypes.StrategicMergePatchType, err + } + + // Get a versioned object + versionedObject := kube.AsVersioned(expected) + + // Unstructured objects, such as CRDs, may not have an not registered error + // returned from ConvertToVersion. Anything that's unstructured should + // use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported + // on objects like CRDs. + _, isUnstructured := versionedObject.(runtime.Unstructured) + + // On newer K8s versions, CRDs aren't unstructured but have a dedicated type + _, isV1CRD := versionedObject.(*apiextv1.CustomResourceDefinition) + _, isV1beta1CRD := versionedObject.(*apiextv1beta1.CustomResourceDefinition) + isCRD := isV1CRD || isV1beta1CRD + + if isUnstructured || isCRD { + // fall back to generic JSON merge patch + patch, err := createJSONMergePatch(existingJSON, expectedJSON) + return patch, apitypes.JSONPatchType, err + } + + patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject) + if err != nil { + return nil, apitypes.StrategicMergePatchType, err + } + + patch, err := strategicpatch.CreateThreeWayMergePatch(expectedJSON, expectedJSON, existingJSON, patchMeta, true) + if err != nil { + return nil, apitypes.StrategicMergePatchType, err + } + // An empty patch could be in the form of "{}" which represents an empty map out of the 3-way merge; + // filter them out here too to avoid sending the apiserver empty patch requests. + if len(patch) == 0 || bytes.Equal(patch, []byte("{}")) { + return nil, apitypes.StrategicMergePatchType, nil + } + return patch, apitypes.StrategicMergePatchType, nil +} + +func createJSONMergePatch(existingJSON, expectedJSON []byte) ([]byte, error) { + ops, err := jsonpatch.CreatePatch(existingJSON, expectedJSON) + if err != nil { + return nil, err + } + + // We ignore the "remove" operations from the full patch because they are + // fields added by Kubernetes or by the user after the existing release + // resource has been applied. The goal for this patch is to make sure that + // the fields managed by the Helm chart are applied. + // All "add" operations without a value (null) can be ignored + patchOps := make([]jsonpatch.JsonPatchOperation, 0) + for _, op := range ops { + if op.Operation != "remove" && !(op.Operation == "add" && op.Value == nil) { + patchOps = append(patchOps, op) + } + } + + // If there are no patch operations, return nil. Callers are expected + // to check for a nil response and skip the patch operation to avoid + // unnecessary chatter with the API server. + if len(patchOps) == 0 { + return nil, nil + } + + return json.Marshal(patchOps) +} + +// UninstallRelease performs a Helm release uninstall. +func (m manager) UninstallRelease(ctx context.Context, opts ...UninstallOption) (*rpb.Release, error) { + uninstall := action.NewUninstall(m.actionConfig) + for _, o := range opts { + if err := o(uninstall); err != nil { + return nil, fmt.Errorf("failed to apply uninstall option: %w", err) + } + } + uninstallResponse, err := uninstall.Run(m.releaseName) + if uninstallResponse == nil { + return nil, err + } + return uninstallResponse.Release, err +} + +// CleanupRelease deletes resources if they are not deleted already. +// Return true if all the resources are deleted, false otherwise. +func (m manager) CleanupRelease(ctx context.Context, manifest string) (bool, error) { + dc, err := m.actionConfig.RESTClientGetter.ToDiscoveryClient() + if err != nil { + return false, fmt.Errorf("failed to get Kubernetes discovery client: %w", err) + } + apiVersions, err := action.GetVersionSet(dc) + if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { + return false, fmt.Errorf("failed to get apiVersions from Kubernetes: %w", err) + } + manifests := releaseutil.SplitManifests(manifest) + _, files, err := releaseutil.SortManifests(manifests, apiVersions, releaseutil.UninstallOrder) + if err != nil { + return false, fmt.Errorf("failed to sort manifests: %w", err) + } + // do not delete resources that are annotated with the Helm resource policy 'keep' + _, filesToDelete := manifestutil.FilterManifestsToKeep(files) + var builder strings.Builder + for _, file := range filesToDelete { + builder.WriteString("\n---\n" + file.Content) + } + resources, err := m.kubeClient.Build(strings.NewReader(builder.String()), false) + if err != nil { + return false, fmt.Errorf("failed to build resources from manifests: %w", err) + } + if resources == nil || len(resources) <= 0 { + return true, nil + } + for _, resource := range resources { + err = resource.Get() + if err != nil { + if apierrors.IsNotFound(err) { + continue // resource is already delete, check the next one. + } + return false, fmt.Errorf("failed to get resource: %w", err) + } + // found at least one resource that is not deleted so just delete everything again. + _, errs := m.kubeClient.Delete(resources) + if len(errs) > 0 { + return false, fmt.Errorf("failed to delete resources: %v", apiutilerrors.NewAggregate(errs)) + } + return false, nil + } + return true, nil +} diff --git a/internal/legacy/release/manager_factory.go b/internal/legacy/release/manager_factory.go new file mode 100644 index 00000000..75ec0492 --- /dev/null +++ b/internal/legacy/release/manager_factory.go @@ -0,0 +1,199 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 release + +import ( + "fmt" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/kube" + helmrelease "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" + "helm.sh/helm/v3/pkg/strvals" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + v1 "k8s.io/client-go/kubernetes/typed/core/v1" + crmanager "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/operator-framework/helm-operator-plugins/internal/legacy/helm/client" + "github.com/operator-framework/helm-operator-plugins/internal/legacy/helm/types" +) + +// ManagerFactory creates Managers that are specific to custom resources. It is +// used by the HelmOperatorReconciler during resource reconciliation, and it +// improves decoupling between reconciliation logic and the Helm backend +// components used to manage releases. +type ManagerFactory interface { + NewManager(r *unstructured.Unstructured, overrideValues map[string]string) (Manager, error) +} + +type managerFactory struct { + mgr crmanager.Manager + chartDir string +} + +// NewManagerFactory returns a new Helm manager factory capable of installing and uninstalling releases. +func NewManagerFactory(mgr crmanager.Manager, chartDir string) ManagerFactory { + return &managerFactory{mgr, chartDir} +} + +func (f managerFactory) NewManager(cr *unstructured.Unstructured, overrideValues map[string]string) (Manager, error) { + // Get both v2 and v3 storage backends + clientv1, err := v1.NewForConfig(f.mgr.GetConfig()) + if err != nil { + return nil, fmt.Errorf("failed to get core/v1 client: %w", err) + } + storageBackend := storage.Init(driver.NewSecrets(clientv1.Secrets(cr.GetNamespace()))) + + // Get the necessary clients and client getters. Use a client that injects the CR + // as an owner reference into all resources templated by the chart. + rcg, err := client.NewRESTClientGetter(f.mgr, cr.GetNamespace()) + if err != nil { + return nil, fmt.Errorf("failed to get REST client getter from manager: %w", err) + } + + kubeClient := kube.New(rcg) + restMapper := f.mgr.GetRESTMapper() + ownerRefClient, err := client.NewOwnerRefInjectingClient(*kubeClient, restMapper, cr) + if err != nil { + return nil, fmt.Errorf("failed to inject owner references: %w", err) + } + + crChart, err := loader.LoadDir(f.chartDir) + if err != nil { + return nil, fmt.Errorf("failed to load chart dir: %w", err) + } + + releaseName, err := getReleaseName(storageBackend, crChart.Name(), cr) + if err != nil { + return nil, fmt.Errorf("failed to get helm release name: %w", err) + } + + crValues, ok := cr.Object["spec"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to get spec: expected map[string]interface{}") + } + + expOverrides, err := parseOverrides(overrideValues) + if err != nil { + return nil, fmt.Errorf("failed to parse override values: %w", err) + } + values := mergeMaps(crValues, expOverrides) + + actionConfig := &action.Configuration{ + RESTClientGetter: rcg, + Releases: storageBackend, + KubeClient: ownerRefClient, + Log: func(_ string, _ ...interface{}) {}, + } + + return &manager{ + actionConfig: actionConfig, + storageBackend: storageBackend, + kubeClient: ownerRefClient, + + releaseName: releaseName, + namespace: cr.GetNamespace(), + + chart: crChart, + values: values, + status: types.StatusFor(cr), + }, nil +} + +// getReleaseName returns a release name for the CR. +// +// getReleaseName searches for a release using the CR name. If a release +// cannot be found, or if it is found and was created by the chart managed +// by this manager, the CR name is returned. +// +// If a release is found but it was created by another chart, that means we +// have a release name collision, so return an error. This case is possible +// because Kubernetes allows instances of different types to have the same name +// in the same namespace. +// +// TODO(jlanford): As noted above, using the CR name as the release name raises +// the possibility of collision. We should move this logic to a validating +// admission webhook so that the CR owner receives immediate feedback of the +// collision. As is, the only indication of collision will be in the CR status +// and operator logs. +func getReleaseName(storageBackend *storage.Storage, crChartName string, + cr *unstructured.Unstructured) (string, error) { + // If a release with the CR name does not exist, return the CR name. + releaseName := cr.GetName() + history, exists, err := releaseHistory(storageBackend, releaseName) + if err != nil { + return "", err + } + if !exists { + return releaseName, nil + } + + // If a release name with the CR name exists, but the release's chart is + // different than the chart managed by this operator, return an error + // because something else created the existing release. + if history[0].Chart == nil { + return "", fmt.Errorf("could not find chart metadata in release with name %q", releaseName) + } + existingChartName := history[0].Chart.Name() + if existingChartName != crChartName { + return "", fmt.Errorf("duplicate release name: found existing release with name %q for chart %q", + releaseName, existingChartName) + } + + return releaseName, nil +} + +func releaseHistory(storageBackend *storage.Storage, releaseName string) ([]*helmrelease.Release, bool, error) { + releaseHistory, err := storageBackend.History(releaseName) + if err != nil { + if notFoundErr(err) { + return nil, false, nil + } + return nil, false, err + } + return releaseHistory, len(releaseHistory) > 0, nil +} + +func parseOverrides(in map[string]string) (map[string]interface{}, error) { + out := make(map[string]interface{}) + for k, v := range in { + val := fmt.Sprintf("%s=%s", k, v) + if err := strvals.ParseIntoString(val, out); err != nil { + return nil, err + } + } + return out, nil +} + +func mergeMaps(a, b map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(a)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + if v, ok := v.(map[string]interface{}); ok { + if bv, ok := out[k]; ok { + if bv, ok := bv.(map[string]interface{}); ok { + out[k] = mergeMaps(bv, v) + continue + } + } + } + out[k] = v + } + return out +} diff --git a/internal/legacy/release/manager_test.go b/internal/legacy/release/manager_test.go new file mode 100644 index 00000000..466bb693 --- /dev/null +++ b/internal/legacy/release/manager_test.go @@ -0,0 +1,215 @@ +// Copyright 2018 The Operator-SDK Authors +// +// 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 release + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + apitypes "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/resource" + + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime" +) + +func newTestUnstructured(containers []interface{}) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "MyResource", + "apiVersion": "myApi", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "ns", + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "containers": containers, + }, + }, + }, + }, + } +} + +func newTestDeployment(containers []v1.Container) *appsv1.Deployment { + return &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: containers, + }, + }, + }, + } +} + +func TestManagerGenerateStrategicMergePatch(t *testing.T) { + + tests := []struct { + o1 runtime.Object + o2 runtime.Object + patch string + patchType apitypes.PatchType + }{ + { + o1: newTestUnstructured([]interface{}{ + map[string]interface{}{ + "name": "test1", + }, + map[string]interface{}{ + "name": "test2", + }, + }), + o2: newTestUnstructured([]interface{}{ + map[string]interface{}{ + "name": "test1", + }, + }), + patch: ``, + patchType: apitypes.JSONPatchType, + }, + { + o1: newTestUnstructured([]interface{}{ + map[string]interface{}{ + "name": "test1", + }, + }), + o2: newTestUnstructured([]interface{}{ + map[string]interface{}{ + "name": "test1", + }, + map[string]interface{}{ + "name": "test2", + }, + }), + patch: `[{"op":"add","path":"/spec/template/spec/containers/1","value":{"name":"test2"}}]`, + patchType: apitypes.JSONPatchType, + }, + { + o1: newTestUnstructured([]interface{}{ + map[string]interface{}{ + "name": "test1", + }, + }), + o2: newTestUnstructured([]interface{}{ + map[string]interface{}{ + "name": "test1", + "test": nil, + }, + }), + patch: ``, + patchType: apitypes.JSONPatchType, + }, + { + o1: newTestUnstructured([]interface{}{ + map[string]interface{}{ + "name": "test1", + }, + }), + o2: newTestUnstructured([]interface{}{ + map[string]interface{}{ + "name": "test2", + }, + }), + patch: `[{"op":"replace","path":"/spec/template/spec/containers/0/name","value":"test2"}]`, + patchType: apitypes.JSONPatchType, + }, + { + o1: newTestDeployment([]v1.Container{ + {Name: "test1"}, + {Name: "test2"}, + }), + o2: newTestDeployment([]v1.Container{ + {Name: "test1"}, + }), + patch: `{"spec":{"template":{"spec":{"$setElementOrder/containers":[{"name":"test1"}]}}}}`, + patchType: apitypes.StrategicMergePatchType, + }, + { + o1: newTestDeployment([]v1.Container{ + {Name: "test1"}, + }), + o2: newTestDeployment([]v1.Container{ + {Name: "test1"}, + {Name: "test2"}, + }), + patch: `{"spec":{"template":{"spec":{"$setElementOrder/containers":[{"name":"test1"},{"name":"test2"}],"containers":[{"name":"test2","resources":{}}]}}}}`, + patchType: apitypes.StrategicMergePatchType, + }, + { + o1: newTestDeployment([]v1.Container{ + {Name: "test1"}, + }), + o2: newTestDeployment([]v1.Container{ + {Name: "test1", LivenessProbe: nil}, + }), + patch: ``, + patchType: apitypes.StrategicMergePatchType, + }, + { + o1: newTestDeployment([]v1.Container{ + {Name: "test1"}, + }), + o2: newTestDeployment([]v1.Container{ + {Name: "test2"}, + }), + patch: `{"spec":{"template":{"spec":{"$setElementOrder/containers":[{"name":"test2"}],"containers":[{"name":"test2","resources":{}}]}}}}`, + patchType: apitypes.StrategicMergePatchType, + }, + { + o1: &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "ns", + Annotations: map[string]string{ + "testannotation": "testvalue", + }, + }, + Spec: appsv1.DeploymentSpec{}, + }, + o2: &appsv1.Deployment{ + TypeMeta: metav1.TypeMeta{Kind: "Deployment", APIVersion: "apps/v1"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "ns", + }, + Spec: appsv1.DeploymentSpec{}, + }, + patch: ``, + patchType: apitypes.StrategicMergePatchType, + }, + } + + for _, test := range tests { + + o2Info := &resource.Info{ + Object: test.o2, + } + + diff, patchType, err := createPatch(test.o1, o2Info) + assert.NoError(t, err) + assert.Equal(t, test.patchType, patchType) + assert.Equal(t, test.patch, string(diff)) + } +} diff --git a/internal/legacy/watches/watches.go b/internal/legacy/watches/watches.go new file mode 100644 index 00000000..e2f43a1d --- /dev/null +++ b/internal/legacy/watches/watches.go @@ -0,0 +1,159 @@ +// Copyright 2019 The Operator-SDK Authors +// +// 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 watches + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "text/template" + + sprig "github.com/go-task/slim-sprig" + "helm.sh/helm/v3/pkg/chartutil" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" +) + +const WatchesFile = "watches.yaml" + +// Watch defines options for configuring a watch for a Helm-based +// custom resource. +type Watch struct { + schema.GroupVersionKind `json:",inline"` + ChartDir string `json:"chart"` + WatchDependentResources *bool `json:"watchDependentResources,omitempty"` + OverrideValues map[string]string `json:"overrideValues,omitempty"` + Selector metav1.LabelSelector `json:"selector"` +} + +// UnmarshalYAML unmarshals an individual watch from the Helm watches.yaml file +// into a Watch struct. +// +// Deprecated: This function is no longer used internally to unmarshal +// watches.yaml data. To ensure the correct defaults are applied when loading +// watches.yaml, use Load() or LoadReader() instead of this function and/or +// yaml.Unmarshal(). +func (w *Watch) UnmarshalYAML(unmarshal func(interface{}) error) error { + // by default, the operator will watch dependent resources + trueVal := true + w.WatchDependentResources = &trueVal + + // hide watch data in plain struct to prevent unmarshal from calling + // UnmarshalYAML again + type plain Watch + + return unmarshal((*plain)(w)) +} + +// Load loads a slice of Watches from the watch file at `path`. For each entry +// in the watches file, it verifies the configuration. If an error is +// encountered loading the file or verifying the configuration, it will be +// returned. +func Load(path string) ([]Watch, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("could not open watches file: %w", err) + } + w, err := LoadReader(f) + + // Make sure to close the file, regardless of the error returned by + // LoadReader. + if err := f.Close(); err != nil { + return nil, fmt.Errorf("could not close watches file: %w", err) + } + return w, err +} + +// LoadReader loads a slice of Watches from the provided reader. For each entry +// in the watches file, it verifies the configuration. If an error is +// encountered reading or verifying the configuration, it will be returned. +func LoadReader(reader io.Reader) ([]Watch, error) { + b, err := ioutil.ReadAll(reader) + if err != nil { + return nil, err + } + + watches := []Watch{} + err = yaml.Unmarshal(b, &watches) + if err != nil { + return nil, err + } + + watchesMap := make(map[schema.GroupVersionKind]struct{}) + for i, w := range watches { + gvk := w.GroupVersionKind + + if err := verifyGVK(gvk); err != nil { + return nil, fmt.Errorf("invalid GVK: %s: %w", gvk, err) + } + + if _, err := chartutil.IsChartDir(w.ChartDir); err != nil { + return nil, fmt.Errorf("invalid chart directory %s: %w", w.ChartDir, err) + } + + if _, ok := watchesMap[gvk]; ok { + return nil, fmt.Errorf("duplicate GVK: %s", gvk) + } + watchesMap[gvk] = struct{}{} + if w.WatchDependentResources == nil { + trueVal := true + w.WatchDependentResources = &trueVal + } + w.OverrideValues, err = expandOverrideValues(w.OverrideValues) + if err != nil { + return nil, fmt.Errorf("failed to expand override values: %v", err) + } + watches[i] = w + } + return watches, nil +} + +func expandOverrideValues(in map[string]string) (map[string]string, error) { + if in == nil { + return nil, nil + } + out := make(map[string]string) + for k, v := range in { + envV := os.ExpandEnv(v) + + v := &bytes.Buffer{} + tmplV, err := template.New(k).Funcs(sprig.TxtFuncMap()).Parse(envV) + if err != nil { + return nil, fmt.Errorf("invalid template string %q: %v", envV, err) + } + if err := tmplV.Execute(v, nil); err != nil { + return nil, fmt.Errorf("failed to execute template %q: %v", envV, err) + } + out[k] = v.String() + } + return out, nil +} + +func verifyGVK(gvk schema.GroupVersionKind) error { + // A GVK without a group is valid. Certain scenarios may cause a GVK + // without a group to fail in other ways later in the initialization + // process. + if gvk.Version == "" { + return errors.New("version must not be empty") + } + if gvk.Kind == "" { + return errors.New("kind must not be empty") + } + return nil +} diff --git a/internal/legacy/watches/watches_test.go b/internal/legacy/watches/watches_test.go new file mode 100644 index 00000000..5e854e7f --- /dev/null +++ b/internal/legacy/watches/watches_test.go @@ -0,0 +1,310 @@ +// Copyright 2019 The Operator-SDK Authors +// +// 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 watches + +import ( + "bytes" + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestLoadReader(t *testing.T) { + trueVal, falseVal := true, false + testCases := []struct { + name string + data string + env map[string]string + expectWatches []Watch + expectErr bool + }{ + { + name: "valid", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart + watchDependentResources: false + overrideValues: + key: value +`, + expectWatches: []Watch{ + { + GroupVersionKind: schema.GroupVersionKind{Group: "mygroup", Version: "v1alpha1", Kind: "MyKind"}, + ChartDir: "../../../pkg/internal/testdata/test-chart", + WatchDependentResources: &falseVal, + OverrideValues: map[string]string{"key": "value"}, + }, + }, + expectErr: false, + }, + { + name: "valid with override env expansion", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart + watchDependentResources: false + overrideValues: + key: $MY_VALUE +`, + env: map[string]string{"MY_VALUE": "value"}, + expectWatches: []Watch{ + { + GroupVersionKind: schema.GroupVersionKind{Group: "mygroup", Version: "v1alpha1", Kind: "MyKind"}, + ChartDir: "../../../pkg/internal/testdata/test-chart", + WatchDependentResources: &falseVal, + OverrideValues: map[string]string{"key": "value"}, + }, + }, + expectErr: false, + }, + { + name: "valid with override template expansion", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart + watchDependentResources: false + overrideValues: + repo: '{{ ("$MY_IMAGE" | split ":")._0 }}' + tag: '{{ ("$MY_IMAGE" | split ":")._1 }}' +`, + env: map[string]string{"MY_IMAGE": "quay.io/operator-framework/helm-operator:latest"}, + expectWatches: []Watch{ + { + GroupVersionKind: schema.GroupVersionKind{Group: "mygroup", Version: "v1alpha1", Kind: "MyKind"}, + ChartDir: "../../../pkg/internal/testdata/test-chart", + WatchDependentResources: &falseVal, + OverrideValues: map[string]string{ + "repo": "quay.io/operator-framework/helm-operator", + "tag": "latest", + }, + }, + }, + expectErr: false, + }, + + { + name: "invalid with override template expansion", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart + watchDependentResources: false + overrideValues: + repo: '{{ ("$MY_IMAGE" | split ":")._0 }}' + tag: '{{ ("$MY_IMAGE" | split ":")._1' +`, + env: map[string]string{"MY_IMAGE": "quay.io/operator-framework/helm-operator:latest"}, + expectErr: true, + }, + { + name: "multiple gvk", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyFirstKind + chart: ../../../pkg/internal/testdata/test-chart +- group: mygroup + version: v1alpha1 + kind: MySecondKind + chart: ../../../pkg/internal/testdata/test-chart +`, + expectWatches: []Watch{ + { + GroupVersionKind: schema.GroupVersionKind{Group: "mygroup", Version: "v1alpha1", Kind: "MyFirstKind"}, + ChartDir: "../../../pkg/internal/testdata/test-chart", + WatchDependentResources: &trueVal, + }, + { + GroupVersionKind: schema.GroupVersionKind{Group: "mygroup", Version: "v1alpha1", Kind: "MySecondKind"}, + ChartDir: "../../../pkg/internal/testdata/test-chart", + WatchDependentResources: &trueVal, + }, + }, + expectErr: false, + }, + { + name: "duplicate gvk", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart +`, + expectErr: true, + }, + { + name: "no version", + data: `--- +- group: mygroup + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart +`, + expectErr: true, + }, + { + name: "no kind", + data: `--- +- group: mygroup + version: v1alpha1 + chart: ../../../pkg/internal/testdata/test-chart +`, + expectErr: true, + }, + { + name: "bad chart path", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: nonexistent/path/to/chart +`, + expectErr: true, + }, + { + name: "invalid overrides", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart + overrideValues: + key1: + key2: value +`, + expectErr: true, + }, + { + name: "invalid yaml", + data: `--- +foo: bar +`, + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + if err := os.Setenv(k, v); err != nil { + t.Fatalf("Failed to set environment variable %q: %v", k, err) + } + } + + watchesData := bytes.NewBufferString(tc.data) + watches, err := LoadReader(watchesData) + if !tc.expectErr && err != nil { + t.Fatalf("Expected no error; got error: %v", err) + } else if tc.expectErr && err == nil { + t.Fatalf("Expected error; got no error") + } + assert.Equal(t, tc.expectWatches, watches) + + for k := range tc.env { + if err := os.Unsetenv(k); err != nil { + t.Fatalf("Failed to unset environment variable %q: %v", k, err) + } + } + }) + } +} + +func TestLoad(t *testing.T) { + falseVal := false + testCases := []struct { + name string + data string + env map[string]string + expectWatches []Watch + expectErr bool + }{ + { + name: "valid", + data: `--- +- group: mygroup + version: v1alpha1 + kind: MyKind + chart: ../../../pkg/internal/testdata/test-chart + watchDependentResources: false + overrideValues: + key: value +`, + expectWatches: []Watch{ + { + GroupVersionKind: schema.GroupVersionKind{Group: "mygroup", Version: "v1alpha1", Kind: "MyKind"}, + ChartDir: "../../../pkg/internal/testdata/test-chart", + WatchDependentResources: &falseVal, + OverrideValues: map[string]string{"key": "value"}, + }, + }, + expectErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + if err := os.Setenv(k, v); err != nil { + t.Fatalf("Failed to set environment variable %q: %v", k, err) + } + } + + f, err := ioutil.TempFile("", "osdk-test-load") + if err != nil { + t.Fatalf("Failed to create temporary watches file: %v", err) + } + defer removeFile(t, f) + if _, err := f.WriteString(tc.data); err != nil { + t.Fatalf("Failed to write temporary watches file: %v", err) + } + watches, err := Load(f.Name()) + if !tc.expectErr && err != nil { + t.Fatalf("Expected no error; got error: %v", err) + } else if tc.expectErr && err == nil { + t.Fatalf("Expected error; got no error") + } + assert.Equal(t, tc.expectWatches, watches) + + for k := range tc.env { + if err := os.Unsetenv(k); err != nil { + t.Fatalf("Failed to unset environment variable %q: %v", k, err) + } + } + }) + } +} + +// remove removes path from disk. Used in defer statements. +func removeFile(t *testing.T, f *os.File) { + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := os.Remove(f.Name()); err != nil { + t.Fatal(err) + } +} diff --git a/main.go b/main.go index 3fdbdf32..b5e9aa43 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,7 @@ import ( "sigs.k8s.io/kubebuilder/v3/pkg/model/stage" "sigs.k8s.io/kubebuilder/v3/pkg/plugin" - "github.com/operator-framework/helm-operator-plugins/internal/cmd/run" + "github.com/operator-framework/helm-operator-plugins/internal/cmd/hybrid-operator/run" "github.com/operator-framework/helm-operator-plugins/internal/version" pluginv1alpha "github.com/operator-framework/helm-operator-plugins/pkg/plugins/hybrid/v1alpha" kustomizev1 "sigs.k8s.io/kubebuilder/v3/pkg/plugins/common/kustomize/v1" diff --git a/pkg/client/actionclient.go b/pkg/client/actionclient.go index 10b61701..00def322 100644 --- a/pkg/client/actionclient.go +++ b/pkg/client/actionclient.go @@ -44,8 +44,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/controllerutil" "github.com/operator-framework/helm-operator-plugins/pkg/manifestutil" + "github.com/operator-framework/helm-operator-plugins/pkg/sdk/controllerutil" ) type ActionClientGetter interface { @@ -323,7 +323,7 @@ func (pr *ownerPostRenderer) Run(in *bytes.Buffer) (*bytes.Buffer, error) { return err } u := &unstructured.Unstructured{Object: objMap} - useOwnerRef, err := controllerutil.SupportsOwnerReference(pr.rm, pr.owner, u) + useOwnerRef, err := controllerutil.SupportsOwnerReference(pr.rm, pr.owner.(*unstructured.Unstructured), u) if err != nil { return err } diff --git a/pkg/internal/sdk/fake/controller.go b/pkg/internal/fake/controller.go similarity index 100% rename from pkg/internal/sdk/fake/controller.go rename to pkg/internal/fake/controller.go diff --git a/pkg/internal/sdk/predicate/predicates.go b/pkg/internal/predicate/predicates.go similarity index 100% rename from pkg/internal/sdk/predicate/predicates.go rename to pkg/internal/predicate/predicates.go diff --git a/pkg/internal/sdk/status/conditions.go b/pkg/internal/status/conditions.go similarity index 100% rename from pkg/internal/sdk/status/conditions.go rename to pkg/internal/status/conditions.go diff --git a/pkg/internal/sdk/status/conditions_test.go b/pkg/internal/status/conditions_test.go similarity index 100% rename from pkg/internal/sdk/status/conditions_test.go rename to pkg/internal/status/conditions_test.go diff --git a/pkg/reconciler/internal/conditions/conditions.go b/pkg/reconciler/internal/conditions/conditions.go index 1148e125..55e2c656 100644 --- a/pkg/reconciler/internal/conditions/conditions.go +++ b/pkg/reconciler/internal/conditions/conditions.go @@ -21,7 +21,7 @@ import ( corev1 "k8s.io/api/core/v1" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/status" + "github.com/operator-framework/helm-operator-plugins/pkg/internal/status" ) const ( diff --git a/pkg/reconciler/internal/conditions/conditions_test.go b/pkg/reconciler/internal/conditions/conditions_test.go index f9dea6f6..20ada312 100644 --- a/pkg/reconciler/internal/conditions/conditions_test.go +++ b/pkg/reconciler/internal/conditions/conditions_test.go @@ -23,7 +23,7 @@ import ( . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/status" + "github.com/operator-framework/helm-operator-plugins/pkg/internal/status" . "github.com/operator-framework/helm-operator-plugins/pkg/reconciler/internal/conditions" ) diff --git a/pkg/reconciler/internal/hook/hook.go b/pkg/reconciler/internal/hook/hook.go index f76ac796..59456545 100644 --- a/pkg/reconciler/internal/hook/hook.go +++ b/pkg/reconciler/internal/hook/hook.go @@ -32,9 +32,9 @@ import ( "sigs.k8s.io/yaml" "github.com/operator-framework/helm-operator-plugins/pkg/hook" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/controllerutil" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/predicate" + "github.com/operator-framework/helm-operator-plugins/pkg/internal/predicate" "github.com/operator-framework/helm-operator-plugins/pkg/manifestutil" + "github.com/operator-framework/helm-operator-plugins/pkg/sdk/controllerutil" ) func NewDependentResourceWatcher(c controller.Controller, rm meta.RESTMapper) hook.PostHook { diff --git a/pkg/reconciler/internal/hook/hook_test.go b/pkg/reconciler/internal/hook/hook_test.go index b31e9ddb..22583bc8 100644 --- a/pkg/reconciler/internal/hook/hook_test.go +++ b/pkg/reconciler/internal/hook/hook_test.go @@ -30,7 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "github.com/operator-framework/helm-operator-plugins/pkg/hook" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/fake" + "github.com/operator-framework/helm-operator-plugins/pkg/internal/fake" internalhook "github.com/operator-framework/helm-operator-plugins/pkg/reconciler/internal/hook" ) diff --git a/pkg/reconciler/internal/updater/updater.go b/pkg/reconciler/internal/updater/updater.go index 28c06857..d8b2e5fb 100644 --- a/pkg/reconciler/internal/updater/updater.go +++ b/pkg/reconciler/internal/updater/updater.go @@ -26,8 +26,8 @@ import ( "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/controllerutil" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/status" + "github.com/operator-framework/helm-operator-plugins/pkg/internal/status" + "github.com/operator-framework/helm-operator-plugins/pkg/sdk/controllerutil" ) func New(client client.Client) Updater { diff --git a/pkg/reconciler/reconciler.go b/pkg/reconciler/reconciler.go index 4fcb2504..533cfff5 100644 --- a/pkg/reconciler/reconciler.go +++ b/pkg/reconciler/reconciler.go @@ -47,11 +47,11 @@ import ( "github.com/operator-framework/helm-operator-plugins/pkg/annotation" helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" "github.com/operator-framework/helm-operator-plugins/pkg/hook" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/controllerutil" "github.com/operator-framework/helm-operator-plugins/pkg/reconciler/internal/conditions" internalhook "github.com/operator-framework/helm-operator-plugins/pkg/reconciler/internal/hook" "github.com/operator-framework/helm-operator-plugins/pkg/reconciler/internal/updater" internalvalues "github.com/operator-framework/helm-operator-plugins/pkg/reconciler/internal/values" + "github.com/operator-framework/helm-operator-plugins/pkg/sdk/controllerutil" "github.com/operator-framework/helm-operator-plugins/pkg/values" ) diff --git a/pkg/reconciler/reconciler_test.go b/pkg/reconciler/reconciler_test.go index f272ae8f..290a5f39 100644 --- a/pkg/reconciler/reconciler_test.go +++ b/pkg/reconciler/reconciler_test.go @@ -52,11 +52,11 @@ import ( "github.com/operator-framework/helm-operator-plugins/pkg/annotation" helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" "github.com/operator-framework/helm-operator-plugins/pkg/hook" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/controllerutil" - "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/status" + "github.com/operator-framework/helm-operator-plugins/pkg/internal/status" "github.com/operator-framework/helm-operator-plugins/pkg/internal/testutil" "github.com/operator-framework/helm-operator-plugins/pkg/reconciler/internal/conditions" helmfake "github.com/operator-framework/helm-operator-plugins/pkg/reconciler/internal/fake" + "github.com/operator-framework/helm-operator-plugins/pkg/sdk/controllerutil" "github.com/operator-framework/helm-operator-plugins/pkg/values" ) diff --git a/pkg/internal/sdk/controllerutil/controllerutil.go b/pkg/sdk/controllerutil/controllerutil.go similarity index 95% rename from pkg/internal/sdk/controllerutil/controllerutil.go rename to pkg/sdk/controllerutil/controllerutil.go index 0c95f82d..91d3039d 100644 --- a/pkg/internal/sdk/controllerutil/controllerutil.go +++ b/pkg/sdk/controllerutil/controllerutil.go @@ -23,6 +23,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/wait" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -56,7 +57,7 @@ func WaitForDeletion(ctx context.Context, cl client.Reader, o client.Object) err }, ctx.Done()) } -func SupportsOwnerReference(restMapper meta.RESTMapper, owner, dependent client.Object) (bool, error) { +func SupportsOwnerReference(restMapper meta.RESTMapper, owner, dependent *unstructured.Unstructured) (bool, error) { ownerGVK := owner.GetObjectKind().GroupVersionKind() ownerMapping, err := restMapper.RESTMapping(ownerGVK.GroupKind(), ownerGVK.Version) if err != nil { @@ -72,6 +73,7 @@ func SupportsOwnerReference(restMapper meta.RESTMapper, owner, dependent client. ownerClusterScoped := ownerMapping.Scope.Name() == meta.RESTScopeNameRoot ownerNamespace := owner.GetNamespace() depClusterScoped := depMapping.Scope.Name() == meta.RESTScopeNameRoot + depNamespace := dependent.GetNamespace() if ownerClusterScoped { diff --git a/pkg/internal/sdk/controllerutil/controllerutil_suite_test.go b/pkg/sdk/controllerutil/controllerutil_suite_test.go similarity index 100% rename from pkg/internal/sdk/controllerutil/controllerutil_suite_test.go rename to pkg/sdk/controllerutil/controllerutil_suite_test.go diff --git a/pkg/internal/sdk/controllerutil/controllerutil_test.go b/pkg/sdk/controllerutil/controllerutil_test.go similarity index 97% rename from pkg/internal/sdk/controllerutil/controllerutil_test.go rename to pkg/sdk/controllerutil/controllerutil_test.go index 6ad13f38..dbadc3bb 100644 --- a/pkg/internal/sdk/controllerutil/controllerutil_test.go +++ b/pkg/sdk/controllerutil/controllerutil_test.go @@ -31,7 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - . "github.com/operator-framework/helm-operator-plugins/pkg/internal/sdk/controllerutil" + . "github.com/operator-framework/helm-operator-plugins/pkg/sdk/controllerutil" ) var _ = Describe("Controllerutil", func() { @@ -74,8 +74,8 @@ var _ = Describe("Controllerutil", func() { Describe("SupportsOwnerReference", func() { var ( rm *meta.DefaultRESTMapper - owner client.Object - dependent client.Object + owner *unstructured.Unstructured + dependent *unstructured.Unstructured clusterScoped = schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "ClusterScoped"} namespaceScoped = schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "NamespaceScoped"} )