Skip to content

Commit

Permalink
Add support for loading dashboards in orgs (#173)
Browse files Browse the repository at this point in the history
* Add support for loading dashboards in orgs

---------

Co-authored-by: Herve Nicol <[email protected]>
Co-authored-by: Quentin Bisson <[email protected]>
  • Loading branch information
3 people authored Jan 9, 2025
1 parent c9b69ff commit dc15d3d
Show file tree
Hide file tree
Showing 9 changed files with 402 additions and 30 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `observability.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:
Expand Down
326 changes: 326 additions & 0 deletions internal/controller/dashboard_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
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/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"
grafanaOrganizationLabel = "observability.giantswarm.io/organization"
)

func SetupDashboardReconciler(mgr manager.Manager, conf config.Config) error {
grafanaAPI, err := grafanaclient.GenerateGrafanaClient(conf.GrafanaURL, conf)
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/[email protected]/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 getOrgFromDashboardConfigmap(dashboard *v1.ConfigMap) (string, error) {
// Try to look for an annotation first
annotations := dashboard.GetAnnotations()
if annotations != nil && annotations[grafanaOrganizationLabel] != "" {
return annotations[grafanaOrganizationLabel], nil
}

// Then look for a label
labels := dashboard.GetLabels()
if labels != nil && labels[grafanaOrganizationLabel] != "" {
return labels[grafanaOrganizationLabel], 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 := getOrgFromDashboardConfigmap(dashboardCM)
if err != nil {
logger.Error(err, "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.Error(err, "Failed converting dashboard to json")
continue
}

dashboardUID, err := getDashboardUID(dashboard)
if err != nil {
logger.Error(err, "Skipping dashboard, no UID found")
continue
}

// Create or update dashboard
err = grafana.PublishDashboard(r.GrafanaAPI, dashboard)
if err != nil {
logger.Error(err, "Failed updating dashboard")
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 := getOrgFromDashboardConfigmap(dashboardCM)
if err != nil {
logger.Error(err, "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.Error(err, "Failed converting dashboard to json")
continue
}

dashboardUID, err := getDashboardUID(dashboard)
if err != nil {
logger.Error(err, "Skipping dashboard, no UID found")
continue
}

_, err = r.GrafanaAPI.Dashboards.GetDashboardByUID(dashboardUID)
if err != nil {
logger.Error(err, "Failed getting dashboard")
continue
}

_, err = r.GrafanaAPI.Dashboards.DeleteDashboardByUID(dashboardUID)
if err != nil {
logger.Error(err, "Failed deleting dashboard")
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
}
14 changes: 14 additions & 0 deletions internal/controller/dashboard_controller_test.go
Original file line number Diff line number Diff line change
@@ -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.
})
})
})
20 changes: 1 addition & 19 deletions internal/controller/grafanaorganization_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,25 +39,7 @@ type GrafanaOrganizationReconciler struct {
}

func SetupGrafanaOrganizationReconciler(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)
grafanaAPI, err := grafanaclient.GenerateGrafanaClient(conf.GrafanaURL, conf)
if err != nil {
return fmt.Errorf("unable to create grafana client: %w", err)
}
Expand Down
Loading

0 comments on commit dc15d3d

Please sign in to comment.