From beae65dd38cefc13bcc7ab967f059fd81fed141f Mon Sep 17 00:00:00 2001 From: Herve Nicol <12008875+hervenicol@users.noreply.github.com> Date: Thu, 28 Nov 2024 15:29:06 +0100 Subject: [PATCH] Add support for loading dashboards in orgs --- CHANGELOG.md | 4 + go.mod | 2 +- internal/controller/dashboard_controller.go | 288 ++++++++++++++++++ .../controller/dashboard_controller_test.go | 32 ++ main.go | 9 + pkg/grafana/grafana.go | 14 +- 6 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 internal/controller/dashboard_controller.go create mode 100644 internal/controller/dashboard_controller_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c0eb9767..2bf20962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support for loading dashboards in organizations + ## [0.9.1] - 2024-11-21 ### Fixed diff --git a/go.mod b/go.mod index 047e2066..6e017548 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/blang/semver v3.5.1+incompatible github.com/giantswarm/apiextensions-application v0.6.2 github.com/go-logr/logr v1.4.2 + github.com/go-openapi/strfmt v0.23.0 github.com/grafana/grafana-openapi-client-go v0.0.0-20241126111151-59d2d35e24eb github.com/onsi/ginkgo/v2 v2.22.0 github.com/onsi/gomega v1.36.0 @@ -96,7 +97,6 @@ require ( github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/runtime v0.28.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/klauspost/compress v1.17.9 // indirect diff --git a/internal/controller/dashboard_controller.go b/internal/controller/dashboard_controller.go new file mode 100644 index 00000000..c2b8dec3 --- /dev/null +++ b/internal/controller/dashboard_controller.go @@ -0,0 +1,288 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "encoding/json" + "time" + + "github.com/go-openapi/strfmt" + grafanaAPI "github.com/grafana/grafana-openapi-client-go/client" + "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/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/giantswarm/observability-operator/pkg/grafana" + + "github.com/giantswarm/observability-operator/internal/controller/predicates" +) + +// DashboardReconciler reconciles a Dashboard object +type DashboardReconciler struct { + client.Client + Scheme *runtime.Scheme + GrafanaAPI *grafanaAPI.GrafanaHTTPAPI +} + +const ( + DashboardFinalizer = "observability.giantswarm.io/grafanadashboard" +) + +//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=configmaps/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=core,resources=configmaps/finalizers,verbs=update + +// Reconcile is part of the main Kubernetes reconciliation loop which aims to +// move the current state of the Dashboard closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.0/pkg/reconcile +func (r *DashboardReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + logger.Info("Started reconciling Grafana Dashboard Configmaps") + defer logger.Info("Finished reconciling Grafana Dashboard Configmaps") + + dashboard := &v1.ConfigMap{} + err := r.Client.Get(ctx, req.NamespacedName, dashboard) + if err != nil { + return ctrl.Result{}, errors.WithStack(client.IgnoreNotFound(err)) + } + + // Handle deleted grafana dashboards + if !dashboard.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.reconcileDelete(ctx, dashboard) + } + + // Handle non-deleted grafana dashboards + return r.reconcileCreate(ctx, dashboard) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *DashboardReconciler) SetupWithManager(mgr ctrl.Manager) error { + + labelSelectorPredicate, err := predicate.LabelSelectorPredicate(metav1.LabelSelector{MatchLabels: map[string]string{"app.giantswarm.io/kind": "dashboard"}}) + if err != nil { + return errors.WithStack(err) + } + + return ctrl.NewControllerManagedBy(mgr). + For(&v1.ConfigMap{}, builder.WithPredicates(labelSelectorPredicate)). + // Watch for grafana pod's status changes + Watches( + &v1.Pod{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + var logger = log.FromContext(ctx) + var dashboards v1.ConfigMapList + + err := mgr.GetClient().List(ctx, &dashboards, client.MatchingLabels{"app.giantswarm.io/kind": "dashboard"}) + if err != nil { + logger.Error(err, "failed to list grafana dashboard configmaps") + return []reconcile.Request{} + } + + // Reconcile all grafana dashboards when the grafana pod is recreated + requests := make([]reconcile.Request, 0, len(dashboards.Items)) + for _, dashboard := range dashboards.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: dashboard.Name, + }, + }) + } + return requests + }), + builder.WithPredicates(predicates.GrafanaPodRecreatedPredicate{}), + ). + Complete(r) +} + +// reconcileCreate creates the dashboard. +// reconcileCreate ensures the Grafana dashboard described in configmap is created in Grafana. +// This function is also responsible for: +// - Adding the finalizer to the configmap +func (r DashboardReconciler) reconcileCreate(ctx context.Context, dashboard *v1.ConfigMap) (ctrl.Result, error) { // nolint:unparam + logger := log.FromContext(ctx) + + // Add finalizer first if not set to avoid the race condition between init and delete. + if !controllerutil.ContainsFinalizer(dashboard, DashboardFinalizer) { + // We use a patch rather than an update to avoid conflicts when multiple controllers are adding their finalizer to the grafana dashboard + // We use the patch from sigs.k8s.io/cluster-api/util/patch to handle the patching without conflicts + logger.Info("adding finalizer", "finalizer", DashboardFinalizer) + patchHelper, err := patch.NewHelper(dashboard, r.Client) + if err != nil { + return ctrl.Result{}, errors.WithStack(err) + } + controllerutil.AddFinalizer(dashboard, DashboardFinalizer) + if err := patchHelper.Patch(ctx, dashboard); err != nil { + logger.Error(err, "failed to add finalizer", "finalizer", DashboardFinalizer) + return ctrl.Result{}, errors.WithStack(err) + } + logger.Info("added finalizer", "finalizer", DashboardFinalizer) + return ctrl.Result{}, nil + } + + // Configure the dashboard in Grafana + if err := r.configureDashboard(ctx, dashboard); err != nil { + return ctrl.Result{}, errors.WithStack(err) + } + + return ctrl.Result{}, nil +} + +func getDashboardUID(jsonDashboard string) (UID string, err error) { + + var Dashboard map[string]interface{} + err = json.Unmarshal([]byte(jsonDashboard), &Dashboard) + if err != nil { + return "", err + } + + UID, ok := Dashboard["uid"].(string) + if !ok { + return "", errors.New("dashboard UID not found in configmap") + } + return UID, nil +} + +func getDashboardCMOrg(dashboard *v1.ConfigMap) (Org string, err error) { + + orgLabel := dashboard.GetLabels()["giantswarm.io/organization"] + + if orgLabel == "" { + return "", errors.New("No organization label found in configmap") + } + + return orgLabel, nil +} + +func (r DashboardReconciler) configureDashboard(ctx context.Context, dashboardCM *v1.ConfigMap) (err error) { + logger := log.FromContext(ctx) + + dashboardOrg, err := getDashboardCMOrg(dashboardCM) + if err != nil { + logger.Info("Skipping dashboard, no organization found") + return nil + } + + // We always switch back to the shared org + defer func() { + if _, err = r.GrafanaAPI.SignedInUser.UserSetUsingOrg(grafana.SharedOrg.ID); err != nil { + logger.Error(err, "failed to change current org for signed in user") + } + }() + + for _, dashboard := range dashboardCM.Data { + dashboardUID, err := getDashboardUID(dashboard) + if err != nil { + logger.Info("Skipping dashboard, no UID found") + continue + } + // Switch context to the current org + organization, err := grafana.FindOrgByName(r.GrafanaAPI, dashboardOrg) + if err != nil { + // TODO: FindOrgByName looks broken, I never managed to have it find the org + logger.Error(err, "failed to find organization", "organization", dashboardOrg) + organization = &grafana.Organization{ID: 1} + // return errors.WithStack(err) + } + + if _, err = r.GrafanaAPI.SignedInUser.UserSetUsingOrg(organization.ID); err != nil { + logger.Error(err, "failed to change current org for signed in user") + return errors.WithStack(err) + } + + // Create or update dashboard + _, err = r.GrafanaAPI.Dashboards.PostDashboard(&models.SaveDashboardCommand{ + UpdatedAt: strfmt.DateTime(time.Now()), + Dashboard: dashboard, + FolderID: 0, + FolderUID: "", + IsFolder: false, + Message: "Added by observability-operator", + Overwrite: true, + UserID: 0, + }) + if err != nil { + logger.Info("Failed updating dashboard") + return errors.WithStack(err) + } + + logger.Info("updated dashboard", "Dashboard UID", dashboardUID, "Dashboard Org", dashboardOrg) + } + + // return nil + return errors.New("All good, but not implemented yet") +} + +// reconcileDelete deletes the grafana dashboard. +func (r DashboardReconciler) reconcileDelete(ctx context.Context, dashboardCM *v1.ConfigMap) error { + logger := log.FromContext(ctx) + + // We do not need to delete anything if there is no finalizer on the grafana dashboard + if !controllerutil.ContainsFinalizer(dashboardCM, DashboardFinalizer) { + return nil + } + + dashboardOrg, err := getDashboardCMOrg(dashboardCM) + if err != nil { + logger.Info("Skipping dashboard, no organization found") + return nil + } + + for _, dashboard := range dashboardCM.Data { + dashboardUID, err := getDashboardUID(dashboard) + if err != nil { + logger.Info("Skipping dashboard, no UID found") + continue + } + + // TODO: search for dashboard by ID + // TODO: delete dashboard if it exits + logger.Info("deleted dashboard", "Dashboard UID", dashboardUID, "Dashboard Org", dashboardOrg) + } + + // 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", DashboardFinalizer) + patchHelper, err := patch.NewHelper(dashboardCM, r.Client) + if err != nil { + return errors.WithStack(err) + } + + controllerutil.RemoveFinalizer(dashboardCM, DashboardFinalizer) + if err := patchHelper.Patch(ctx, dashboardCM); err != nil { + logger.Error(err, "failed to remove finalizer, requeuing", "finalizer", DashboardFinalizer) + return errors.WithStack(err) + } + logger.Info("removed finalizer", "finalizer", DashboardFinalizer) + + return nil +} diff --git a/internal/controller/dashboard_controller_test.go b/internal/controller/dashboard_controller_test.go new file mode 100644 index 00000000..d86dcce6 --- /dev/null +++ b/internal/controller/dashboard_controller_test.go @@ -0,0 +1,32 @@ +/* +Copyright 2024. + +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 controller + +import ( + . "github.com/onsi/ginkgo/v2" +) + +var _ = Describe("Dashboard Controller", func() { + Context("When reconciling a resource", func() { + + It("should successfully reconcile the resource", func() { + + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) diff --git a/main.go b/main.go index d89f1a6c..4dc69268 100644 --- a/main.go +++ b/main.go @@ -288,6 +288,15 @@ func main() { os.Exit(1) } + if err = (&controller.DashboardReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + GrafanaAPI: grafanaAPI, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Dashboard") + os.Exit(1) + } + if err = (&controller.GrafanaOrganizationReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/pkg/grafana/grafana.go b/pkg/grafana/grafana.go index 08dc61e9..5845bae3 100644 --- a/pkg/grafana/grafana.go +++ b/pkg/grafana/grafana.go @@ -84,7 +84,7 @@ func UpdateOrganization(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, logger := log.FromContext(ctx) logger.Info("updating organization") - found, err := findByID(grafanaAPI, organization.ID) + found, err := FindOrgByID(grafanaAPI, organization.ID) if err != nil { if isNotFound(err) { logger.Info("organization id not found, creating") @@ -124,7 +124,7 @@ func DeleteByID(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, id int64 logger := log.FromContext(ctx) logger.Info("deleting organization") - _, err := findByID(grafanaAPI, id) + _, err := FindOrgByID(grafanaAPI, id) if err != nil { logger.Error(err, fmt.Sprintf("failed to find organization with ID: %d", id)) } @@ -264,7 +264,7 @@ func isNotFound(err error) bool { func assertNameIsAvailable(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, organization Organization) error { logger := log.FromContext(ctx) - found, err := findByName(grafanaAPI, organization.Name) + found, err := FindOrgByName(grafanaAPI, organization.Name) if err != nil { // We only error if we have any error other than a 404 if !isNotFound(err) { @@ -281,8 +281,8 @@ func assertNameIsAvailable(ctx context.Context, grafanaAPI *client.GrafanaHTTPAP 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) { +// FindOrgByName is a wrapper function used to find a Grafana organization by its name +func FindOrgByName(grafanaAPI *client.GrafanaHTTPAPI, name string) (*Organization, error) { organization, err := grafanaAPI.Orgs.GetOrgByName(name) if err != nil { return nil, errors.WithStack(err) @@ -294,8 +294,8 @@ func findByName(grafanaAPI *client.GrafanaHTTPAPI, name string) (*Organization, }, nil } -// findByID is a wrapper function used to find a Grafana organization by its id -func findByID(grafanaAPI *client.GrafanaHTTPAPI, orgID int64) (*Organization, error) { +// FindOrgByID is a wrapper function used to find a Grafana organization by its id +func FindOrgByID(grafanaAPI *client.GrafanaHTTPAPI, orgID int64) (*Organization, error) { organization, err := grafanaAPI.Orgs.GetOrgByID(orgID) if err != nil { return nil, errors.WithStack(err)