From d2774a34d6d8e67d2eeef7467b3602d30d18832f Mon Sep 17 00:00:00 2001 From: Zirko <64951262+QuantumEnigmaa@users.noreply.github.com> Date: Tue, 5 Nov 2024 13:25:57 +0100 Subject: [PATCH] Enable org creation (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add org CRD in helm chart * layout foundations for CRD with kubebuilder * remove useless files * update CRD names * add generated crd * applying suggestions * fix naming issues * update Dockerfile with copy command for the api package * update changelog * add symlink to crd in helm chart and updated crd * fix symlink * fix symlink * Update main.go Co-authored-by: Quentin Bisson * add symlink back * add finalizer * Apply suggestions from code review Co-authored-by: Théo Brigitte * regenerate crd * update sample * add logic to grafana organization reconciler * fix build * Split grafana client creation in its own folder * add exclusions to golangci * add crds template in chart * Apply suggestions from code review Co-authored-by: Quentin Bisson * update const names in files * Update pkg/grafana/client/client.go Co-authored-by: Quentin Bisson * fix const name in client * rework getAdminCredentials function * remove unused variable in client * fix missing return statement error * add authorizations for grafanaorganizations in operator rbac * Apply suggestions from code review Co-authored-by: Quentin Bisson * fix merging errors * rename main org * sort imports * Enable grafanaOrganization creation * first draft for org creation * refactor code * Apply suggestions from code review Co-authored-by: Quentin Bisson * make orgID and dataSource ID int * try out dirty trick * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Jose Armesto * update error handling * Apply suggestions from code review Co-authored-by: Théo Brigitte * fix errors in code * remove connection to Grafana check * enhance error handling * rename variables * add back deleted code * changelog * update crd sample * add debug logs to solve current issue * fix grafana admin secret name for admin-user * remove debug logs * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Quentin Bisson * Apply suggestions from code review Co-authored-by: Quentin Bisson * fix name error * refacto code * refactor code * refactor code for better readability * fix grafana isNotFound error handling * add organization type * remove one level of indentation * improve status update handling * add and fix comments * remove unecessary type casting * refacto and add todos to make the code cleaner * add initial org mapping (#133) * add org CRD in helm chart * layout foundations for CRD with kubebuilder * remove useless files * update CRD names * add generated crd * applying suggestions * fix naming issues * update Dockerfile with copy command for the api package * update changelog * add symlink to crd in helm chart and updated crd * fix symlink * fix symlink * Update main.go Co-authored-by: Quentin Bisson * add symlink back * add finalizer * Apply suggestions from code review Co-authored-by: Théo Brigitte * regenerate crd * update sample * add logic to grafana organization reconciler * fix build * Split grafana client creation in its own folder * add exclusions to golangci * add crds template in chart * Apply suggestions from code review Co-authored-by: Quentin Bisson * update const names in files * Update pkg/grafana/client/client.go Co-authored-by: Quentin Bisson * fix const name in client * rework getAdminCredentials function * remove unused variable in client * fix missing return statement error * add authorizations for grafanaorganizations in operator rbac * Apply suggestions from code review Co-authored-by: Quentin Bisson * fix merging errors * rename main org * sort imports * add initial org mapping * move crds to helm official repo to respect installation order * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Quentin Bisson * Set alloy wal truncate_frequency to 15m (#130) * try truncate frequency * Add flag to be able to set truncate_frequency * set default monitoring agent to alloy (#134) * Release v0.8.0 (#135) * fix flag redefined error (#136) * Release v0.8.1 (#137) * add logic to grafana organization reconciler (#129) * add org CRD in helm chart * layout foundations for CRD with kubebuilder * remove useless files * update CRD names * add generated crd * applying suggestions * fix naming issues * update Dockerfile with copy command for the api package * update changelog * add symlink to crd in helm chart and updated crd * fix symlink * fix symlink * Update main.go Co-authored-by: Quentin Bisson * add symlink back * add finalizer * Apply suggestions from code review Co-authored-by: Théo Brigitte * regenerate crd * update sample * add logic to grafana organization reconciler * fix build * Split grafana client creation in its own folder * add exclusions to golangci * add crds template in chart * Apply suggestions from code review Co-authored-by: Quentin Bisson * update const names in files * Update pkg/grafana/client/client.go Co-authored-by: Quentin Bisson * fix const name in client * rework getAdminCredentials function * remove unused variable in client * fix missing return statement error * add authorizations for grafanaorganizations in operator rbac * Apply suggestions from code review Co-authored-by: Quentin Bisson * fix merging errors * rename main org * sort imports * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Jose Armesto * Apply suggestions from code review Co-authored-by: Théo Brigitte * fix errors in code * address reviews * address reviews --------- Co-authored-by: QuentinBisson Co-authored-by: Théo Brigitte Co-authored-by: Jose Armesto * Update github.com/grafana/grafana-openapi-client-go digest to 9d96c20 (#138) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Update module github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring to v0.77.2 (#139) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Update internal/controller/grafanaorganization_controller.go --------- Co-authored-by: QuantumEnigmaa Co-authored-by: Zirko <64951262+QuantumEnigmaa@users.noreply.github.com> Co-authored-by: Théo Brigitte Co-authored-by: Taylor Bot Co-authored-by: Jose Armesto Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * fix grafana admin password in deployment * fix grafana admin password in deployment * Update CHANGELOG.md * fix go build * add deletion * move grafana templating to another pkg * make datasources field in the CR optional * add watches over the grafana pod * add rule for watching & listing pods in clusterrole * reduce scope of grafana pod watch * disable configureGrafana function for testing * fix grafana pod reconciliation * add configureGrafana back * fix watch * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Hervé Nicol * fix comment * fix comment * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update pkg/grafana/grafana.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * Update internal/controller/grafanaorganization_controller.go * Update internal/controller/grafanaorganization_controller.go Co-authored-by: Théo Brigitte * address-reviews --------- Co-authored-by: QuentinBisson Co-authored-by: Théo Brigitte Co-authored-by: Jose Armesto Co-authored-by: Taylor Bot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Hervé Nicol --- CHANGELOG.md | 7 +- api/v1alpha1/grafanaorganization_types.go | 5 +- ...ty.giantswarm.io_grafanaorganizations.yaml | 9 +- config/manager/manager.yaml | 31 +-- ...vability_v1alpha1_grafanaorganization.yaml | 6 +- .../templates/rbac.yaml | 1 + .../grafanaorganization_controller.go | 202 +++++++++++++++--- internal/controller/predicates/predicates.go | 21 ++ pkg/grafana/grafana.go | 159 ++++++++++++++ .../grafana-user-values.yaml.template | 4 + pkg/grafana/templating/templating.go | 71 ++++++ pkg/grafana/types.go | 6 + 12 files changed, 455 insertions(+), 67 deletions(-) create mode 100644 internal/controller/predicates/predicates.go create mode 100644 pkg/grafana/grafana.go create mode 100644 pkg/grafana/templating/templates/grafana-user-values.yaml.template create mode 100644 pkg/grafana/templating/templating.go create mode 100644 pkg/grafana/types.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ca97d1c4..8441b1e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Grafana Organization creation logic in reconciler. +- Add creation and update of Grafana organizations. +- Add configuration of the Grafana org_mapping via user-values. + ### Fixed - Disable crd installation from alloy-metrics as this is causing issues with the new v29 releases. @@ -24,7 +30,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add wal `truncate_frequency` configuration to alloy-metrics with a default set to 15m. - Add grafanaOrganization CRD in helm chart. -- Add logic to grafanaOrganization reconciler. ### Changed diff --git a/api/v1alpha1/grafanaorganization_types.go b/api/v1alpha1/grafanaorganization_types.go index 9ccc62ab..d5c3f690 100644 --- a/api/v1alpha1/grafanaorganization_types.go +++ b/api/v1alpha1/grafanaorganization_types.go @@ -62,9 +62,10 @@ type RBAC struct { // GrafanaOrganizationStatus defines the observed state of GrafanaOrganization type GrafanaOrganizationStatus struct { // OrgID is the actual organisation ID in grafana. - OrgID string `json:"orgID"` + OrgID int64 `json:"orgID"` // DataSources is a list of grafana data sources that are available to the Grafana organization. + // +optional DataSources []DataSources `json:"dataSources"` } @@ -74,7 +75,7 @@ type DataSources struct { Name string `json:"name"` // ID is the unique id of the data source. - ID string `json:"id"` + ID int64 `json:"id"` } //+kubebuilder:object:root=true diff --git a/config/crd/observability.giantswarm.io_grafanaorganizations.yaml b/config/crd/observability.giantswarm.io_grafanaorganizations.yaml index c219c7d5..fd9be0c0 100644 --- a/config/crd/observability.giantswarm.io_grafanaorganizations.yaml +++ b/config/crd/observability.giantswarm.io_grafanaorganizations.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.4 + controller-gen.kubebuilder.io/version: v0.16.3 name: grafanaorganizations.observability.giantswarm.io spec: group: observability.giantswarm.io @@ -83,7 +83,8 @@ spec: properties: id: description: ID is the unique id of the data source. - type: string + format: int64 + type: integer name: description: Name is the name of the data source. type: string @@ -94,9 +95,9 @@ spec: type: array orgID: description: OrgID is the actual organisation ID in grafana. - type: string + format: int64 + type: integer required: - - dataSources - orgID type: object type: object diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 7ac0ab6e..04bda02f 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -36,35 +36,10 @@ spec: labels: control-plane: controller-manager spec: - # TODO(user): Uncomment the following code to configure the nodeAffinity expression - # according to the platforms which are supported by your solution. - # It is considered best practice to support multiple architectures. You can - # build your manager image using the makefile target docker-buildx. - # affinity: - # nodeAffinity: - # requiredDuringSchedulingIgnoredDuringExecution: - # nodeSelectorTerms: - # - matchExpressions: - # - key: kubernetes.io/arch - # operator: In - # values: - # - amd64 - # - arm64 - # - ppc64le - # - s390x - # - key: kubernetes.io/os - # operator: In - # values: - # - linux securityContext: runAsNonRoot: true - # TODO(user): For common cases that do not require escalating privileges - # it is recommended to ensure that all your Pods/Containers are restrictive. - # More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted - # Please uncomment the following code if your project does NOT have to work on old Kubernetes - # versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ). - # seccompProfile: - # type: RuntimeDefault + seccompProfile: + type: RuntimeDefault containers: - command: - /manager @@ -89,8 +64,6 @@ spec: port: 8081 initialDelaySeconds: 5 periodSeconds: 10 - # TODO(user): Configure the resources accordingly based on the project requirements. - # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ resources: limits: cpu: 500m diff --git a/config/samples/observability_v1alpha1_grafanaorganization.yaml b/config/samples/observability_v1alpha1_grafanaorganization.yaml index 005ebadc..47afe577 100644 --- a/config/samples/observability_v1alpha1_grafanaorganization.yaml +++ b/config/samples/observability_v1alpha1_grafanaorganization.yaml @@ -18,9 +18,9 @@ spec: - my-awesome-viewers - other-viewers status: - orgID: "2" + orgID: 2 dataSources: - name: "mimir" - id: "1" + id: 1 - name: "loki" - id: "2" + id: 2 diff --git a/helm/observability-operator/templates/rbac.yaml b/helm/observability-operator/templates/rbac.yaml index a8246fbe..f07cc714 100644 --- a/helm/observability-operator/templates/rbac.yaml +++ b/helm/observability-operator/templates/rbac.yaml @@ -9,6 +9,7 @@ rules: - "" resources: - namespaces + - pods verbs: - list - watch diff --git a/internal/controller/grafanaorganization_controller.go b/internal/controller/grafanaorganization_controller.go index b0d2a84c..9a8b2276 100644 --- a/internal/controller/grafanaorganization_controller.go +++ b/internal/controller/grafanaorganization_controller.go @@ -23,17 +23,25 @@ import ( grafanaAPI "github.com/grafana/grafana-openapi-client-go/client" grafanaAPIModels "github.com/grafana/grafana-openapi-client-go/models" "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/cluster-api/util/patch" 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/reconcile" "github.com/giantswarm/observability-operator/api/v1alpha1" + "github.com/giantswarm/observability-operator/internal/controller/predicates" + "github.com/giantswarm/observability-operator/pkg/grafana" + "github.com/giantswarm/observability-operator/pkg/grafana/templating" ) -const sharedOrgName = "Shared Org." - // GrafanaOrganizationReconciler reconciles a GrafanaOrganization object type GrafanaOrganizationReconciler struct { client.Client @@ -62,16 +70,6 @@ func (r *GrafanaOrganizationReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, errors.WithStack(client.IgnoreNotFound(err)) } - // Test connection to Grafana - // TODO(zirko) Remove in the next iteration - _, err = r.GrafanaAPI.Health.GetHealth() - if err != nil { - logger.Error(err, "Failed to connect to Grafana") - return ctrl.Result{}, errors.WithStack(err) - } - - logger.Info("Successfully connected to Grafana, lets start hacking...") - // Handle deleted grafana organizations if !grafanaOrganization.DeletionTimestamp.IsZero() { return ctrl.Result{}, r.reconcileDelete(ctx, grafanaOrganization) @@ -82,30 +80,76 @@ func (r *GrafanaOrganizationReconciler) Reconcile(ctx context.Context, req ctrl. } // reconcileCreate creates the grafanaOrganization. +// reconcileCreate ensures the Grafana organization described in grafanaOrganization CR is created in Grafana. +// This function is also responsible for: +// - Adding the finalizer to the CR +// - Updating the CR status field +// - Renaming the Grafana Main Org. func (r GrafanaOrganizationReconciler) reconcileCreate(ctx context.Context, grafanaOrganization *v1alpha1.GrafanaOrganization) (ctrl.Result, error) { // nolint:unparam logger := log.FromContext(ctx) - originalGrafanaOrganization := grafanaOrganization.DeepCopy() - // If the grafanaOrganization doesn't have our finalizer, add it. - if controllerutil.AddFinalizer(grafanaOrganization, v1alpha1.GrafanaOrganizationFinalizer) { - logger.Info("Add finalizer to Grafana Organization") - // Register the finalizer immediately to avoid orphaning resources on delete - if err := r.Client.Patch(ctx, grafanaOrganization, client.MergeFrom(originalGrafanaOrganization)); err != nil { + // Add finalizer first if not set to avoid the race condition between init and delete. + if !controllerutil.ContainsFinalizer(grafanaOrganization, v1alpha1.GrafanaOrganizationFinalizer) { + // We use a patch rather than an update to avoid conflicts when multiple controllers are adding their finalizer to the grafana organization + // We use the patch from sigs.k8s.io/cluster-api/util/patch to handle the patching without conflicts + logger.Info("adding finalizer", "finalizer", v1alpha1.GrafanaOrganizationFinalizer) + patchHelper, err := patch.NewHelper(grafanaOrganization, r.Client) + if err != nil { return ctrl.Result{}, errors.WithStack(err) } + controllerutil.AddFinalizer(grafanaOrganization, v1alpha1.GrafanaOrganizationFinalizer) + if err := patchHelper.Patch(ctx, grafanaOrganization); err != nil { + logger.Error(err, "failed to add finalizer", "finalizer", v1alpha1.GrafanaOrganizationFinalizer) + return ctrl.Result{}, errors.WithStack(err) + } + logger.Info("added finalizer", "finalizer", v1alpha1.GrafanaOrganizationFinalizer) + return ctrl.Result{}, nil } // Ensure the first organization is renamed. _, err := r.GrafanaAPI.Orgs.UpdateOrg(1, &grafanaAPIModels.UpdateOrgForm{ - Name: sharedOrgName, + Name: grafana.SharedOrgName, }) if err != nil { - logger.Error(err, fmt.Sprintf("Could not rename Main Org. to %s", sharedOrgName)) + logger.Error(err, fmt.Sprintf("failed to rename Main Org. to %s", grafana.SharedOrgName)) return ctrl.Result{}, errors.WithStack(err) } - //TODO Implement the logic to create the Grafana organization + // TODO add datasources for shared org. + + // Create or update organization in Grafana + var organization = grafana.Organization{ + ID: grafanaOrganization.Status.OrgID, + Name: grafanaOrganization.Spec.DisplayName, + } + + if organization.ID == 0 { + // if the CR doesn't have an orgID, create the organization in Grafana + organization, err = grafana.CreateOrganization(ctx, r.GrafanaAPI, organization) + } else { + organization, err = grafana.UpdateOrganization(ctx, r.GrafanaAPI, organization) + } + + if err != nil { + return ctrl.Result{}, err + } + + // Update CR status if anything was changed + if organization.ID != grafanaOrganization.Status.OrgID { + grafanaOrganization.Status.OrgID = organization.ID + + if err = r.Status().Update(ctx, grafanaOrganization); err != nil { + logger.Error(err, "failed to update grafanaOrganization status") + return ctrl.Result{}, errors.WithStack(err) + } + } + + // TODO add datasources for the organization. + err = r.configureGrafana(ctx) + if err != nil { + return ctrl.Result{}, errors.WithStack(err) + } return ctrl.Result{}, nil } @@ -113,18 +157,45 @@ func (r GrafanaOrganizationReconciler) reconcileCreate(ctx context.Context, graf func (r GrafanaOrganizationReconciler) reconcileDelete(ctx context.Context, grafanaOrganization *v1alpha1.GrafanaOrganization) error { logger := log.FromContext(ctx) - //TODO Implement the logic to delete the organization from Grafana. + // We do not need to delete anything if there is no finalizer on the grafana organization + if !controllerutil.ContainsFinalizer(grafanaOrganization, v1alpha1.GrafanaOrganizationFinalizer) { + return nil + } - logger.Info("Remove finalizer from grafana organization") - // Remove the finalizer. - originalGrafanaOrganization := grafanaOrganization.DeepCopy() - if controllerutil.RemoveFinalizer(grafanaOrganization, v1alpha1.GrafanaOrganizationFinalizer) { - err := r.Client.Patch(ctx, grafanaOrganization, client.MergeFrom(originalGrafanaOrganization)) + // Delete organization in Grafana if it exists + if grafanaOrganization.Status.OrgID > 0 { + err := grafana.DeleteByID(ctx, r.GrafanaAPI, grafanaOrganization.Status.OrgID) if err != nil { - return err + return errors.WithStack(err) } + + grafanaOrganization.Status.OrgID = 0 + if err = r.Status().Update(ctx, grafanaOrganization); err != nil { + logger.Error(err, "failed to update grafanaOrganization status") + return errors.WithStack(err) + } + } + + err := r.configureGrafana(ctx) + if err != nil { + return errors.WithStack(err) + } + + // Finalizer handling needs to come last. + // We use the patch from sigs.k8s.io/cluster-api/util/patch to handle the patching without conflicts + logger.Info("removing finalizer", "finalizer", v1alpha1.GrafanaOrganizationFinalizer) + patchHelper, err := patch.NewHelper(grafanaOrganization, r.Client) + if err != nil { + return errors.WithStack(err) } + controllerutil.RemoveFinalizer(grafanaOrganization, v1alpha1.GrafanaOrganizationFinalizer) + if err := patchHelper.Patch(ctx, grafanaOrganization); err != nil { + logger.Error(err, "failed to remove finalizer, requeuing", "finalizer", v1alpha1.GrafanaOrganizationFinalizer) + return errors.WithStack(err) + } + logger.Info("removed finalizer", "finalizer", v1alpha1.GrafanaOrganizationFinalizer) + return nil } @@ -132,5 +203,80 @@ func (r GrafanaOrganizationReconciler) reconcileDelete(ctx context.Context, graf func (r *GrafanaOrganizationReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.GrafanaOrganization{}). + // Watch for grafana pod's status changes + Watches( + &v1.Pod{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + k8sClient := mgr.GetClient() + var organizations v1alpha1.GrafanaOrganizationList + + err := k8sClient.List(ctx, &organizations) + if err != nil { + log.FromContext(ctx).Error(err, "failed to list grafana organization CRs") + return []reconcile.Request{} + } + + // Reconcile all grafana organizations when the grafana pod is recreated + requests := make([]reconcile.Request, 0, len(organizations.Items)) + for _, organization := range organizations.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: organization.Name, + }, + }) + } + return requests + }), + builder.WithPredicates(predicates.GrafanaPodRecreatedPredicate{}), + ). Complete(r) } + +// configureGrafana ensures the RBAC configuration is set in Grafana. +func (r *GrafanaOrganizationReconciler) configureGrafana(ctx context.Context) error { + logger := log.FromContext(ctx) + + organizations := v1alpha1.GrafanaOrganizationList{} + err := r.Client.List(ctx, &organizations) + if err != nil { + logger.Error(err, "failed to list grafana organizations.") + return errors.WithStack(err) + } + + grafanaConfig := v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "grafana-user-values", + Namespace: "giantswarm", + }, + } + + _, err = controllerutil.CreateOrPatch(ctx, r.Client, &grafanaConfig, func() error { + config, err := templating.GenerateGrafanaConfiguration(organizations.Items) + if err != nil { + logger.Error(err, "failed to generate grafana user configmap values.") + return errors.WithStack(err) + } + + for _, organization := range organizations.Items { + // Set owner reference to the config map to be able to clean it up when all organizations are deleted + err = controllerutil.SetOwnerReference(&organization, &grafanaConfig, r.Scheme) + if err != nil { + return errors.WithStack(err) + } + } + + logger.Info("updating grafana-user-values", "config", config) + + grafanaConfig.Data = make(map[string]string) + grafanaConfig.Data["values"] = config + + return nil + }) + + if err != nil { + logger.Error(err, "failed to configure grafana.") + return errors.WithStack(err) + } + + return nil +} diff --git a/internal/controller/predicates/predicates.go b/internal/controller/predicates/predicates.go new file mode 100644 index 00000000..85dedba9 --- /dev/null +++ b/internal/controller/predicates/predicates.go @@ -0,0 +1,21 @@ +package predicates + +import ( + "strings" + + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// GrafanaPodRecreatedPredicate implements a default predicate function for grafana's pod deleted events. +type GrafanaPodRecreatedPredicate struct { + predicate.Funcs +} + +func (GrafanaPodRecreatedPredicate) Delete(e event.DeleteEvent) bool { + if e.Object != nil && strings.Contains(e.Object.GetName(), "grafana") && e.Object.GetNamespace() == "monitoring" { + return true + } + + return false +} diff --git a/pkg/grafana/grafana.go b/pkg/grafana/grafana.go new file mode 100644 index 00000000..c51229ed --- /dev/null +++ b/pkg/grafana/grafana.go @@ -0,0 +1,159 @@ +package grafana + +import ( + "context" + _ "embed" + "fmt" + "strings" + + "github.com/grafana/grafana-openapi-client-go/client" + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/pkg/errors" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + SharedOrgName = "Shared Org" +) + +func CreateOrganization(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, organization Organization) (Organization, error) { + logger := log.FromContext(ctx) + + logger.Info("creating organization") + err := assertNameIsAvailable(ctx, grafanaAPI, organization) + if err != nil { + return organization, errors.WithStack(err) + } + + createdOrg, err := grafanaAPI.Orgs.CreateOrg(&models.CreateOrgCommand{ + Name: organization.Name, + }) + if err != nil { + logger.Error(err, "failed to create organization") + return organization, errors.WithStack(err) + } + logger.Info("created organization") + + return Organization{ + ID: *createdOrg.Payload.OrgID, + Name: organization.Name, + }, nil +} + +func UpdateOrganization(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, organization Organization) (Organization, error) { + logger := log.FromContext(ctx) + + logger.Info("updating organization") + found, err := findByID(grafanaAPI, organization.ID) + if err != nil { + if isNotFound(err) { + logger.Info("organization id not found, creating") + // If the CR orgID does not exist in Grafana, then we create the organization + return CreateOrganization(ctx, grafanaAPI, organization) + } + logger.Error(err, fmt.Sprintf("failed to find organization with ID: %d", organization.ID)) + return organization, errors.WithStack(err) + } + + // If both name matches, there is nothing to do. + if found.Name == organization.Name { + logger.Info("the organization already exists in Grafana and does not need to be updated.") + return organization, nil + } + + err = assertNameIsAvailable(ctx, grafanaAPI, organization) + if err != nil { + return organization, errors.WithStack(err) + } + + // if the name of the CR is different from the name of the org in Grafana, update the name of the org in Grafana using the CR's display name. + _, err = grafanaAPI.Orgs.UpdateOrg(organization.ID, &models.UpdateOrgForm{ + Name: organization.Name, + }) + if err != nil { + logger.Error(err, "failed to update organization name") + return organization, errors.WithStack(err) + } + + logger.Info("updated organization") + + return Organization{ + ID: organization.ID, + Name: organization.Name, + }, nil +} + +func DeleteByID(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, id int64) error { + logger := log.FromContext(ctx) + + logger.Info("deleting organization") + _, err := findByID(grafanaAPI, id) + if err != nil { + logger.Error(err, fmt.Sprintf("failed to find organization with ID: %d", id)) + } + + _, err = grafanaAPI.Orgs.DeleteOrgByID(id) + if err != nil { + logger.Error(err, "failed to delete organization") + return errors.WithStack(err) + } + logger.Info("deleted organization") + + return nil +} + +func isNotFound(err error) bool { + if err == nil { + return false + } + + // Parsing error message to find out the error code + return strings.Contains(err.Error(), "(status 404)") +} + +// assertNameIsAvailable is a helper function to check if the organization name is available in Grafana +func assertNameIsAvailable(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, organization Organization) error { + logger := log.FromContext(ctx) + + found, err := findByName(grafanaAPI, organization.Name) + if err != nil { + // We only error if we have any error other than a 404 + if !isNotFound(err) { + logger.Error(err, fmt.Sprintf("failed to find organization with name: %s", organization.Name)) + return errors.WithStack(err) + } + + if found != nil { + logger.Error(err, "a grafana organization with the same name already exists. Please choose a different display name.") + return errors.WithStack(err) + } + } + + return nil +} + +// findByName is a wrapper function used to find a Grafana organization by its name +func findByName(grafanaAPI *client.GrafanaHTTPAPI, name string) (*Organization, error) { + organization, err := grafanaAPI.Orgs.GetOrgByName(name) + if err != nil { + return nil, errors.WithStack(err) + } + + return &Organization{ + ID: organization.Payload.ID, + Name: organization.Payload.Name, + }, nil +} + +// findByID is a wrapper function used to find a Grafana organization by its id +func findByID(grafanaAPI *client.GrafanaHTTPAPI, orgID int64) (*Organization, error) { + organization, err := grafanaAPI.Orgs.GetOrgByID(orgID) + if err != nil { + return nil, errors.WithStack(err) + } + + return &Organization{ + ID: organization.Payload.ID, + Name: organization.Payload.Name, + }, nil +} diff --git a/pkg/grafana/templating/templates/grafana-user-values.yaml.template b/pkg/grafana/templating/templates/grafana-user-values.yaml.template new file mode 100644 index 00000000..adfd272d --- /dev/null +++ b/pkg/grafana/templating/templates/grafana-user-values.yaml.template @@ -0,0 +1,4 @@ +grafana: + grafana.ini: + auth.generic_oauth: + org_mapping: '{{ .OrgMapping }}' diff --git a/pkg/grafana/templating/templating.go b/pkg/grafana/templating/templating.go new file mode 100644 index 00000000..b7899740 --- /dev/null +++ b/pkg/grafana/templating/templating.go @@ -0,0 +1,71 @@ +package templating + +import ( + "bytes" + _ "embed" + "fmt" + "html/template" + "strings" + + "github.com/pkg/errors" + + "github.com/giantswarm/observability-operator/api/v1alpha1" + "github.com/giantswarm/observability-operator/pkg/grafana" +) + +const ( + grafanaAdminRole = "Admin" + grafanaEditorRole = "Editor" + grafanaViewerRole = "Viewer" +) + +var ( + //go:embed templates/grafana-user-values.yaml.template + grafanaUserConfig string + grafanaUserConfigTemplate *template.Template +) + +func init() { + grafanaUserConfigTemplate = template.Must(template.New("grafana-user-values.yaml").Parse(grafanaUserConfig)) +} + +func GenerateGrafanaConfiguration(organizations []v1alpha1.GrafanaOrganization) (string, error) { + var orgMappings []string + orgMappings = append(orgMappings, fmt.Sprintf(`"*:%s:%s"`, grafana.SharedOrgName, grafanaAdminRole)) + for _, organization := range organizations { + rbac := organization.Spec.RBAC + organizationName := organization.Spec.DisplayName + for _, adminOrgAttribute := range rbac.Admins { + orgMappings = append(orgMappings, buildOrgMapping(organizationName, adminOrgAttribute, grafanaAdminRole)) + } + for _, editorOrgAttribute := range rbac.Editors { + orgMappings = append(orgMappings, buildOrgMapping(organizationName, editorOrgAttribute, grafanaEditorRole)) + } + for _, viewerOrgAttribute := range rbac.Viewers { + orgMappings = append(orgMappings, buildOrgMapping(organizationName, viewerOrgAttribute, grafanaViewerRole)) + } + } + + orgMapping := strings.Join(orgMappings, " ") + + data := struct { + OrgMapping string + }{ + OrgMapping: orgMapping, + } + + var values bytes.Buffer + err := grafanaUserConfigTemplate.Execute(&values, data) + if err != nil { + return "", errors.WithStack(err) + } + + return values.String(), nil +} + +func buildOrgMapping(organizationName, userOrgAttribute, role string) string { + // We need to escape the colon in the userOrgAttribute + u := strings.ReplaceAll(userOrgAttribute, ":", "\\:") + // We add double quotes to the org mapping to support spaces in display names + return fmt.Sprintf(`"%s:%s:%s"`, u, organizationName, role) +} diff --git a/pkg/grafana/types.go b/pkg/grafana/types.go new file mode 100644 index 00000000..503aedf7 --- /dev/null +++ b/pkg/grafana/types.go @@ -0,0 +1,6 @@ +package grafana + +type Organization struct { + ID int64 + Name string +}