diff --git a/api/bases/watcher.openstack.org_watcherapis.yaml b/api/bases/watcher.openstack.org_watcherapis.yaml index 9330018..33b07ce 100644 --- a/api/bases/watcher.openstack.org_watcherapis.yaml +++ b/api/bases/watcher.openstack.org_watcherapis.yaml @@ -35,13 +35,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 07f5bf9..6e3fea3 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -74,7 +74,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. @@ -130,6 +130,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. @@ -145,6 +146,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. @@ -246,6 +254,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 @@ -408,7 +432,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 9330018..33b07ce 100644 --- a/config/crd/bases/watcher.openstack.org_watcherapis.yaml +++ b/config/crd/bases/watcher.openstack.org_watcherapis.yaml @@ -35,13 +35,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..7c8acef 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,242 @@ 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) + + // all our input checks out so report InputReady + instance.Status.Conditions.MarkTrue(condition.InputReadyCondition, condition.InputReadyMessage) + + err = r.generateServiceConfigs(ctx, instance, secret, 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, + helper *helper.Helper, envVars *map[string]env.Setter, +) error { + Log := r.GetLogger(ctx) + Log.Info("generateServiceConfigs - reconciling") + + 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 err + } + + // replace by actual usage in future iterations + _ = db + _ = 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") + l.Info(fmt.Sprintf("Entering findObjectsForSrc function with src %s of type %s", src.GetName(), src.GetObjectKind())) + + 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, + ) + }) + }) +})