diff --git a/api/integreatly/v1alpha1/grafanafolder_types.go b/api/integreatly/v1alpha1/grafanafolder_types.go new file mode 100644 index 000000000..2e07e70b8 --- /dev/null +++ b/api/integreatly/v1alpha1/grafanafolder_types.go @@ -0,0 +1,94 @@ +/* +Copyright 2021. + +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 v1alpha1 + +import ( + "crypto/sha256" + "fmt" + "io" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type GrafanaPermissionItem struct { + PermissionTargetType string `json:"permissionTargetType"` + PermissionTarget string `json:"permissionTarget"` + PermissionLevel int `json:"permissionLevel"` +} + +type GrafanaFolderSpec struct { + // FolderName is the display-name of the folder and must match CustomFolderName of any GrafanaDashboard you want to put in + FolderName string `json:"title"` + + // FolderPermissions shall contain the _complete_ permissions for the folder. + // Any permission not listed here, will be removed from the folder. + FolderPermissions []GrafanaPermissionItem `json:"permissions,omitempty"` +} + +// GrafanaFolder is the Schema for the grafana folders and folderpermissions API +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +type GrafanaFolder struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec GrafanaFolderSpec `json:"spec,omitempty"` +} + +// GrafanaFolderList contains a list of GrafanaFolder +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +type GrafanaFolderList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []GrafanaFolder `json:"items"` +} + +// GrafanaFolderRef is used to keep a folder reference without having access to the folder-struct itself +type GrafanaFolderRef struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Hash string `json:"hash"` +} + +func init() { + SchemeBuilder.Register(&GrafanaFolder{}, &GrafanaFolderList{}) +} + +func (f *GrafanaFolder) Hash() string { + hash := sha256.New() + + io.WriteString(hash, f.Spec.FolderName) // nolint + io.WriteString(hash, f.Namespace) // nolint + + for _, p := range f.Spec.FolderPermissions { + io.WriteString(hash, p.PermissionTarget) // nolint + io.WriteString(hash, p.PermissionTargetType) // nolint + io.WriteString(hash, fmt.Sprint(p.PermissionLevel)) // nolint + } + + return fmt.Sprintf("%x", hash.Sum(nil)) +} + +func (f *GrafanaFolder) GetPermissions() []*GrafanaPermissionItem { + var permissions = make([]*GrafanaPermissionItem, 0, len(f.Spec.FolderPermissions)) + for _, p := range f.Spec.FolderPermissions { + var p2 = p // ensure allocated memory for current item + permissions = append(permissions, &p2) + } + + return permissions +} diff --git a/api/integreatly/v1alpha1/grafanafolder_types_test.go b/api/integreatly/v1alpha1/grafanafolder_types_test.go new file mode 100644 index 000000000..5624167de --- /dev/null +++ b/api/integreatly/v1alpha1/grafanafolder_types_test.go @@ -0,0 +1,56 @@ +/* +Copyright 2021. + +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 v1alpha1 + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +var folderPermissions = []GrafanaPermissionItem{ + { + PermissionTargetType: "role", + PermissionTarget: "Viewer", + PermissionLevel: 1, + }, + { + PermissionTargetType: "role", + PermissionTarget: "Editor", + PermissionLevel: 2, + }, +} + +func TestGetPermissions(t *testing.T) { + folder := new(GrafanaFolder) + folder.Spec.FolderName = "TEST" + folder.Spec.FolderPermissions = folderPermissions + + result := folder.GetPermissions() + require.NotNil(t, result) + require.Equal(t, 2, len(result)) + require.Equal(t, "Viewer", result[0].PermissionTarget) + require.Equal(t, "Editor", result[1].PermissionTarget) +} + +func TestHash(t *testing.T) { + folder := new(GrafanaFolder) + folder.Spec.FolderName = "TEST" + folder.Spec.FolderPermissions = folderPermissions + + result := folder.Hash() + require.Equal(t, "c44659960c5741f3ee2f949e3df5a41c04a03acac15dbeb05f6c2a7423232b6c", result) +} diff --git a/api/integreatly/v1alpha1/selectors.go b/api/integreatly/v1alpha1/selectors.go index 2223e1c70..5153966cc 100644 --- a/api/integreatly/v1alpha1/selectors.go +++ b/api/integreatly/v1alpha1/selectors.go @@ -46,6 +46,31 @@ func (d *GrafanaDashboard) MatchesSelectors(s []*metav1.LabelSelector) (bool, er return result, nil } +func (d *GrafanaFolder) matchesSelector(s *metav1.LabelSelector) (bool, error) { + selector, err := metav1.LabelSelectorAsSelector(s) + if err != nil { + return false, err + } + + return selector.Empty() || selector.Matches(labels.Set(d.Labels)), nil +} + +// Check if the dashboard-folder matches at least one of the selectors +func (d *GrafanaFolder) MatchesSelectors(s []*metav1.LabelSelector) (bool, error) { + result := false + + for _, selector := range s { + match, err := d.matchesSelector(selector) + if err != nil { + return false, err + } + + result = result || match + } + + return result, nil +} + func (d *GrafanaNotificationChannel) matchesSelector(s *metav1.LabelSelector) (bool, error) { selector, err := metav1.LabelSelectorAsSelector(s) if err != nil { diff --git a/api/integreatly/v1alpha1/zz_generated.deepcopy.go b/api/integreatly/v1alpha1/zz_generated.deepcopy.go index e78a8a8aa..90b12bd96 100644 --- a/api/integreatly/v1alpha1/zz_generated.deepcopy.go +++ b/api/integreatly/v1alpha1/zz_generated.deepcopy.go @@ -1938,6 +1938,99 @@ func (in *GrafanaDeployment) DeepCopy() *GrafanaDeployment { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaFolder) DeepCopyInto(out *GrafanaFolder) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaFolder. +func (in *GrafanaFolder) DeepCopy() *GrafanaFolder { + if in == nil { + return nil + } + out := new(GrafanaFolder) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GrafanaFolder) 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 *GrafanaFolderList) DeepCopyInto(out *GrafanaFolderList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]GrafanaFolder, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaFolderList. +func (in *GrafanaFolderList) DeepCopy() *GrafanaFolderList { + if in == nil { + return nil + } + out := new(GrafanaFolderList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GrafanaFolderList) 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 *GrafanaFolderRef) DeepCopyInto(out *GrafanaFolderRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaFolderRef. +func (in *GrafanaFolderRef) DeepCopy() *GrafanaFolderRef { + if in == nil { + return nil + } + out := new(GrafanaFolderRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaFolderSpec) DeepCopyInto(out *GrafanaFolderSpec) { + *out = *in + if in.FolderPermissions != nil { + in, out := &in.FolderPermissions, &out.FolderPermissions + *out = make([]GrafanaPermissionItem, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaFolderSpec. +func (in *GrafanaFolderSpec) DeepCopy() *GrafanaFolderSpec { + if in == nil { + return nil + } + out := new(GrafanaFolderSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaHttpProxy) DeepCopyInto(out *GrafanaHttpProxy) { *out = *in @@ -2133,6 +2226,21 @@ func (in *GrafanaNotificationChannelStatusMessage) DeepCopy() *GrafanaNotificati return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaPermissionItem) DeepCopyInto(out *GrafanaPermissionItem) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaPermissionItem. +func (in *GrafanaPermissionItem) DeepCopy() *GrafanaPermissionItem { + if in == nil { + return nil + } + out := new(GrafanaPermissionItem) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaPlugin) DeepCopyInto(out *GrafanaPlugin) { *out = *in diff --git a/bundle/manifests/integreatly.org_grafanafolders.yaml b/bundle/manifests/integreatly.org_grafanafolders.yaml new file mode 100644 index 000000000..775ffa084 --- /dev/null +++ b/bundle/manifests/integreatly.org_grafanafolders.yaml @@ -0,0 +1,65 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.1 + creationTimestamp: null + name: grafanafolders.integreatly.org +spec: + group: integreatly.org + names: + kind: GrafanaFolder + listKind: GrafanaFolderList + plural: grafanafolders + singular: grafanafolder + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: GrafanaFolder is the Schema for the grafana folders and folderpermissions + API + 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: + permissions: + items: + properties: + permissionLevel: + type: integer + permissionTarget: + type: string + permissionTargetType: + type: string + required: + - permissionLevel + - permissionTarget + - permissionTargetType + type: object + type: array + title: + type: string + required: + - title + type: object + type: object + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/bases/integreatly.org_grafanafolders.yaml b/config/crd/bases/integreatly.org_grafanafolders.yaml new file mode 100644 index 000000000..8816fbb08 --- /dev/null +++ b/config/crd/bases/integreatly.org_grafanafolders.yaml @@ -0,0 +1,72 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.6.2 + creationTimestamp: null + name: grafanafolders.integreatly.org +spec: + group: integreatly.org + names: + kind: GrafanaFolder + listKind: GrafanaFolderList + plural: grafanafolders + singular: grafanafolder + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: GrafanaFolder is the Schema for the grafana folders and folderpermissions + API + 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: + permissions: + description: FolderPermissions shall contain the _complete_ permissions + for the folder. Any permission not listed here, will be removed + from the folder. + items: + properties: + permissionLevel: + type: integer + permissionTarget: + type: string + permissionTargetType: + type: string + required: + - permissionLevel + - permissionTarget + - permissionTargetType + type: object + type: array + title: + description: FolderName is the display-name of the folder and must + match CustomFolderName of any GrafanaDashboard you want to put in + type: string + required: + - title + type: object + type: object + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 1fd0b5275..6ac4960bb 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,6 +5,7 @@ resources: - bases/integreatly.org_grafanas.yaml - bases/integreatly.org_grafanadashboards.yaml - bases/integreatly.org_grafanadatasources.yaml + - bases/integreatly.org_grafanafolders.yaml - bases/integreatly.org_grafananotificationchannels.yaml # +kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/grafanafolder_editor_role.yaml b/config/rbac/grafanafolder_editor_role.yaml new file mode 100644 index 000000000..07f5d796d --- /dev/null +++ b/config/rbac/grafanafolder_editor_role.yaml @@ -0,0 +1,18 @@ +# permissions for end users to edit grafanafolders. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: grafanafolder-editor-role +rules: + - apiGroups: + - integreatly.org + resources: + - grafanafolders + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/config/rbac/grafanafolder_viewer_role.yaml b/config/rbac/grafanafolder_viewer_role.yaml new file mode 100644 index 000000000..b4eeb0985 --- /dev/null +++ b/config/rbac/grafanafolder_viewer_role.yaml @@ -0,0 +1,14 @@ +# permissions for end users to view grafanafolders. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: grafanafolder-viewer-role +rules: + - apiGroups: + - integreatly.org + resources: + - grafanafolders + verbs: + - get + - list + - watch diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2c62a20a6..48430caa2 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -94,6 +94,26 @@ rules: - get - patch - update +- apiGroups: + - integreatly.org + resources: + - grafanafolders + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - integreatly.org + resources: + - grafanafolders/status + verbs: + - get + - patch + - update - apiGroups: - integreatly.org resources: diff --git a/controllers/config/controller_config.go b/controllers/config/controller_config.go index 5882c956f..4ab4a3cdc 100644 --- a/controllers/config/controller_config.go +++ b/controllers/config/controller_config.go @@ -31,6 +31,7 @@ const ( ConfigMapsMountDir = "/etc/grafana-configmaps/" ConfigRouteWatch = "watch.routes" ConfigGrafanaDashboardsSynced = "grafana.dashboards.synced" + ConfigGrafanaFoldersSynced = "grafana.dashboard.folders.synced" ConfigGrafanaNotificationChannelsSynced = "grafana.notificationchannels.synced" JsonnetBasePath = "/opt/jsonnet" ) @@ -40,6 +41,7 @@ type ControllerConfig struct { Values map[string]interface{} Plugins map[string]v1alpha1.PluginList Dashboards map[string][]*v1alpha1.GrafanaDashboardRef + Folders map[string][]*v1alpha1.GrafanaFolderRef RequeueDelay time.Duration } @@ -53,6 +55,7 @@ func GetControllerConfig() *ControllerConfig { Values: map[string]interface{}{}, Plugins: map[string]v1alpha1.PluginList{}, Dashboards: map[string][]*v1alpha1.GrafanaDashboardRef{}, + Folders: map[string][]*v1alpha1.GrafanaFolderRef{}, RequeueDelay: time.Second * 10, } }) @@ -92,6 +95,25 @@ func (c *ControllerConfig) RemovePluginsFor(dashboard *v1alpha1.GrafanaDashboard delete(c.Plugins, id) } +func (c *ControllerConfig) AddFolder(folder *v1alpha1.GrafanaFolder) { + namespace := folder.Namespace + c.Lock() + defer c.Unlock() + if i, exists := c.HasFolder(namespace, folder.Spec.FolderName); !exists { + c.Folders[namespace] = append(c.Folders[namespace], &v1alpha1.GrafanaFolderRef{ + Name: folder.Spec.FolderName, + Namespace: namespace, + Hash: folder.Hash(), + }) + } else { + c.Folders[namespace][i] = &v1alpha1.GrafanaFolderRef{ + Name: folder.Spec.FolderName, + Namespace: namespace, + Hash: folder.Hash(), + } + } +} + func (c *ControllerConfig) AddDashboard(dashboard *v1alpha1.GrafanaDashboard, folderId *int64, folderName string) { ns := dashboard.Namespace if i, exists := c.HasDashboard(ns, dashboard.UID()); !exists { @@ -119,6 +141,15 @@ func (c *ControllerConfig) AddDashboard(dashboard *v1alpha1.GrafanaDashboard, fo } } +func (c *ControllerConfig) HasFolder(namespace, name string) (int, bool) { + for i, folder := range c.Folders[namespace] { + if folder.Name == name { + return i, true + } + } + return -1, false +} + func (c *ControllerConfig) HasDashboard(ns, uid string) (int, bool) { for i, v := range c.Dashboards[ns] { if v.UID == uid { @@ -151,6 +182,25 @@ func (c *ControllerConfig) RemoveDashboard(hash string) { } } +func (c *ControllerConfig) GetFolders(namespace string) []*v1alpha1.GrafanaFolderRef { + c.Lock() + defer c.Unlock() + + if c.Folders[namespace] != nil { + return c.Folders[namespace] + } + + folders := []*v1alpha1.GrafanaFolderRef{} + + if namespace == "" { + for _, folder := range c.Folders { + folders = append(folders, folder...) + } + } + + return folders +} + func (c *ControllerConfig) GetDashboards(namespace string) []*v1alpha1.GrafanaDashboardRef { c.Lock() defer c.Unlock() diff --git a/controllers/grafana/grafana_controller.go b/controllers/grafana/grafana_controller.go index 08cb6d2d4..6f5322308 100644 --- a/controllers/grafana/grafana_controller.go +++ b/controllers/grafana/grafana_controller.go @@ -177,8 +177,10 @@ func (r *ReconcileGrafana) Reconcile(ctx context.Context, request reconcile.Requ return reconcile.Result{}, err } + log.V(1).Info("Found grafana-instance, proceed Reconcile with deepcopy...") cr := instance.DeepCopy() + log.V(1).Info("determine clusterState...") // Read current state currentState := common.NewClusterState() err = currentState.Read(ctx, cr, r.Client) @@ -188,10 +190,11 @@ func (r *ReconcileGrafana) Reconcile(ctx context.Context, request reconcile.Requ } // Get the actions required to reach the desired state - + log.V(1).Info("Create GrafanaReconciler and determine desiredState...") reconciler := NewGrafanaReconciler() desiredState := reconciler.Reconcile(currentState, cr) + log.V(1).Info("Determined desiredStates - starting actionRunner") // Run the actions to reach the desired state actionRunner := common.NewClusterActionRunner(ctx, r.Client, r.Scheme, cr) err = actionRunner.RunAll(desiredState) @@ -213,6 +216,8 @@ func (r *ReconcileGrafana) manageError(cr *grafanav1alpha1.Grafana, issue error, cr.Status.Phase = grafanav1alpha1.PhaseFailing cr.Status.Message = issue.Error() + log.Error(issue, "error processing GrafanaInstance", "name", cr.Name, "namespace", cr.Namespace) + instance := &grafanav1alpha1.Grafana{} err := r.Client.Get(r.Context, request.NamespacedName, instance) if err != nil { @@ -296,6 +301,7 @@ func (r *ReconcileGrafana) manageSuccess(cr *grafanav1alpha1.Grafana, state *com cr.Status.Phase = grafanav1alpha1.PhaseReconciling cr.Status.Message = "success" + log.V(1).Info("ReconcileGrafana success") // Only update the status if the dashboard controller had a chance to sync the cluster // dashboards first. Otherwise reuse the existing dashboard config from the CR. if r.Config.GetConfigBool(config.ConfigGrafanaDashboardsSynced, false) { diff --git a/controllers/grafanadashboardfolder/grafana_client.go b/controllers/grafanadashboardfolder/grafana_client.go new file mode 100644 index 000000000..bf78da0a9 --- /dev/null +++ b/controllers/grafanadashboardfolder/grafana_client.go @@ -0,0 +1,255 @@ +package grafanadashboardfolder + +import ( + "bytes" + "crypto/md5" //nolint + "encoding/json" + "fmt" + "github.com/grafana-operator/grafana-operator/v4/api/integreatly/v1alpha1" + "io" + "net/http" + "net/url" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "strings" + "time" +) + +const ( + CreateOrUpdateFolderUrl = "%v/api/folders" + FolderPermissionsUrl = "%v/api/folders/%v/permissions" +) + +type GrafanaFolderPermissionsResponse struct { + ID *int64 `json:"id"` + Title string `json:"title"` + Message string `json:"message"` +} + +type GrafanaFolderResponse struct { + ID *int64 `json:"id"` + Title string `json:"title"` + UID string `json:"uid"` +} + +type GrafanaFolderRequest struct { + Title string `json:"title"` + UID string `json:"uid"` +} + +type GrafanaClient interface { + FindOrCreateFolder(folderName string) (GrafanaFolderResponse, error) + ApplyFolderPermissions(folderName string, folderPermissions []*v1alpha1.GrafanaPermissionItem) (GrafanaFolderPermissionsResponse, error) +} + +type GrafanaClientImpl struct { + url string + user string + password string + client *http.Client +} + +func setHeaders(req *http.Request) { + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "grafana-operator") +} + +func NewGrafanaClient(url, user, password string, transport *http.Transport, timeoutSeconds time.Duration) GrafanaClient { + client := &http.Client{ + Transport: transport, + Timeout: time.Second * timeoutSeconds, + } + + return &GrafanaClientImpl{ + url: url, + user: user, + password: password, + client: client, + } +} + +var logger = logf.Log.WithName("folder-grafana-client") + +func (r *GrafanaClientImpl) getAllFolders() ([]GrafanaFolderResponse, error) { + rawURL := fmt.Sprintf(CreateOrUpdateFolderUrl, r.url) + parsed, err := url.Parse(rawURL) + + if err != nil { + return nil, err + } + + parsed.User = url.UserPassword(r.user, r.password) + req, err := http.NewRequest("GET", parsed.String(), nil) + + if err != nil { + return nil, err + } + + setHeaders(req) + + resp, err := r.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + // Grafana might be unavailable, no reason to panic, other checks are in place + if resp.StatusCode == 503 { + return nil, nil + } else { + return nil, fmt.Errorf( + "error getting folders, expected status 200 but got %v", + resp.StatusCode) + } + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var folders []GrafanaFolderResponse + err = json.Unmarshal(data, &folders) + + return folders, err +} + +func newFolderResponse() GrafanaFolderResponse { + var id int64 = 0 + + return GrafanaFolderResponse{ + ID: &id, + } +} + +func (r *GrafanaClientImpl) FindOrCreateFolder(folderName string) (GrafanaFolderResponse, error) { + response := newFolderResponse() + + existingFolders, err := r.getAllFolders() + if err != nil { + return response, err + } + + for _, folder := range existingFolders { + if folder.Title == folderName { + return folder, nil + } + } + + rawURL := fmt.Sprintf(CreateOrUpdateFolderUrl, r.url) + apiUrl, err := url.Parse(rawURL) + if err != nil { + return response, err + } + + raw, err := json.Marshal(GrafanaFolderRequest{ + Title: folderName, + UID: buildFolderUidFromName(folderName), + }) + if err != nil { + return response, err + } + + apiUrl.User = url.UserPassword(r.user, r.password) + req, err := http.NewRequest("POST", apiUrl.String(), bytes.NewBuffer(raw)) + if err != nil { + return response, err + } + + setHeaders(req) + + resp, err := r.client.Do(req) + if err != nil { + return response, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + if resp.StatusCode == 503 { + return GrafanaFolderResponse{}, nil + } else { + return response, fmt.Errorf( + "error creating folder, expected status 200 but got %v", + resp.StatusCode) + } + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return response, err + } + + err = json.Unmarshal(data, &response) + + return response, err +} + +func buildFolderUidFromName(folderName string) string { + // uid must not exceed 40 chars + return fmt.Sprintf("%x", md5.Sum([]byte(folderName))) //nolint +} + +func (r *GrafanaClientImpl) ApplyFolderPermissions(folderName string, folderPermissions []*v1alpha1.GrafanaPermissionItem) (GrafanaFolderPermissionsResponse, error) { + response := GrafanaFolderPermissionsResponse{} + + // ensure folder exists - permissions can only be applied to existing folders, so we need its UID + existingFolder, err := r.FindOrCreateFolder(folderName) + if err != nil { + return response, err + } + + rawURL := fmt.Sprintf(FolderPermissionsUrl, r.url, existingFolder.UID) + apiUrl, err := url.Parse(rawURL) + if err != nil { + return response, err + } + + requestBody := buildFolderPermissionRequestBody(folderPermissions) + apiUrl.User = url.UserPassword(r.user, r.password) + req, err := http.NewRequest("POST", apiUrl.String(), bytes.NewBuffer([]byte(requestBody))) + if err != nil { + return response, err + } + + setHeaders(req) + resp, err := r.client.Do(req) + if err != nil { + return response, err + } + defer resp.Body.Close() + + if resp.StatusCode == 503 { + return response, nil + } else if resp.StatusCode != 200 { + logger.V(1).Info(fmt.Sprintf("used request-data: url '%s' with body '%s'", rawURL, requestBody)) // use rawURL instead of apiUrl to not expose credentials in log + return response, fmt.Errorf("error setting folder-permissions, expected status 200 but got %v", resp.StatusCode) + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return response, err + } + err = json.Unmarshal(responseBody, &response) + + return response, err +} + +// buildFolderPermissionRequestBody creates a JSON-String according to the Spec of the HTTP-API: +// https://grafana.com/docs/grafana/latest/developers/http_api/folder_permissions/#update-permissions-for-a-folder +// Due to the rather dynamic key-names, that must be done programmatically rather than via marshaling an object +func buildFolderPermissionRequestBody(folderPermissions []*v1alpha1.GrafanaPermissionItem) string { + var b strings.Builder + b.WriteString("{ \"items\": [ ") + + // TODO: support other targetTypes (teamId and userId) - value-type must be integer then + for i, item := range folderPermissions { + fmt.Fprintf(&b, "{%q: %q, \"permission\": %d}", item.PermissionTargetType, item.PermissionTarget, item.PermissionLevel) + if i+1 < len(folderPermissions) { + b.WriteString(",") + } + } + + b.WriteString(" ]}") + return b.String() +} diff --git a/controllers/grafanadashboardfolder/grafana_client_test.go b/controllers/grafanadashboardfolder/grafana_client_test.go new file mode 100644 index 000000000..e716fa51e --- /dev/null +++ b/controllers/grafanadashboardfolder/grafana_client_test.go @@ -0,0 +1,32 @@ +package grafanadashboardfolder + +import ( + "github.com/grafana-operator/grafana-operator/v4/api/integreatly/v1alpha1" + "github.com/stretchr/testify/require" + "testing" +) + +const expectedFolderPermissionRequestBody = `{ "items": [ {"role": "Viewer", "permission": 1},{"role": "Editor", "permission": 2} ]}` + +var folderPermissions = []*v1alpha1.GrafanaPermissionItem{ + { + PermissionTargetType: "role", + PermissionTarget: "Viewer", + PermissionLevel: 1, + }, + { + PermissionTargetType: "role", + PermissionTarget: "Editor", + PermissionLevel: 2, + }, +} + +func TestBuildFolderPermissionRequestBody(t *testing.T) { + result := buildFolderPermissionRequestBody(folderPermissions) + require.Equal(t, expectedFolderPermissionRequestBody, result) +} + +func TestBuildFolderUidFromName(t *testing.T) { + result := buildFolderUidFromName("Test Folder") + require.Equal(t, "ced3f5903fc61238671a36457c61fc81", result) +} diff --git a/controllers/grafanadashboardfolder/grafanadashboardfolder_controller.go b/controllers/grafanadashboardfolder/grafanadashboardfolder_controller.go new file mode 100644 index 000000000..4fa3be791 --- /dev/null +++ b/controllers/grafanadashboardfolder/grafanadashboardfolder_controller.go @@ -0,0 +1,281 @@ +package grafanadashboardfolder + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "os" + "time" + + grafanav1alpha1 "github.com/grafana-operator/grafana-operator/v4/api/integreatly/v1alpha1" + "github.com/grafana-operator/grafana-operator/v4/controllers/constants" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/controller" + "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/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "net/http" + + "github.com/go-logr/logr" + "github.com/grafana-operator/grafana-operator/v4/controllers/common" + "github.com/grafana-operator/grafana-operator/v4/controllers/config" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + ControllerName = "controller_grafanadashboardfolder" +) + +// GrafanaDashboardFolderReconciler reconciles a GrafanaFolder object +type GrafanaDashboardFolderReconciler struct { + // This client, initialized using mgr.Client() above, is a split client + // that reads objects from the cache and writes to the apiserver + + Client client.Client + Scheme *runtime.Scheme + transport *http.Transport + config *config.ControllerConfig + context context.Context + cancel context.CancelFunc + recorder record.EventRecorder + state common.ControllerState + Log logr.Logger +} + +// Add creates a new GrafanaDashboardFolder Controller and adds it to the Manager. The Manager will set fields on the Controller +// and Start it when the Manager is Started. +func Add(mgr manager.Manager, namespace string) error { + return SetupWithManager(mgr, newReconciler(mgr), namespace) +} + +// SetupWithManager sets up the controller with the Manager. +func SetupWithManager(mgr ctrl.Manager, r reconcile.Reconciler, namespace string) error { + c, err := controller.New("grafanadashboardfolder-controller", mgr, controller.Options{Reconciler: r}) + if err != nil { + return err + } + + // Watch for changes to primary resource GrafanaDashboard + err = c.Watch(&source.Kind{Type: &grafanav1alpha1.GrafanaFolder{}}, &handler.EnqueueRequestForObject{}) + if err == nil { + log.Log.Info("Starting dashboardfolder controller") + } + + ref := r.(*GrafanaDashboardFolderReconciler) //nolint + ticker := time.NewTicker(config.GetControllerConfig().RequeueDelay) + sendEmptyRequest := func() { + request := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: namespace, + Name: "", + }, + } + _, err = r.Reconcile(ref.context, request) + if err != nil { + return + } + } + + go func() { + for range ticker.C { + log.Log.Info("running periodic dashboardfolder resync") + sendEmptyRequest() + } + }() + + go func() { + for stateChange := range common.ControllerEvents { + // Controller state updated + ref.state = stateChange + } + }() + return ctrl.NewControllerManagedBy(mgr). + For(&grafanav1alpha1.GrafanaFolder{}). + Complete(r) +} + +var _ reconcile.Reconciler = &GrafanaDashboardFolderReconciler{} + +// +kubebuilder:rbac:groups=integreatly.org,resources=grafanafolders,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=integreatly.org,resources=grafanafolders/status,verbs=get;update;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// the GrafanaDashboard object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.7.0/pkg/reconcile +func (r *GrafanaDashboardFolderReconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) { + logger := r.Log.WithValues(ControllerName, request.NamespacedName) + + // If Grafana is not running there is no need to continue + if !r.state.GrafanaReady { + logger.Info("no grafana instance available") + return reconcile.Result{Requeue: false}, nil + } + + grafanaClient, err := r.getClient() + if err != nil { + return reconcile.Result{RequeueAfter: config.GetControllerConfig().RequeueDelay}, err + } + + // Initial request? + if request.Name == "" { + return r.reconcileDashboardFolders(request, grafanaClient) + } + + // Check if the label selectors are available yet. If not then the grafana controller + // has not finished initializing and we can't continue. Reschedule for later. + if r.state.DashboardSelectors == nil { + return reconcile.Result{RequeueAfter: config.GetControllerConfig().RequeueDelay}, nil + } + + // Fetch the GrafanaDashboard instance + instance := &grafanav1alpha1.GrafanaFolder{} + err = r.Client.Get(r.context, request.NamespacedName, instance) + if err != nil { + if k8serrors.IsNotFound(err) { + // If some dashboard has been deleted, then always re sync the world + logger.Info("deleting dashboard", "namespace", request.Namespace, "name", request.Name) + return r.reconcileDashboardFolders(request, grafanaClient) + } + // Error reading the object - requeue the request. + return reconcile.Result{}, err + } + + // If the dashboard does not match the label selectors then we ignore it + cr := instance.DeepCopy() + if !r.isMatch(cr) { + logger.V(1).Info(fmt.Sprintf("folder %v/%v found but selectors do not match", cr.Namespace, cr.Name)) + return ctrl.Result{}, nil + } + // Otherwise always re sync all dashboards in the namespace + return r.reconcileDashboardFolders(request, grafanaClient) +} + +func (r *GrafanaDashboardFolderReconciler) reconcileDashboardFolders(request ctrl.Request, grafanaClient GrafanaClient) (reconcile.Result, error) { + foldersInNamespace := &grafanav1alpha1.GrafanaFolderList{} + + opts := &client.ListOptions{ + Namespace: request.Namespace, + } + err := r.Client.List(r.context, foldersInNamespace, opts) + if err != nil { + return reconcile.Result{}, err + } + + for i := range foldersInNamespace.Items { + folder := foldersInNamespace.Items[i] + if !r.isMatch(&folder) { + log.Log.Info("dashboard found but selectors do not match", "namespace", folder.Namespace, "name", folder.Name) + continue + } + _, err := grafanaClient.ApplyFolderPermissions(folder.Spec.FolderName, folder.GetPermissions()) + if err != nil { + r.manageError(&folder, err) + continue + } + + r.manageSuccess(&folder) + } + + // Mark the folders as synced so that the current state can be written + // to the Grafana CR by the grafana controller + r.config.AddConfigItem(config.ConfigGrafanaFoldersSynced, true) + + return reconcile.Result{Requeue: false}, nil +} + +// newReconciler returns a new reconcile.Reconciler +func newReconciler(mgr manager.Manager) reconcile.Reconciler { + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + + return &GrafanaDashboardFolderReconciler{ + Client: mgr.GetClient(), + /* #nosec G402 */ + transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + Log: mgr.GetLogger(), + config: config.GetControllerConfig(), + context: ctx, + cancel: cancel, + recorder: mgr.GetEventRecorderFor(ControllerName), + state: common.ControllerState{}, + } +} + +// Get an authenticated grafana API client +func (r *GrafanaDashboardFolderReconciler) getClient() (GrafanaClient, error) { + url := r.state.AdminUrl + if url == "" { + return nil, errors.New("cannot get grafana admin url") + } + + username := os.Getenv(constants.GrafanaAdminUserEnvVar) + if username == "" { + return nil, errors.New("invalid credentials (username)") + } + + password := os.Getenv(constants.GrafanaAdminPasswordEnvVar) + if password == "" { + return nil, errors.New("invalid credentials (password)") + } + + duration := time.Duration(r.state.ClientTimeout) + + return NewGrafanaClient(url, username, password, r.transport, duration), nil +} + +// Handle success case: update dashboardfolder metadata (name, hash) +func (r *GrafanaDashboardFolderReconciler) manageSuccess(folder *grafanav1alpha1.GrafanaFolder) { + msg := fmt.Sprintf("folder %v/%v successfully submitted", folder.Namespace, folder.Name) + r.recorder.Event(folder, "Normal", "Success", msg) + log.Log.Info("folder successfully submitted", "name", folder.Name, "namespace", folder.Namespace) + r.config.AddFolder(folder) +} + +// Handle error case: update dashboardfolder with error message and status +func (r *GrafanaDashboardFolderReconciler) manageError(folder *grafanav1alpha1.GrafanaFolder, issue error) { + r.recorder.Event(folder, "Warning", "ProcessingError", issue.Error()) + // Ignore conflicts. Resource might just be outdated, also ignore if grafana isn't available. + if k8serrors.IsConflict(issue) || k8serrors.IsServiceUnavailable(issue) { + return + } + log.Log.Error(issue, "error updating folder", "name", folder.Name, "namespace", folder.Namespace) +} + +// Test if a given dashboardfolder matches an array of label selectors +func (r *GrafanaDashboardFolderReconciler) isMatch(item *grafanav1alpha1.GrafanaFolder) bool { + if r.state.DashboardSelectors == nil { + return false + } + + match, err := item.MatchesSelectors(r.state.DashboardSelectors) + if err != nil { + log.Log.Error(err, "error matching selectors", + "item.Namespace", item.Namespace, + "item.Name", item.Name) + return false + } + return match +} + +func (r *GrafanaDashboardFolderReconciler) SetupWithManager(mgr manager.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&grafanav1alpha1.GrafanaFolder{}). + Complete(r) +} diff --git a/deploy/examples/folders/public-folder.yaml b/deploy/examples/folders/public-folder.yaml new file mode 100644 index 000000000..984aa7a88 --- /dev/null +++ b/deploy/examples/folders/public-folder.yaml @@ -0,0 +1,21 @@ +--- +apiVersion: integreatly.org/v1alpha1 +kind: GrafanaFolder +metadata: + name: public-stats-folder + namespace: example-namespace + labels: + app: grafana +spec: + title: public-stats + permissions: + - permissionLevel: 1 + permissionTarget: "Viewer" + permissionTargetType: "role" + - permissionLevel: 2 + permissionTarget: "Editor" + permissionTargetType: "role" + +# according to spec (https://grafana.com/docs/grafana/latest/developers/http_api/folder_permissions/): +# permissionTarget "admin" must not be specified +# admin-users have access anyway diff --git a/deploy/examples/folders/restricted-folder.yaml b/deploy/examples/folders/restricted-folder.yaml new file mode 100644 index 000000000..39b5c1b02 --- /dev/null +++ b/deploy/examples/folders/restricted-folder.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: integreatly.org/v1alpha1 +kind: GrafanaFolder +metadata: + name: default-folder + namespace: example-namespace + labels: + app: grafana +spec: + title: restricted-stats + permissions: + - permissionLevel: 2 + permissionTarget: "Editor" + permissionTargetType: "role" + +# permissions are "removed" by just not specifying them diff --git a/deploy/manifests/latest/crds.yaml b/deploy/manifests/latest/crds.yaml index d71592856..4f270e895 100644 --- a/deploy/manifests/latest/crds.yaml +++ b/deploy/manifests/latest/crds.yaml @@ -589,6 +589,77 @@ status: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.6.2 + creationTimestamp: null + name: grafanafolders.integreatly.org +spec: + group: integreatly.org + names: + kind: GrafanaFolder + listKind: GrafanaFolderList + plural: grafanafolders + singular: grafanafolder + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: GrafanaFolder is the Schema for the grafana folders and folderpermissions + API + 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: + permissions: + description: FolderPermissions shall contain the _complete_ permissions + for the folder. Any permission not listed here, will be removed + from the folder. + items: + properties: + permissionLevel: + type: integer + permissionTarget: + type: string + permissionTargetType: + type: string + required: + - permissionLevel + - permissionTarget + - permissionTargetType + type: object + type: array + title: + description: FolderName is the display-name of the folder and must + match CustomFolderName of any GrafanaDashboard you want to put in + type: string + required: + - title + type: object + type: object + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.6.2 diff --git a/deploy/manifests/latest/rbac.yaml b/deploy/manifests/latest/rbac.yaml index dab88f3c0..0fb35a008 100644 --- a/deploy/manifests/latest/rbac.yaml +++ b/deploy/manifests/latest/rbac.yaml @@ -135,6 +135,26 @@ rules: - get - patch - update +- apiGroups: + - integreatly.org + resources: + - grafanafolders + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - integreatly.org + resources: + - grafanafolders/status + verbs: + - get + - patch + - update - apiGroups: - integreatly.org resources: diff --git a/documentation/README.md b/documentation/README.md index 30d7f6076..c0dc740fe 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -2,6 +2,7 @@ * [Installing Grafana](./deploy_grafana.md) * [Dashboards](./dashboards.md) +* [Dashboard folder permissions](./folder_permissions.md) * [Data Sources](./datasources.md) * [Develop](./develop.md) * [Multi namespace support](./multi_namespace_support.md) @@ -34,6 +35,11 @@ The following example CRs are provided: * [DashboardFromURL.yaml](../deploy/examples/dashboards/DashboardFromURL.yaml): A dashboard that downloads its contents from a URL and falls back to embedded json if the URL cannot be resolved. * [KeycloakDashboard.yaml](../deploy/examples/dashboards/KeycloakDashboard.yaml): A dashboard that shows keycloak metrics and demonstrates how to use datasource inputs. +### Folders + +* [public-folder.yaml](../deploy/examples/folders/public-folder.yaml): Folder with access-permissions for users with role "Viewer" +* [restricted-folder.yaml](../deploy/examples/folders/restricted-folder.yaml): Folder only accessible for Editors + ### Data sources * [Prometheus.yaml](../deploy/examples/datasources/Prometheus.yaml): Prometheus data source, expects a service named `prometheus-service` listening on port 9090 in the same namespace. diff --git a/documentation/api.md b/documentation/api.md index 7c94c486b..bcd6a85c5 100644 --- a/documentation/api.md +++ b/documentation/api.md @@ -12,6 +12,8 @@ Resource Types: - [GrafanaDataSource](#grafanadatasource) +- [GrafanaFolder](#grafanafolder) + - [GrafanaNotificationChannel](#grafananotificationchannel) - [Grafana](#grafana) @@ -1792,6 +1794,127 @@ GrafanaDataSourceSecureJsonData contains the most common secure json options See +## GrafanaFolder +[↩ Parent](#integreatlyorgv1alpha1 ) + + + + + + +GrafanaFolder is the Schema for the grafana folders and folderpermissions API + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringintegreatly.org/v1alpha1true
kindstringGrafanaFoldertrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject +
+
false
+ + +### GrafanaFolder.spec +[↩ Parent](#grafanafolder) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
titlestring + FolderName is the display-name of the folder and must match CustomFolderName of any GrafanaDashboard you want to put in
+
true
permissions[]object + FolderPermissions shall contain the _complete_ permissions for the folder. Any permission not listed here, will be removed from the folder.
+
false
+ + +### GrafanaFolder.spec.permissions[index] +[↩ Parent](#grafanafolderspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
permissionLevelinteger +
+
true
permissionTargetstring +
+
true
permissionTargetTypestring +
+
true
+ ## GrafanaNotificationChannel [↩ Parent](#integreatlyorgv1alpha1 ) diff --git a/documentation/dashboards.md b/documentation/dashboards.md index 70635380c..6164c97ce 100644 --- a/documentation/dashboards.md +++ b/documentation/dashboards.md @@ -176,6 +176,7 @@ spec: ## Dashboard Folder Support Due to the fact that the operator now supports the discovery of cluster-wide dashboards. +For folder-permissions see: [folder-permissions](./folder_permissions.md) ### Managed folders diff --git a/documentation/folder_permissions.md b/documentation/folder_permissions.md new file mode 100644 index 000000000..00c0b7699 --- /dev/null +++ b/documentation/folder_permissions.md @@ -0,0 +1,31 @@ +# Dashboard-Folder permissions + +In Grafana, Dashboards inherit the permissions of the folder they're in - and they can't be more restrictive than those! +And in some cases, you'd want to restrict the visibility of Dashboards, e.g. to only developers. + +## Example use-case + +Say you have some Dashboards that shall be accessible for all departments in your company. +And some other Dashboards with infrastructure- and applications-stats, that only developers should be allowed to access. + +You'd need to give all developers the role "editor" and everyone else the role "viewer". +(e.g. via corresponding groups, if you're using authentication via +[AAD-SSO](https://grafana.com/docs/grafana/latest/setup-grafana/configure-security/configure-authentication/azuread/)) + +Then configure at least two folders - one for the public Dashboards and one for the developers Dashboards. +See [deploy/examples/folders/](../deploy/examples/folders/) for `GrafanaFolder` example configurations - they cover exactly this use-case. + +## Folder properties + +As mentioned in the description for [Dashboards](./dashboards.md), you can create a folder implicitly by specifying a `customFolderName`. +But in order to set permissions, you have to create a `GrafanaFolder` custom resource as well. +(Of course you can also create a folder by just deploying a `GrafanaFolder` without referencing it in a `GrafanaDashboard` right away) + +To get a quick overview of the GrafanaFolder you can also look at the [API docs](api.md). +The following properties are accepted in the `spec`: + +* *title*: The displayname of the folder. It must match the `customFolderName` of the GrafanaDashboard in order to "assign" it. +* *permissions*: the __complete__ permissions for the folder. Any permission not listed here, will be removed from the folder. + * *permissionLevel*: 1 == View; 2 == Edit; 4 == Admin + * *permissionTarget*: The target-role (can be "Viewer" or "Editor" - no need for admins as they always have access) + * *permissionTargetType*: currently only "role" is supported diff --git a/main.go b/main.go index e28721db9..5f7ec6c66 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ import ( "context" "flag" "fmt" + "github.com/grafana-operator/grafana-operator/v4/controllers/grafanadashboardfolder" "os" "runtime" "strconv" @@ -114,8 +115,8 @@ func assignOpts() { func main() { // nolint - printVersion() assignOpts() + printVersion() namespace, err := k8sutil.GetWatchNamespace() if err != nil { @@ -230,11 +231,16 @@ func main() { // nolint // Start one dashboard controller per watch namespace for _, ns := range dashboardNamespaces { startDashboardController(ns, cfg, context.Background()) + startDashboardFolderController(ns, cfg, context.Background()) startNotificationChannelController(ns, cfg, context.Background()) } + log.Log.Info("Starting background context.") + ctx := context.Background() ctx, cancel := context.WithCancel(ctx) + + log.Log.Info("SetupWithManager Grafana.") if err = (&grafana.ReconcileGrafana{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -247,6 +253,8 @@ func main() { // nolint setupLog.Error(err, "unable to create controller", "controller", "Grafana") os.Exit(1) } + + log.Log.Info("SetupWithManager GrafanaDashboard.") if err = (&grafanadashboard.GrafanaDashboardReconciler{ Client: mgr.GetClient(), Log: ctrl.Log.WithName("controllers").WithName("GrafanaDashboard"), @@ -255,6 +263,18 @@ func main() { // nolint setupLog.Error(err, "unable to create controller", "controller", "GrafanaDashboard") os.Exit(1) } + + log.Log.Info("SetupWithManager GrafanaFolder.") + if err = (&grafanadashboardfolder.GrafanaDashboardFolderReconciler{ + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("GrafanaDashboardFolder"), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "GrafanaDashboardFolder") + os.Exit(1) + } + + log.Log.Info("SetupWithManager GrafanaDatasource.") if err = (&grafanadatasource.GrafanaDatasourceReconciler{ Client: mgr.GetClient(), Context: ctx, @@ -319,6 +339,38 @@ func startDashboardController(ns string, cfg *rest.Config, ctx context.Context) }() } +// Starts a separate controller for the dashboardfolder reconciliation in the background +func startDashboardFolderController(ns string, cfg *rest.Config, ctx context.Context) { + // Create a new Cmd to provide shared dependencies and start components + dashboardFolderMgr, err := manager.New(cfg, manager.Options{ + MetricsBindAddress: "0", + Namespace: ns, + }) + if err != nil { + log.Log.Error(err, "") + os.Exit(1) + } + + // Setup Scheme for the dashboard resource + if err := apis.AddToScheme(dashboardFolderMgr.GetScheme()); err != nil { + log.Log.Error(err, "") + os.Exit(1) + } + + // Use a separate manager for the dashboardfolder controller + err = grafanadashboardfolder.Add(dashboardFolderMgr, ns) + if err != nil { + log.Log.Error(err, "") + } + + go func() { + if err := dashboardFolderMgr.Start(ctx); err != nil { + log.Log.Error(err, "dashboardfolder manager exited non-zero") + os.Exit(1) + } + }() +} + // Starts a separate controller for the notification channels reconciliation in the background func startNotificationChannelController(ns string, cfg *rest.Config, ctx context.Context) { // Create a new Cmd to provide shared dependencies and start components