diff --git a/Makefile b/Makefile index bcb81783..3645ee3e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # To re-generate a bundle for another specific version without changing the standard setup, you can: # - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2) # - use environment variables to overwrite this value (e.g export VERSION=0.0.2) -VERSION ?= 2.12.0 +VERSION ?= 2.13.0 # CHANNELS define the bundle channels used in the bundle. # Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable") diff --git a/bundle/manifests/siteconfig.clusterserviceversion.yaml b/bundle/manifests/siteconfig.clusterserviceversion.yaml index c9002ace..6b5cb056 100644 --- a/bundle/manifests/siteconfig.clusterserviceversion.yaml +++ b/bundle/manifests/siteconfig.clusterserviceversion.yaml @@ -210,7 +210,7 @@ metadata: capabilities: Basic Install operators.operatorframework.io/builder: operator-sdk-v1.33.0 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 - name: siteconfig.v2.12.0 + name: siteconfig.v2.13.0 namespace: placeholder spec: apiservicedefinitions: {} @@ -440,7 +440,7 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace - image: quay.io/stolostron/siteconfig-operator:2.12.0 + image: quay.io/stolostron/siteconfig-operator:2.13.0 imagePullPolicy: Always livenessProbe: httpGet: @@ -534,4 +534,4 @@ spec: maturity: alpha provider: name: Red Hat - version: 2.12.0 + version: 2.13.0 diff --git a/cmd/main.go b/cmd/main.go index 5bf62c82..4a976c90 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -33,12 +33,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/metrics/server" ci "github.com/stolostron/siteconfig/internal/controller/clusterinstance" + "github.com/stolostron/siteconfig/internal/controller/configuration" "github.com/stolostron/siteconfig/internal/controller/retry" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" + k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -120,8 +122,9 @@ func main() { } // Check that the SiteConfig namespace value is defined - if getSiteConfigNamespace(setupLog) == "" { - setupLog.Error("Unable to retrieve the SiteConfig namespace") + siteConfigNamespace := configuration.GetPodNamespace(setupLog) + if siteConfigNamespace == "" { + setupLog.Error("Unable to retrieve the SiteConfig Operator namespace") os.Exit(1) } @@ -131,18 +134,46 @@ func main() { mgr.GetClient(), setupLog, ); err != nil { - setupLog.Error("Unable to initialize the default reference install template ConfigMaps", + setupLog.Error("Unable to initialize the default reference installation template ConfigMaps", zap.Error(err)) os.Exit(1) } + // Create initial SiteConfig Operator configuration + operatorConfig, err := getSiteConfigConfiguration(context.TODO(), mgr.GetClient(), siteConfigNamespace, setupLog) + if err != nil { + setupLog.Error("Failed to process SiteConfig Operator configuration", + zap.Error(err)) + os.Exit(1) + } + sharedConfigStore := configuration.NewConfigurationStore(operatorConfig) + if sharedConfigStore == nil { + setupLog.Error("Failed to initialize the ConfigurationStore for the SiteConfig Operator") + os.Exit(1) + } + + // Create configuration monitor controller to track SiteConfig Operator configuration change(s) + if err = (&controller.ConfigurationMonitor{ + Client: mgr.GetClient(), + Log: siteconfigLogger.Named("ConfigurationMonitor"), + Scheme: mgr.GetScheme(), + ConfigStore: sharedConfigStore, + }).SetupWithManager(mgr); err != nil { + setupLog.Error("Unable to create controller", + zap.String("controller", "ConfigurationMonitor"), + zap.Error(err)) + os.Exit(1) + } + + // Create ClusterInstance controller for reconciling ClusterInstance CRs clusterInstanceLogger := siteconfigLogger.Named("ClusterInstanceController") if err = (&controller.ClusterInstanceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("ClusterInstanceController"), - Log: clusterInstanceLogger, - TmplEngine: ci.NewTemplateEngine(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("ClusterInstanceController"), + Log: clusterInstanceLogger, + TmplEngine: ci.NewTemplateEngine(), + ConfigStore: sharedConfigStore, }).SetupWithManager(mgr); err != nil { setupLog.Error("Unable to create controller", zap.String("controller", "ClusterInstance"), @@ -151,6 +182,7 @@ func main() { os.Exit(1) } + // Create ClusterDeployment controller for monitoring cluster provisioning progress if err = (&controller.ClusterDeploymentReconciler{ Client: mgr.GetClient(), Log: siteconfigLogger.Named("ClusterDeploymentReconciler"), @@ -179,14 +211,6 @@ func main() { } } -func getSiteConfigNamespace(log *zap.Logger) string { - namespace := os.Getenv("POD_NAMESPACE") - if namespace == "" { - log.Info("POD_NAMESPACE environment variable is not defined") - } - return namespace -} - func initConfigMapTemplates(ctx context.Context, c client.Client, log *zap.Logger) error { templates := make(map[string]map[string]string, 4) templates[ai_templates.ClusterLevelInstallTemplates] = ai_templates.GetClusterTemplates() @@ -194,7 +218,7 @@ func initConfigMapTemplates(ctx context.Context, c client.Client, log *zap.Logge templates[ibi_templates.ClusterLevelInstallTemplates] = ibi_templates.GetClusterTemplates() templates[ibi_templates.NodeLevelInstallTemplates] = ibi_templates.GetNodeTemplates() - siteConfigNamespace := getSiteConfigNamespace(log) + siteConfigNamespace := configuration.GetPodNamespace(log) for k, v := range templates { immutable := true @@ -220,3 +244,22 @@ func initConfigMapTemplates(ctx context.Context, c client.Client, log *zap.Logge return nil } + +func getSiteConfigConfiguration( + ctx context.Context, + c client.Client, + siteConfigNamespace string, + log *zap.Logger, +) (*configuration.Configuration, error) { + config, err := configuration.LoadFromConfigMap(ctx, c, siteConfigNamespace) + if err != nil && k8serrors.IsNotFound(err) { + // Configuration CM not found, creating default + config, err = configuration.CreateDefaultConfigurationConfigMap(ctx, c, siteConfigNamespace) + if err != nil { + log.Error("Unable to create the default SiteConfig Operator configuration ConfigMap", + zap.Error(err)) + return nil, err + } + } + return config, err +} diff --git a/cmd/main_test.go b/cmd/main_test.go index f879dfde..930789c6 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -18,6 +18,7 @@ package main import ( "context" + "reflect" "testing" "go.uber.org/zap" @@ -29,6 +30,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/stolostron/siteconfig/internal/controller/configuration" ai_templates "github.com/stolostron/siteconfig/internal/templates/assisted-installer" ibi_templates "github.com/stolostron/siteconfig/internal/templates/image-based-installer" ) @@ -44,7 +46,7 @@ var _ = Describe("initConfigMapTemplates", func() { c client.Client ctx = context.Background() testLogger = zap.NewNop().Named("Test") - SiteConfigNamespace = getSiteConfigNamespace(testLogger) + SiteConfigNamespace = configuration.GetPodNamespace(testLogger) ) BeforeEach(func() { @@ -122,4 +124,77 @@ var _ = Describe("initConfigMapTemplates", func() { Expect(c.Get(ctx, key, aiNodeCM)).To(Succeed()) Expect(aiNodeCM.Data).To(Equal(data)) }) + + It("creates a default SiteConfig Operator configuration ConfigMap on initialization if not created previously", func() { + initConfig, err := getSiteConfigConfiguration(ctx, c, SiteConfigNamespace, testLogger) + // Given that a configuration CM has not been created, the initConfig is expected to be the default configuration + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(initConfig, configuration.NewDefaultConfiguration())).To(BeTrue()) + + // retrieve the configuration CM and verify that the data is correctly set to the default configuration + cm := &corev1.ConfigMap{} + key := types.NamespacedName{ + Name: configuration.SiteConfigOperatorConfigMap, + Namespace: SiteConfigNamespace, + } + Expect(c.Get(ctx, key, cm)).To(Succeed()) + config, err := configuration.FromMap(cm.Data) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(config, configuration.NewDefaultConfiguration())).To(BeTrue()) + }) + + It("uses an existing SiteConfig Operator configuration ConfigMap on initialization", func() { + expectedConfig := &configuration.Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 10, + } + // create the configuration CM + key := types.NamespacedName{ + Name: configuration.SiteConfigOperatorConfigMap, + Namespace: SiteConfigNamespace, + } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Data: expectedConfig.ToMap(), + } + Expect(c.Create(ctx, cm)).To(Succeed()) + + // retrieve the configuration CM and verify that the data is correctly set + actualConfig, err := getSiteConfigConfiguration(ctx, c, SiteConfigNamespace, testLogger) + // Given that a configuration CM has not been created, the initConfig is expected to be the default configuration + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(actualConfig, expectedConfig)).To(BeTrue()) + + Expect(c.Get(ctx, key, cm)).To(Succeed()) + gotConfig, err := configuration.FromMap(cm.Data) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(gotConfig, expectedConfig)).To(BeTrue()) + }) + + It("exits when encounters an error with the configuration object", func() { + // create the configuration CM + key := types.NamespacedName{ + Name: configuration.SiteConfigOperatorConfigMap, + Namespace: SiteConfigNamespace, + } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Data: map[string]string{ + "allowReinstalls": "foobar", + "maxConcurrentReconciles": "1", + }, + } + Expect(c.Create(ctx, cm)).To(Succeed()) + + // retrieve the configuration CM + _, err := getSiteConfigConfiguration(ctx, c, SiteConfigNamespace, testLogger) + // Given that a configuration CM has not been created, the initConfig is expected to be the default configuration + Expect(err).To(HaveOccurred()) + }) }) diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index a759e593..aea256d7 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -5,4 +5,4 @@ kind: Kustomization images: - name: controller newName: quay.io/stolostron/siteconfig-operator - newTag: 2.12.0 + newTag: 2.13.0 diff --git a/internal/controller/clusterinstance/suite_test.go b/internal/controller/clusterinstance/suite_test.go index cc89a65e..95925918 100644 --- a/internal/controller/clusterinstance/suite_test.go +++ b/internal/controller/clusterinstance/suite_test.go @@ -33,7 +33,7 @@ import ( //+kubebuilder:scaffold:imports ) -func TestControllers(t *testing.T) { +func TestClusterInstance(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "ClusterInstanceSuite") diff --git a/internal/controller/clusterinstance_controller.go b/internal/controller/clusterinstance_controller.go index b568d3bc..3efc43a4 100644 --- a/internal/controller/clusterinstance_controller.go +++ b/internal/controller/clusterinstance_controller.go @@ -26,6 +26,7 @@ import ( ci "github.com/stolostron/siteconfig/internal/controller/clusterinstance" "github.com/stolostron/siteconfig/internal/controller/conditions" + "github.com/stolostron/siteconfig/internal/controller/configuration" "go.uber.org/zap" "golang.org/x/exp/maps" corev1 "k8s.io/api/core/v1" @@ -57,10 +58,11 @@ const ( // ClusterInstanceReconciler reconciles a ClusterInstance object type ClusterInstanceReconciler struct { client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder - Log *zap.Logger - TmplEngine *ci.TemplateEngine + Scheme *runtime.Scheme + Recorder record.EventRecorder + Log *zap.Logger + TmplEngine *ci.TemplateEngine + ConfigStore *configuration.ConfigurationStore } func doNotRequeue() ctrl.Result { @@ -134,6 +136,12 @@ func (r *ClusterInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Requ return doNotRequeue(), nil } + if r.ConfigStore.GetConfiguration().AllowReinstalls { + log.Info("SiteConfig Operator is configured to allow reinstalls") + } else { + log.Info("SiteConfig Operator is not configured for reinstalls") + } + // Validate ClusterInstance if err := r.handleValidate(ctx, log, clusterInstance); err != nil { return requeueWithError(err) @@ -825,6 +833,6 @@ func (r *ClusterInstanceReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.ClusterInstance{}). WithEventFilter(predicate.Or(predicate.GenerationChangedPredicate{}, predicate.LabelChangedPredicate{})). - WithOptions(controller.Options{MaxConcurrentReconciles: 1}). + WithOptions(controller.Options{MaxConcurrentReconciles: r.ConfigStore.GetConfiguration().MaxConcurrentReconciles}). Complete(r) } diff --git a/internal/controller/clusterinstance_controller_test.go b/internal/controller/clusterinstance_controller_test.go index f3eccd61..65fe6520 100644 --- a/internal/controller/clusterinstance_controller_test.go +++ b/internal/controller/clusterinstance_controller_test.go @@ -27,6 +27,7 @@ import ( hivev1 "github.com/openshift/hive/apis/hive/v1" "github.com/stolostron/siteconfig/api/v1alpha1" ci "github.com/stolostron/siteconfig/internal/controller/clusterinstance" + "github.com/stolostron/siteconfig/internal/controller/configuration" ai_templates "github.com/stolostron/siteconfig/internal/templates/assisted-installer" ibi_templates "github.com/stolostron/siteconfig/internal/templates/image-based-installer" "go.uber.org/zap" @@ -104,10 +105,11 @@ var _ = Describe("Reconcile", func() { Build() r = &ClusterInstanceReconciler{ - Client: c, - Scheme: scheme.Scheme, - Log: testLogger, - TmplEngine: ci.NewTemplateEngine(), + Client: c, + Scheme: scheme.Scheme, + Log: testLogger, + TmplEngine: ci.NewTemplateEngine(), + ConfigStore: configuration.NewConfigurationStore(configuration.NewDefaultConfiguration()), } Expect(c.Create(ctx, testParams.GeneratePullSecret())).To(Succeed()) diff --git a/internal/controller/configuration/configuration.go b/internal/controller/configuration/configuration.go new file mode 100644 index 00000000..37a2427f --- /dev/null +++ b/internal/controller/configuration/configuration.go @@ -0,0 +1,112 @@ +/* +Copyright 2024. + +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 configuration + +import ( + "context" + "fmt" + "strconv" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const SiteConfigOperatorConfigMap = "siteconfig-operator-configuration" + +const ( + DefaultAllowReinstalls = false + DefaultMaxConcurrentReconciles = 1 +) + +type Configuration struct { + AllowReinstalls bool `json:"allowReinstalls"` + MaxConcurrentReconciles int `json:"maxConcurrentReconciles"` +} + +func NewDefaultConfiguration() *Configuration { + return &Configuration{ + AllowReinstalls: DefaultAllowReinstalls, + MaxConcurrentReconciles: DefaultMaxConcurrentReconciles, + } +} + +func CreateDefaultConfigurationConfigMap(ctx context.Context, c client.Client, namespace string) (*Configuration, error) { + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: SiteConfigOperatorConfigMap, + Namespace: namespace, + }, + Data: NewDefaultConfiguration().ToMap(), + } + + if err := c.Create(ctx, cm); err != nil { + if !errors.IsAlreadyExists(err) { + return nil, fmt.Errorf("failed to create default SiteConfig Configuration: %w", err) + } + // Configuration exists, return it instead + return LoadFromConfigMap(ctx, c, namespace) + } + + return NewDefaultConfiguration(), nil +} + +func LoadFromConfigMap(ctx context.Context, c client.Client, namespace string) (*Configuration, error) { + // Fetch the ConfigMap + cm := &corev1.ConfigMap{} + err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: SiteConfigOperatorConfigMap}, cm) + if err != nil { + return nil, err + } + + return FromMap(cm.Data) +} + +func (c *Configuration) ToMap() map[string]string { + return map[string]string{ + "allowReinstalls": strconv.FormatBool(c.AllowReinstalls), + "maxConcurrentReconciles": strconv.Itoa(c.MaxConcurrentReconciles), + } +} + +func FromMap(input map[string]string) (*Configuration, error) { + config := &Configuration{} + for key, value := range input { + switch key { + case "allowReinstalls": + boolValue, err := strconv.ParseBool(value) + if err != nil { + return nil, fmt.Errorf("invalid value for %q: %w", key, err) + } + config.AllowReinstalls = boolValue + + case "maxConcurrentReconciles": + intValue, err := strconv.Atoi(value) + if err != nil { + return nil, fmt.Errorf("invalid value for %q: %w", key, err) + } + config.MaxConcurrentReconciles = intValue + + default: + return nil, fmt.Errorf("unsupported key in input map: %q", key) + } + } + return config, nil +} diff --git a/internal/controller/configuration/configuration_store.go b/internal/controller/configuration/configuration_store.go new file mode 100644 index 00000000..985fbe40 --- /dev/null +++ b/internal/controller/configuration/configuration_store.go @@ -0,0 +1,51 @@ +/* +Copyright 2024. + +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 configuration + +import ( + "sync" +) + +type ConfigurationStore struct { + mu sync.RWMutex + configuration *Configuration +} + +// NewConfigurationStore creates a new shared instance of the configuration store +func NewConfigurationStore(initialConfig *Configuration) *ConfigurationStore { + if initialConfig == nil { + return nil + } + + return &ConfigurationStore{ + configuration: initialConfig, + } +} + +// GetConfiguration provides a read-only copy of the configuration +func (s *ConfigurationStore) GetConfiguration() *Configuration { + s.mu.RLock() + defer s.mu.RUnlock() + return s.configuration +} + +// SetConfiguration updates the configuration +func (s *ConfigurationStore) SetConfiguration(newConfig *Configuration) { + s.mu.Lock() + defer s.mu.Unlock() + s.configuration = newConfig +} diff --git a/internal/controller/configuration/configuration_store_test.go b/internal/controller/configuration/configuration_store_test.go new file mode 100644 index 00000000..7b4bf15f --- /dev/null +++ b/internal/controller/configuration/configuration_store_test.go @@ -0,0 +1,111 @@ +/* +Copyright 2024. + +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 configuration + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("NewConfigurationStore", func() { + It("creates a new ConfigurationStore object with the given Configuration object", func() { + testConfigs := []*Configuration{ + { + AllowReinstalls: false, + MaxConcurrentReconciles: 5, + }, + { + AllowReinstalls: true, + MaxConcurrentReconciles: 1, + }, + NewDefaultConfiguration(), + } + for _, config := range testConfigs { + configStore := NewConfigurationStore(config) + Expect(configStore.configuration).To(Equal(config)) + } + }) + + It("returns a nil type when the given Configuration object is nil", func() { + configStore := NewConfigurationStore(nil) + Expect(configStore).To(BeNil()) + }) +}) + +var _ = Describe("ConfigurationStore.GetConfiguration", func() { + It("successfully returns the Configuration object from the ConfigurationStore", func() { + config := &Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 1, + } + configStore := NewConfigurationStore(config) + + Expect(configStore).ToNot(BeNil()) + Expect(configStore.GetConfiguration()).To(Equal(&Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 1, + })) + + config.AllowReinstalls = false + Expect(configStore.GetConfiguration()).To(Equal(&Configuration{ + AllowReinstalls: false, + MaxConcurrentReconciles: 1, + })) + + config.MaxConcurrentReconciles = 10 + Expect(configStore.GetConfiguration()).To(Equal(&Configuration{ + AllowReinstalls: false, + MaxConcurrentReconciles: 10, + })) + + config.AllowReinstalls = true + config.MaxConcurrentReconciles = 5 + Expect(configStore.GetConfiguration()).To(Equal(&Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 5, + })) + }) + + It("successfully updates the Configuration object from the ConfigurationStore with the given Configuration object", func() { + config := &Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 1, + } + configStore := NewConfigurationStore(config) + + Expect(configStore).ToNot(BeNil()) + Expect(configStore.configuration).To(Equal(&Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 1, + })) + + config1 := &Configuration{ + AllowReinstalls: false, + MaxConcurrentReconciles: 1, + } + configStore.SetConfiguration(config1) + Expect(configStore.configuration).To(Equal(config1)) + + config2 := &Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 10, + } + configStore.SetConfiguration(config2) + Expect(configStore.configuration).To(Equal(config2)) + + }) +}) diff --git a/internal/controller/configuration/configuration_test.go b/internal/controller/configuration/configuration_test.go new file mode 100644 index 00000000..fd3fbd7b --- /dev/null +++ b/internal/controller/configuration/configuration_test.go @@ -0,0 +1,278 @@ +/* +Copyright 2024. + +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 configuration + +import ( + "context" + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "go.uber.org/zap" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var _ = Describe("NewDefaultConfiguration", func() { + It("creates a new Configuration object with default values for the SiteConfig Operator", func() { + expected := &Configuration{ + AllowReinstalls: DefaultAllowReinstalls, + MaxConcurrentReconciles: DefaultMaxConcurrentReconciles, + } + gotConfig := NewDefaultConfiguration() + Expect(reflect.DeepEqual(gotConfig, expected)).To(BeTrue()) + }) +}) + +var _ = Describe("CreateDefaultConfigurationConfigMap", func() { + var ( + c client.Client + ctx = context.Background() + testLogger = zap.NewNop().Named("Test") + siteConfigNamespace string + ) + + BeforeEach(func() { + c = fakeclient.NewClientBuilder(). + WithScheme(scheme.Scheme). + Build() + + siteConfigNamespace = GetPodNamespace(testLogger) + }) + + It("creates default configuration ConfigMap when it does not exist", func() { + config, err := CreateDefaultConfigurationConfigMap(ctx, c, siteConfigNamespace) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(config, NewDefaultConfiguration())) + + // Check that the ConfigMap is created with correct data + key := types.NamespacedName{ + Name: SiteConfigOperatorConfigMap, + Namespace: siteConfigNamespace, + } + cm := &corev1.ConfigMap{} + Expect(c.Get(ctx, key, cm)).To(Succeed()) + + cmConfig, err := FromMap(cm.Data) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(cmConfig, config)).To(BeTrue()) + }) + + It("does not create the SiteConfig Operator configuration ConfigMap if it exists", func() { + config := &Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 10, + } + + // create the configuration CM + key := types.NamespacedName{ + Name: SiteConfigOperatorConfigMap, + Namespace: siteConfigNamespace, + } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Data: config.ToMap(), + } + Expect(c.Create(ctx, cm)).To(Succeed()) + + got, err := CreateDefaultConfigurationConfigMap(ctx, c, siteConfigNamespace) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(got, config)).To(BeTrue()) + }) +}) + +var _ = Describe("LoadFromConfigMap", func() { + var ( + ctx = context.Background() + c client.Client + siteConfigNamespace string + testLogger = zap.NewNop().Named("Test") + ) + + BeforeEach(func() { + c = fakeclient.NewClientBuilder(). + WithScheme(scheme.Scheme). + Build() + + siteConfigNamespace = GetPodNamespace(testLogger) + }) + + It("returns a Configuration object from the SiteConfig Operator Configuration ConfigMap that is correctly defined", func() { + config := &Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 10, + } + + // create the configuration CM + key := types.NamespacedName{ + Name: SiteConfigOperatorConfigMap, + Namespace: siteConfigNamespace, + } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Data: config.ToMap(), + } + Expect(c.Create(ctx, cm)).To(Succeed()) + + got, err := LoadFromConfigMap(ctx, c, siteConfigNamespace) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(got, config)).To(BeTrue()) + }) + + It("errors when the SiteConfig Operator Configuration ConfigMap is not found", func() { + got, err := LoadFromConfigMap(ctx, c, siteConfigNamespace) + Expect(err).To(HaveOccurred()) + Expect(got).To(BeNil()) + }) + + It("errors when the SiteConfig Operator Configuration ConfigMap is not defined properly", func() { + // create the configuration CM + key := types.NamespacedName{ + Name: SiteConfigOperatorConfigMap, + Namespace: siteConfigNamespace, + } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Data: map[string]string{ + "allowReinstalls": "foobar", + "maxConcurrentReconciles": "10", + }, + } + Expect(c.Create(ctx, cm)).To(Succeed()) + + got, err := LoadFromConfigMap(ctx, c, siteConfigNamespace) + Expect(err).To(HaveOccurred()) + Expect(got).To(BeNil()) + }) +}) + +var _ = Describe("Configuration.ToMap", func() { + It("correctly converts Configuration objects to maps", func() { + + testConfigs := []Configuration{ + { + AllowReinstalls: false, + MaxConcurrentReconciles: 5, + }, + { + AllowReinstalls: true, + MaxConcurrentReconciles: 1, + }, + } + expectedMaps := []map[string]string{ + { + "allowReinstalls": "false", + "maxConcurrentReconciles": "5", + }, + { + "allowReinstalls": "true", + "maxConcurrentReconciles": "1", + }, + } + + for index, config := range testConfigs { + got := config.ToMap() + Expect(got).To(Equal(expectedMaps[index])) + } + }) +}) + +var _ = Describe("FromMap", func() { + It("correctly converts maps to Configuration objects", func() { + testMaps := []map[string]string{ + { + "allowReinstalls": "false", + "maxConcurrentReconciles": "5", + }, + { + "allowReinstalls": "true", + "maxConcurrentReconciles": "1", + }, + } + + expectedConfigs := []*Configuration{ + { + AllowReinstalls: false, + MaxConcurrentReconciles: 5, + }, + { + AllowReinstalls: true, + MaxConcurrentReconciles: 1, + }, + } + + for index, tMap := range testMaps { + got, err := FromMap(tMap) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(expectedConfigs[index])) + } + }) + + It("errors when it fails to convert maps to Configuration objects", func() { + testMaps := []map[string]string{ + { + "allowReinstalls": "foobar", + "maxConcurrentReconciles": "5", + }, + { + "allowReinstalls": "true", + "maxConcurrentReconciles": "ONE", + }, + } + + for _, tMap := range testMaps { + got, err := FromMap(tMap) + Expect(err).To(HaveOccurred()) + Expect(got).To(BeNil()) + } + }) + + It("errors when unknown fields are found while converting maps to Configuration objects", func() { + testMaps := []map[string]string{ + { + "allowReinstall$": "true", + "maxConcurrentReconciles": "1", + }, + { + "allowReinstalls": "false", + "maxConcurrentReconciles": "10", + "foo": "bar", + }, + } + + for _, tMap := range testMaps { + got, err := FromMap(tMap) + Expect(err).To(HaveOccurred()) + Expect(got).To(BeNil()) + } + }) +}) diff --git a/internal/controller/configuration/suite_test.go b/internal/controller/configuration/suite_test.go new file mode 100644 index 00000000..7e480421 --- /dev/null +++ b/internal/controller/configuration/suite_test.go @@ -0,0 +1,31 @@ +/* +Copyright 2024. + +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 configuration + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + //+kubebuilder:scaffold:imports +) + +func TestConfiguration(t *testing.T) { + RegisterFailHandler(Fail) + t.Setenv("POD_NAMESPACE", "siteconfig-operator") + RunSpecs(t, "ConfigurationSuite") +} diff --git a/internal/controller/configuration/utils.go b/internal/controller/configuration/utils.go new file mode 100644 index 00000000..1168a5c8 --- /dev/null +++ b/internal/controller/configuration/utils.go @@ -0,0 +1,31 @@ +/* +Copyright 2024. + +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 configuration + +import ( + "os" + + "go.uber.org/zap" +) + +func GetPodNamespace(log *zap.Logger) string { + namespace := os.Getenv("POD_NAMESPACE") + if namespace == "" { + log.Info("POD_NAMESPACE environment variable is not defined") + } + return namespace +} diff --git a/internal/controller/configuration_monitor.go b/internal/controller/configuration_monitor.go new file mode 100644 index 00000000..eab8f9a6 --- /dev/null +++ b/internal/controller/configuration_monitor.go @@ -0,0 +1,114 @@ +/* +Copyright 2024. + +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" + "time" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlruntime "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/stolostron/siteconfig/internal/controller/configuration" +) + +// ConfigurationMonitor reconciles a ConfigMap object to +// update the SiteConfig Operator controller parameters +type ConfigurationMonitor struct { + client.Client + Log *zap.Logger + Scheme *runtime.Scheme + ConfigStore *configuration.ConfigurationStore +} + +//+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete + +func (r *ConfigurationMonitor) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + + log := r.Log.With( + zap.String("name", req.Name), + zap.String("namespace", req.Namespace), + ) + + log.Info("Start reconcile") + defer log.Info("Completed reconcile") + + if req.Name != configuration.SiteConfigOperatorConfigMap { + log.Sugar().Infof("Ignoring ConfigMap %s", req.NamespacedName) + return ctrl.Result{}, nil + } + + // Fetch the configuration from ConfigMap + config, err := configuration.LoadFromConfigMap(ctx, r.Client, req.Namespace) + if err != nil { + if errors.IsNotFound(err) { + _, err = configuration.CreateDefaultConfigurationConfigMap(ctx, r.Client, req.Namespace) + return requeueWithError(err) + } + log.Error("Failed to get SiteConfig Configuration", zap.Error(err)) + + // This is likely a case where the API is down, so requeue and try again shortly + return ctrl.Result{RequeueAfter: 30 * time.Second}, err + } + + log.Sugar().Infof("Loaded SiteConfig Configuration %s", req.NamespacedName) + + // Update configuration + r.ConfigStore.SetConfiguration(config) + log.Sugar().Infof("Updated SiteConfig Configuration with %v", config) + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ConfigurationMonitor) SetupWithManager(mgr ctrl.Manager) error { + filterFn := func(name, namespace string) bool { + if name != configuration.SiteConfigOperatorConfigMap || namespace != configuration.GetPodNamespace(r.Log) { + return false + } + return true + } + + // Predicate that checks for the SiteConfig Configuration ConfigMap + siteConfigCMPredicate := predicate.Funcs{ + GenericFunc: func(e event.GenericEvent) bool { return false }, + CreateFunc: func(e event.CreateEvent) bool { + return filterFn(e.Object.GetName(), e.Object.GetNamespace()) + }, + DeleteFunc: func(e event.DeleteEvent) bool { + // recreate the SiteConfig configuration ConfigMap with default values on deletion + return filterFn(e.Object.GetName(), e.Object.GetNamespace()) + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return filterFn(e.ObjectNew.GetName(), e.ObjectNew.GetNamespace()) + }, + } + + return ctrl.NewControllerManagedBy(mgr). + Named("ConfigurationMonitor"). + For(&corev1.ConfigMap{}). + WithOptions(ctrlruntime.Options{MaxConcurrentReconciles: 1}). + WithEventFilter(siteConfigCMPredicate). + Complete(r) +} diff --git a/internal/controller/configuration_monitor_test.go b/internal/controller/configuration_monitor_test.go new file mode 100644 index 00000000..991d18a2 --- /dev/null +++ b/internal/controller/configuration_monitor_test.go @@ -0,0 +1,172 @@ +/* +Copyright 2024. + +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" + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stolostron/siteconfig/internal/controller/configuration" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +var _ = Describe("Reconcile", func() { + var ( + c client.Client + r *ConfigurationMonitor + ctx = context.Background() + SiteConfigNamespace = "siteconfig-operator" + testLogger = zap.NewNop().Named("Test") + ) + + BeforeEach(func() { + c = fakeclient.NewClientBuilder(). + WithScheme(scheme.Scheme). + WithStatusSubresource(&corev1.ConfigMap{}). + Build() + + r = &ConfigurationMonitor{ + Client: c, + Scheme: scheme.Scheme, + Log: testLogger, + ConfigStore: configuration.NewConfigurationStore(configuration.NewDefaultConfiguration()), + } + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: SiteConfigNamespace, + }, + } + Expect(c.Create(ctx, namespace)).To(Succeed()) + }) + + It("creates the default SiteConfig Configuration ConfigMap", func() { + cm := &corev1.ConfigMap{} + key := types.NamespacedName{ + Name: configuration.SiteConfigOperatorConfigMap, + Namespace: SiteConfigNamespace, + } + Expect(c.Get(ctx, key, cm)).ToNot(Succeed()) + + res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(ctrl.Result{})) + + Expect(c.Get(ctx, key, cm)).To(Succeed()) + config, err := configuration.FromMap(cm.Data) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(config, configuration.NewDefaultConfiguration())).To(BeTrue()) + }) + + It("updates the SiteConfig Configuration when the ConfigMap changes ", func() { + + key := types.NamespacedName{ + Name: configuration.SiteConfigOperatorConfigMap, + Namespace: SiteConfigNamespace, + } + + // Kick-off a reconcile that should auto-create a default SiteConfig Configuration CM + res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(ctrl.Result{})) + + cm := &corev1.ConfigMap{} + Expect(c.Get(ctx, key, cm)).To(Succeed()) + config, err := configuration.FromMap(cm.Data) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(config, configuration.NewDefaultConfiguration())).To(BeTrue()) + + // Update config and verify changes + updatedConfig := &configuration.Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 10, + } + + // create the configuration CM + cm.Data = updatedConfig.ToMap() + Expect(c.Update(ctx, cm)).To(Succeed()) + + // Kick-off a reconcile and verify that the configuration object is udpated + res, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(ctrl.Result{})) + + cm1 := &corev1.ConfigMap{} + Expect(c.Get(ctx, key, cm1)).To(Succeed()) + gotConfig, err := configuration.FromMap(cm1.Data) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(gotConfig, updatedConfig)).To(BeTrue()) + }) + + It("recreates the SiteConfig Configuration ConfigMap with default config when deleted", func() { + initConfig := &configuration.Configuration{ + AllowReinstalls: true, + MaxConcurrentReconciles: 10, + } + + // create the configuration CM + key := types.NamespacedName{ + Name: configuration.SiteConfigOperatorConfigMap, + Namespace: SiteConfigNamespace, + } + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Data: initConfig.ToMap(), + } + Expect(c.Create(ctx, cm)).To(Succeed()) + + // Update the shared configuration store config + r.ConfigStore.SetConfiguration(initConfig) + + // Kick-off a reconcile and verify that the SiteConfig Configuration CM has not changed + res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(ctrl.Result{})) + + Expect(c.Get(ctx, key, cm)).To(Succeed()) + gotConfig, err := configuration.FromMap(cm.Data) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(gotConfig, initConfig)).To(BeTrue()) + + // Delete the CM + Expect(c.Delete(ctx, cm)).To(Succeed()) + Expect(c.Get(ctx, key, cm)).ToNot(Succeed()) + + // Verify default config CM is recreated on reconcile + res, err = r.Reconcile(ctx, ctrl.Request{NamespacedName: key}) + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(ctrl.Result{})) + + Expect(c.Get(ctx, key, cm)).To(Succeed()) + config, err := configuration.FromMap(cm.Data) + Expect(err).ToNot(HaveOccurred()) + Expect(reflect.DeepEqual(config, configuration.NewDefaultConfiguration())).To(BeTrue()) + }) +})