diff --git a/apis/clusterresources/v1alpha1/opensearchuser_types.go b/apis/clusterresources/v1alpha1/opensearchuser_types.go new file mode 100644 index 000000000..166329d2c --- /dev/null +++ b/apis/clusterresources/v1alpha1/opensearchuser_types.go @@ -0,0 +1,75 @@ +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/instaclustr/operator/pkg/models" +) + +// OpenSearchUserSpec defines the desired state of OpenSearchUser +type OpenSearchUserSpec struct { + SecretRef *SecretReference `json:"secretRef"` +} + +// OpenSearchUserStatus defines the observed state of OpenSearchUser +type OpenSearchUserStatus struct { + State string `json:"state"` + ClusterID string `json:"clusterId"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// OpenSearchUser is the Schema for the opensearchusers API +type OpenSearchUser struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec OpenSearchUserSpec `json:"spec,omitempty"` + Status OpenSearchUserStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// OpenSearchUserList contains a list of OpenSearchUser +type OpenSearchUserList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []OpenSearchUser `json:"items"` +} + +func (u *OpenSearchUser) ToInstaAPI(username, password string) *models.InstaOpenSearchUser { + return &models.InstaOpenSearchUser{ + InstaUser: &models.InstaUser{ + Username: username, + Password: password, + InitialPermission: "standard", + }, + } +} + +func (u *OpenSearchUser) NewPatch() client.Patch { + old := u.DeepCopy() + return client.MergeFrom(old) +} + +func init() { + SchemeBuilder.Register(&OpenSearchUser{}, &OpenSearchUserList{}) +} diff --git a/apis/clusterresources/v1alpha1/zz_generated.deepcopy.go b/apis/clusterresources/v1alpha1/zz_generated.deepcopy.go index 87cba436d..16075748e 100644 --- a/apis/clusterresources/v1alpha1/zz_generated.deepcopy.go +++ b/apis/clusterresources/v1alpha1/zz_generated.deepcopy.go @@ -1098,6 +1098,100 @@ func (in *NodeReloadStatus) DeepCopy() *NodeReloadStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenSearchUser) DeepCopyInto(out *OpenSearchUser) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchUser. +func (in *OpenSearchUser) DeepCopy() *OpenSearchUser { + if in == nil { + return nil + } + out := new(OpenSearchUser) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OpenSearchUser) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenSearchUserList) DeepCopyInto(out *OpenSearchUserList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]OpenSearchUser, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchUserList. +func (in *OpenSearchUserList) DeepCopy() *OpenSearchUserList { + if in == nil { + return nil + } + out := new(OpenSearchUserList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OpenSearchUserList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenSearchUserSpec) DeepCopyInto(out *OpenSearchUserSpec) { + *out = *in + if in.SecretRef != nil { + in, out := &in.SecretRef, &out.SecretRef + *out = new(SecretReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchUserSpec. +func (in *OpenSearchUserSpec) DeepCopy() *OpenSearchUserSpec { + if in == nil { + return nil + } + out := new(OpenSearchUserSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenSearchUserStatus) DeepCopyInto(out *OpenSearchUserStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchUserStatus. +func (in *OpenSearchUserStatus) DeepCopy() *OpenSearchUserStatus { + if in == nil { + return nil + } + out := new(OpenSearchUserStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Operation) DeepCopyInto(out *Operation) { *out = *in diff --git a/apis/clusters/v1alpha1/opensearch_types.go b/apis/clusters/v1alpha1/opensearch_types.go index f51996770..051789d3c 100644 --- a/apis/clusters/v1alpha1/opensearch_types.go +++ b/apis/clusters/v1alpha1/opensearch_types.go @@ -52,6 +52,7 @@ type OpenSearchSpec struct { IndexManagementPlugin bool `json:"indexManagementPlugin,omitempty"` AlertingPlugin bool `json:"alertingPlugin,omitempty"` BundledUseOnly bool `json:"bundleUseOnly,omitempty"` + UserRef *UserReference `json:"userRef,omitempty"` } type OpenSearchDataCentre struct { diff --git a/apis/clusters/v1alpha1/zz_generated.deepcopy.go b/apis/clusters/v1alpha1/zz_generated.deepcopy.go index eef1f668a..72dcef38c 100644 --- a/apis/clusters/v1alpha1/zz_generated.deepcopy.go +++ b/apis/clusters/v1alpha1/zz_generated.deepcopy.go @@ -1386,6 +1386,11 @@ func (in *OpenSearchSpec) DeepCopyInto(out *OpenSearchSpec) { } } } + if in.UserRef != nil { + in, out := &in.UserRef, &out.UserRef + *out = new(UserReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchSpec. diff --git a/config/crd/bases/clusterresources.instaclustr.com_opensearchusers.yaml b/config/crd/bases/clusterresources.instaclustr.com_opensearchusers.yaml new file mode 100644 index 000000000..e2ea7b5a7 --- /dev/null +++ b/config/crd/bases/clusterresources.instaclustr.com_opensearchusers.yaml @@ -0,0 +1,66 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.9.2 + creationTimestamp: null + name: opensearchusers.clusterresources.instaclustr.com +spec: + group: clusterresources.instaclustr.com + names: + kind: OpenSearchUser + listKind: OpenSearchUserList + plural: opensearchusers + singular: opensearchuser + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: OpenSearchUser is the Schema for the opensearchusers API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: OpenSearchUserSpec defines the desired state of OpenSearchUser + properties: + secretRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + - namespace + type: object + required: + - secretRef + type: object + status: + description: OpenSearchUserStatus defines the observed state of OpenSearchUser + properties: + clusterId: + type: string + state: + type: string + required: + - clusterId + - state + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/clusters.instaclustr.com_opensearches.yaml b/config/crd/bases/clusters.instaclustr.com_opensearches.yaml index e8d432316..cbfceb4cd 100644 --- a/config/crd/bases/clusters.instaclustr.com_opensearches.yaml +++ b/config/crd/bases/clusters.instaclustr.com_opensearches.yaml @@ -212,6 +212,16 @@ spec: - email type: object type: array + userRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + - namespace + type: object version: type: string required: diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 1ad351c96..dde36bb2f 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -25,6 +25,7 @@ resources: - bases/clusterresources.instaclustr.com_redisusers.yaml - bases/clusterresources.instaclustr.com_awsencryptionkeys.yaml - bases/clusterresources.instaclustr.com_cassandrausers.yaml +- bases/clusterresources.instaclustr.com_opensearchusers.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d23931ae1..2251605e5 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -309,6 +309,32 @@ rules: - get - patch - update +- apiGroups: + - clusterresources.instaclustr.com + resources: + - opensearchusers + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - clusterresources.instaclustr.com + resources: + - opensearchusers/finalizers + verbs: + - update +- apiGroups: + - clusterresources.instaclustr.com + resources: + - opensearchusers/status + verbs: + - get + - patch + - update - apiGroups: - clusterresources.instaclustr.com resources: diff --git a/config/samples/clusterresources_v1alpha1_opensearchuser.yaml b/config/samples/clusterresources_v1alpha1_opensearchuser.yaml new file mode 100644 index 000000000..b62a461f2 --- /dev/null +++ b/config/samples/clusterresources_v1alpha1_opensearchuser.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Secret +metadata: + name: test-secret-1 +data: + username: dGVzdC11c2VyLTEK # test-user-1 + password: VGVzdFBhc3MxMjMhCg== # TestPass123! +--- +apiVersion: clusterresources.instaclustr.com/v1alpha1 +kind: OpenSearchUser +metadata: + name: test-user-1 +spec: + secretRef: + name: "test-secret-1" + namespace: "default" diff --git a/config/samples/clusters_v1alpha1_opensearch.yaml b/config/samples/clusters_v1alpha1_opensearch.yaml index 18743432e..1df1455c9 100644 --- a/config/samples/clusters_v1alpha1_opensearch.yaml +++ b/config/samples/clusters_v1alpha1_opensearch.yaml @@ -14,6 +14,9 @@ spec: alertingPlugin: false anomalyDetectionPlugin: false asynchronousSearchPlugin: false +# userRef: +# name: "test-user-1" +# namespace: "default" clusterManagerNodes: - dedicatedManager: false nodeSize: SRH-DEV-t4g.small-5 @@ -37,7 +40,7 @@ spec: # - nodeSize: SRH-DEV-t4g.small-5 # oidcProvider: '' # version: opensearch-dashboards:2.5.0 - version: 2.5.0 + version: 2.7.0 pciCompliance: false privateNetworkCluster: false reportingPlugin: false diff --git a/controllers/clusterresources/cassandrauser_controller.go b/controllers/clusterresources/cassandrauser_controller.go index 7d1407820..8657459c4 100644 --- a/controllers/clusterresources/cassandrauser_controller.go +++ b/controllers/clusterresources/cassandrauser_controller.go @@ -132,7 +132,7 @@ func (r *CassandraUserReconciler) Reconcile(ctx context.Context, req ctrl.Reques if u.Status.ClusterID != "" && u.Status.State != models.Created { patch := u.NewPatch() - username, password, err := r.getUserCreds(s) + username, password, err := getUserCreds(s) if err != nil { l.Error(err, "Cannot get user credentials", "user", u.Name) r.EventRecorder.Eventf(u, models.Warning, models.CreatingEvent, @@ -204,24 +204,13 @@ func (r *CassandraUserReconciler) Reconcile(ctx context.Context, req ctrl.Reques return models.ExitReconcile, nil } -func (r *CassandraUserReconciler) getUserCreds(secret *k8sCore.Secret) (username, password string, err error) { - password = string(secret.Data["password"]) - username = string(secret.Data["username"]) - - if len(username) == 0 || len(password) == 0 { - return "", "", models.ErrMissingSecretKeys - } - - return username[:len(username)-1], password[:len(password)-1], nil -} - func (r *CassandraUserReconciler) handleDeleteUser( ctx context.Context, l logr.Logger, s *k8sCore.Secret, u *clusterresourcesv1alpha1.CassandraUser, ) error { - username, _, err := r.getUserCreds(s) + username, _, err := getUserCreds(s) if err != nil { l.Error(err, "Cannot get user credentials", "user", u.Name) r.EventRecorder.Eventf(u, models.Warning, models.CreatingEvent, diff --git a/controllers/clusterresources/helpers.go b/controllers/clusterresources/helpers.go index 059f317e1..d880ba9ed 100644 --- a/controllers/clusterresources/helpers.go +++ b/controllers/clusterresources/helpers.go @@ -17,7 +17,10 @@ limitations under the License. package clusterresources import ( + k8sCore "k8s.io/api/core/v1" + "github.com/instaclustr/operator/apis/clusterresources/v1alpha1" + "github.com/instaclustr/operator/pkg/models" ) func areFirewallRuleStatusesEqual(a, b *v1alpha1.FirewallRuleStatus) bool { @@ -61,3 +64,14 @@ func areEncryptionKeyStatusesEqual(a, b *v1alpha1.AWSEncryptionKeyStatus) bool { return true } + +func getUserCreds(secret *k8sCore.Secret) (username, password string, err error) { + password = string(secret.Data["password"]) + username = string(secret.Data["username"]) + + if len(username) == 0 || len(password) == 0 { + return "", "", models.ErrMissingSecretKeys + } + + return username[:len(username)-1], password[:len(password)-1], nil +} diff --git a/controllers/clusterresources/opensearchuser_controller.go b/controllers/clusterresources/opensearchuser_controller.go new file mode 100644 index 000000000..f4098f6ae --- /dev/null +++ b/controllers/clusterresources/opensearchuser_controller.go @@ -0,0 +1,280 @@ +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package clusterresources + +import ( + "context" + "errors" + + "github.com/go-logr/logr" + k8sCore "k8s.io/api/core/v1" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + clusterresourcesv1alpha1 "github.com/instaclustr/operator/apis/clusterresources/v1alpha1" + "github.com/instaclustr/operator/pkg/instaclustr" + "github.com/instaclustr/operator/pkg/models" +) + +// OpenSearchUserReconciler reconciles a OpenSearchUser object +type OpenSearchUserReconciler struct { + client.Client + Scheme *runtime.Scheme + API instaclustr.API + EventRecorder record.EventRecorder +} + +//+kubebuilder:rbac:groups=clusterresources.instaclustr.com,resources=opensearchusers,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=clusterresources.instaclustr.com,resources=opensearchusers/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=clusterresources.instaclustr.com,resources=opensearchusers/finalizers,verbs=update +//+kubebuilder:rbac:groups="",resources=events,verbs=create;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. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.12.2/pkg/reconcile +func (r *OpenSearchUserReconciler) Reconcile( + ctx context.Context, + req ctrl.Request, +) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + user := &clusterresourcesv1alpha1.OpenSearchUser{} + err := r.Get(ctx, req.NamespacedName, user) + if err != nil { + if k8sErrors.IsNotFound(err) { + logger.Error(err, "OpenSearch user resource is not found", + "request", req, + ) + return models.ExitReconcile, nil + } + logger.Error(err, "Cannot fetch OpenSearch user resource", + "request", req, + ) + return models.ReconcileRequeue, nil + } + + secret := &k8sCore.Secret{} + err = r.Get(ctx, types.NamespacedName{ + Namespace: user.Spec.SecretRef.Namespace, + Name: user.Spec.SecretRef.Name, + }, secret) + if err != nil { + if k8sErrors.IsNotFound(err) { + logger.Info("OpenSearch user secret is not found", + "request", req, + ) + r.EventRecorder.Event(user, models.Warning, models.NotFound, + "Secret is not found, please create a new secret or set an actual reference", + ) + return models.ReconcileRequeue, nil + } + + logger.Error(err, "Cannot get OpenSearch user's secret") + r.EventRecorder.Eventf( + user, models.Warning, models.FetchFailed, + "User's secret fetching has been failed. Reason: %v", err, + ) + + return models.ReconcileRequeue, nil + } + + if user.DeletionTimestamp != nil { + if secret.DeletionTimestamp != nil { + return r.deleteUser(ctx, user, secret, logger) + } + logger.Info("The user resource waits until the secret has been deleted. Please delete the user's secret") + r.EventRecorder.Eventf(user, models.Warning, models.DeletingEvent, + "The user resource waits until the secret has been deleted. Please delete the user's secret", + ) + return models.ReconcileRequeue, nil + } + + if secret.Labels == nil { + secret.Labels = map[string]string{} + } + + patch := user.NewPatch() + if controllerutil.AddFinalizer(user, models.DeletionFinalizer) { + err = r.Patch(ctx, user, patch) + if err != nil { + logger.Error(err, "Cannot patch OpenSearch user resource with deletion finalizer", + "cluster ID", user.Status.ClusterID, + ) + r.EventRecorder.Eventf( + user, models.Warning, models.PatchFailed, + "Resource patching with deletion finalizer has been failed. Reason: %v", + err, + ) + return models.ReconcileRequeue, nil + } + } + + if secret.Labels[models.OpenSearchUserNamespaceLabel] == "" || secret.Labels[models.ControlledByLabel] == "" { + secret.Labels[models.OpenSearchUserNamespaceLabel] = user.Namespace + secret.Labels[models.ControlledByLabel] = user.Name + controllerutil.AddFinalizer(secret, models.DeletionFinalizer) + err = r.Update(ctx, secret) + if err != nil { + logger.Error(err, "Cannot update OpenSearch user's secret with deletion secret") + r.EventRecorder.Eventf( + secret, models.Warning, models.UpdateFailed, + "Resource updating with deletion finalizer has been failed. Reason: %v", err, + ) + return models.ReconcileRequeue, nil + } + } + + if user.Status.ClusterID != "" && user.Status.State == "" { + return r.createUser(ctx, user, secret, logger) + } + + return models.ExitReconcile, nil +} + +func (r *OpenSearchUserReconciler) createUser( + ctx context.Context, + user *clusterresourcesv1alpha1.OpenSearchUser, + secret *k8sCore.Secret, + logger logr.Logger, +) (ctrl.Result, error) { + username, password, err := getUserCreds(secret) + if err != nil { + logger.Error(err, "Cannot get user's credentials during creating on the cluster") + r.EventRecorder.Eventf(user, models.Warning, models.CreatingEvent, + "Cannot get user's credentials during creating on the cluster. Reason: %v", err, + ) + return models.ReconcileRequeue, nil + } + + err = r.API.CreateUser(user.ToInstaAPI(username, password), user.Status.ClusterID, models.OpenSearchAppKind) + if err != nil { + logger.Error(err, "Cannot create OpenSearch user on Instaclustr", + "username", username, + "cluster ID", user.Status.ClusterID, + ) + r.EventRecorder.Eventf( + user, models.Warning, models.CreationFailed, + "OpenSearch user creating on Instaclustr has been failed. Reason: %v", err, + ) + return models.ReconcileRequeue, nil + } + + patch := user.NewPatch() + user.Status.State = models.Created + err = r.Status().Patch(ctx, user, patch) + if err != nil { + logger.Error(err, "Cannot patch user resource with created state", + "cluster ID", user.Status.ClusterID, + ) + r.EventRecorder.Eventf( + user, models.Warning, models.PatchFailed, + "Resource patching with created state has been failed. Reason: %v", err, + ) + return models.ReconcileRequeue, nil + } + + logger.Info("OpenSearch user has been created", + "cluster ID", user.Status.ClusterID, + ) + + return models.ExitReconcile, nil +} + +func (r *OpenSearchUserReconciler) deleteUser( + ctx context.Context, + user *clusterresourcesv1alpha1.OpenSearchUser, + secret *k8sCore.Secret, + logger logr.Logger, +) (ctrl.Result, error) { + username, _, err := getUserCreds(secret) + if err != nil { + logger.Error(err, "Cannot get user's credentials during deleting") + r.EventRecorder.Eventf(user, models.Warning, models.DeletingEvent, + "Resource deleting has been failed. Reason: %v", err, + ) + return models.ReconcileRequeue, nil + } + + err = r.API.DeleteUser(username, user.Status.ClusterID, models.OpenSearchAppKind) + if err != nil && !errors.Is(err, instaclustr.NotFound) { + logger.Error(err, "Cannot delete OpenSearch user resource from Instaclustr", + "cluster ID", user.Status.ClusterID, + ) + r.EventRecorder.Eventf( + user, models.Warning, models.DeletionFailed, + "Resource deletion on Instaclustr has been failed. Reason: %v", + err, + ) + return models.ReconcileRequeue, nil + } + + r.EventRecorder.Eventf( + user, models.Normal, models.DeletionStarted, + "Resource deletion request has been sent to the Instaclustr API.", + ) + + controllerutil.RemoveFinalizer(secret, models.DeletionFinalizer) + err = r.Update(ctx, secret) + if err != nil { + logger.Error(err, "Cannot delete deletion finalizer from the user's secret") + r.EventRecorder.Eventf( + secret, models.Warning, models.UpdateFailed, + "Deleting finalizer from the user's secret has been failed. Reason: %v", + ) + return models.ReconcileRequeue, nil + } + + patch := user.NewPatch() + controllerutil.RemoveFinalizer(user, models.DeletionFinalizer) + err = r.Patch(ctx, user, patch) + if err != nil { + logger.Error(err, "Cannot delete deletion finalizer from the OpenSearch user resource", + "cluster ID", user.Status.ClusterID, + ) + r.EventRecorder.Eventf( + user, models.Warning, models.PatchFailed, + "Deleting deletion finalizer from the OpenSearch user resource has been failed. Reason: %v", + err, + ) + return models.ReconcileRequeue, nil + } + + logger.Info("OpenSearch user has been deleted", + "cluster ID", user.Status.ClusterID, + ) + r.EventRecorder.Eventf( + user, models.Normal, models.Deleted, + "OpenSearchUser resource has been deleted deleted", + ) + + return models.ExitReconcile, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *OpenSearchUserReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&clusterresourcesv1alpha1.OpenSearchUser{}). + Complete(r) +} diff --git a/controllers/clusters/datatest/opensearch_v1alpha1.yaml b/controllers/clusters/datatest/opensearch_v1alpha1.yaml index b94a82684..fc1e270f1 100644 --- a/controllers/clusters/datatest/opensearch_v1alpha1.yaml +++ b/controllers/clusters/datatest/opensearch_v1alpha1.yaml @@ -6,6 +6,9 @@ spec: alertingPlugin: false anomalyDetectionPlugin: false asynchronousSearchPlugin: false + userRef: + name: test-user + namespace: default clusterManagerNodes: - dedicatedManager: false nodeSize: SRH-DEV-t4g.small-5 diff --git a/controllers/clusters/opensearch_controller.go b/controllers/clusters/opensearch_controller.go index 306c29503..3ad145cf2 100644 --- a/controllers/clusters/opensearch_controller.go +++ b/controllers/clusters/opensearch_controller.go @@ -227,6 +227,22 @@ func (r *OpenSearchReconciler) HandleCreateCluster( "cluster name", o.Name, "cluster ID", o.Status.ID, "api version", o.APIVersion, "namespace", o.Namespace) + if o.Spec.UserRef != nil { + patch := o.NewPatch() + o.Annotations[models.ResourceStateAnnotation] = models.UpdatingEvent + err = r.Patch(ctx, o, patch) + if err != nil { + logger.Error(err, "Cannot patch cluster", + "cluster name", o.Spec.Name, + "cluster ID", o.Status.ID, + ) + r.EventRecorder.Eventf(o, models.Warning, models.PatchFailed, + "Cluster resource patch is failed. Reason: %v", err, + ) + } + return models.ReconcileRequeue + } + return models.ExitReconcile } @@ -290,6 +306,13 @@ func (r *OpenSearchReconciler) HandleUpdateCluster( } } + if o.Spec.UserRef != nil { + err = r.handleCreateUser(ctx, o, logger) + if err != nil { + return models.ReconcileRequeue + } + } + patch := o.NewPatch() o.Annotations[models.ResourceStateAnnotation] = models.UpdatedEvent err = r.Patch(ctx, o, patch) @@ -312,6 +335,54 @@ func (r *OpenSearchReconciler) HandleUpdateCluster( return models.ExitReconcile } +func (r *OpenSearchReconciler) handleCreateUser( + ctx context.Context, + o *clustersv1alpha1.OpenSearch, + logger logr.Logger, +) error { + req := types.NamespacedName{ + Namespace: o.Spec.UserRef.Namespace, + Name: o.Spec.UserRef.Name, + } + + u := &clusterresourcesv1alpha1.OpenSearchUser{} + err := r.Get(ctx, req, u) + if err != nil { + if k8serrors.IsNotFound(err) { + logger.Info("OpenSearch user is not found", "request", req) + r.EventRecorder.Event( + u, models.Warning, "Not Found", + "User is not found, create a new one OpenSearch User or provide correct userRef.", + ) + return err + } + + logger.Error(err, "Cannot get OpenSearch user secret", "user", u.Spec) + return err + } + + if u.Status.ClusterID == "" { + u.Status.ClusterID = o.Status.ID + err = r.Status().Update(ctx, u) + if err != nil { + logger.Error(err, "Cannot update OpenSearch User with cluster ID", + "cluster name", o.Spec.Name, + "cluster ID", o.Status.ID, + ) + r.EventRecorder.Eventf(o, models.Warning, models.CreationFailed, + "Cannot update OpenSearch User with cluster ID. Reason: %v", err, + ) + return err + } + logger.Info("OpenSearch user has been assigned to cluster", + "cluster name", o.Spec.Name, + "cluster ID", o.Status.ID, + ) + } + + return nil +} + func (r *OpenSearchReconciler) handleExternalChanges(o, iO *clustersv1alpha1.OpenSearch, l logr.Logger) reconcile.Result { if o.Annotations[models.AllowSpecAmendAnnotation] != models.True { l.Info("Update is blocked until k8s resource specification is equal with Instaclustr", diff --git a/main.go b/main.go index b392dcd87..2ed9cee6f 100644 --- a/main.go +++ b/main.go @@ -378,6 +378,15 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "RedisUser") os.Exit(1) } + if err = (&clusterresourcescontrollers.OpenSearchUserReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + API: instaClient, + EventRecorder: eventRecorder, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "OpenSearchUser") + os.Exit(1) + } if err = (&clustersv1alpha1.Zookeeper{}).SetupWebhookWithManager(mgr, instaClient); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "Zookeeper") os.Exit(1) diff --git a/pkg/instaclustr/client.go b/pkg/instaclustr/client.go index 7e54b18c8..e69510734 100644 --- a/pkg/instaclustr/client.go +++ b/pkg/instaclustr/client.go @@ -2153,6 +2153,10 @@ func (c *Client) DeleteUser(username, clusterID, app string) error { return err } + if resp.StatusCode == http.StatusNotFound { + return NotFound + } + if resp.StatusCode != http.StatusOK { return fmt.Errorf("status code: %d, message: %s", resp.StatusCode, body) } diff --git a/pkg/models/apiv1.go b/pkg/models/apiv1.go index ce662049f..fdd1f4d26 100644 --- a/pkg/models/apiv1.go +++ b/pkg/models/apiv1.go @@ -179,3 +179,13 @@ type InstaUser struct { Password string `json:"password"` InitialPermission string `json:"initial-permissions"` } + +type InstaOpenSearchUser struct { + *InstaUser `json:",inline"` + Options Options `json:"options"` +} + +type Options struct { + IndexPattern string `json:"indexPattern"` + Role string `json:"role"` +} diff --git a/pkg/models/operator.go b/pkg/models/operator.go index d841c50ce..fceb663f8 100644 --- a/pkg/models/operator.go +++ b/pkg/models/operator.go @@ -37,6 +37,7 @@ const ( ClusterresourcesV1alpha1APIVersion = "clusterresources.instaclustr.com/v1alpha1" RedisUserNamespaceLabel = "instaclustr.com/redisUserNamespace" CassandraUserNamespaceLabel = "instaclustr.com/cassandraUserNamespace" + OpenSearchUserNamespaceLabel = "instaclustr.com/openSearchUserNamespace" CassandraKind = "Cassandra" CassandraChildPrefix = "cassandra-"