From 6e604783a0248858b56325251a56b883a7ce92c0 Mon Sep 17 00:00:00 2001 From: Douglas Viroel Date: Tue, 21 Jan 2025 17:12:26 -0300 Subject: [PATCH] Add initial reconciler methods for WatcherApplier Adds initial logic for reconciler on Watcher Applier controller, including inputs, serviceConfig and memcached configurations. --- ...watcher.openstack.org_watcherappliers.yaml | 98 ++++++ api/v1beta1/watcherapplier_types.go | 22 ++ api/v1beta1/zz_generated.deepcopy.go | 17 +- ...watcher.openstack.org_watcherappliers.yaml | 98 ++++++ .../watcher_v1beta1_watcherapplier.yaml | 4 +- controllers/watcher_common.go | 6 + controllers/watcherapplier_controller.go | 317 +++++++++++++++++- tests/functional/base_test.go | 36 ++ tests/functional/sample_test.go | 14 + tests/functional/watcher_test_data.go | 5 + .../watcherapplier_controller_test.go | 282 ++++++++++++++++ 11 files changed, 893 insertions(+), 6 deletions(-) create mode 100644 tests/functional/watcherapplier_controller_test.go diff --git a/api/bases/watcher.openstack.org_watcherappliers.yaml b/api/bases/watcher.openstack.org_watcherappliers.yaml index a44661e..cf4bafb 100644 --- a/api/bases/watcher.openstack.org_watcherappliers.yaml +++ b/api/bases/watcher.openstack.org_watcherappliers.yaml @@ -43,6 +43,35 @@ spec: description: The service specific Container Image URL (will be set to environmental default if empty) 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 @@ -107,16 +136,85 @@ 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 required: + - secret - serviceAccount type: object status: description: WatcherApplierStatus defines the observed state of WatcherApplier + properties: + conditions: + description: Conditions + 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 + readyCount: + description: ReadyCount of watcher API instances + format: int32 + type: integer type: object type: object served: true diff --git a/api/v1beta1/watcherapplier_types.go b/api/v1beta1/watcherapplier_types.go index cf9b2cf..e17bed1 100644 --- a/api/v1beta1/watcherapplier_types.go +++ b/api/v1beta1/watcherapplier_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" ) @@ -28,6 +29,12 @@ type WatcherApplierSpec 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 +42,21 @@ type WatcherApplierSpec struct { type WatcherApplierStatus struct { // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster // Important: Run "make" to regenerate code after modifying this file + + // Conditions + 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"` + + // ReadyCount of watcher API instances + ReadyCount int32 `json:"readyCount,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 911e832..7ded3ed 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -239,7 +239,7 @@ func (in *WatcherApplier) DeepCopyInto(out *WatcherApplier) { 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 WatcherApplier. @@ -295,6 +295,7 @@ func (in *WatcherApplierList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatcherApplierSpec) DeepCopyInto(out *WatcherApplierSpec) { *out = *in + in.WatcherCommon.DeepCopyInto(&out.WatcherCommon) in.WatcherSubCrsCommon.DeepCopyInto(&out.WatcherSubCrsCommon) } @@ -311,6 +312,20 @@ func (in *WatcherApplierSpec) DeepCopy() *WatcherApplierSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatcherApplierStatus) DeepCopyInto(out *WatcherApplierStatus) { *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 WatcherApplierStatus. diff --git a/config/crd/bases/watcher.openstack.org_watcherappliers.yaml b/config/crd/bases/watcher.openstack.org_watcherappliers.yaml index a44661e..cf4bafb 100644 --- a/config/crd/bases/watcher.openstack.org_watcherappliers.yaml +++ b/config/crd/bases/watcher.openstack.org_watcherappliers.yaml @@ -43,6 +43,35 @@ spec: description: The service specific Container Image URL (will be set to environmental default if empty) 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 @@ -107,16 +136,85 @@ 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 required: + - secret - serviceAccount type: object status: description: WatcherApplierStatus defines the observed state of WatcherApplier + properties: + conditions: + description: Conditions + 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 + readyCount: + description: ReadyCount of watcher API instances + format: int32 + type: integer type: object type: object served: true diff --git a/config/samples/watcher_v1beta1_watcherapplier.yaml b/config/samples/watcher_v1beta1_watcherapplier.yaml index 65d2b0f..89d8c36 100644 --- a/config/samples/watcher_v1beta1_watcherapplier.yaml +++ b/config/samples/watcher_v1beta1_watcherapplier.yaml @@ -9,4 +9,6 @@ metadata: app.kubernetes.io/created-by: watcher-operator name: watcherapplier-sample spec: - # TODO(user): Add fields here + secret: "watcher" + memcachedInstance: "memcached" + serviceAccount: "watcher-watcher" diff --git a/controllers/watcher_common.go b/controllers/watcher_common.go index 6e55b2c..9bf977e 100644 --- a/controllers/watcher_common.go +++ b/controllers/watcher_common.go @@ -31,6 +31,9 @@ var ( apiWatchFields = []string{ passwordSecretField, } + applierWatchFields = []string{ + passwordSecretField, + } ) const ( @@ -47,6 +50,9 @@ const ( // WatcherAPILabelPrefix - a unique, service binary specific prefix for the // labels the WatcherAPI controller uses on children objects WatcherAPILabelPrefix = "watcher-api" + // WatcherApplierLabelPrefix - a unique, service binary specific prefix for the + // labels the WatcherApplier controller uses on children objects + WatcherApplierLabelPrefix = "watcher-applier" ) // GetLogger returns a logger object with a prefix of "controller.name" and additional controller context fields diff --git a/controllers/watcherapplier_controller.go b/controllers/watcherapplier_controller.go index c75a1f6..e6ea213 100644 --- a/controllers/watcherapplier_controller.go +++ b/controllers/watcherapplier_controller.go @@ -18,11 +18,32 @@ 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" + "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" + "github.com/openstack-k8s-operators/lib-common/modules/common/labels" + "github.com/openstack-k8s-operators/lib-common/modules/common/util" 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" ) // WatcherApplierReconciler reconciles a WatcherApplier object @@ -30,9 +51,18 @@ type WatcherApplierReconciler struct { ReconcilerBase } +// GetLogger returns a logger object with a prefix of "controller.name" and +// additional controller context fields +func (r *WatcherApplierReconciler) GetLogger(ctx context.Context) logr.Logger { + return log.FromContext(ctx).WithName("Controllers").WithName("WatcherApplier") +} + //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherappliers,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherappliers/status,verbs=get;update;patch //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherappliers/finalizers,verbs=update +//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete; +//+kubebuilder:rbac:groups=memcached.openstack.org,resources=memcacheds,verbs=get;list;watch;update;patch +//+kubebuilder:rbac:groups=memcached.openstack.org,resources=memcacheds/finalizers,verbs=update;patch // 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 +73,296 @@ type WatcherApplierReconciler 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 *WatcherApplierReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) - _ = req - // TODO(user): your logic here +func (r *WatcherApplierReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, _err error) { + + Log := r.GetLogger(ctx) + instance := &watcherv1beta1.WatcherApplier{} + err := r.Client.Get(ctx, req.NamespacedName, instance) + if err != nil { + if k8s_errors.IsNotFound(err) { + // Request object not found, could have been deleted after reconcile request. + // Owned objects are automatically garbage collected. + // For additional cleanup logic use finalizers. Return and don't requeue. + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + return ctrl.Result{}, err + } + + Log.Info(fmt.Sprintf("Reconciling WatcherApplier instance '%s'", instance.Name)) + + helper, err := helper.NewHelper( + instance, + r.Client, + r.Kclient, + r.Scheme, + Log, + ) + if err != nil { + return ctrl.Result{}, err + } + + isNewInstance := instance.Status.Conditions == nil + // Save a copy of the conditions 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 + secretHash, result, _, err := ensureSecret( + ctx, + types.NamespacedName{Namespace: instance.Namespace, Name: instance.Spec.Secret}, + []string{ + instance.Spec.PasswordSelectors.Service, + TransportURLSelector, + }, + 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, helper, &configVars) + if err != nil { + return ctrl.Result{}, err + } + + Log.Info(fmt.Sprintf("[Applier] Getting input hash '%s'", instance.Name)) + // + // create hash over all the different input resources to identify if any those changed + // and a restart/recreate is required. + // + + _, hashChanged, errorHash := r.createHashOfInputHashes(ctx, instance, configVars) + if errorHash != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + condition.ServiceConfigReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.ServiceConfigReadyErrorMessage, + err.Error())) + return ctrl.Result{}, err + } else if hashChanged { + // Hash changed and instance status should be updated (which will be done by main defer func), + // so we need to return and reconcile again + return ctrl.Result{}, nil + } + + instance.Status.Conditions.MarkTrue(condition.ServiceConfigReadyCondition, condition.ServiceConfigReadyMessage) + + // TODO(dviroel): Add create Deployment/StatefulSet + + // 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) + } + + Log.Info(fmt.Sprintf("Successfully reconciled WatcherApplier instance '%s'", instance.Name)) return ctrl.Result{}, nil } +func (r *WatcherApplierReconciler) initStatus(instance *watcherv1beta1.WatcherApplier) 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 +} + +func (r *WatcherApplierReconciler) reconcileDelete(ctx context.Context, instance *watcherv1beta1.WatcherApplier, 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 +} + +func (r *WatcherApplierReconciler) createHashOfInputHashes( + ctx context.Context, + instance *watcherv1beta1.WatcherApplier, + envVars map[string]env.Setter, +) (string, bool, error) { + Log := r.GetLogger(ctx) + var hashMap map[string]string + changed := false + mergedMapVars := env.MergeEnvs([]corev1.EnvVar{}, envVars) + hash, err := util.ObjectHash(mergedMapVars) + if err != nil { + return hash, changed, err + } + if hashMap, changed = util.SetHash(instance.Status.Hash, common.InputHashName, hash); changed { + instance.Status.Hash = hashMap + Log.Info(fmt.Sprintf("Input maps hash %s - %s", common.InputHashName, hash)) + } + return hash, changed, nil +} + +// generateServiceConfigs - create Secret which holds the service configuration +func (r *WatcherApplierReconciler) generateServiceConfigs( + ctx context.Context, + instance *watcherv1beta1.WatcherApplier, + helper *helper.Helper, + envVars *map[string]env.Setter, +) error { + Log := r.GetLogger(ctx) + Log.Info("generateServiceConfigs - reconciling") + + labels := labels.GetLabels(instance, labels.GetGroupLabel(WatcherApplierLabelPrefix), map[string]string{}) + + // TODO(dviroel): CustomServiceConfig to be implemented + customData := map[string]string{} + templateParameters := map[string]interface{}{} + + return GenerateConfigsGeneric(ctx, helper, instance, envVars, templateParameters, customData, labels, false) +} + // SetupWithManager sets up the controller with the Manager. func (r *WatcherApplierReconciler) SetupWithManager(mgr ctrl.Manager) error { + // index passwordSecretField + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &watcherv1beta1.WatcherApplier{}, passwordSecretField, func(rawObj client.Object) []string { + // Extract the secret name from the spec, if one is provided + cr := rawObj.(*watcherv1beta1.WatcherApplier) + if cr.Spec.Secret == "" { + return nil + } + return []string{cr.Spec.Secret} + }); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). For(&watcherv1beta1.WatcherApplier{}). + Owns(&corev1.Secret{}). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). Complete(r) } + +func (r *WatcherApplierReconciler) findObjectsForSrc(ctx context.Context, src client.Object) []reconcile.Request { + requests := []reconcile.Request{} + + l := log.FromContext(ctx).WithName("Controllers").WithName("WatcherApplier") + + for _, field := range applierWatchFields { + crList := &watcherv1beta1.WatcherApplierList{} + 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 +} diff --git a/tests/functional/base_test.go b/tests/functional/base_test.go index 459199f..fefcbad 100644 --- a/tests/functional/base_test.go +++ b/tests/functional/base_test.go @@ -61,6 +61,16 @@ func GetDefaultWatcherAPISpec() map[string]interface{} { } } +func GetDefaultWatcherApplierSpec() 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", @@ -113,6 +123,32 @@ func WatcherAPIConditionGetter(name types.NamespacedName) condition.Conditions { return instance.Status.Conditions } +func CreateWatcherApplier(name types.NamespacedName, spec map[string]interface{}) client.Object { + raw := map[string]interface{}{ + "apiVersion": "watcher.openstack.org/v1beta1", + "kind": "WatcherApplier", + "metadata": map[string]interface{}{ + "name": name.Name, + "namespace": name.Namespace, + }, + "spec": spec, + } + return th.CreateUnstructured(raw) +} + +func GetWatcherApplier(name types.NamespacedName) *watcherv1.WatcherApplier { + instance := &watcherv1.WatcherApplier{} + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, name, instance)).Should(Succeed()) + }, timeout, interval).Should(Succeed()) + return instance +} + +func WatcherApplierConditionGetter(name types.NamespacedName) condition.Conditions { + instance := GetWatcherApplier(name) + return instance.Status.Conditions +} + func CreateWatcherMessageBusSecret(namespace string, name string) *corev1.Secret { s := th.CreateSecret( types.NamespacedName{Namespace: namespace, Name: name}, diff --git a/tests/functional/sample_test.go b/tests/functional/sample_test.go index 91d6c57..9095981 100644 --- a/tests/functional/sample_test.go +++ b/tests/functional/sample_test.go @@ -55,6 +55,13 @@ func CreateWatcherAPIFromSample(sampleFileName string, name types.NamespacedName return types.NamespacedName{Name: instance.GetName(), Namespace: instance.GetNamespace()} } +func CreateWatcherApplierFromSample(sampleFileName string, name types.NamespacedName) types.NamespacedName { + raw := ReadSample(sampleFileName) + instance := CreateWatcherApplier(name, raw["spec"].(map[string]interface{})) + DeferCleanup(th.DeleteInstance, instance) + return types.NamespacedName{Name: instance.GetName(), Namespace: instance.GetNamespace()} +} + // This is a set of test for our samples. It only validates that the sample // file has all the required field with proper types. But it does not // validate that using a sample file will result in a working deployment. @@ -76,4 +83,11 @@ var _ = Describe("Samples", func() { GetWatcherAPI(name) }) }) + + When("watcher_v1beta1_watcherapplier.yaml sample is applied", func() { + It("WatcherApplier is created", func() { + name := CreateWatcherApplierFromSample("watcher_v1beta1_watcherapplier.yaml", watcherTest.WatcherApplier) + GetWatcherApplier(name) + }) + }) }) diff --git a/tests/functional/watcher_test_data.go b/tests/functional/watcher_test_data.go index b201e5c..60ef31e 100644 --- a/tests/functional/watcher_test_data.go +++ b/tests/functional/watcher_test_data.go @@ -51,6 +51,7 @@ type WatcherTestData struct { WatcherInternalServiceName types.NamespacedName WatcherRouteName types.NamespacedName WatcherInternalRouteName types.NamespacedName + WatcherApplier types.NamespacedName } // GetWatcherTestData is a function that initialize the WatcherTestData @@ -135,5 +136,9 @@ func GetWatcherTestData(watcherName types.NamespacedName) WatcherTestData { Namespace: watcherName.Namespace, Name: "watcher-internal", }, + WatcherApplier: types.NamespacedName{ + Namespace: watcherName.Namespace, + Name: "watcher-applier", + }, } } diff --git a/tests/functional/watcherapplier_controller_test.go b/tests/functional/watcherapplier_controller_test.go new file mode 100644 index 0000000..072fb9d --- /dev/null +++ b/tests/functional/watcherapplier_controller_test.go @@ -0,0 +1,282 @@ +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 ( + MinimalWatcherApplierSpec = map[string]interface{}{ + "secret": "osp-secret", + "memcachedInstance": "memcached", + } +) + +var _ = Describe("WatcherApplier controller with minimal spec values", func() { + When("A Watcher instance is created from minimal spec", func() { + BeforeEach(func() { + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, MinimalWatcherApplierSpec)) + }) + + It("should have the Spec fields defaulted", func() { + WatcherApplier := GetWatcherApplier(watcherTest.WatcherApplier) + Expect(WatcherApplier.Spec.Secret).Should(Equal("osp-secret")) + Expect(WatcherApplier.Spec.MemcachedInstance).Should(Equal("memcached")) + Expect(WatcherApplier.Spec.PasswordSelectors).Should(Equal(watcherv1beta1.PasswordSelector{Service: "WatcherPassword"})) + }) + + It("should have the Status fields initialized", func() { + WatcherApplier := GetWatcherApplier(watcherTest.WatcherApplier) + Expect(WatcherApplier.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 GetWatcherApplier(watcherTest.WatcherApplier).Finalizers + }, timeout, interval).Should(ContainElement("openstack.org/watcherapplier")) + }) + + }) +}) + +var _ = Describe("WatcherApplier controller", func() { + When("A WatcherApplier instance is created", func() { + BeforeEach(func() { + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + }) + + It("should have the Spec fields defaulted", func() { + WatcherApplier := GetWatcherApplier(watcherTest.WatcherApplier) + Expect(WatcherApplier.Spec.Secret).Should(Equal("test-osp-secret")) + Expect(WatcherApplier.Spec.MemcachedInstance).Should(Equal("memcached")) + Expect(WatcherApplier.Spec.PasswordSelectors).Should(Equal(watcherv1beta1.PasswordSelector{Service: "WatcherPassword"})) + }) + + It("should have the Status fields initialized", func() { + WatcherApplier := GetWatcherApplier(watcherTest.WatcherApplier) + Expect(WatcherApplier.Status.ObservedGeneration).To(Equal(int64(0))) + }) + + It("should have ReadyCondition false", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.ReadyCondition, + corev1.ConditionFalse, + ) + }) + + It("should have input not ready", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.InputReadyCondition, + corev1.ConditionFalse, + ) + }) + + It("should have service config input unknown", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + 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 GetWatcherApplier(watcherTest.WatcherApplier).Finalizers + }, timeout, interval).Should(ContainElement("openstack.org/watcherapplier")) + }) + }) + When("the secret is created with all the expected fields and has all the required infra", func() { + BeforeEach(func() { + secret := th.CreateSecret( + watcherTest.InternalTopLevelSecretName, + map[string][]byte{ + "WatcherPassword": []byte("service-password"), + "transport_url": []byte("url"), + }, + ) + DeferCleanup(k8sClient.Delete, ctx, secret) + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + DeferCleanup(keystone.DeleteKeystoneAPI, keystone.CreateKeystoneAPI(watcherTest.WatcherApplier.Namespace)) + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{ + Replicas: ptr.To(int32(1)), + }, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.WatcherApplier.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + }) + It("should have input ready", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.InputReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have memcached ready true", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.MemcachedReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have config service input ready", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.ServiceConfigReadyCondition, + 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, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + }) + It("should have input false", func() { + errorString := fmt.Sprintf( + condition.InputReadyErrorMessage, + "field 'WatcherPassword' not found in secret/test-osp-secret", + ) + th.ExpectConditionWithDetails( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.InputReadyCondition, + corev1.ConditionFalse, + condition.ErrorReason, + errorString, + ) + }) + It("should have config service input unknown", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.ServiceConfigReadyCondition, + corev1.ConditionUnknown, + ) + }) + }) + When("A WatcherApplier instance without secret is created", func() { + BeforeEach(func() { + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + }) + It("is missing the secret", func() { + th.ExpectConditionWithDetails( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.InputReadyCondition, + corev1.ConditionFalse, + condition.RequestedReason, + condition.InputReadyWaitingMessage, + ) + }) + }) + When("secret and db are created, but there is no memcached", func() { + BeforeEach(func() { + secret := th.CreateSecret( + watcherTest.InternalTopLevelSecretName, + map[string][]byte{ + "WatcherPassword": []byte("service-password"), + "transport_url": []byte("url"), + "database_username": []byte("username"), + "database_password": []byte("password"), + "database_hostname": []byte("hostname"), + }, + ) + DeferCleanup(k8sClient.Delete, ctx, secret) + + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + }) + It("should have input ready true", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.InputReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have memcached ready false", func() { + th.ExpectConditionWithDetails( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.MemcachedReadyCondition, + corev1.ConditionFalse, + condition.RequestedReason, + condition.MemcachedReadyWaitingMessage, + ) + }) + }) + When("secret, db and memcached are created", func() { + BeforeEach(func() { + secret := th.CreateSecret( + watcherTest.InternalTopLevelSecretName, + map[string][]byte{ + "WatcherPassword": []byte("service-password"), + "transport_url": []byte("url"), + "database_username": []byte("username"), + "database_password": []byte("password"), + "database_hostname": []byte("hostname"), + }, + ) + DeferCleanup(k8sClient.Delete, ctx, secret) + memcachedSpec := memcachedv1.MemcachedSpec{ + MemcachedSpecCore: memcachedv1.MemcachedSpecCore{ + Replicas: ptr.To(int32(1)), + }, + } + DeferCleanup(infra.DeleteMemcached, infra.CreateMemcached(watcherTest.WatcherApplier.Namespace, MemcachedInstance, memcachedSpec)) + infra.SimulateMemcachedReady(watcherTest.MemcachedNamespace) + DeferCleanup(th.DeleteInstance, CreateWatcherApplier(watcherTest.WatcherApplier, GetDefaultWatcherApplierSpec())) + + }) + It("should have input ready true", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.InputReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have memcached ready true", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.MemcachedReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have config service input true", func() { + th.ExpectCondition( + watcherTest.WatcherApplier, + ConditionGetterFunc(WatcherApplierConditionGetter), + condition.ServiceConfigReadyCondition, + corev1.ConditionTrue, + ) + }) + }) +})