diff --git a/CHANGELOG.md b/CHANGELOG.md index 452e4072..8992df2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - command line args to configure mimir and grafana URLs +- Support for loading dashboards in organizations ## [0.10.2] - 2024-12-17 diff --git a/README.md b/README.md index 9ddf1460..3c43b9eb 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,18 @@ This operator is in charge of handling the setup and configuration of the Giant It reconciles `cluster.cluster.x-k8s.io` objects and makes sure each `Cluster` is provided with: - TODO(atlas) update this section +## Features + +### Grafana dashboards provisioning + +It will look for kubernetes `ConfigMaps` and use them as dashboards if they meet these criteria: +- a label `app.giantswarm.io/kind: "dashboard"` +- an annotation or label `giantswarm.io/organization` set to the organization the dasboard should be loaded in. + +Current limitations: +- no support for folders +- each dashboard belongs to one and only one organization + ## Getting started Get the code and build it via: diff --git a/internal/controller/dashboard_controller.go b/internal/controller/dashboard_controller.go new file mode 100644 index 00000000..b3f43d79 --- /dev/null +++ b/internal/controller/dashboard_controller.go @@ -0,0 +1,345 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + + grafanaAPI "github.com/grafana/grafana-openapi-client-go/client" + "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/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/giantswarm/observability-operator/pkg/common/organization" + "github.com/giantswarm/observability-operator/pkg/config" + "github.com/giantswarm/observability-operator/pkg/grafana" + grafanaclient "github.com/giantswarm/observability-operator/pkg/grafana/client" + + "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" + DashboardSelectorLabelName = "app.giantswarm.io/kind" + DashboardSelectorLabelValue = "dashboard" +) + +func SetupDashboardReconciler(mgr manager.Manager, conf config.Config) error { + // Generate Grafana client + // Get grafana admin-password and admin-user + grafanaAdminCredentials := grafanaclient.AdminCredentials{ + Username: conf.Environment.GrafanaAdminUsername, + Password: conf.Environment.GrafanaAdminPassword, + } + if grafanaAdminCredentials.Username == "" { + return fmt.Errorf("GrafanaAdminUsername not set: %q", conf.Environment.GrafanaAdminUsername) + } + if grafanaAdminCredentials.Password == "" { + return fmt.Errorf("GrafanaAdminPassword not set: %q", conf.Environment.GrafanaAdminPassword) + } + + grafanaTLSConfig := grafanaclient.TLSConfig{ + Cert: conf.Environment.GrafanaTLSCertFile, + Key: conf.Environment.GrafanaTLSKeyFile, + } + grafanaAPI, err := grafanaclient.GenerateGrafanaClient(conf.GrafanaURL, grafanaAdminCredentials, grafanaTLSConfig) + if err != nil { + return fmt.Errorf("unable to create grafana client: %w", err) + } + + r := &DashboardReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + GrafanaAPI: grafanaAPI, + } + + err = r.SetupWithManager(mgr) + if err != nil { + return err + } + + return nil +} + +//+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{DashboardSelectorLabelName: DashboardSelectorLabelValue}}) + if err != nil { + return errors.WithStack(err) + } + + return ctrl.NewControllerManagedBy(mgr). + Named("dashboards"). + 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, + Namespace: dashboard.Namespace, + }, + }) + } + 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(dashboard map[string]interface{}) (string, error) { + UID, ok := dashboard["uid"].(string) + if !ok { + return "", errors.New("dashboard UID not found in configmap") + } + return UID, nil +} + +func getDashboardCMOrg(dashboard *v1.ConfigMap) (string, error) { + // Try to look for an annotation first + annotations := dashboard.GetAnnotations() + if annotations != nil && annotations[organization.OrganizationLabel] != "" { + return annotations[organization.OrganizationLabel], nil + } + + // Then look for a label + labels := dashboard.GetLabels() + if labels != nil && labels[organization.OrganizationLabel] != "" { + return labels[organization.OrganizationLabel], nil + } + + // Return an error if no label was found + return "", errors.New("No organization label found in configmap") +} + +func (r DashboardReconciler) configureDashboard(ctx context.Context, dashboardCM *v1.ConfigMap) 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") + } + }() + + // Switch context to the dashboards-defined org + organization, err := grafana.FindOrgByName(r.GrafanaAPI, dashboardOrg) + if err != nil { + logger.Error(err, "failed to find organization", "organization", dashboardOrg) + 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) + } + + for _, dashboardString := range dashboardCM.Data { + var dashboard map[string]any + err = json.Unmarshal([]byte(dashboardString), &dashboard) + if err != nil { + logger.Info("Failed converting dashboard to json", "Error", err) + continue + } + + dashboardUID, err := getDashboardUID(dashboard) + if err != nil { + logger.Info("Skipping dashboard, no UID found") + continue + } + + // Create or update dashboard + err = grafana.SetDashboard(r.GrafanaAPI, dashboard) + if err != nil { + logger.Info("Failed updating dashboard", "Error", err) + continue + } + + logger.Info("updated dashboard", "Dashboard UID", dashboardUID, "Dashboard Org", dashboardOrg) + } + + return nil +} + +// 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 + } + + // 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") + } + }() + + // Switch context to the dashboards-defined org + organization, err := grafana.FindOrgByName(r.GrafanaAPI, dashboardOrg) + if err != nil { + logger.Error(err, "failed to find organization", "organization", dashboardOrg) + 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) + } + + for _, dashboardString := range dashboardCM.Data { + var dashboard map[string]interface{} + err = json.Unmarshal([]byte(dashboardString), &dashboard) + if err != nil { + logger.Info("Failed converting dashboard to json", "Error", err) + continue + } + + dashboardUID, err := getDashboardUID(dashboard) + if err != nil { + logger.Info("Skipping dashboard, no UID found") + continue + } + + // TODO: search for dashboard by ID + _, err = r.GrafanaAPI.Dashboards.GetDashboardByUID(dashboardUID) + if err != nil { + logger.Info("Failed getting dashboard", "Error", err) + continue + } + + // TODO: delete dashboard if it exits + _, err = r.GrafanaAPI.Dashboards.DeleteDashboardByUID(dashboardUID) + if err != nil { + logger.Info("Failed deleting dashboard", "Error", err) + continue + } + + 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..e15b1839 --- /dev/null +++ b/internal/controller/dashboard_controller_test.go @@ -0,0 +1,14 @@ +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 a7dc8d15..d93baaba 100644 --- a/main.go +++ b/main.go @@ -199,6 +199,13 @@ func main() { } //+kubebuilder:scaffold:builder + err = controller.SetupDashboardReconciler(mgr, conf) + if err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Dashboard") + os.Exit(1) + } + //+kubebuilder:scaffold:builder + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) diff --git a/pkg/common/organization/repository.go b/pkg/common/organization/repository.go index 5057cf9f..91d8599c 100644 --- a/pkg/common/organization/repository.go +++ b/pkg/common/organization/repository.go @@ -10,7 +10,7 @@ import ( ) const ( - organizationLabel string = "giantswarm.io/organization" + OrganizationLabel string = "giantswarm.io/organization" ) type OrganizationRepository interface { @@ -32,7 +32,7 @@ func (r NamespaceOrganizationRepository) Read(ctx context.Context, cluster *clus return "", err } - if organization, ok := namespace.Labels[organizationLabel]; ok { + if organization, ok := namespace.Labels[OrganizationLabel]; ok { return organization, nil } return "", errors.New("cluster namespace missing organization label") diff --git a/pkg/grafana/grafana.go b/pkg/grafana/grafana.go index c1a39213..a0a63318 100644 --- a/pkg/grafana/grafana.go +++ b/pkg/grafana/grafana.go @@ -95,7 +95,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") @@ -135,7 +135,7 @@ func DeleteOrganization(ctx context.Context, grafanaAPI *client.GrafanaHTTPAPI, logger := log.FromContext(ctx) logger.Info("deleting organization") - _, err := findByID(grafanaAPI, organization.ID) + _, err := FindOrgByID(grafanaAPI, organization.ID) if err != nil { if isNotFound(err) { logger.Info("organization id was not found, skipping deletion") @@ -282,7 +282,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) { @@ -299,8 +299,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) @@ -312,8 +312,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) @@ -324,3 +324,14 @@ func findByID(grafanaAPI *client.GrafanaHTTPAPI, orgID int64) (*Organization, er Name: organization.Payload.Name, }, nil } + +// SetDashboard creates or updates a dashboard in Grafana +func SetDashboard(grafanaAPI *client.GrafanaHTTPAPI, dashboard map[string]any) error { + _, err := grafanaAPI.Dashboards.PostDashboard(&models.SaveDashboardCommand{ + Dashboard: any(dashboard), + Message: "Added by observability-operator", + Overwrite: true, // allows dashboard to be updated by the same UID + + }) + return err +}