From 573ece5b80983fe71baa554d986587d40aa8f050 Mon Sep 17 00:00:00 2001 From: jgilaber Date: Fri, 22 Nov 2024 12:31:43 +0100 Subject: [PATCH] Create basic Reconcile methods for WatcherAPI This patch is basic recond methods to the watcherapi controller, as well as some initial input validation. The change adds the required CR spec and status, and creates the initial structure for initialization and deletion. In addition it some some input validation by accessing the osp secret and the database that should be created by the watcher controller. Finally, it is also adding some initial structure test for functional envtest testing in WatcherAPI. Related: OSPRH-11483 --- .../watcher.openstack.org_watcherapis.yaml | 81 +++++- api/v1beta1/common_types.go | 14 +- api/v1beta1/watcherapi_types.go | 17 +- api/v1beta1/zz_generated.deepcopy.go | 28 +- .../watcher.openstack.org_watcherapis.yaml | 81 +++++- .../samples/watcher_v1beta1_watcherapi.yaml | 2 +- controllers/watcher_common.go | 87 ++++++ controllers/watcherapi_controller.go | 248 +++++++++++++++++- tests/functional/base_test.go | 33 +++ tests/functional/sample_test.go | 14 + tests/functional/watcher_test_data.go | 5 + .../functional/watcherapi_controller_test.go | 176 +++++++++++++ .../default/{ => test}/03-assert.yaml | 6 - 13 files changed, 757 insertions(+), 35 deletions(-) create mode 100644 tests/functional/watcherapi_controller_test.go rename tests/kuttl/test-suites/default/{ => test}/03-assert.yaml (56%) diff --git a/api/bases/watcher.openstack.org_watcherapis.yaml b/api/bases/watcher.openstack.org_watcherapis.yaml index 2d146a4..e8083eb 100644 --- a/api/bases/watcher.openstack.org_watcherapis.yaml +++ b/api/bases/watcher.openstack.org_watcherapis.yaml @@ -39,13 +39,88 @@ spec: spec: description: WatcherAPISpec defines the desired state of WatcherAPI properties: - foo: - description: Foo is an example field of WatcherAPI. Edit watcherapi_types.go - to remove/update + databaseAccount: + default: watcher + description: DatabaseAccount - MariaDBAccount CR name used for watcher + DB, defaults to watcher type: string + databaseInstance: + description: |- + MariaDB instance name + Required to use the mariadb-operator instance to create the DB and user + type: string + 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 + secret: + default: osp-secret + description: Secret containing all passwords / keys needed + type: string + required: + - databaseInstance type: object status: description: WatcherAPIStatus defines the observed state of WatcherAPI + 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 + 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/common_types.go b/api/v1beta1/common_types.go index b1d422e..23fc40f 100644 --- a/api/v1beta1/common_types.go +++ b/api/v1beta1/common_types.go @@ -16,11 +16,8 @@ limitations under the License. package v1beta1 -// WatcherTemplate defines a spec based reusable for all the CRDs -type WatcherTemplate struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file - +// WatcherCommon defines a spec based reusable for all the CRDs +type WatcherCommon struct { // +kubebuilder:validation:Optional // +kubebuilder:default=osp-secret // Secret containing all passwords / keys needed @@ -42,6 +39,13 @@ type WatcherTemplate struct { DatabaseAccount string `json:"databaseAccount"` } +// WatcherTemplate defines the fields used in the top level CR +type WatcherTemplate struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + WatcherCommon `json:",inline"` +} + // PasswordSelector to identify the DB and AdminUser password from the Secret type PasswordSelector struct { // +kubebuilder:validation:Optional diff --git a/api/v1beta1/watcherapi_types.go b/api/v1beta1/watcherapi_types.go index 8c63bbf..03a49d3 100644 --- a/api/v1beta1/watcherapi_types.go +++ b/api/v1beta1/watcherapi_types.go @@ -17,25 +17,28 @@ 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" ) -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - // WatcherAPISpec defines the desired state of WatcherAPI type WatcherAPISpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - // Foo is an example field of WatcherAPI. Edit watcherapi_types.go to remove/update - Foo string `json:"foo,omitempty"` + WatcherCommon `json:",inline"` } // WatcherAPIStatus defines the observed state of WatcherAPI type WatcherAPIStatus 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"` } //+kubebuilder:object:root=true diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 9d3cf2b..cbdefed 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -73,7 +73,7 @@ func (in *WatcherAPI) DeepCopyInto(out *WatcherAPI) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) out.Spec = in.Spec - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherAPI. @@ -129,6 +129,7 @@ func (in *WatcherAPIList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatcherAPISpec) DeepCopyInto(out *WatcherAPISpec) { *out = *in + out.WatcherCommon = in.WatcherCommon } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherAPISpec. @@ -144,6 +145,13 @@ func (in *WatcherAPISpec) DeepCopy() *WatcherAPISpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatcherAPIStatus) DeepCopyInto(out *WatcherAPIStatus) { *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]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherAPIStatus. @@ -245,6 +253,22 @@ func (in *WatcherApplierStatus) DeepCopy() *WatcherApplierStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WatcherCommon) DeepCopyInto(out *WatcherCommon) { + *out = *in + out.PasswordSelectors = in.PasswordSelectors +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherCommon. +func (in *WatcherCommon) DeepCopy() *WatcherCommon { + if in == nil { + return nil + } + out := new(WatcherCommon) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatcherDecisionEngine) DeepCopyInto(out *WatcherDecisionEngine) { *out = *in @@ -407,7 +431,7 @@ func (in *WatcherStatus) DeepCopy() *WatcherStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatcherTemplate) DeepCopyInto(out *WatcherTemplate) { *out = *in - out.PasswordSelectors = in.PasswordSelectors + out.WatcherCommon = in.WatcherCommon } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WatcherTemplate. diff --git a/config/crd/bases/watcher.openstack.org_watcherapis.yaml b/config/crd/bases/watcher.openstack.org_watcherapis.yaml index 2d146a4..e8083eb 100644 --- a/config/crd/bases/watcher.openstack.org_watcherapis.yaml +++ b/config/crd/bases/watcher.openstack.org_watcherapis.yaml @@ -39,13 +39,88 @@ spec: spec: description: WatcherAPISpec defines the desired state of WatcherAPI properties: - foo: - description: Foo is an example field of WatcherAPI. Edit watcherapi_types.go - to remove/update + databaseAccount: + default: watcher + description: DatabaseAccount - MariaDBAccount CR name used for watcher + DB, defaults to watcher type: string + databaseInstance: + description: |- + MariaDB instance name + Required to use the mariadb-operator instance to create the DB and user + type: string + 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 + secret: + default: osp-secret + description: Secret containing all passwords / keys needed + type: string + required: + - databaseInstance type: object status: description: WatcherAPIStatus defines the observed state of WatcherAPI + 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 + 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/samples/watcher_v1beta1_watcherapi.yaml b/config/samples/watcher_v1beta1_watcherapi.yaml index f00842a..734ba1b 100644 --- a/config/samples/watcher_v1beta1_watcherapi.yaml +++ b/config/samples/watcher_v1beta1_watcherapi.yaml @@ -6,4 +6,4 @@ metadata: app.kubernetes.io/managed-by: kustomize name: watcherapi-sample spec: - # TODO(user): Add fields here + databaseInstance: "openstack" diff --git a/controllers/watcher_common.go b/controllers/watcher_common.go index 1a81d57..efbde8f 100644 --- a/controllers/watcher_common.go +++ b/controllers/watcher_common.go @@ -2,14 +2,31 @@ package controllers import ( "context" + "fmt" "time" "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + "github.com/openstack-k8s-operators/lib-common/modules/common/util" +) + +const ( + passwordSecretField = ".spec.secret" +) + +var ( + apiWatchFields = []string{ + passwordSecretField, + } ) // GetLogger returns a logger object with a prefix of "controller.name" and additional controller context fields @@ -96,3 +113,73 @@ func (r *Reconcilers) OverrideRequeueTimeout(timeout time.Duration) { reconciler.SetRequeueTimeout(timeout) } } + +type conditionUpdater interface { + Set(c *condition.Condition) + MarkTrue(t condition.Type, messageFormat string, messageArgs ...interface{}) +} + +// ensureSecret - ensures that the Secret object exists and the expected fields +// are in the Secret. It returns a hash of the values of the expected fields. +func ensureSecret( + ctx context.Context, + secretName types.NamespacedName, + expectedFields []string, + reader client.Reader, + conditionUpdater conditionUpdater, + requeueTimeout time.Duration, +) (string, ctrl.Result, corev1.Secret, error) { + secret := &corev1.Secret{} + err := reader.Get(ctx, secretName, secret) + if err != nil { + if k8s_errors.IsNotFound(err) { + log.FromContext(ctx).Info(fmt.Sprintf("secret %s not found", secretName)) + conditionUpdater.Set(condition.FalseCondition( + condition.InputReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + condition.InputReadyWaitingMessage)) + return "", + ctrl.Result{RequeueAfter: requeueTimeout}, + *secret, + nil + } + conditionUpdater.Set(condition.FalseCondition( + condition.InputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.InputReadyErrorMessage, + err.Error())) + return "", ctrl.Result{}, *secret, err + } + + // collect the secret values the caller expects to exist + values := [][]byte{} + for _, field := range expectedFields { + val, ok := secret.Data[field] + if !ok { + err := fmt.Errorf("field '%s' not found in secret/%s", field, secretName.Name) + conditionUpdater.Set(condition.FalseCondition( + condition.InputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.InputReadyErrorMessage, + err.Error())) + return "", ctrl.Result{}, *secret, err + } + values = append(values, val) + } + + hash, err := util.ObjectHash(values) + if err != nil { + conditionUpdater.Set(condition.FalseCondition( + condition.InputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.InputReadyErrorMessage, + err.Error())) + return "", ctrl.Result{}, *secret, err + } + + return hash, ctrl.Result{}, *secret, nil +} diff --git a/controllers/watcherapi_controller.go b/controllers/watcherapi_controller.go index 9e27934..c7b4bfc 100644 --- a/controllers/watcherapi_controller.go +++ b/controllers/watcherapi_controller.go @@ -18,11 +18,30 @@ 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" + "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" + mariadbv1 "github.com/openstack-k8s-operators/mariadb-operator/api/v1beta1" watcherv1beta1 "github.com/openstack-k8s-operators/watcher-operator/api/v1beta1" + "github.com/openstack-k8s-operators/watcher-operator/pkg/watcher" + + corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" ) // WatcherAPIReconciler reconciles a WatcherAPI object @@ -30,30 +49,243 @@ type WatcherAPIReconciler struct { ReconcilerBase } +// GetLogger returns a logger object with a prefix of "controller.name" and +// additional controller context fields +func (r *WatcherAPIReconciler) GetLogger(ctx context.Context) logr.Logger { + return log.FromContext(ctx).WithName("Controllers").WithName("WatcherAPI") +} + //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherapis,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherapis/status,verbs=get;update;patch //+kubebuilder:rbac:groups=watcher.openstack.org,resources=watcherapis/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. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the WatcherAPI object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.17.3/pkg/reconcile -func (r *WatcherAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) - _ = req - // TODO(user): your logic here +func (r *WatcherAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, _err error) { + + Log := r.GetLogger(ctx) + instance := &watcherv1beta1.WatcherAPI{} + 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 WatcherAPI 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 + Log.Info(fmt.Sprintf("[API] 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) + + db, err := mariadbv1.GetDatabaseByNameAndAccount(ctx, helper, watcher.DatabaseCRName, instance.Spec.DatabaseAccount, instance.Namespace) + if err != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + condition.ServiceConfigReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.ServiceConfigReadyErrorMessage, + err.Error())) + return ctrl.Result{}, err + } + + // all our input checks out so report InputReady + instance.Status.Conditions.MarkTrue(condition.InputReadyCondition, condition.InputReadyMessage) + + err = r.generateServiceConfigs(ctx, instance, secret, db, 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 } +// 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 *WatcherAPIReconciler) generateServiceConfigs( + ctx context.Context, instance *watcherv1beta1.WatcherAPI, + secret corev1.Secret, db *mariadbv1.Database, + helper *helper.Helper, envVars *map[string]env.Setter, +) error { + Log := r.GetLogger(ctx) + Log.Info("generateServiceConfigs - reconciling") + + // replace by actual usage in future iterations + _ = db + _ = helper + _ = instance + _ = secret + _ = envVars + + return nil +} + +func (r *WatcherAPIReconciler) reconcileDelete(ctx context.Context, instance *watcherv1beta1.WatcherAPI, helper *helper.Helper) (ctrl.Result, error) { + Log := r.GetLogger(ctx) + Log.Info(fmt.Sprintf("Reconcile Service '%s' delete started", instance.Name)) + + controllerutil.RemoveFinalizer(instance, helper.GetFinalizer()) + Log.Info(fmt.Sprintf("Reconciled Service '%s' delete successfully", instance.Name)) + return ctrl.Result{}, nil +} + +func (r *WatcherAPIReconciler) initStatus(instance *watcherv1beta1.WatcherAPI) 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.ServiceConfigReadyMessage), + ) + + instance.Status.Conditions.Init(&cl) + + // Update the lastObserved generation before evaluating conditions + instance.Status.ObservedGeneration = instance.Generation + + return nil +} + // SetupWithManager sets up the controller with the Manager. func (r *WatcherAPIReconciler) SetupWithManager(mgr ctrl.Manager) error { + // index passwordSecretField + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &watcherv1beta1.WatcherAPI{}, passwordSecretField, func(rawObj client.Object) []string { + // Extract the secret name from the spec, if one is provided + cr := rawObj.(*watcherv1beta1.WatcherAPI) + if cr.Spec.Secret == "" { + return nil + } + return []string{cr.Spec.Secret} + }); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). For(&watcherv1beta1.WatcherAPI{}). + Owns(&corev1.Secret{}). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). Complete(r) } + +func (r *WatcherAPIReconciler) findObjectsForSrc(ctx context.Context, src client.Object) []reconcile.Request { + requests := []reconcile.Request{} + + l := log.FromContext(ctx).WithName("Controllers").WithName("WatcherAPI") + + for _, field := range apiWatchFields { + crList := &watcherv1beta1.WatcherAPIList{} + 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 f3d1b6e..7568e1f 100644 --- a/tests/functional/base_test.go +++ b/tests/functional/base_test.go @@ -33,6 +33,13 @@ func GetDefaultWatcherSpec() map[string]interface{} { } } +func GetDefaultWatcherAPISpec() map[string]interface{} { + return map[string]interface{}{ + "databaseInstance": "openstack", + "secret": SecretName, + } +} + func CreateWatcher(name types.NamespacedName, spec map[string]interface{}) client.Object { raw := map[string]interface{}{ "apiVersion": "watcher.openstack.org/v1beta1", @@ -58,3 +65,29 @@ func WatcherConditionGetter(name types.NamespacedName) condition.Conditions { instance := GetWatcher(name) return instance.Status.Conditions } + +func CreateWatcherAPI(name types.NamespacedName, spec map[string]interface{}) client.Object { + raw := map[string]interface{}{ + "apiVersion": "watcher.openstack.org/v1beta1", + "kind": "WatcherAPI", + "metadata": map[string]interface{}{ + "name": name.Name, + "namespace": name.Namespace, + }, + "spec": spec, + } + return th.CreateUnstructured(raw) +} + +func GetWatcherAPI(name types.NamespacedName) *watcherv1.WatcherAPI { + instance := &watcherv1.WatcherAPI{} + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, name, instance)).Should(Succeed()) + }, timeout, interval).Should(Succeed()) + return instance +} + +func WatcherAPIConditionGetter(name types.NamespacedName) condition.Conditions { + instance := GetWatcherAPI(name) + return instance.Status.Conditions +} diff --git a/tests/functional/sample_test.go b/tests/functional/sample_test.go index 95b3461..4efea79 100644 --- a/tests/functional/sample_test.go +++ b/tests/functional/sample_test.go @@ -48,6 +48,13 @@ func CreateWatcherFromSample(sampleFileName string, name types.NamespacedName) t return types.NamespacedName{Name: instance.GetName(), Namespace: instance.GetNamespace()} } +func CreateWatcherAPIFromSample(sampleFileName string, name types.NamespacedName) types.NamespacedName { + raw := ReadSample(sampleFileName) + instance := CreateWatcherAPI(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. @@ -62,4 +69,11 @@ var _ = Describe("Samples", func() { GetWatcher(name) }) }) + + When("watcher_v1beta1_watcherapi.yaml sample is applied", func() { + It("WatcherAPI is created", func() { + name := CreateWatcherAPIFromSample("watcher_v1beta1_watcherapi.yaml", watcherTest.Instance) + GetWatcherAPI(name) + }) + }) }) diff --git a/tests/functional/watcher_test_data.go b/tests/functional/watcher_test_data.go index 875d0c2..a6374b9 100644 --- a/tests/functional/watcher_test_data.go +++ b/tests/functional/watcher_test_data.go @@ -36,6 +36,7 @@ type WatcherTestData struct { WatcherDatabaseName types.NamespacedName WatcherDatabaseAccount types.NamespacedName WatcherDatabaseAccountSecret types.NamespacedName + InternalTopLevelSecretName types.NamespacedName } // GetWatcherTestData is a function that initialize the WatcherTestData @@ -63,5 +64,9 @@ func GetWatcherTestData(watcherName types.NamespacedName) WatcherTestData { Namespace: watcherName.Namespace, Name: fmt.Sprintf("%s-%s", watcherName.Name, "db-secret"), }, + InternalTopLevelSecretName: types.NamespacedName{ + Namespace: watcherName.Namespace, + Name: "test-osp-secret", + }, } } diff --git a/tests/functional/watcherapi_controller_test.go b/tests/functional/watcherapi_controller_test.go new file mode 100644 index 0000000..daaade8 --- /dev/null +++ b/tests/functional/watcherapi_controller_test.go @@ -0,0 +1,176 @@ +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 + condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + . "github.com/openstack-k8s-operators/lib-common/modules/common/test/helpers" + mariadbv1 "github.com/openstack-k8s-operators/mariadb-operator/api/v1beta1" + watcherv1beta1 "github.com/openstack-k8s-operators/watcher-operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" +) + +var _ = Describe("WatcherAPI controller with minimal spec values", func() { + When("A Watcher instance is created from minimal spec", func() { + BeforeEach(func() { + DeferCleanup(th.DeleteInstance, CreateWatcherAPI(watcherTest.Instance, MinimalWatcherSpec)) + }) + + It("should have the Spec fields defaulted", func() { + WatcherAPI := GetWatcherAPI(watcherTest.Instance) + Expect(WatcherAPI.Spec.DatabaseInstance).Should(Equal("openstack")) + Expect(WatcherAPI.Spec.DatabaseAccount).Should(Equal("watcher")) + Expect(WatcherAPI.Spec.Secret).Should(Equal("osp-secret")) + Expect(WatcherAPI.Spec.PasswordSelectors).Should(Equal(watcherv1beta1.PasswordSelector{Service: "WatcherPassword"})) + }) + + It("should have the Status fields initialized", func() { + WatcherAPI := GetWatcherAPI(watcherTest.Instance) + Expect(WatcherAPI.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 GetWatcherAPI(watcherTest.Instance).Finalizers + }, timeout, interval).Should(ContainElement("openstack.org/watcherapi")) + }) + + }) +}) + +var _ = Describe("WatcherAPI controller", func() { + When("A WatcherAPI instance is created", func() { + BeforeEach(func() { + DeferCleanup(th.DeleteInstance, CreateWatcherAPI(watcherTest.Instance, GetDefaultWatcherAPISpec())) + }) + + It("should have the Spec fields defaulted", func() { + WatcherAPI := GetWatcherAPI(watcherTest.Instance) + Expect(WatcherAPI.Spec.DatabaseInstance).Should(Equal("openstack")) + Expect(WatcherAPI.Spec.DatabaseAccount).Should(Equal("watcher")) + Expect(WatcherAPI.Spec.Secret).Should(Equal("test-osp-secret")) + }) + + It("should have the Status fields initialized", func() { + WatcherAPI := GetWatcherAPI(watcherTest.Instance) + Expect(WatcherAPI.Status.ObservedGeneration).To(Equal(int64(0))) + }) + + It("should have ReadyCondition false", func() { + th.ExpectCondition( + watcherTest.Instance, + ConditionGetterFunc(WatcherAPIConditionGetter), + condition.ReadyCondition, + corev1.ConditionFalse, + ) + }) + + It("should have input not ready", func() { + th.ExpectCondition( + watcherTest.Instance, + ConditionGetterFunc(WatcherAPIConditionGetter), + condition.InputReadyCondition, + corev1.ConditionFalse, + ) + }) + + It("should have service config input unknown", func() { + th.ExpectCondition( + watcherTest.Instance, + ConditionGetterFunc(WatcherAPIConditionGetter), + 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 GetWatcherAPI(watcherTest.Instance).Finalizers + }, timeout, interval).Should(ContainElement("openstack.org/watcherapi")) + }) + }) + 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) + mariadb.CreateMariaDBDatabase(watcherTest.WatcherDatabaseName.Namespace, watcherTest.WatcherDatabaseName.Name, mariadbv1.MariaDBDatabaseSpec{}) + DeferCleanup(k8sClient.Delete, ctx, mariadb.GetMariaDBDatabase(watcherTest.WatcherDatabaseName)) + + mariadb.SimulateMariaDBTLSDatabaseCompleted(watcherTest.WatcherDatabaseName) + apiMariaDBAccount, apiMariaDBSecret := mariadb.CreateMariaDBAccountAndSecret( + watcherTest.WatcherDatabaseAccount, mariadbv1.MariaDBAccountSpec{}) + DeferCleanup(k8sClient.Delete, ctx, apiMariaDBAccount) + DeferCleanup(k8sClient.Delete, ctx, apiMariaDBSecret) + DeferCleanup(th.DeleteInstance, CreateWatcherAPI(watcherTest.Instance, GetDefaultWatcherAPISpec())) + }) + It("should have input ready", func() { + th.ExpectCondition( + watcherTest.Instance, + ConditionGetterFunc(WatcherAPIConditionGetter), + condition.InputReadyCondition, + corev1.ConditionTrue, + ) + }) + It("should have config service input ready", func() { + th.ExpectCondition( + watcherTest.Instance, + ConditionGetterFunc(WatcherAPIConditionGetter), + 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) + mariadb.CreateMariaDBDatabase(watcherTest.WatcherDatabaseName.Namespace, watcherTest.WatcherDatabaseName.Name, mariadbv1.MariaDBDatabaseSpec{}) + DeferCleanup(k8sClient.Delete, ctx, mariadb.GetMariaDBDatabase(watcherTest.WatcherDatabaseName)) + + mariadb.SimulateMariaDBTLSDatabaseCompleted(watcherTest.WatcherDatabaseName) + apiMariaDBAccount, apiMariaDBSecret := mariadb.CreateMariaDBAccountAndSecret( + watcherTest.WatcherDatabaseAccount, mariadbv1.MariaDBAccountSpec{}) + DeferCleanup(k8sClient.Delete, ctx, apiMariaDBAccount) + DeferCleanup(k8sClient.Delete, ctx, apiMariaDBSecret) + DeferCleanup(th.DeleteInstance, CreateWatcherAPI(watcherTest.Instance, GetDefaultWatcherAPISpec())) + }) + It("should have input false", func() { + errorString := fmt.Sprintf( + condition.InputReadyErrorMessage, + "field 'WatcherPassword' not found in secret/test-osp-secret", + ) + th.ExpectConditionWithDetails( + watcherTest.Instance, + ConditionGetterFunc(WatcherAPIConditionGetter), + condition.InputReadyCondition, + corev1.ConditionFalse, + condition.ErrorReason, + errorString, + ) + }) + It("should have config service input unknown", func() { + th.ExpectCondition( + watcherTest.Instance, + ConditionGetterFunc(WatcherAPIConditionGetter), + condition.ServiceConfigReadyCondition, + corev1.ConditionUnknown, + ) + }) + }) +}) diff --git a/tests/kuttl/test-suites/default/03-assert.yaml b/tests/kuttl/test-suites/default/test/03-assert.yaml similarity index 56% rename from tests/kuttl/test-suites/default/03-assert.yaml rename to tests/kuttl/test-suites/default/test/03-assert.yaml index fbd3c04..2c1bf1e 100644 --- a/tests/kuttl/test-suites/default/03-assert.yaml +++ b/tests/kuttl/test-suites/default/test/03-assert.yaml @@ -1,9 +1,3 @@ -apiVersion: v1 -kind: Secret -metadata: - name: watcher-db-secret - namespace: watcher-kuttl-default ---- apiVersion: mariadb.openstack.org/v1beta1 kind: MariaDBAccount metadata: