diff --git a/pkg/install/install.go b/pkg/install/install.go index d6ac1b2f..850e291e 100644 --- a/pkg/install/install.go +++ b/pkg/install/install.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "net/url" + "os" "strings" "github.com/AlecAivazis/survey/v2" @@ -82,6 +83,8 @@ const ( monitoringNamespace = "everest-monitoring" // EverestMonitoringNamespaceEnvVar is the name of the environment variable that holds the monitoring namespace. EverestMonitoringNamespaceEnvVar = "MONITORING_NAMESPACE" + // disableTelemetryEnvVar is the name of the environment variable that disables telemetry. + disableTelemetryEnvVar = "DISABLE_TELEMETRY" ) type ( @@ -471,6 +474,11 @@ func (o *Install) installOperator(ctx context.Context, channel, operatorName, na o.l.Infof("Installing %s operator", operatorName) + disableTelemetry, ok := os.LookupEnv(disableTelemetryEnvVar) + if !ok || disableTelemetry != "true" { + disableTelemetry = "false" + } + params := kubernetes.InstallOperatorRequest{ Namespace: namespace, Name: operatorName, @@ -479,21 +487,27 @@ func (o *Install) installOperator(ctx context.Context, channel, operatorName, na CatalogSourceNamespace: catalogSourceNamespace, Channel: channel, InstallPlanApproval: v1alpha1.ApprovalManual, - } - if len(o.config.Namespaces) != 0 && operatorName == everestOperatorName { - params.TargetNamespaces = o.config.Namespaces - params.SubscriptionConfig = &v1alpha1.SubscriptionConfig{ + SubscriptionConfig: &v1alpha1.SubscriptionConfig{ Env: []corev1.EnvVar{ { - Name: kubernetes.EverestDBNamespacesEnvVar, - Value: strings.Join(o.config.Namespaces, ","), - }, - { - Name: EverestMonitoringNamespaceEnvVar, - Value: monitoringNamespace, + Name: disableTelemetryEnvVar, + Value: disableTelemetry, }, }, - } + }, + } + if operatorName == everestOperatorName { + params.TargetNamespaces = o.config.Namespaces + params.SubscriptionConfig.Env = append(params.SubscriptionConfig.Env, []corev1.EnvVar{ + { + Name: EverestMonitoringNamespaceEnvVar, + Value: monitoringNamespace, + }, + { + Name: kubernetes.EverestDBNamespacesEnvVar, + Value: strings.Join(o.config.Namespaces, ","), + }, + }...) } if err := o.kubeClient.InstallOperator(ctx, params); err != nil { diff --git a/pkg/kubernetes/client/client.go b/pkg/kubernetes/client/client.go index 7a8ccb3e..b11a2608 100644 --- a/pkg/kubernetes/client/client.go +++ b/pkg/kubernetes/client/client.go @@ -1192,6 +1192,22 @@ func (c *Client) CreateSubscription(ctx context.Context, namespace string, subsc return sub, nil } +// UpdateSubscription updates an OLM subscription. +func (c *Client) UpdateSubscription(ctx context.Context, namespace string, subscription *v1alpha1.Subscription) (*v1alpha1.Subscription, error) { + operatorClient, err := versioned.NewForConfig(c.restConfig) + if err != nil { + return nil, errors.Join(err, errors.New("cannot create an operator client instance")) + } + sub, err := operatorClient. + OperatorsV1alpha1(). + Subscriptions(namespace). + Update(ctx, subscription, metav1.UpdateOptions{}) + if err != nil { + return sub, err + } + return sub, nil +} + // CreateSubscriptionForCatalog creates an OLM subscription. func (c *Client) CreateSubscriptionForCatalog(ctx context.Context, namespace, name, catalogNamespace, catalog, packageName, channel, startingCSV string, approval v1alpha1.Approval, diff --git a/pkg/kubernetes/client/kubeclient_interface.go b/pkg/kubernetes/client/kubeclient_interface.go index 7eadd8fd..9965cc26 100644 --- a/pkg/kubernetes/client/kubeclient_interface.go +++ b/pkg/kubernetes/client/kubeclient_interface.go @@ -91,6 +91,8 @@ type KubeClientConnector interface { CreateOperatorGroup(ctx context.Context, namespace, name string, targetNamespaces []string) (*v1.OperatorGroup, error) // CreateSubscription creates an OLM subscription. CreateSubscription(ctx context.Context, namespace string, subscription *v1alpha1.Subscription) (*v1alpha1.Subscription, error) + // UpdateSubscription updates an OLM subscription. + UpdateSubscription(ctx context.Context, namespace string, subscription *v1alpha1.Subscription) (*v1alpha1.Subscription, error) // CreateSubscriptionForCatalog creates an OLM subscription. CreateSubscriptionForCatalog(ctx context.Context, namespace, name, catalogNamespace, catalog, packageName, channel, startingCSV string, approval v1alpha1.Approval) (*v1alpha1.Subscription, error) // GetSubscription retrieves an OLM subscription by namespace and name. diff --git a/pkg/kubernetes/client/mock_kube_client_connector.go b/pkg/kubernetes/client/mock_kube_client_connector.go index d1b24962..9bf06ae9 100644 --- a/pkg/kubernetes/client/mock_kube_client_connector.go +++ b/pkg/kubernetes/client/mock_kube_client_connector.go @@ -1267,6 +1267,36 @@ func (_m *MockKubeClientConnector) UpdateInstallPlan(ctx context.Context, namesp return r0, r1 } +// UpdateSubscription provides a mock function with given fields: ctx, namespace, subscription +func (_m *MockKubeClientConnector) UpdateSubscription(ctx context.Context, namespace string, subscription *v1alpha1.Subscription) (*v1alpha1.Subscription, error) { + ret := _m.Called(ctx, namespace, subscription) + + if len(ret) == 0 { + panic("no return value specified for UpdateSubscription") + } + + var r0 *v1alpha1.Subscription + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, *v1alpha1.Subscription) (*v1alpha1.Subscription, error)); ok { + return rf(ctx, namespace, subscription) + } + if rf, ok := ret.Get(0).(func(context.Context, string, *v1alpha1.Subscription) *v1alpha1.Subscription); ok { + r0 = rf(ctx, namespace, subscription) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v1alpha1.Subscription) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, *v1alpha1.Subscription) error); ok { + r1 = rf(ctx, namespace, subscription) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // NewMockKubeClientConnector creates a new instance of MockKubeClientConnector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockKubeClientConnector(t interface { diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index b09b7792..081f60d2 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -26,7 +26,7 @@ import ( "io/fs" "log" "net/http" - "os" + "sort" "strings" "time" @@ -87,7 +87,6 @@ const ( databaseClusterAPIVersion = "everest.percona.com/v1alpha1" restartAnnotationKey = "everest.percona.com/restart" managedByKey = "everest.percona.com/managed-by" - disableTelemetryEnvVar = "DISABLE_TELEMETRY" // ContainerStateWaiting represents a state when container requires some // operations being done in order to complete start up. ContainerStateWaiting ContainerState = "waiting" @@ -629,47 +628,112 @@ type InstallOperatorRequest struct { SubscriptionConfig *olmv1alpha1.SubscriptionConfig } +func mergeNamespacesEnvVar(str1, str2 string) string { + ns1 := strings.Split(str1, ",") + ns2 := strings.Split(str2, ",") + nsMap := map[string]struct{}{} + + for _, ns := range ns1 { + if ns == "" { + continue + } + nsMap[ns] = struct{}{} + } + + for _, ns := range ns2 { + if ns == "" { + continue + } + nsMap[ns] = struct{}{} + } + + namespaces := []string{} + for ns := range nsMap { + namespaces = append(namespaces, ns) + } + + sort.Strings(namespaces) + + return strings.Join(namespaces, ",") +} + +func mergeSubscriptionConfig(sub *olmv1alpha1.SubscriptionConfig, cfg *olmv1alpha1.SubscriptionConfig) *olmv1alpha1.SubscriptionConfig { + if sub == nil { + sub = &olmv1alpha1.SubscriptionConfig{Env: []corev1.EnvVar{}} + } + + if cfg == nil { + return sub + } + + for _, e := range cfg.Env { + found := false + for i, se := range sub.Env { + if e.Name == se.Name { + found = true + // If the environment variable is not the namespaces, just override it + if e.Name != EverestDBNamespacesEnvVar { + sub.Env[i].Value = e.Value + break + } + + // Merge the namespaces + sub.Env[i].Value = mergeNamespacesEnvVar(se.Value, e.Value) + + break + } + } + if !found { + sub.Env = append(sub.Env, e) + } + } + + return sub +} + // InstallOperator installs an operator via OLM. func (k *Kubernetes) InstallOperator(ctx context.Context, req InstallOperatorRequest) error { //nolint:funlen - disableTelemetry, ok := os.LookupEnv(disableTelemetryEnvVar) - if !ok || disableTelemetry != "true" { - disableTelemetry = "false" - } - config := &olmv1alpha1.SubscriptionConfig{Env: []corev1.EnvVar{}} - if req.SubscriptionConfig != nil { - config = req.SubscriptionConfig + subscription, err := k.client.GetSubscription(ctx, req.Namespace, req.Name) + if err != nil && !apierrors.IsNotFound(err) { + return errors.Join(err, errors.New("cannot get subscription")) } - config.Env = append(config.Env, corev1.EnvVar{ - Name: disableTelemetryEnvVar, - Value: disableTelemetry, - }) - subscription := &olmv1alpha1.Subscription{ - TypeMeta: metav1.TypeMeta{ - Kind: olmv1alpha1.SubscriptionKind, - APIVersion: olmv1alpha1.SubscriptionCRDAPIVersion, - }, - ObjectMeta: metav1.ObjectMeta{ - Namespace: req.Namespace, - Name: req.Name, - }, - Spec: &olmv1alpha1.SubscriptionSpec{ - CatalogSource: req.CatalogSource, - CatalogSourceNamespace: req.CatalogSourceNamespace, - Package: req.Name, - Channel: req.Channel, - StartingCSV: req.StartingCSV, - InstallPlanApproval: req.InstallPlanApproval, - Config: config, - }, + if apierrors.IsNotFound(err) { + subscription = &olmv1alpha1.Subscription{ + TypeMeta: metav1.TypeMeta{ + Kind: olmv1alpha1.SubscriptionKind, + APIVersion: olmv1alpha1.SubscriptionCRDAPIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: req.Namespace, + Name: req.Name, + }, + Spec: &olmv1alpha1.SubscriptionSpec{ + CatalogSource: req.CatalogSource, + CatalogSourceNamespace: req.CatalogSourceNamespace, + Package: req.Name, + Channel: req.Channel, + StartingCSV: req.StartingCSV, + InstallPlanApproval: req.InstallPlanApproval, + }, + } } - subs, err := k.client.CreateSubscription(ctx, req.Namespace, subscription) - if err != nil { - return errors.Join(err, errors.New("cannot create a subscription to install the operator")) + + subscription.Spec.Config = mergeSubscriptionConfig(subscription.Spec.Config, req.SubscriptionConfig) + if apierrors.IsNotFound(err) { + _, err := k.client.CreateSubscription(ctx, req.Namespace, subscription) + if err != nil { + return errors.Join(err, errors.New("cannot create a subscription to install the operator")) + } + } else { + _, err := k.client.UpdateSubscription(ctx, req.Namespace, subscription) + if err != nil { + return errors.Join(err, errors.New("cannot update a subscription to install the operator")) + } } err = wait.PollUntilContextTimeout(ctx, pollInterval, pollDuration, false, func(ctx context.Context) (bool, error) { k.l.Debugf("Polling subscription %s/%s", req.Namespace, req.Name) - subs, err = k.client.GetSubscription(ctx, req.Namespace, req.Name) + subs, err := k.client.GetSubscription(ctx, req.Namespace, req.Name) if err != nil { return false, errors.Join(err, fmt.Errorf("cannot get an install plan for the operator subscription: %q", req.Name)) } diff --git a/pkg/kubernetes/kubernetes_test.go b/pkg/kubernetes/kubernetes_test.go new file mode 100644 index 00000000..c2adaceb --- /dev/null +++ b/pkg/kubernetes/kubernetes_test.go @@ -0,0 +1,104 @@ +// percona-everest-cli +// Copyright (C) 2023 Percona LLC +// +// 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 kubernetes provides functionality for kubernetes. +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeNamesspacesEnvVar(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ns1 string + ns2 string + want string + }{ + { + name: "0-0", + ns1: "", + ns2: "", + want: "", + }, + { + name: "1-0", + ns1: "ns1", + ns2: "", + want: "ns1", + }, + { + name: "0-1", + ns1: "", + ns2: "ns2", + want: "ns2", + }, + { + name: "1-1", + ns1: "ns1", + ns2: "ns2", + want: "ns1,ns2", + }, + { + name: "1-2", + ns1: "ns1", + ns2: "ns2,ns3", + want: "ns1,ns2,ns3", + }, + { + name: "1-2 unsorted", + ns1: "ns2", + ns2: "ns3,ns1", + want: "ns1,ns2,ns3", + }, + { + name: "1-2 extra commas", + ns1: ",ns1,", + ns2: ",,ns2,,,,ns3,,,", + want: "ns1,ns2,ns3", + }, + { + name: "2-2", + ns1: "ns4,ns1", + ns2: "ns2,ns3", + want: "ns1,ns2,ns3,ns4", + }, + { + name: "0-2", + ns1: "", + ns2: "ns2,ns3", + want: "ns2,ns3", + }, + { + name: "2-0 unsorted", + ns1: "ns4,ns1", + ns2: "", + want: "ns1,ns4", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mergedNS := mergeNamespacesEnvVar(tt.ns1, tt.ns2) + assert.Equal(t, tt.want, mergedNS) + }) + } +} diff --git a/pkg/kubernetes/olm_operator_test.go b/pkg/kubernetes/olm_operator_test.go index e82863d4..b4236a5a 100644 --- a/pkg/kubernetes/olm_operator_test.go +++ b/pkg/kubernetes/olm_operator_test.go @@ -27,6 +27,8 @@ import ( "go.uber.org/zap" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "github.com/percona/percona-everest-cli/pkg/kubernetes/client" @@ -83,11 +85,16 @@ func TestInstallOlmOperator(t *testing.T) { }, }, } + groupResource := schema.GroupResource{ + Group: "operators.coreos.com", + Resource: "subscriptions", + } + k8sclient.On("GetSubscription", mock.Anything, subscriptionNamespace, operatorName).Return(&v1alpha1.Subscription{}, apierrors.NewNotFound(groupResource, operatorName)).Once() k8sclient.On( "CreateSubscription", mock.Anything, subscriptionNamespace, mockSubscription, ).Return(mockSubscription, nil) - k8sclient.On("GetSubscription", mock.Anything, subscriptionNamespace, operatorName).Return(mockSubscription, nil) + k8sclient.On("GetSubscription", mock.Anything, subscriptionNamespace, operatorName).Return(mockSubscription, nil).Once() mockInstallPlan := &v1alpha1.InstallPlan{} k8sclient.On( "GetInstallPlan", mock.Anything,