diff --git a/apis/clusterresources/v1beta1/opensearchegressrules_types.go b/apis/clusterresources/v1beta1/opensearchegressrules_types.go new file mode 100644 index 000000000..058219146 --- /dev/null +++ b/apis/clusterresources/v1beta1/opensearchegressrules_types.go @@ -0,0 +1,54 @@ +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/instaclustr/operator/pkg/models" +) + +type OpenSearchEgressRulesSpec struct { + ClusterID string `json:"clusterId"` + Name string `json:"name"` + OpenSearchBindingId string `json:"openSearchBindingId"` + Source string `json:"source"` + Type string `json:"type"` +} + +type OpenSearchEgressRulesStatus struct { + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +type OpenSearchEgressRule struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec OpenSearchEgressRulesSpec `json:"spec,omitempty"` + Status OpenSearchEgressRulesStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +type OpenSearchEgressRuleList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []OpenSearchEgressRule `json:"items"` +} + +func (er *OpenSearchEgressRule) GetJobID(jobName string) string { + return client.ObjectKeyFromObject(er).String() + "/" + jobName +} + +func (er *OpenSearchEgressRule) NewPatch() client.Patch { + old := er.DeepCopy() + old.Annotations[models.ResourceStateAnnotation] = "" + return client.MergeFrom(old) +} + +func init() { + SchemeBuilder.Register(&OpenSearchEgressRule{}, &OpenSearchEgressRuleList{}) +} diff --git a/apis/clusterresources/v1beta1/opensearchegressrules_webhook.go b/apis/clusterresources/v1beta1/opensearchegressrules_webhook.go new file mode 100644 index 000000000..2b08e6b28 --- /dev/null +++ b/apis/clusterresources/v1beta1/opensearchegressrules_webhook.go @@ -0,0 +1,68 @@ +package v1beta1 + +import ( + "fmt" + "regexp" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/instaclustr/operator/pkg/models" +) + +var opensearchEgressRulesJog = logf.Log.WithName("opensearchegressrules-resource") +var openSearchBindingIDPattern, _ = regexp.Compile(`[\w-]+`) +var egressRulesIDPattern, _ = regexp.Compile(`[a-zA-Z\d-]+~\w+~[\w-]+`) + +func (r *OpenSearchEgressRule) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +//+kubebuilder:webhook:path=/validate-clusterresources-instaclustr-com-v1beta1-opensearchuser,mutating=false,failurePolicy=fail,sideEffects=None,groups=clusterresources.instaclustr.com,resources=opensearchusers,verbs=create;update,versions=v1beta1,name=vopensearchuser.kb.io,admissionReviewVersions=v1 + +var _ webhook.Validator = &OpenSearchEgressRule{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *OpenSearchEgressRule) ValidateCreate() error { + opensearchEgressRulesJog.Info("validate create", "name", r.Name) + + if r.Spec.ClusterID == "" || r.Spec.OpenSearchBindingId == "" || r.Spec.Source == "" { + return fmt.Errorf("spec.ClusterID, spec.OpenSearchBindingId, spec.Source must be filled") + } + + if !openSearchBindingIDPattern.MatchString(r.Spec.OpenSearchBindingId) { + return fmt.Errorf("mismatching openSearchBindingID to [\\w-]+ pattern") + } + + return nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *OpenSearchEgressRule) ValidateUpdate(old runtime.Object) error { + opensearchEgressRulesJog.Info("validate update", "name", r.Name) + + oldRules := old.(*OpenSearchEgressRule) + + if r.Status.ID == "" { + return r.ValidateCreate() + } + + if r.Spec != oldRules.Spec { + return models.ErrImmutableSpec + } + + return nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *OpenSearchEgressRule) ValidateDelete() error { + if !openSearchBindingIDPattern.MatchString(r.Spec.OpenSearchBindingId) { + return fmt.Errorf("mismatching openSearchBindingID to [a-zA-Z\\d-]+~\\w+~[\\w-]+ pattern") + } + + return nil +} diff --git a/apis/clusterresources/v1beta1/zz_generated.deepcopy.go b/apis/clusterresources/v1beta1/zz_generated.deepcopy.go index 17f6fb467..19ad1b33c 100644 --- a/apis/clusterresources/v1beta1/zz_generated.deepcopy.go +++ b/apis/clusterresources/v1beta1/zz_generated.deepcopy.go @@ -1105,6 +1105,95 @@ 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 *OpenSearchEgressRule) DeepCopyInto(out *OpenSearchEgressRule) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchEgressRule. +func (in *OpenSearchEgressRule) DeepCopy() *OpenSearchEgressRule { + if in == nil { + return nil + } + out := new(OpenSearchEgressRule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OpenSearchEgressRule) 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 *OpenSearchEgressRuleList) DeepCopyInto(out *OpenSearchEgressRuleList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]OpenSearchEgressRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchEgressRuleList. +func (in *OpenSearchEgressRuleList) DeepCopy() *OpenSearchEgressRuleList { + if in == nil { + return nil + } + out := new(OpenSearchEgressRuleList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OpenSearchEgressRuleList) 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 *OpenSearchEgressRulesSpec) DeepCopyInto(out *OpenSearchEgressRulesSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchEgressRulesSpec. +func (in *OpenSearchEgressRulesSpec) DeepCopy() *OpenSearchEgressRulesSpec { + if in == nil { + return nil + } + out := new(OpenSearchEgressRulesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenSearchEgressRulesStatus) DeepCopyInto(out *OpenSearchEgressRulesStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenSearchEgressRulesStatus. +func (in *OpenSearchEgressRulesStatus) DeepCopy() *OpenSearchEgressRulesStatus { + if in == nil { + return nil + } + out := new(OpenSearchEgressRulesStatus) + in.DeepCopyInto(out) + 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 diff --git a/config/crd/bases/clusterresources.instaclustr.com_opensearchegressrules.yaml b/config/crd/bases/clusterresources.instaclustr.com_opensearchegressrules.yaml new file mode 100644 index 000000000..a650e6305 --- /dev/null +++ b/config/crd/bases/clusterresources.instaclustr.com_opensearchegressrules.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.9.2 + creationTimestamp: null + name: opensearchegressrules.clusterresources.instaclustr.com +spec: + group: clusterresources.instaclustr.com + names: + kind: OpenSearchEgressRule + listKind: OpenSearchEgressRuleList + plural: opensearchegressrules + singular: opensearchegressrule + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + 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: + properties: + clusterId: + type: string + name: + type: string + openSearchBindingId: + type: string + source: + type: string + type: + type: string + required: + - clusterId + - name + - openSearchBindingId + - source + - type + type: object + status: + properties: + id: + type: string + status: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 33fede0e2..bf9cff053 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -572,6 +572,26 @@ webhooks: resources: - opensearchusers sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-clusterresources-instaclustr-com-v1beta1-opensearchuser + failurePolicy: Fail + name: vopensearchuser.kb.io + rules: + - apiGroups: + - clusterresources.instaclustr.com + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - opensearchusers + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/controllers/clusterresources/opensearchegressrules_controller.go b/controllers/clusterresources/opensearchegressrules_controller.go new file mode 100644 index 000000000..362aa6724 --- /dev/null +++ b/controllers/clusterresources/opensearchegressrules_controller.go @@ -0,0 +1,162 @@ +package clusterresources + +import ( + "context" + "encoding/json" + "errors" + + "github.com/go-logr/logr" + 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" + + clusterresourcesv1beta1 "github.com/instaclustr/operator/apis/clusterresources/v1beta1" + "github.com/instaclustr/operator/pkg/instaclustr" + "github.com/instaclustr/operator/pkg/models" + "github.com/instaclustr/operator/pkg/scheduler" +) + +type OpenSearchEgressRulesReconciler struct { + client.Client + Scheme *runtime.Scheme + API instaclustr.API + Scheduler scheduler.Interface + EventRecorder record.EventRecorder +} + +// 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.14.4/pkg/reconcile +func (r *OpenSearchEgressRulesReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + l := log.FromContext(ctx) + + rule := &clusterresourcesv1beta1.OpenSearchEgressRule{} + err := r.Client.Get(ctx, types.NamespacedName{ + Namespace: req.Namespace, + Name: req.Name, + }, rule) + if err != nil { + if k8serrors.IsNotFound(err) { + return models.ExitReconcile, nil + } + + l.Error(err, "Unable to fetch OpenSearch Egress Rules resource") + + return models.ReconcileRequeue, err + } + + // Handle resource deletion + if rule.DeletionTimestamp != nil { + err = r.handleDelete(ctx, l, rule) + if err != nil { + return models.ReconcileRequeue, err + } + + return models.ExitReconcile, nil + } + + // Handle resource creation + if rule.Status.ID == "" { + err = r.handleCreate(ctx, l, rule) + if err != nil { + return models.ReconcileRequeue, nil + } + + return models.ExitReconcile, nil + } + + return models.ExitReconcile, nil +} + +func (r *OpenSearchEgressRulesReconciler) handleCreate(ctx context.Context, l logr.Logger, rule *clusterresourcesv1beta1.OpenSearchEgressRule) error { + b, err := r.API.CreateOpenSearchEgressRules(rule.Spec.ClusterID, rule.Spec) + if err != nil { + l.Error(err, "failed to create OpenSearch Egress Rule resource on Instaclustr") + r.EventRecorder.Eventf(rule, models.Warning, models.CreationFailed, + "Failed to create OpenSearch Egress Rule on Instaclustr. Reason: %v", err, + ) + + return err + } + + patch := rule.NewPatch() + err = json.Unmarshal(b, &rule.Status) + if err != nil { + l.Error(err, "failed to parse OpenSearch Egress Rule resource response from Instaclustr") + r.EventRecorder.Eventf(rule, models.Warning, models.ConvertionFailed, + "Failed to parse OpenSearch Egress Rule response from Instaclustr. Reason: %v", err, + ) + + return err + } + + err = r.Status().Patch(ctx, rule, patch) + if err != nil { + l.Error(err, "failed to patch OpenSearch Egress Rule status with its id") + r.EventRecorder.Eventf(rule, models.Warning, models.PatchFailed, + "Failed to patch OpenSearch Egress Rule with its id. Reason: %v", err, + ) + + return err + } + + controllerutil.AddFinalizer(rule, models.DeletionFinalizer) + err = r.Patch(ctx, rule, patch) + if err != nil { + l.Error(err, "failed to patch OpenSearch Egress Rule with finalizer") + r.EventRecorder.Eventf(rule, models.Warning, models.PatchFailed, + "Failed to patch OpenSearch Egress Rule with finalizer. Reason: %v", err, + ) + + return err + } + + l.Info("OpenSearch Egress Rule has been created") + r.EventRecorder.Event(rule, models.Normal, models.Created, + "OpenSearch Egress Rule has been created", + ) + + return nil +} + +func (r *OpenSearchEgressRulesReconciler) handleDelete(ctx context.Context, logger logr.Logger, resource *clusterresourcesv1beta1.OpenSearchEgressRule) error { + err := r.API.DeleteOpenSearchEgressRule(resource.Status.ID) + if err != nil && !errors.Is(err, instaclustr.NotFound) { + logger.Error(err, "failed to delete OpenSearch Egress Rule on Instaclustr") + r.EventRecorder.Eventf(resource, models.Warning, models.DeletionFailed, + "Failed to delete OpenSearch Egress Rule on Instaclustr. Reason: %v", err, + ) + + return err + } + + patch := resource.NewPatch() + controllerutil.RemoveFinalizer(resource, models.DeletionFinalizer) + err = r.Patch(ctx, resource, patch) + if err != nil { + logger.Error(err, "failed to delete finalizer OpenSearch Egress Rule") + r.EventRecorder.Eventf(resource, models.Warning, models.PatchFailed, + "Failed to delete finalizer from OpenSearch Egress Rule. Reason: %v", err, + ) + + return err + } + + logger.Info("OpenSearch Egress Rule has been deleted") + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *OpenSearchEgressRulesReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&clusterresourcesv1beta1.OpenSearchEgressRule{}). + Complete(r) +} diff --git a/pkg/instaclustr/client.go b/pkg/instaclustr/client.go index cccf45589..370765170 100644 --- a/pkg/instaclustr/client.go +++ b/pkg/instaclustr/client.go @@ -2241,3 +2241,53 @@ func (c *Client) UpdateClusterSettings(clusterID string, settings *models.Cluste return nil } + +func (c *Client) CreateOpenSearchEgressRules(clusterID string, spec any) ([]byte, error) { + url := c.serverHostname + fmt.Sprintf(OpenSearchEgressRulesEndpoint, clusterID) + + b, err := json.Marshal(spec) + if err != nil { + return nil, err + } + + resp, err := c.DoRequest(url, http.MethodPost, b) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + b, err = io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("status code: %d, message: %s", resp.StatusCode, b) + } + + return b, nil +} + +func (c *Client) DeleteOpenSearchEgressRule(egressRuleId string) error { + url := c.serverHostname + OpenSearchEgressRuleDeleteEndpoint + egressRuleId + resp, err := c.DoRequest(url, http.MethodDelete, nil) + if err != nil { + return err + } + + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusNotFound { + return NotFound + } + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("status code: %d, message: %s", resp.StatusCode, b) + } + + return nil +} diff --git a/pkg/instaclustr/config.go b/pkg/instaclustr/config.go index 28324af15..d0af13d0e 100644 --- a/pkg/instaclustr/config.go +++ b/pkg/instaclustr/config.go @@ -52,6 +52,8 @@ const ( AWSEncryptionKeyEndpoint = "/cluster-management/v2/resources/providers/aws/encryption-keys/v2/" ListAppsVersionsEndpoint = "%s/cluster-management/v2/data-sources/applications/%s/versions/v2/" ClusterSettingsEndpoint = "%s/cluster-management/v2/operations/clusters/v2/%s/change-settings/v2" + OpenSearchEgressRulesEndpoint = "/cluster-management/v2/data-sources/opensearch_cluster/%s/egress-rules/v2/" + OpenSearchEgressRuleDeleteEndpoint = "/cluster-management/v2/resources/applications/opensearch/egress-rules/v2/" ) // constants for API v1 diff --git a/pkg/instaclustr/interfaces.go b/pkg/instaclustr/interfaces.go index 3747de569..ef0af3e88 100644 --- a/pkg/instaclustr/interfaces.go +++ b/pkg/instaclustr/interfaces.go @@ -98,4 +98,6 @@ type API interface { ListAppVersions(app string) ([]*models.AppVersions, error) GetDefaultCredentialsV1(clusterID string) (string, string, error) UpdateClusterSettings(clusterID string, settings *models.ClusterSettings) error + CreateOpenSearchEgressRules(clusterID string, spec any) ([]byte, error) + DeleteOpenSearchEgressRule(egressRuleId string) error } diff --git a/pkg/models/errors.go b/pkg/models/errors.go index df31744b1..b14ef4edc 100644 --- a/pkg/models/errors.go +++ b/pkg/models/errors.go @@ -56,4 +56,5 @@ var ( ErrMissingSecretKeys = errors.New("the secret is missing the correct keys for the user") ErrUserStillExist = errors.New("the user is still attached to cluster") ErrOnlyOneEntityTwoFactorDelete = errors.New("currently only one entity of two factor delete can be filled") + ErrImmutableSpec = errors.New("spec is immutable") )