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, + ) + }) + }) +})