From e28dbe1b93144becfa214b7d842fe8138f1348db Mon Sep 17 00:00:00 2001 From: m Date: Wed, 15 Jan 2025 10:31:06 +0200 Subject: [PATCH] Add basic reconcile methods for WatcherDecisionEngine Adds the basic attributes to the WatcherDecisionEngineSpec and Status structs (like WatcherCommon) and adds the basic reconcile functions like GetLogger and initStatus and skeleton for generateServiceConfigs Includes base unit tests and next commit will have kuttl. --- ....openstack.org_watcherdecisionengines.yaml | 132 +++++++++ api/v1beta1/watcherdecisionengine_types.go | 15 + api/v1beta1/zz_generated.deepcopy.go | 19 +- ....openstack.org_watcherdecisionengines.yaml | 132 +++++++++ ...atcher-operator.clusterserviceversion.yaml | 4 + .../watcherdecisionengine_controller.go | 270 +++++++++++++++++- hack/boilerplate.go.txt | 2 +- tests/functional/base_test.go | 36 +++ tests/functional/watcher_test_data.go | 5 + .../watcherdecisionengine_controller_test.go | 189 ++++++++++++ 10 files changed, 797 insertions(+), 7 deletions(-) create mode 100644 tests/functional/watcherdecisionengine_controller_test.go diff --git a/api/bases/watcher.openstack.org_watcherdecisionengines.yaml b/api/bases/watcher.openstack.org_watcherdecisionengines.yaml index acfcb9f..782ceec 100644 --- a/api/bases/watcher.openstack.org_watcherdecisionengines.yaml +++ b/api/bases/watcher.openstack.org_watcherdecisionengines.yaml @@ -44,6 +44,41 @@ spec: description: The service specific Container Image URL (will be set to environmental default if empty) type: string + customServiceConfig: + description: |- + CustomServiceConfig - customize the service config using this parameter to change service defaults, + or overwrite rendered information using raw OpenStack config format. The content gets added to + to /etc//.conf.d directory as a custom config file. + type: string + memcachedInstance: + default: memcached + description: MemcachedInstance is the name of the Memcached CR that + all watcher service will use. + type: string + nodeSelector: + additionalProperties: + type: string + description: |- + NodeSelector to target subset of worker nodes running this component. Setting here overrides + any global NodeSelector settings within the Watcher CR. + type: object + passwordSelectors: + default: + service: WatcherPassword + description: PasswordSelectors - Selectors to identify the ServiceUser + password from the Secret + properties: + service: + default: WatcherPassword + description: Service - Selector to get the watcher service user + password from the Secret + type: string + type: object + preserveJobs: + default: false + description: PreserveJobs - do not delete jobs after they finished + e.g. to check logs + type: boolean replicas: default: 1 description: Replicas of Watcher service to run @@ -108,17 +143,114 @@ spec: More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object type: object + secret: + description: Secret containing all passwords / keys needed + type: string serviceAccount: description: |- ServiceAccount - service account name used internally to provide Watcher services the default SA name type: string + serviceUser: + default: watcher + description: ServiceUser - optional username used for this service + to register in keystone + type: string + tls: + description: TLS - Parameters related to the TLS + properties: + api: + description: API tls type which encapsulates for API services + properties: + internal: + description: Internal GenericService - holds the secret for + the internal endpoint + properties: + secretName: + description: SecretName - holding the cert, key for the + service + type: string + type: object + public: + description: Public GenericService - holds the secret for + the public endpoint + properties: + secretName: + description: SecretName - holding the cert, key for the + service + type: string + type: object + type: object + caBundleSecretName: + description: CaBundleSecretName - holding the CA certs in a pre-created + bundle file + type: string + type: object required: + - secret - serviceAccount type: object status: description: WatcherDecisionEngineStatus defines the observed state of WatcherDecisionEngine + properties: + conditions: + description: |- + INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + Important: Run "make" to regenerate code after modifying this file + items: + description: Condition defines an observation of a API resource + operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition + in CamelCase. + type: string + severity: + description: |- + Severity provides a classification of Reason code, so the current situation is immediately + understandable and could act accordingly. + It is meant for situations where Status=False and it should be indicated if it is just + informational, warning (next reconciliation might fix it) or an error (e.g. DB create issue + and no actions to automatically resolve the issue can/should be done). + For conditions where Status=Unknown or Status=True the Severity should be SeverityNone. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition in CamelCase. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + hash: + additionalProperties: + type: string + description: Map of hashes to track e.g. job status + type: object + observedGeneration: + description: |- + ObservedGeneration - the most recent generation observed for this + service. If the observed generation is less than the spec generation, + then the controller has not processed the latest changes injected by + the openstack-operator in the top-level CR (e.g. the ContainerImage) + format: int64 + type: integer type: object type: object served: true diff --git a/api/v1beta1/watcherdecisionengine_types.go b/api/v1beta1/watcherdecisionengine_types.go index 4d0ae31..d049a51 100644 --- a/api/v1beta1/watcherdecisionengine_types.go +++ b/api/v1beta1/watcherdecisionengine_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1beta1 import ( + "github.com/openstack-k8s-operators/lib-common/modules/common/condition" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -27,6 +28,11 @@ import ( type WatcherDecisionEngineSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file + WatcherCommon `json:",inline"` + + // +kubebuilder:validation:Required + // Secret containing all passwords / keys needed + Secret string `json:"secret"` WatcherSubCrsCommon `json:",inline"` } @@ -35,6 +41,15 @@ type WatcherDecisionEngineSpec struct { type WatcherDecisionEngineStatus struct { // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster // Important: Run "make" to regenerate code after modifying this file + Conditions condition.Conditions `json:"conditions,omitempty" optional:"true"` + + // ObservedGeneration - the most recent generation observed for this + // service. If the observed generation is less than the spec generation, + // then the controller has not processed the latest changes injected by + // the openstack-operator in the top-level CR (e.g. the ContainerImage) + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Map of hashes to track e.g. job status + Hash map[string]string `json:"hash,omitempty"` } //+kubebuilder:object:root=true diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index b234d6e..270bd56 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1,7 +1,7 @@ //go:build !ignore_autogenerated /* -Copyright 2024. +Copyright 2025. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -372,7 +372,7 @@ func (in *WatcherDecisionEngine) DeepCopyInto(out *WatcherDecisionEngine) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherDecisionEngine. @@ -428,6 +428,7 @@ func (in *WatcherDecisionEngineList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatcherDecisionEngineSpec) DeepCopyInto(out *WatcherDecisionEngineSpec) { *out = *in + in.WatcherCommon.DeepCopyInto(&out.WatcherCommon) in.WatcherSubCrsCommon.DeepCopyInto(&out.WatcherSubCrsCommon) } @@ -444,6 +445,20 @@ func (in *WatcherDecisionEngineSpec) DeepCopy() *WatcherDecisionEngineSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatcherDecisionEngineStatus) DeepCopyInto(out *WatcherDecisionEngineStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(condition.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Hash != nil { + in, out := &in.Hash, &out.Hash + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherDecisionEngineStatus. diff --git a/config/crd/bases/watcher.openstack.org_watcherdecisionengines.yaml b/config/crd/bases/watcher.openstack.org_watcherdecisionengines.yaml index acfcb9f..782ceec 100644 --- a/config/crd/bases/watcher.openstack.org_watcherdecisionengines.yaml +++ b/config/crd/bases/watcher.openstack.org_watcherdecisionengines.yaml @@ -44,6 +44,41 @@ spec: description: The service specific Container Image URL (will be set to environmental default if empty) type: string + customServiceConfig: + description: |- + CustomServiceConfig - customize the service config using this parameter to change service defaults, + or overwrite rendered information using raw OpenStack config format. The content gets added to + to /etc//.conf.d directory as a custom config file. + type: string + memcachedInstance: + default: memcached + description: MemcachedInstance is the name of the Memcached CR that + all watcher service will use. + type: string + nodeSelector: + additionalProperties: + type: string + description: |- + NodeSelector to target subset of worker nodes running this component. Setting here overrides + any global NodeSelector settings within the Watcher CR. + type: object + passwordSelectors: + default: + service: WatcherPassword + description: PasswordSelectors - Selectors to identify the ServiceUser + password from the Secret + properties: + service: + default: WatcherPassword + description: Service - Selector to get the watcher service user + password from the Secret + type: string + type: object + preserveJobs: + default: false + description: PreserveJobs - do not delete jobs after they finished + e.g. to check logs + type: boolean replicas: default: 1 description: Replicas of Watcher service to run @@ -108,17 +143,114 @@ spec: More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object type: object + secret: + description: Secret containing all passwords / keys needed + type: string serviceAccount: description: |- ServiceAccount - service account name used internally to provide Watcher services the default SA name type: string + serviceUser: + default: watcher + description: ServiceUser - optional username used for this service + to register in keystone + type: string + tls: + description: TLS - Parameters related to the TLS + properties: + api: + description: API tls type which encapsulates for API services + properties: + internal: + description: Internal GenericService - holds the secret for + the internal endpoint + properties: + secretName: + description: SecretName - holding the cert, key for the + service + type: string + type: object + public: + description: Public GenericService - holds the secret for + the public endpoint + properties: + secretName: + description: SecretName - holding the cert, key for the + service + type: string + type: object + type: object + caBundleSecretName: + description: CaBundleSecretName - holding the CA certs in a pre-created + bundle file + type: string + type: object required: + - secret - serviceAccount type: object status: description: WatcherDecisionEngineStatus defines the observed state of WatcherDecisionEngine + properties: + conditions: + description: |- + INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + Important: Run "make" to regenerate code after modifying this file + items: + description: Condition defines an observation of a API resource + operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition + in CamelCase. + type: string + severity: + description: |- + Severity provides a classification of Reason code, so the current situation is immediately + understandable and could act accordingly. + It is meant for situations where Status=False and it should be indicated if it is just + informational, warning (next reconciliation might fix it) or an error (e.g. DB create issue + and no actions to automatically resolve the issue can/should be done). + For conditions where Status=Unknown or Status=True the Severity should be SeverityNone. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition in CamelCase. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + hash: + additionalProperties: + type: string + description: Map of hashes to track e.g. job status + type: object + observedGeneration: + description: |- + ObservedGeneration - the most recent generation observed for this + service. If the observed generation is less than the spec generation, + then the controller has not processed the latest changes injected by + the openstack-operator in the top-level CR (e.g. the ContainerImage) + format: int64 + type: integer type: object type: object served: true diff --git a/config/manifests/bases/watcher-operator.clusterserviceversion.yaml b/config/manifests/bases/watcher-operator.clusterserviceversion.yaml index 812702e..aea18ff 100644 --- a/config/manifests/bases/watcher-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/watcher-operator.clusterserviceversion.yaml @@ -42,6 +42,10 @@ spec: displayName: Watcher Decision Engine kind: WatcherDecisionEngine name: watcherdecisionengines.watcher.openstack.org + specDescriptors: + - description: TLS - Parameters related to the TLS + displayName: TLS + path: tls version: v1beta1 - description: Watcher is the Schema for the watchers API displayName: Watcher diff --git a/controllers/watcherdecisionengine_controller.go b/controllers/watcherdecisionengine_controller.go index 4d38fb2..cce4ba6 100644 --- a/controllers/watcherdecisionengine_controller.go +++ b/controllers/watcherdecisionengine_controller.go @@ -18,11 +18,28 @@ package controllers import ( "context" + "fmt" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/go-logr/logr" + memcachedv1 "github.com/openstack-k8s-operators/infra-operator/apis/memcached/v1beta1" + "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + "github.com/openstack-k8s-operators/lib-common/modules/common/env" + "github.com/openstack-k8s-operators/lib-common/modules/common/helper" watcherv1beta1 "github.com/openstack-k8s-operators/watcher-operator/api/v1beta1" + + corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" ) // WatcherDecisionEngineReconciler reconciles a WatcherDecisionEngine object @@ -30,9 +47,16 @@ type WatcherDecisionEngineReconciler struct { ReconcilerBase } +// GetLogger returns a logger object with a prefix of "controller.name" and +// additional controller context fields +func (r *WatcherDecisionEngineReconciler) GetLogger(ctx context.Context) logr.Logger { + return log.FromContext(ctx).WithName("Controllers").WithName("WatcherDecisionEngine") +} + //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherdecisionengines,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherdecisionengines/status,verbs=get;update;patch //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherdecisionengines/finalizers,verbs=update +//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete; // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. @@ -43,17 +67,255 @@ type WatcherDecisionEngineReconciler struct { // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.1/pkg/reconcile -func (r *WatcherDecisionEngineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) - _ = req - // TODO(user): your logic here +func (r *WatcherDecisionEngineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, _err error) { + Log := r.GetLogger(ctx) + + instance := &watcherv1beta1.WatcherDecisionEngine{} + err := r.Client.Get(ctx, req.NamespacedName, instance) + if err != nil { + if k8s_errors.IsNotFound(err) { + // Object not found + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + return ctrl.Result{}, err + } + Log.Info(fmt.Sprintf("Reconciling WatcherDecisionEngine %s", instance.Name)) + + helper, err := helper.NewHelper( + instance, + r.Client, + r.Kclient, + r.Scheme, + Log, + ) + if err != nil { + return ctrl.Result{}, err + } + + // initialize status if Conditions is nil, but do not reset if it already + // exists + isNewInstance := instance.Status.Conditions == nil + if isNewInstance { + instance.Status.Conditions = condition.Conditions{} + } + + // Save a copy of the condtions so that we can restore the LastTransitionTime + // when a condition's state doesn't change. + savedConditions := instance.Status.Conditions.DeepCopy() + + // Always patch the instance status when exiting this function so we can + // persist any changes. + defer func() { + condition.RestoreLastTransitionTimes( + &instance.Status.Conditions, savedConditions) + if instance.Status.Conditions.IsUnknown(condition.ReadyCondition) { + instance.Status.Conditions.Set( + instance.Status.Conditions.Mirror(condition.ReadyCondition)) + } + err := helper.PatchInstance(ctx, instance) + if err != nil { + _err = err + return + } + }() + err = r.initStatus(instance) + if err != nil { + return ctrl.Result{}, nil + } + + // If we're not deleting this and the service object doesn't have our finalizer, add it. + if instance.DeletionTimestamp.IsZero() && controllerutil.AddFinalizer(instance, helper.GetFinalizer()) || isNewInstance { + return ctrl.Result{}, nil + } + + // Handle service delete + if !instance.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, instance, helper) + } + + configVars := make(map[string]env.Setter) + // check for required OpenStack secret holding passwords for service/admin user and add hash to the vars map + Log.Info(fmt.Sprintf("[DecisionEngine] Get secret 1 '%s'", instance.Spec.Secret)) + secretHash, result, secret, err := ensureSecret( + ctx, + types.NamespacedName{Namespace: instance.Namespace, Name: instance.Spec.Secret}, + []string{ + instance.Spec.PasswordSelectors.Service, + }, + helper.GetClient(), + &instance.Status.Conditions, + r.RequeueTimeout, + ) + if (err != nil || result != ctrl.Result{}) { + return result, err + } + + configVars[instance.Spec.Secret] = env.SetValue(secretHash) + + // all our input checks out so report InputReady + instance.Status.Conditions.MarkTrue(condition.InputReadyCondition, condition.InputReadyMessage) + + memcached, err := ensureMemcached(ctx, helper, instance.Namespace, instance.Spec.MemcachedInstance, &instance.Status.Conditions) + + if err != nil { + return ctrl.Result{}, err + } + // Add finalizer to Memcached to prevent it from being deleted now that we're using it + if controllerutil.AddFinalizer(memcached, helper.GetFinalizer()) { + err := helper.GetClient().Update(ctx, memcached) + if err != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + condition.MemcachedReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.MemcachedReadyErrorMessage, + err.Error())) + return ctrl.Result{}, err + } + } + + err = r.generateServiceConfigs(ctx, instance, secret, memcached, helper, &configVars) + if err != nil { + return ctrl.Result{}, err + } + + instance.Status.Conditions.MarkTrue(condition.ServiceConfigReadyCondition, condition.ServiceConfigReadyMessage) + + // We reached the end of the Reconcile, update the Ready condition based on + // the sub conditions + if instance.Status.Conditions.AllSubConditionIsTrue() { + instance.Status.Conditions.MarkTrue( + condition.ReadyCondition, condition.ReadyMessage) + } return ctrl.Result{}, nil + +} + +func (r *WatcherDecisionEngineReconciler) initStatus(instance *watcherv1beta1.WatcherDecisionEngine) error { + + cl := condition.CreateList( + // Mark ReadyCondition as Unknown from the beginning, because the + // Reconcile function is in progress. If this condition is not marked + // as True and is still in the "Unknown" state, we `Mirror(` the actual + // failure/in-progress operation + condition.UnknownCondition(condition.ReadyCondition, condition.InitReason, condition.ReadyInitMessage), + condition.UnknownCondition(condition.InputReadyCondition, condition.InitReason, condition.InputReadyInitMessage), + condition.UnknownCondition(condition.ServiceConfigReadyCondition, condition.InitReason, condition.ServiceConfigReadyInitMessage), + condition.UnknownCondition(condition.MemcachedReadyCondition, condition.InitReason, condition.MemcachedReadyInitMessage), + ) + + instance.Status.Conditions.Init(&cl) + + // Update the lastObserved generation before evaluating conditions + instance.Status.ObservedGeneration = instance.Generation + + if instance.Status.Hash == nil { + instance.Status.Hash = map[string]string{} + } + return nil } // SetupWithManager sets up the controller with the Manager. func (r *WatcherDecisionEngineReconciler) SetupWithManager(mgr ctrl.Manager) error { + // index passwordSecretField + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &watcherv1beta1.WatcherDecisionEngine{}, passwordSecretField, func(rawObj client.Object) []string { + // Extract the secret name from the spec, if one is provided + cr := rawObj.(*watcherv1beta1.WatcherDecisionEngine) + if cr.Spec.Secret == "" { + return nil + } + return []string{cr.Spec.Secret} + }); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). For(&watcherv1beta1.WatcherDecisionEngine{}). + Owns(&corev1.Secret{}). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). Complete(r) } + +func (r *WatcherDecisionEngineReconciler) findObjectsForSrc(ctx context.Context, src client.Object) []reconcile.Request { + requests := []reconcile.Request{} + + l := log.FromContext(ctx).WithName("Controllers").WithName("WatcherDecisionEngine") + + for _, field := range apiWatchFields { + crList := &watcherv1beta1.WatcherDecisionEngineList{} + listOps := &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(field, src.GetName()), + Namespace: src.GetNamespace(), + } + err := r.Client.List(ctx, crList, listOps) + if err != nil { + l.Error(err, fmt.Sprintf("listing %s for field: %s - %s", crList.GroupVersionKind().Kind, field, src.GetNamespace())) + return requests + } + + for _, item := range crList.Items { + l.Info(fmt.Sprintf("input source %s changed, reconcile: %s - %s", src.GetName(), item.GetName(), item.GetNamespace())) + + requests = append(requests, + reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: item.GetName(), + Namespace: item.GetNamespace(), + }, + }, + ) + } + } + + return requests +} + +// generateServiceConfigs - create Secret which holds the service configuration +// NOTE - jgilaber this function is WIP, currently implements a fraction of its +// functionality and will be expanded of further iteration to actually generate +// the service configs +func (r *WatcherDecisionEngineReconciler) generateServiceConfigs( + ctx context.Context, instance *watcherv1beta1.WatcherDecisionEngine, + secret corev1.Secret, + memcachedInstance *memcachedv1.Memcached, + helper *helper.Helper, envVars *map[string]env.Setter, +) error { + Log := r.GetLogger(ctx) + Log.Info("generateServiceConfigs - reconciling") + + _ = instance + _ = secret + _ = memcachedInstance + _ = helper + _ = envVars + return nil +} + +func (r *WatcherDecisionEngineReconciler) reconcileDelete(ctx context.Context, instance *watcherv1beta1.WatcherDecisionEngine, helper *helper.Helper) (ctrl.Result, error) { + Log := r.GetLogger(ctx) + Log.Info(fmt.Sprintf("Reconcile Service '%s' delete started", instance.Name)) + + // Remove our finalizer from Memcached + memcached, err := memcachedv1.GetMemcachedByName(ctx, helper, instance.Spec.MemcachedInstance, instance.Namespace) + if err != nil && !k8s_errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + if memcached != nil { + if controllerutil.RemoveFinalizer(memcached, helper.GetFinalizer()) { + err := helper.GetClient().Update(ctx, memcached) + if err != nil { + return ctrl.Result{}, err + } + } + } + + controllerutil.RemoveFinalizer(instance, helper.GetFinalizer()) + Log.Info(fmt.Sprintf("Reconciled Service '%s' delete successfully", instance.Name)) + return ctrl.Result{}, nil +} diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index 06a460e..4671de8 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,5 +1,5 @@ /* -Copyright 2024. +Copyright 2025. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/tests/functional/base_test.go b/tests/functional/base_test.go index 21ee525..f9c62b0 100644 --- a/tests/functional/base_test.go +++ b/tests/functional/base_test.go @@ -76,6 +76,16 @@ func GetDefaultWatcherApplierSpec() map[string]interface{} { } } +func GetDefaultWatcherDecisionEngineSpec() map[string]interface{} { + return map[string]interface{}{ + "databaseInstance": "openstack", + "secret": SecretName, + "memcachedInstance": "memcached", + "serviceAccount": "watcher-sa", + "containerImage": "test://watcher", + } +} + func CreateWatcher(name types.NamespacedName, spec map[string]interface{}) client.Object { raw := map[string]interface{}{ "apiVersion": "watcher.openstack.org/v1beta1", @@ -154,6 +164,11 @@ func WatcherApplierConditionGetter(name types.NamespacedName) condition.Conditio return instance.Status.Conditions } +func WatcherDecisionEngineConditionGetter(name types.NamespacedName) condition.Conditions { + instance := GetWatcherDecisionEngine(name) + return instance.Status.Conditions +} + func CreateWatcherMessageBusSecret(namespace string, name string) *corev1.Secret { s := th.CreateSecret( types.NamespacedName{Namespace: namespace, Name: name}, @@ -164,3 +179,24 @@ func CreateWatcherMessageBusSecret(namespace string, name string) *corev1.Secret logger.Info("Secret created", "name", name) return s } + +func CreateWatcherDecisionEngine(name types.NamespacedName, spec map[string]interface{}) client.Object { + raw := map[string]interface{}{ + "apiVersion": "watcher.openstack.org/v1beta1", + "kind": "WatcherDecisionEngine", + "metadata": map[string]interface{}{ + "name": name.Name, + "namespace": name.Namespace, + }, + "spec": spec, + } + return th.CreateUnstructured(raw) +} + +func GetWatcherDecisionEngine(name types.NamespacedName) *watcherv1.WatcherDecisionEngine { + instance := &watcherv1.WatcherDecisionEngine{} + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, name, instance)).Should(Succeed()) + }, timeout, interval).Should(Succeed()) + return instance +} diff --git a/tests/functional/watcher_test_data.go b/tests/functional/watcher_test_data.go index 2091813..a385f54 100644 --- a/tests/functional/watcher_test_data.go +++ b/tests/functional/watcher_test_data.go @@ -47,6 +47,7 @@ type WatcherTestData struct { RoleBindingName types.NamespacedName WatcherDBSync types.NamespacedName WatcherAPIDeployment types.NamespacedName + WatcherDecisionEngine types.NamespacedName WatcherPublicServiceName types.NamespacedName WatcherInternalServiceName types.NamespacedName WatcherRouteName types.NamespacedName @@ -97,6 +98,10 @@ func GetWatcherTestData(watcherName types.NamespacedName) WatcherTestData { Namespace: watcherName.Namespace, Name: "watcher-api", }, + WatcherDecisionEngine: types.NamespacedName{ + Namespace: watcherName.Namespace, + Name: "watcher-decision-engine", + }, MemcachedNamespace: types.NamespacedName{ Namespace: watcherName.Namespace, Name: "memcached", diff --git a/tests/functional/watcherdecisionengine_controller_test.go b/tests/functional/watcherdecisionengine_controller_test.go new file mode 100644 index 0000000..5cf9390 --- /dev/null +++ b/tests/functional/watcherdecisionengine_controller_test.go @@ -0,0 +1,189 @@ +package functional + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" //revive:disable:dot-imports + . "github.com/onsi/gomega" //revive:disable:dot-imports + + //revive:disable-next-line:dot-imports + memcachedv1 "github.com/openstack-k8s-operators/infra-operator/apis/memcached/v1beta1" + condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + . "github.com/openstack-k8s-operators/lib-common/modules/common/test/helpers" + watcherv1beta1 "github.com/openstack-k8s-operators/watcher-operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" +) + +var ( + MinimalWatcherDecisionEngineSpec = map[string]interface{}{ + "secret": "osp-secret", + "memcachedInstance": "memcached", + } +) + +var _ = Describe("WatcherDecisionEngine controller with minimal spec values", func() { + When("A WatcherDecisionEngine instance is created from minimal spec", func() { + BeforeEach(func() { + DeferCleanup(th.DeleteInstance, CreateWatcherDecisionEngine(watcherTest.WatcherDecisionEngine, MinimalWatcherDecisionEngineSpec)) + }) + + It("should have the Spec fields defaulted", func() { + WatcherDecisionEngine := GetWatcherDecisionEngine(watcherTest.WatcherDecisionEngine) + Expect(WatcherDecisionEngine.Spec.MemcachedInstance).Should(Equal("memcached")) + Expect(WatcherDecisionEngine.Spec.Secret).Should(Equal("osp-secret")) + Expect(WatcherDecisionEngine.Spec.PasswordSelectors).Should(Equal(watcherv1beta1.PasswordSelector{Service: "WatcherPassword"})) + }) + + It("should have the Status fields initialized", func() { + WatcherDecisionEngine := GetWatcherDecisionEngine(watcherTest.WatcherDecisionEngine) + Expect(WatcherDecisionEngine.Status.ObservedGeneration).To(Equal(int64(0))) + }) + + It("should have a finalizer", func() { + // the reconciler loop adds the finalizer so we have to wait for + // it to run + Eventually(func() []string { + return GetWatcherDecisionEngine(watcherTest.WatcherDecisionEngine).Finalizers + }, timeout, interval).Should(ContainElement("openstack.org/watcherdecisionengine")) + }) + + }) +}) + +var _ = Describe("WatcherDecisionEngine controller", func() { + When("A WatcherDecisionEngine instance is created", func() { + BeforeEach(func() { + DeferCleanup(th.DeleteInstance, CreateWatcherDecisionEngine(watcherTest.WatcherDecisionEngine, GetDefaultWatcherDecisionEngineSpec())) + }) + + It("should have the Spec fields defaulted", func() { + WatcherDecisionEngine := GetWatcherDecisionEngine(watcherTest.WatcherDecisionEngine) + Expect(WatcherDecisionEngine.Spec.Secret).Should(Equal("test-osp-secret")) + Expect(WatcherDecisionEngine.Spec.MemcachedInstance).Should(Equal("memcached")) + }) + + It("should have the Status fields initialized", func() { + WatcherDecisionEngine := GetWatcherDecisionEngine(watcherTest.WatcherDecisionEngine) + Expect(WatcherDecisionEngine.Status.ObservedGeneration).To(Equal(int64(0))) + }) + + It("should have ReadyCondition false", func() { + th.ExpectCondition( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.ReadyCondition, + corev1.ConditionFalse, + ) + }) + + It("should have input not ready", func() { + th.ExpectCondition( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.InputReadyCondition, + corev1.ConditionFalse, + ) + }) + + It("should have service config input unknown", func() { + th.ExpectCondition( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.ServiceConfigReadyCondition, + corev1.ConditionUnknown, + ) + }) + + It("should have a finalizer", func() { + // the reconciler loop adds the finalizer so we have to wait for + // it to run + Eventually(func() []string { + return GetWatcherDecisionEngine(watcherTest.WatcherDecisionEngine).Finalizers + }, timeout, interval).Should(ContainElement("openstack.org/watcherdecisionengine")) + }) + }) + When("the secret is created with all the expected fields", func() { + BeforeEach(func() { + secret := th.CreateSecret( + watcherTest.InternalTopLevelSecretName, + map[string][]byte{ + "WatcherPassword": []byte("service-password"), + }, + ) + DeferCleanup(k8sClient.Delete, ctx, secret) + DeferCleanup(th.DeleteInstance, CreateWatcherDecisionEngine(watcherTest.WatcherDecisionEngine, GetDefaultWatcherDecisionEngineSpec())) + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{ + Replicas: ptr.To(int32(1)), + }, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.WatcherDecisionEngine.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + }) + It("should have input ready", func() { + th.ExpectCondition( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.InputReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have config service input ready", func() { + th.ExpectCondition( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.ServiceConfigReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have memcached ready true", func() { + th.ExpectCondition( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.MemcachedReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have ReadyCondition ready", func() { + th.ExpectCondition( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.ReadyCondition, + corev1.ConditionTrue, + ) + }) + }) + When("the secret is created but missing fields", func() { + BeforeEach(func() { + secret := th.CreateSecret( + watcherTest.InternalTopLevelSecretName, + map[string][]byte{}, + ) + DeferCleanup(k8sClient.Delete, ctx, secret) + DeferCleanup(th.DeleteInstance, CreateWatcherDecisionEngine(watcherTest.WatcherDecisionEngine, GetDefaultWatcherDecisionEngineSpec())) + }) + It("should have input false", func() { + errorString := fmt.Sprintf( + condition.InputReadyErrorMessage, + "field 'WatcherPassword' not found in secret/test-osp-secret", + ) + th.ExpectConditionWithDetails( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.InputReadyCondition, + corev1.ConditionFalse, + condition.ErrorReason, + errorString, + ) + }) + It("should have config service input unknown", func() { + th.ExpectCondition( + watcherTest.WatcherDecisionEngine, + ConditionGetterFunc(WatcherDecisionEngineConditionGetter), + condition.ServiceConfigReadyCondition, + corev1.ConditionUnknown, + ) + }) + }) +})