diff --git a/api/v1beta1/grafana_types.go b/api/v1beta1/grafana_types.go
index 4284e1c38..6d619a81f 100644
--- a/api/v1beta1/grafana_types.go
+++ b/api/v1beta1/grafana_types.go
@@ -131,15 +131,16 @@ type GrafanaPreferences struct {
// GrafanaStatus defines the observed state of Grafana
type GrafanaStatus struct {
- Stage OperatorStageName `json:"stage,omitempty"`
- StageStatus OperatorStageStatus `json:"stageStatus,omitempty"`
- LastMessage string `json:"lastMessage,omitempty"`
- AdminUrl string `json:"adminUrl,omitempty"`
- Dashboards NamespacedResourceList `json:"dashboards,omitempty"`
- Datasources NamespacedResourceList `json:"datasources,omitempty"`
- Folders NamespacedResourceList `json:"folders,omitempty"`
- LibraryPanels NamespacedResourceList `json:"libraryPanels,omitempty"`
- Version string `json:"version,omitempty"`
+ Stage OperatorStageName `json:"stage,omitempty"`
+ StageStatus OperatorStageStatus `json:"stageStatus,omitempty"`
+ LastMessage string `json:"lastMessage,omitempty"`
+ AdminUrl string `json:"adminUrl,omitempty"`
+ Dashboards NamespacedResourceList `json:"dashboards,omitempty"`
+ Datasources NamespacedResourceList `json:"datasources,omitempty"`
+ ServiceAccounts NamespacedResourceList `json:"serviceaccounts,omitempty"`
+ Folders NamespacedResourceList `json:"folders,omitempty"`
+ LibraryPanels NamespacedResourceList `json:"libraryPanels,omitempty"`
+ Version string `json:"version,omitempty"`
}
// +kubebuilder:object:root=true
diff --git a/api/v1beta1/grafanaserviceaccount_types.go b/api/v1beta1/grafanaserviceaccount_types.go
new file mode 100644
index 000000000..05fb96f78
--- /dev/null
+++ b/api/v1beta1/grafanaserviceaccount_types.go
@@ -0,0 +1,170 @@
+/*
+Copyright 2025.
+
+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 v1beta1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// GrafanaServiceAccountToken describes a token to create.
+type GrafanaServiceAccountToken struct {
+ // Name is the name of the Kubernetes Secret (and token identifier in Grafana). The secret will contain the token value.
+ // +kubebuilder:validation:Required
+ Name string `json:"name"`
+
+ // Expires is the optional expiration time for the token. After this time, the operator may rotate the token.
+ // +kubebuilder:validation:Optional
+ Expires *metav1.Time `json:"expires,omitempty"`
+}
+
+// GrafanaServiceAccountTokenStatus describes a token created in Grafana.
+type GrafanaServiceAccountTokenStatus struct {
+ // Name of the token (same as Secret name).
+ Name string `json:"name"`
+
+ // TokenID is the Grafana-assigned ID of the token.
+ TokenID int64 `json:"tokenId"`
+
+ // SecretName is the name of the Kubernetes Secret that stores the actual token value.
+ // This may seem redundant if the Secret name usually matches the token's Name,
+ // but it's stored explicitly in Status for clarity and future flexibility.
+ SecretName string `json:"secretName"`
+}
+
+// GrafanaServiceAccountPermission defines a permission grant for a user or team.
+// +kubebuilder:validation:XValidation:rule="(has(self.user) && self.user != '') || (has(self.team) && self.team != '')",message="one of user or team must be set"
+// +kubebuilder:validation:XValidation:rule="!((has(self.user) && self.user != '') && (has(self.team) && self.team != ''))",message="user and team cannot both be set"
+type GrafanaServiceAccountPermission struct {
+ // User login or email to grant permissions to (optional).
+ // +kubebuilder:validation:Optional
+ User string `json:"user,omitempty"`
+
+ // Team name to grant permissions to (optional).
+ // +kubebuilder:validation:Optional
+ Team string `json:"team,omitempty"`
+
+ // Permission level: Edit or Admin.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:Enum=Edit;Admin
+ Permission string `json:"permission"`
+}
+
+// GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount.
+type GrafanaServiceAccountSpec struct {
+ GrafanaCommonSpec `json:",inline"`
+
+ // Name is the desired name of the service account in Grafana.
+ // +kubebuilder:validation:Required
+ Name string `json:"name"`
+
+ // Role is the Grafana role for the service account (Viewer, Editor, Admin).
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:Enum=Viewer;Editor;Admin
+ Role string `json:"role"`
+
+ // IsDisabled indicates if the service account should be disabled in Grafana.
+ // +kubebuilder:validation:Optional
+ IsDisabled bool `json:"isDisabled,omitempty"`
+
+ // Tokens defines API tokens to create for this service account. Each token will be stored in a Kubernetes Secret with the given name.
+ // +kubebuilder:validation:Optional
+ Tokens []GrafanaServiceAccountToken `json:"tokens,omitempty"`
+
+ // Permissions specifies additional Grafana permission grants for existing users or teams on this service account.
+ // +kubebuilder:validation:Optional
+ Permissions []GrafanaServiceAccountPermission `json:"permissions,omitempty"`
+
+ // GenerateTokenSecret, if true, will create one default API token in a Secret if no Tokens are specified.
+ // If false, no token is created unless explicitly listed in Tokens.
+ // +kubebuilder:default=true
+ GenerateTokenSecret bool `json:"generateTokenSecret,omitempty"`
+}
+
+// GrafanaServiceAccountInstanceStatus holds status for one Grafana instance.
+type GrafanaServiceAccountInstanceStatus struct {
+ // GrafanaNamespace and GrafanaName specify which Grafana resource this status record belongs to.
+ GrafanaNamespace string `json:"grafanaNamespace"`
+ GrafanaName string `json:"grafanaName"`
+
+ // ServiceAccountID is the numeric ID of the service account in this Grafana.
+ ServiceAccountID int64 `json:"serviceAccountID"`
+
+ // Tokens is the status of tokens for this service account in Grafana.
+ Tokens []GrafanaServiceAccountTokenStatus `json:"tokens,omitempty"`
+}
+
+// GrafanaServiceAccountStatus defines the observed state of a GrafanaServiceAccount.
+type GrafanaServiceAccountStatus struct {
+ GrafanaCommonStatus `json:",inline"`
+
+ // Instances lists Grafana instances where this service account is applied.
+ // +optional
+ Instances []GrafanaServiceAccountInstanceStatus `json:"instances,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+
+// GrafanaServiceAccount is the Schema for the grafanaserviceaccounts API.
+// +kubebuilder:printcolumn:name="Last resync",type="date",format="date-time",JSONPath=".status.lastResync",description=""
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description=""
+// +kubebuilder:resource:categories={grafana-operator}
+type GrafanaServiceAccount struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec GrafanaServiceAccountSpec `json:"spec,omitempty"`
+ Status GrafanaServiceAccountStatus `json:"status,omitempty"`
+}
+
+//+kubebuilder:object:root=true
+
+// GrafanaServiceAccountList contains a list of GrafanaServiceAccount objects.
+type GrafanaServiceAccountList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []GrafanaServiceAccount `json:"items"`
+}
+
+// Find searches for a GrafanaServiceAccount by namespace/name in the list.
+func (in *GrafanaServiceAccountList) Find(namespace, name string) *GrafanaServiceAccount {
+ for _, serviceAccount := range in.Items {
+ if serviceAccount.Namespace == namespace && serviceAccount.Name == name {
+ return &serviceAccount
+ }
+ }
+ return nil
+}
+
+// MatchLabels returns the LabelSelector (from GrafanaCommonSpec) to find matching Grafana instances.
+func (in *GrafanaServiceAccount) MatchLabels() *metav1.LabelSelector {
+ return in.Spec.InstanceSelector
+}
+
+// MatchNamespace returns the namespace where this service account is defined.
+func (in *GrafanaServiceAccount) MatchNamespace() string {
+ return in.ObjectMeta.Namespace
+}
+
+// AllowCrossNamespace indicates whether cross-namespace import is allowed for this resource.
+func (in *GrafanaServiceAccount) AllowCrossNamespace() bool {
+ return in.Spec.AllowCrossNamespaceImport
+}
+
+func init() {
+ SchemeBuilder.Register(&GrafanaServiceAccount{}, &GrafanaServiceAccountList{})
+}
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index 013e22651..c24827e41 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -1730,6 +1730,185 @@ func (in *GrafanaPreferences) DeepCopy() *GrafanaPreferences {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccount) DeepCopyInto(out *GrafanaServiceAccount) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccount.
+func (in *GrafanaServiceAccount) DeepCopy() *GrafanaServiceAccount {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccount)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *GrafanaServiceAccount) 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 *GrafanaServiceAccountInstanceStatus) DeepCopyInto(out *GrafanaServiceAccountInstanceStatus) {
+ *out = *in
+ if in.Tokens != nil {
+ in, out := &in.Tokens, &out.Tokens
+ *out = make([]GrafanaServiceAccountTokenStatus, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountInstanceStatus.
+func (in *GrafanaServiceAccountInstanceStatus) DeepCopy() *GrafanaServiceAccountInstanceStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountInstanceStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccountList) DeepCopyInto(out *GrafanaServiceAccountList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]GrafanaServiceAccount, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountList.
+func (in *GrafanaServiceAccountList) DeepCopy() *GrafanaServiceAccountList {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *GrafanaServiceAccountList) 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 *GrafanaServiceAccountPermission) DeepCopyInto(out *GrafanaServiceAccountPermission) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountPermission.
+func (in *GrafanaServiceAccountPermission) DeepCopy() *GrafanaServiceAccountPermission {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountPermission)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccountSpec) DeepCopyInto(out *GrafanaServiceAccountSpec) {
+ *out = *in
+ in.GrafanaCommonSpec.DeepCopyInto(&out.GrafanaCommonSpec)
+ if in.Tokens != nil {
+ in, out := &in.Tokens, &out.Tokens
+ *out = make([]GrafanaServiceAccountToken, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.Permissions != nil {
+ in, out := &in.Permissions, &out.Permissions
+ *out = make([]GrafanaServiceAccountPermission, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountSpec.
+func (in *GrafanaServiceAccountSpec) DeepCopy() *GrafanaServiceAccountSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccountStatus) DeepCopyInto(out *GrafanaServiceAccountStatus) {
+ *out = *in
+ in.GrafanaCommonStatus.DeepCopyInto(&out.GrafanaCommonStatus)
+ if in.Instances != nil {
+ in, out := &in.Instances, &out.Instances
+ *out = make([]GrafanaServiceAccountInstanceStatus, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountStatus.
+func (in *GrafanaServiceAccountStatus) DeepCopy() *GrafanaServiceAccountStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccountToken) DeepCopyInto(out *GrafanaServiceAccountToken) {
+ *out = *in
+ if in.Expires != nil {
+ in, out := &in.Expires, &out.Expires
+ *out = (*in).DeepCopy()
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountToken.
+func (in *GrafanaServiceAccountToken) DeepCopy() *GrafanaServiceAccountToken {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountToken)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *GrafanaServiceAccountTokenStatus) DeepCopyInto(out *GrafanaServiceAccountTokenStatus) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountTokenStatus.
+func (in *GrafanaServiceAccountTokenStatus) DeepCopy() *GrafanaServiceAccountTokenStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountTokenStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GrafanaSpec) DeepCopyInto(out *GrafanaSpec) {
*out = *in
@@ -1826,6 +2005,11 @@ func (in *GrafanaStatus) DeepCopyInto(out *GrafanaStatus) {
*out = make(NamespacedResourceList, len(*in))
copy(*out, *in)
}
+ if in.ServiceAccounts != nil {
+ in, out := &in.ServiceAccounts, &out.ServiceAccounts
+ *out = make(NamespacedResourceList, len(*in))
+ copy(*out, *in)
+ }
if in.Folders != nil {
in, out := &in.Folders, &out.Folders
*out = make(NamespacedResourceList, len(*in))
diff --git a/config/crd/bases/grafana.integreatly.org_grafanas.yaml b/config/crd/bases/grafana.integreatly.org_grafanas.yaml
index 8c760c40d..ff8f886b3 100644
--- a/config/crd/bases/grafana.integreatly.org_grafanas.yaml
+++ b/config/crd/bases/grafana.integreatly.org_grafanas.yaml
@@ -9515,6 +9515,10 @@ spec:
items:
type: string
type: array
+ serviceaccounts:
+ items:
+ type: string
+ type: array
stage:
type: string
stageStatus:
diff --git a/config/crd/bases/grafana.integreatly.org_grafanaserviceaccounts.yaml b/config/crd/bases/grafana.integreatly.org_grafanaserviceaccounts.yaml
new file mode 100644
index 000000000..54c6ff4a9
--- /dev/null
+++ b/config/crd/bases/grafana.integreatly.org_grafanaserviceaccounts.yaml
@@ -0,0 +1,319 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.16.3
+ name: grafanaserviceaccounts.grafana.integreatly.org
+spec:
+ group: grafana.integreatly.org
+ names:
+ categories:
+ - grafana-operator
+ kind: GrafanaServiceAccount
+ listKind: GrafanaServiceAccountList
+ plural: grafanaserviceaccounts
+ singular: grafanaserviceaccount
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - format: date-time
+ jsonPath: .status.lastResync
+ name: Last resync
+ type: date
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1beta1
+ schema:
+ openAPIV3Schema:
+ description: GrafanaServiceAccount is the Schema for the grafanaserviceaccounts
+ 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:
+ description: GrafanaServiceAccountSpec defines the desired state of a
+ GrafanaServiceAccount.
+ properties:
+ allowCrossNamespaceImport:
+ default: false
+ description: Allow the Operator to match this resource with Grafanas
+ outside the current namespace
+ type: boolean
+ generateTokenSecret:
+ default: true
+ description: |-
+ GenerateTokenSecret, if true, will create one default API token in a Secret if no Tokens are specified.
+ If false, no token is created unless explicitly listed in Tokens.
+ type: boolean
+ instanceSelector:
+ description: Selects Grafana instances for import
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements.
+ The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies
+ to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: spec.instanceSelector is immutable
+ rule: self == oldSelf
+ isDisabled:
+ description: IsDisabled indicates if the service account should be
+ disabled in Grafana.
+ type: boolean
+ name:
+ description: Name is the desired name of the service account in Grafana.
+ type: string
+ permissions:
+ description: Permissions specifies additional Grafana permission grants
+ for existing users or teams on this service account.
+ items:
+ description: GrafanaServiceAccountPermission defines a permission
+ grant for a user or team.
+ properties:
+ permission:
+ description: 'Permission level: Edit or Admin.'
+ enum:
+ - Edit
+ - Admin
+ type: string
+ team:
+ description: Team name to grant permissions to (optional).
+ type: string
+ user:
+ description: User login or email to grant permissions to (optional).
+ type: string
+ required:
+ - permission
+ type: object
+ x-kubernetes-validations:
+ - message: one of user or team must be set
+ rule: (has(self.user) && self.user != '') || (has(self.team) &&
+ self.team != '')
+ - message: user and team cannot both be set
+ rule: '!((has(self.user) && self.user != '''') && (has(self.team)
+ && self.team != ''''))'
+ type: array
+ resyncPeriod:
+ default: 10m0s
+ description: How often the resource is synced, defaults to 10m0s if
+ not set
+ format: duration
+ pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
+ type: string
+ role:
+ description: Role is the Grafana role for the service account (Viewer,
+ Editor, Admin).
+ enum:
+ - Viewer
+ - Editor
+ - Admin
+ type: string
+ tokens:
+ description: Tokens defines API tokens to create for this service
+ account. Each token will be stored in a Kubernetes Secret with the
+ given name.
+ items:
+ description: GrafanaServiceAccountToken describes a token to create.
+ properties:
+ expires:
+ description: Expires is the optional expiration time for the
+ token. After this time, the operator may rotate the token.
+ format: date-time
+ type: string
+ name:
+ description: Name is the name of the Kubernetes Secret (and
+ token identifier in Grafana). The secret will contain the
+ token value.
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ required:
+ - instanceSelector
+ - name
+ - role
+ type: object
+ x-kubernetes-validations:
+ - message: disabling spec.allowCrossNamespaceImport requires a recreate
+ to ensure desired state
+ rule: '!oldSelf.allowCrossNamespaceImport || (oldSelf.allowCrossNamespaceImport
+ && self.allowCrossNamespaceImport)'
+ status:
+ description: GrafanaServiceAccountStatus defines the observed state of
+ a GrafanaServiceAccount.
+ properties:
+ conditions:
+ description: Results when synchonizing resource with Grafana instances
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ instances:
+ description: Instances lists Grafana instances where this service
+ account is applied.
+ items:
+ description: GrafanaServiceAccountInstanceStatus holds status for
+ one Grafana instance.
+ properties:
+ grafanaName:
+ type: string
+ grafanaNamespace:
+ description: GrafanaNamespace and GrafanaName specify which
+ Grafana resource this status record belongs to.
+ type: string
+ serviceAccountID:
+ description: ServiceAccountID is the numeric ID of the service
+ account in this Grafana.
+ format: int64
+ type: integer
+ tokens:
+ description: Tokens is the status of tokens for this service
+ account in Grafana.
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a
+ token created in Grafana.
+ properties:
+ name:
+ description: Name of the token (same as Secret name).
+ type: string
+ secretName:
+ description: |-
+ SecretName is the name of the Kubernetes Secret that stores the actual token value.
+ This may seem redundant if the Secret name usually matches the token's Name,
+ but it's stored explicitly in Status for clarity and future flexibility.
+ type: string
+ tokenId:
+ description: TokenID is the Grafana-assigned ID of the
+ token.
+ format: int64
+ type: integer
+ required:
+ - name
+ - secretName
+ - tokenId
+ type: object
+ type: array
+ required:
+ - grafanaName
+ - grafanaNamespace
+ - serviceAccountID
+ type: object
+ type: array
+ lastResync:
+ description: Last time the resource was synchronized with Grafana
+ instances
+ format: date-time
+ type: string
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 4409f56ca..6f61217b9 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -5,6 +5,7 @@ resources:
- bases/grafana.integreatly.org_grafanas.yaml
- bases/grafana.integreatly.org_grafanadashboards.yaml
- bases/grafana.integreatly.org_grafanadatasources.yaml
+- bases/grafana.integreatly.org_grafanaserviceaccounts.yaml
- bases/grafana.integreatly.org_grafanafolders.yaml
- bases/grafana.integreatly.org_grafanaalertrulegroups.yaml
- bases/grafana.integreatly.org_grafanacontactpoints.yaml
diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml
index e995e200f..a50a56b28 100644
--- a/config/manager/kustomization.yaml
+++ b/config/manager/kustomization.yaml
@@ -15,4 +15,4 @@ configMapGenerator:
images:
- name: controller
newName: ghcr.io/grafana/grafana-operator
- newTag: v5.8.1
+ newTag: v5.17.0
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index bf20de0f4..9619ab742 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -68,6 +68,7 @@ rules:
- grafananotificationpolicyroutes
- grafananotificationtemplates
- grafanas
+ - grafanaserviceaccounts
verbs:
- create
- delete
@@ -90,6 +91,7 @@ rules:
- grafananotificationpolicyroutes/finalizers
- grafananotificationtemplates/finalizers
- grafanas/finalizers
+ - grafanaserviceaccounts/finalizers
verbs:
- update
- apiGroups:
@@ -106,6 +108,7 @@ rules:
- grafananotificationpolicyroutes/status
- grafananotificationtemplates/status
- grafanas/status
+ - grafanaserviceaccounts/status
verbs:
- get
- patch
diff --git a/controllers/metrics/metrics.go b/controllers/metrics/metrics.go
index 23851f50e..aec310c39 100644
--- a/controllers/metrics/metrics.go
+++ b/controllers/metrics/metrics.go
@@ -71,6 +71,13 @@ var (
Help: "time in ms to sync datasources after operator restart",
})
+ InitialServiceAccountSyncDuration = prometheus.NewGauge(prometheus.GaugeOpts{
+ Namespace: "grafana_operator",
+ Subsystem: "serviceaccounts",
+ Name: "initial_sync_duration",
+ Help: "time in ms to sync serviceaccounts after operator restart",
+ })
+
InitialFoldersSyncDuration = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "grafana_operator",
Subsystem: "folders",
diff --git a/controllers/serviceaccount_controller.go b/controllers/serviceaccount_controller.go
new file mode 100644
index 000000000..0daf1287b
--- /dev/null
+++ b/controllers/serviceaccount_controller.go
@@ -0,0 +1,746 @@
+/*
+Copyright 2025.
+
+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 controllers
+
+import (
+ "context"
+ "crypto/sha1"
+ "errors"
+ "fmt"
+ "reflect"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ genapi "github.com/grafana/grafana-openapi-client-go/client"
+ "github.com/grafana/grafana-openapi-client-go/client/access_control"
+ "github.com/grafana/grafana-openapi-client-go/client/health"
+ "github.com/grafana/grafana-openapi-client-go/client/service_accounts"
+ "github.com/grafana/grafana-openapi-client-go/client/teams"
+ "github.com/grafana/grafana-openapi-client-go/client/users"
+ "github.com/grafana/grafana-openapi-client-go/models"
+ v1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1"
+ client2 "github.com/grafana/grafana-operator/v5/controllers/client"
+
+ corev1 "k8s.io/api/core/v1"
+ kuberr "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/labels"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/util/retry"
+ 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/event"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+)
+
+const (
+ conditionServiceAccountSynchronized = "ServiceAccountSynchronized"
+)
+
+// GrafanaServiceAccountReconciler reconciles a GrafanaServiceAccount object.
+type GrafanaServiceAccountReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+}
+
+// +kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafanaserviceaccounts,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafanaserviceaccounts/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafanaserviceaccounts/finalizers,verbs=update
+
+// Reconcile implements the main reconciliation loop.
+func (r *GrafanaServiceAccountReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ log := logf.FromContext(ctx).WithName("GrafanaServiceAccountReconciler")
+ ctx = logf.IntoContext(ctx, log)
+
+ log.V(1).Info("Reconciling GrafanaServiceAccount started")
+ defer log.V(1).Info("Reconciling GrafanaServiceAccount finished")
+
+ cr := &v1beta1.GrafanaServiceAccount{}
+ err := r.Client.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, cr)
+ if err != nil {
+ if kuberr.IsNotFound(err) {
+ log.V(1).Info("GrafanaServiceAccount not found, skipping")
+ return ctrl.Result{}, nil
+ }
+ return ctrl.Result{}, fmt.Errorf("getting GrafanaServiceAccount %q: %w", req, err)
+ }
+
+ if cr.DeletionTimestamp != nil {
+ log.V(1).Info("GrafanaServiceAccount is being deleted")
+ err := r.finalize(ctx, cr)
+ if err != nil {
+ return ctrl.Result{}, fmt.Errorf("running finalizer: %w", err)
+ }
+ return ctrl.Result{}, nil
+ }
+
+ // At the end, update status and set/unset finalizer if needed
+ defer r.updateStatusAndFinalizer(ctx, cr)
+
+ // Find matching Grafana instances
+ grafanas, err := GetScopedMatchingInstances(ctx, r.Client, cr)
+ if err != nil {
+ setNoMatchingInstancesCondition(&cr.Status.Conditions, cr.Generation, err)
+ meta.RemoveStatusCondition(&cr.Status.Conditions, conditionServiceAccountSynchronized)
+ return ctrl.Result{}, fmt.Errorf("fetching Grafana instances: %w", err)
+ }
+ if len(grafanas) == 0 {
+ // No matching Grafana, set a condition and requeue
+ setNoMatchingInstancesCondition(&cr.Status.Conditions, cr.Generation, nil)
+ meta.RemoveStatusCondition(&cr.Status.Conditions, conditionServiceAccountSynchronized)
+ return ctrl.Result{RequeueAfter: RequeueDelay}, nil
+ }
+ removeNoMatchingInstance(&cr.Status.Conditions)
+ removeInvalidSpec(&cr.Status.Conditions)
+ log.V(1).Info("found matching Grafana instances", "count", len(grafanas))
+
+ // Reconcile service accounts for each matching Grafana
+ applyErrors := map[string]string{}
+ for _, grafana := range grafanas {
+ err := r.reconcileGrafana(ctx, grafana, cr)
+ if err == nil {
+ continue
+ }
+
+ switch {
+ case errors.Is(err, &service_accounts.CreateTokenBadRequest{}):
+ setInvalidSpec(&cr.Status.Conditions, cr.Generation, "InvalidSpec", err.Error())
+ meta.RemoveStatusCondition(&cr.Status.Conditions, conditionServiceAccountSynchronized)
+ case kuberr.IsAlreadyExists(err):
+ setInvalidSpec(&cr.Status.Conditions, cr.Generation, "InvalidSpec", err.Error())
+ meta.RemoveStatusCondition(&cr.Status.Conditions, conditionServiceAccountSynchronized)
+ case kuberr.IsConflict(err):
+ meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
+ Type: conditionServiceAccountSynchronized,
+ Status: metav1.ConditionFalse,
+ Reason: "Conflict",
+ Message: err.Error(),
+ ObservedGeneration: cr.GetGeneration(),
+ })
+ meta.RemoveStatusCondition(&cr.Status.Conditions, conditionServiceAccountSynchronized)
+ }
+
+ applyErrors[strings.Join([]string{grafana.Namespace, grafana.Name}, "/")] = fmt.Sprintf("reconciling Grafana: %v", err)
+ }
+
+ meta.SetStatusCondition(&cr.Status.Conditions, buildSynchronizedCondition(
+ "ServiceAccount",
+ conditionServiceAccountSynchronized,
+ cr.Generation,
+ applyErrors,
+ len(grafanas),
+ ))
+
+ if len(applyErrors) > 0 {
+ return ctrl.Result{}, fmt.Errorf("applying changes to some instances: %v", applyErrors)
+ }
+
+ return ctrl.Result{RequeueAfter: cr.Spec.ResyncPeriod.Duration}, nil
+}
+
+func (r *GrafanaServiceAccountReconciler) reconcileGrafana(
+ ctx context.Context,
+ grafana v1beta1.Grafana,
+ cr *v1beta1.GrafanaServiceAccount,
+) error {
+ grafanaClient, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, &grafana)
+ if err != nil {
+ return fmt.Errorf("creating Grafana client: %w", err)
+ }
+
+ resp, err := grafanaClient.Health.GetHealthWithParams(health.NewGetHealthParamsWithContext(ctx))
+ if err != nil {
+ return fmt.Errorf("getting Grafana health: %w", err)
+ }
+ if !resp.IsSuccess() {
+ return fmt.Errorf("Grafana is not healthy: %w", resp)
+ }
+
+ // Reconcile one instance record in cr.Status.Instances
+ grafanaRef, err := r.reconcileGrafanaInstance(ctx, grafanaClient, cr, &grafana)
+ if err != nil {
+ return fmt.Errorf("reconciling Grafana instance: %w", err)
+ }
+
+ // Update the Grafana status.ServiceAccounts (for housekeeping)
+ saUID := strconv.FormatInt(grafanaRef.ServiceAccountID, 10)
+ err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
+ latest := &v1beta1.Grafana{}
+ err := r.Client.Get(ctx, types.NamespacedName{Namespace: grafana.Namespace, Name: grafana.Name}, latest)
+ if err != nil {
+ return fmt.Errorf("getting Grafana %s/%s: %w", grafana.Namespace, grafana.Name, err)
+ }
+ latest.Status.ServiceAccounts = latest.Status.ServiceAccounts.Add(cr.Namespace, cr.Name, saUID)
+ return r.Client.Status().Update(ctx, latest)
+ })
+ if err != nil {
+ return fmt.Errorf("updating Grafana status: %w", err)
+ }
+
+ return nil
+}
+
+// reconcileGrafanaInstance ensures that for a given Grafana instance, we have a record in cr.Status.Instances,
+// sets up (create/update) the service account and tokens, and returns the updated instance record.
+func (r *GrafanaServiceAccountReconciler) reconcileGrafanaInstance(
+ ctx context.Context,
+ grafanaClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+ grafana *v1beta1.Grafana,
+) (*v1beta1.GrafanaServiceAccountInstanceStatus, error) {
+ idx := slices.IndexFunc(cr.Status.Instances, func(si v1beta1.GrafanaServiceAccountInstanceStatus) bool {
+ return si.GrafanaNamespace == grafana.Namespace && si.GrafanaName == grafana.Name
+ })
+ if idx == -1 {
+ cr.Status.Instances = append(cr.Status.Instances, v1beta1.GrafanaServiceAccountInstanceStatus{
+ GrafanaNamespace: grafana.Namespace,
+ GrafanaName: grafana.Name,
+ ServiceAccountID: 0,
+ Tokens: nil,
+ })
+ idx = len(cr.Status.Instances) - 1
+ }
+ instance := &cr.Status.Instances[idx]
+
+ search, err := grafanaClient.ServiceAccounts.SearchOrgServiceAccountsWithPaging(
+ service_accounts.NewSearchOrgServiceAccountsWithPagingParamsWithContext(ctx).
+ WithQuery(&cr.Spec.Name),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("searching service accounts: %w", err)
+ }
+ for _, sa := range search.Payload.ServiceAccounts {
+ if sa.Name == cr.Spec.Name {
+ if instance.ServiceAccountID == 0 || instance.ServiceAccountID == sa.ID {
+ instance.ServiceAccountID = sa.ID
+ break
+ }
+
+ return nil, kuberr.NewConflict(
+ schema.GroupResource{
+ Group: v1beta1.GroupVersion.Group,
+ Resource: "GrafanaServiceAccount",
+ },
+ "GrafanaServiceAccount",
+ fmt.Errorf("Grafana has service account name=%q ID=%d, but instance record ID=%d", sa.Name, sa.ID, instance.ServiceAccountID),
+ )
+ }
+ }
+
+ if instance.ServiceAccountID == 0 {
+ resp, err := grafanaClient.ServiceAccounts.CreateServiceAccount(
+ service_accounts.NewCreateServiceAccountParamsWithContext(ctx).
+ WithBody(&models.CreateServiceAccountForm{
+ Name: cr.Spec.Name,
+ Role: cr.Spec.Role,
+ IsDisabled: cr.Spec.IsDisabled,
+ }),
+ )
+ if err != nil {
+ return instance, fmt.Errorf("creating service account: %w", err)
+ }
+ instance.ServiceAccountID = resp.Payload.ID
+ } else {
+ _, err := grafanaClient.ServiceAccounts.UpdateServiceAccount( // nolint:errcheck
+ service_accounts.NewUpdateServiceAccountParamsWithContext(ctx).
+ WithBody(&models.UpdateServiceAccountForm{
+ Name: cr.Spec.Name,
+ Role: cr.Spec.Role,
+ IsDisabled: cr.Spec.IsDisabled,
+ ServiceAccountID: instance.ServiceAccountID,
+ }).
+ WithServiceAccountID(instance.ServiceAccountID),
+ )
+ if err != nil {
+ return instance, fmt.Errorf("update service account: %w", err)
+ }
+ }
+
+ err = r.reconcileTokens(ctx, grafanaClient, cr, instance)
+ if err != nil {
+ return instance, fmt.Errorf("reconcile tokens: %w", err)
+ }
+ err = r.reconcilePermissionsForInstance(ctx, grafanaClient, cr, instance)
+ if err != nil {
+ return instance, fmt.Errorf("reconcile permissions: %w", err)
+ }
+
+ return instance, nil
+}
+
+// finalize is called when the CR is being deleted. We remove service accounts and secrets
+// from all instances, then remove finalizer.
+func (r *GrafanaServiceAccountReconciler) finalize(ctx context.Context, cr *v1beta1.GrafanaServiceAccount) error {
+ if !controllerutil.ContainsFinalizer(cr, grafanaFinalizer) {
+ return nil
+ }
+ log := logf.FromContext(ctx)
+
+ // For each instance in .status.instances, remove tokens, remove the service account from Grafana,
+ // and remove reference from grafana.Status.ServiceAccounts
+ for _, instance := range cr.Status.Instances {
+ log := log.WithValues(
+ "grafanaNamespace", instance.GrafanaNamespace,
+ "grafanaName", instance.GrafanaName,
+ )
+
+ // Check if the corresponding Grafana object is still around
+ grafana := &v1beta1.Grafana{}
+ err := r.Client.Get(ctx, types.NamespacedName{
+ Namespace: instance.GrafanaNamespace,
+ Name: instance.GrafanaName,
+ }, grafana)
+ if err != nil {
+ log.Error(err, "unable to find Grafana instance for finalization")
+ continue
+ }
+
+ grafanaClient, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, grafana)
+ if err != nil {
+ log.Error(err, "unable to create Grafana client for finalization")
+ continue
+ }
+
+ for _, token := range instance.Tokens {
+ err := r.revokeToken(ctx, grafanaClient, instance.ServiceAccountID, token.TokenID)
+ if err != nil {
+ log.Error(err, "failed to revoke token", "tokenID", token.TokenID)
+ }
+ err = r.revokeSecret(ctx, cr.Namespace, token.SecretName)
+ if err != nil {
+ log.Error(err, "failed to remove token secret", "secretName", token.SecretName)
+ }
+ }
+
+ if instance.ServiceAccountID != 0 {
+ _, err := grafanaClient.ServiceAccounts.DeleteServiceAccountWithParams( // nolint:errcheck
+ service_accounts.NewDeleteServiceAccountParamsWithContext(ctx).
+ WithServiceAccountID(instance.ServiceAccountID),
+ )
+ if err != nil {
+ logf.FromContext(ctx).Error(err, "failed to delete service account from Grafana", "serviceAccountID", instance.ServiceAccountID)
+ return err
+ }
+ }
+
+ err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
+ latest := &v1beta1.Grafana{}
+ err := r.Client.Get(ctx, types.NamespacedName{
+ Namespace: grafana.Namespace,
+ Name: grafana.Name,
+ }, latest)
+ if err != nil {
+ return err
+ }
+ latest.Status.ServiceAccounts = latest.Status.ServiceAccounts.Remove(cr.Namespace, cr.Name)
+ return r.Client.Status().Update(ctx, latest)
+ })
+ if err != nil {
+ log.Error(err, "failed to update Grafana status during finalization", "grafana", grafana.Name)
+ }
+ }
+
+ err := removeFinalizer(ctx, r.Client, cr)
+ if err != nil {
+ return fmt.Errorf("removing finalizer %s/%s: %w", cr.Namespace, cr.Name, err)
+ }
+
+ return nil
+}
+
+func generateTokenSecretName(crName, grafanaName, tokenName string) string {
+ const maxSecretNameLength = 63
+
+ sanitizeK8sName := func(s string) string {
+ s = strings.ToLower(s)
+ s = strings.ReplaceAll(s, "_", "-")
+ return s
+ }
+
+ base := fmt.Sprintf("%s-%s-%s", crName, grafanaName, tokenName)
+ if len(base) <= maxSecretNameLength {
+ return sanitizeK8sName(base)
+ }
+
+ prefixLen := maxSecretNameLength - 7
+ if prefixLen < 1 {
+ prefixLen = 1
+ }
+ prefix := base[:prefixLen]
+
+ sum := sha1.Sum([]byte(base)) // nolint:gosec
+ shortHash := fmt.Sprintf("%x", sum[:3])
+
+ return sanitizeK8sName(fmt.Sprintf("%s-%s", prefix, shortHash))
+}
+
+// reconcileTokens cleans up expired or removed tokens, then creates missing ones.
+func (r *GrafanaServiceAccountReconciler) reconcileTokens(
+ ctx context.Context,
+ grafanaClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+ instanceStatus *v1beta1.GrafanaServiceAccountInstanceStatus,
+) error {
+ if cr.Spec.GenerateTokenSecret && len(cr.Spec.Tokens) == 0 {
+ cr.Spec.Tokens = []v1beta1.GrafanaServiceAccountToken{{
+ Name: fmt.Sprintf("%s-default-token", cr.Name),
+ }}
+ }
+
+ // Remove tokens that are no longer valid or in spec
+ now := time.Now()
+
+ desiredTokens := make(map[string]*metav1.Time, len(cr.Spec.Tokens))
+ for _, t := range cr.Spec.Tokens {
+ desiredTokens[t.Name] = t.Expires
+ }
+
+ var kept []v1beta1.GrafanaServiceAccountTokenStatus
+ for _, existing := range instanceStatus.Tokens {
+ expires, found := desiredTokens[existing.Name]
+ if found && (expires == nil || now.Before(expires.Time)) {
+ kept = append(kept, existing)
+ continue
+ }
+
+ err := r.revokeToken(ctx, grafanaClient, instanceStatus.ServiceAccountID, existing.TokenID)
+ if err != nil {
+ return fmt.Errorf("revoking token %d: %w", existing.TokenID, err)
+ }
+ err = r.revokeSecret(ctx, cr.Namespace, existing.SecretName)
+ if err != nil {
+ return fmt.Errorf("removing token secret %s: %w", existing.SecretName, err)
+ }
+ }
+ instanceStatus.Tokens = kept
+
+ // Create new ones if needed
+ existingSecrets := make(map[string]struct{}, len(instanceStatus.Tokens))
+ for _, token := range instanceStatus.Tokens {
+ existingSecrets[token.SecretName] = struct{}{}
+ }
+ for _, token := range cr.Spec.Tokens {
+ secretName := generateTokenSecretName(cr.Name, instanceStatus.GrafanaName, token.Name)
+ if _, exists := existingSecrets[secretName]; exists {
+ continue
+ }
+
+ cmd := models.AddServiceAccountTokenCommand{Name: token.Name}
+ if token.Expires != nil {
+ cmd.SecondsToLive = int64(time.Until(token.Expires.Time).Seconds())
+ }
+ resp, err := grafanaClient.ServiceAccounts.CreateToken(
+ service_accounts.NewCreateTokenParamsWithContext(ctx).
+ WithServiceAccountID(instanceStatus.ServiceAccountID).
+ WithBody(&cmd),
+ )
+ if err != nil {
+ return fmt.Errorf("creating token %q for service account %d: %w", token.Name, instanceStatus.ServiceAccountID, err)
+ }
+ keyResult := resp.Payload
+
+ // Create a Kubernetes Secret
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: secretName,
+ Namespace: cr.Namespace,
+ Labels: map[string]string{"app": "grafana-serviceaccount-token"},
+ },
+ Data: map[string][]byte{
+ "token": []byte(keyResult.Key),
+ },
+ }
+ if token.Expires != nil {
+ secret.Annotations = map[string]string{
+ "grafana.integreatly.org/token-expiry": token.Expires.Format(time.RFC3339),
+ }
+ }
+ err = controllerutil.SetControllerReference(cr, secret, r.Scheme)
+ if err != nil {
+ logf.FromContext(ctx).Error(err, "failed to set owner reference on token secret")
+ }
+ err = r.Client.Create(ctx, secret)
+ if err != nil {
+ return fmt.Errorf("creating token secret %s: %w", secretName, err)
+ }
+
+ instanceStatus.Tokens = append(instanceStatus.Tokens, v1beta1.GrafanaServiceAccountTokenStatus{
+ Name: keyResult.Name,
+ TokenID: keyResult.ID,
+ SecretName: secretName,
+ })
+ existingSecrets[secretName] = struct{}{}
+ }
+
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) reconcilePermissionsForInstance(
+ ctx context.Context,
+ gafanaClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+ instance *v1beta1.GrafanaServiceAccountInstanceStatus,
+) error {
+ if instance.ServiceAccountID == 0 {
+ // No service account ID yet, so skip
+ return nil
+ }
+
+ const resource = "serviceaccounts"
+ resourceID := strconv.FormatInt(instance.ServiceAccountID, 10)
+
+ // Check if the resource exists
+ resp, err := gafanaClient.AccessControl.GetResourcePermissionsWithParams(
+ access_control.NewGetResourcePermissionsParamsWithContext(ctx).
+ WithResource(resource).
+ WithResourceID(resourceID),
+ )
+ if err != nil {
+ return fmt.Errorf("listing current resource permissions for service account %d: %w", instance.ServiceAccountID, err)
+ }
+
+ type subject struct {
+ teamID int64
+ userID int64
+ }
+ desired := map[subject]string{}
+
+ // Collect desired
+ for _, p := range cr.Spec.Permissions {
+ switch {
+ case p.Team != "" && p.User == "":
+ resp, err := gafanaClient.Teams.SearchTeams(
+ teams.NewSearchTeamsParamsWithContext(ctx).WithQuery(&p.Team),
+ )
+ if err != nil {
+ return fmt.Errorf("searching Grafana team %q: %w", p.Team, err)
+ }
+ if resp.Payload.TotalCount == 0 {
+ return fmt.Errorf("team %q not found in Grafana", p.Team)
+ }
+ if resp.Payload.TotalCount > 1 {
+ return fmt.Errorf("multiple teams found with name %q", p.Team)
+ }
+ desired[subject{teamID: resp.Payload.Teams[0].ID}] = p.Permission
+
+ case p.Team == "" && p.User != "":
+ resp, err := gafanaClient.Users.GetUserByLoginOrEmailWithParams(
+ users.NewGetUserByLoginOrEmailParamsWithContext(ctx).
+ WithLoginOrEmail(p.User),
+ )
+ if err != nil {
+ return fmt.Errorf("searching Grafana user %q: %w", p.User, err)
+ }
+ desired[subject{userID: resp.Payload.ID}] = p.Permission
+
+ default:
+ return fmt.Errorf("malformed permission entry: team=%q user=%q", p.Team, p.User)
+ }
+ }
+
+ var cmds []*models.SetResourcePermissionCommand
+
+ // Compare with existing permissions
+ existing := map[subject]struct{}{}
+ for _, curr := range resp.Payload {
+ s := subject{teamID: curr.TeamID, userID: curr.UserID}
+ desiredPerm, inDesired := desired[s]
+ if inDesired {
+ existing[s] = struct{}{}
+ }
+ if curr.Permission != desiredPerm {
+ cmds = append(cmds, &models.SetResourcePermissionCommand{
+ TeamID: s.teamID,
+ UserID: s.userID,
+ Permission: desiredPerm,
+ })
+ }
+ }
+
+ // Add new
+ for s, desiredPerm := range desired {
+ if _, exists := existing[s]; !exists {
+ cmds = append(cmds, &models.SetResourcePermissionCommand{
+ TeamID: s.teamID,
+ UserID: s.userID,
+ Permission: desiredPerm,
+ })
+ }
+ }
+
+ if len(cmds) == 0 {
+ return nil
+ }
+ _, err = gafanaClient.AccessControl.SetResourcePermissions( // nolint:errcheck
+ access_control.NewSetResourcePermissionsParamsWithContext(ctx).
+ WithResource(resource).
+ WithResourceID(resourceID).
+ WithBody(&models.SetPermissionsCommand{Permissions: cmds}),
+ )
+ if err != nil {
+ return fmt.Errorf("setting permissions for service account %d: %w", instance.ServiceAccountID, err)
+ }
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) SetupWithManager(mgr ctrl.Manager, _ context.Context) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(
+ &v1beta1.GrafanaServiceAccount{},
+ builder.WithPredicates(predicate.GenerationChangedPredicate{}),
+ ).
+ Watches(
+ &v1beta1.Grafana{},
+ handler.EnqueueRequestsFromMapFunc(r.grafanaToServiceAccounts),
+ builder.WithPredicates(
+ predicate.Funcs{
+ CreateFunc: func(e event.CreateEvent) bool { return true },
+ DeleteFunc: func(e event.DeleteEvent) bool { return true },
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ oldG, okOld := e.ObjectOld.(*v1beta1.Grafana)
+ newG, okNew := e.ObjectNew.(*v1beta1.Grafana)
+ if !okOld || !okNew {
+ return false
+ }
+ return !reflect.DeepEqual(oldG.Labels, newG.Labels)
+ },
+ GenericFunc: func(e event.GenericEvent) bool { return false },
+ },
+ ),
+ ).
+ WithEventFilter(ignoreStatusUpdates()).
+ Complete(r)
+}
+
+func (r *GrafanaServiceAccountReconciler) updateStatusAndFinalizer(ctx context.Context, cr *v1beta1.GrafanaServiceAccount) {
+ cr.Status.LastResync = metav1.Time{Time: time.Now()}
+
+ slices.SortFunc(cr.Status.Instances, func(a, b v1beta1.GrafanaServiceAccountInstanceStatus) int {
+ if a.GrafanaNamespace == b.GrafanaNamespace {
+ return strings.Compare(a.GrafanaName, b.GrafanaName)
+ }
+ return strings.Compare(a.GrafanaNamespace, b.GrafanaNamespace)
+ })
+
+ err := r.Status().Update(ctx, cr)
+ if err != nil {
+ logf.FromContext(ctx).Error(err, "failed to update GrafanaServiceAccount status")
+ }
+
+ if meta.IsStatusConditionTrue(cr.Status.Conditions, conditionNoMatchingInstance) {
+ err := removeFinalizer(ctx, r.Client, cr)
+ if err != nil {
+ logf.FromContext(ctx).Error(err, "failed to remove finalizer")
+ }
+ } else {
+ err := addFinalizer(ctx, r.Client, cr)
+ if err != nil {
+ logf.FromContext(ctx).Error(err, "failed to set finalizer")
+ }
+ }
+}
+
+// grafanaToServiceAccounts is used to enqueue GSA reconcile requests when a Grafana changes.
+func (r *GrafanaServiceAccountReconciler) grafanaToServiceAccounts(ctx context.Context, obj client.Object) []reconcile.Request {
+ ctx = logf.IntoContext(ctx, logf.FromContext(ctx).WithName("GrafanaServiceAccountReconciler"))
+
+ var list v1beta1.GrafanaServiceAccountList
+ err := r.List(ctx, &list)
+ if err != nil {
+ return nil
+ }
+
+ grafana, ok := obj.(*v1beta1.Grafana)
+ if !ok {
+ return nil
+ }
+
+ var requests []reconcile.Request
+ for _, serviceAccount := range list.Items {
+ if serviceAccount.Spec.InstanceSelector == nil {
+ continue
+ }
+ selector, err := metav1.LabelSelectorAsSelector(serviceAccount.Spec.InstanceSelector)
+ if err != nil {
+ logf.FromContext(ctx).Error(err, "invalid instanceSelector", "selector", serviceAccount.Spec.InstanceSelector)
+ continue
+ }
+ if selector.Matches(labels.Set(grafana.Labels)) {
+ requests = append(requests, reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Namespace: serviceAccount.Namespace,
+ Name: serviceAccount.Name,
+ },
+ })
+ }
+ }
+ return requests
+}
+
+func (r *GrafanaServiceAccountReconciler) revokeToken(
+ ctx context.Context,
+ client *genapi.GrafanaHTTPAPI,
+ serviceAccountID int64,
+ tokenID int64,
+) error {
+ _, err := client.ServiceAccounts.DeleteTokenWithParams( // nolint:errcheck
+ service_accounts.
+ NewDeleteTokenParamsWithContext(ctx).
+ WithServiceAccountID(serviceAccountID).
+ WithTokenID(tokenID),
+ )
+ if err == nil {
+ return nil
+ }
+
+ // TODO: check if this is the correct error type
+ if !errors.Is(err, &service_accounts.DeleteTokenInternalServerError{}) {
+ return fmt.Errorf("deleting token %d: %w", tokenID, err)
+ }
+
+ logf.FromContext(ctx).Info("token not found in Grafana, skip", "serviceAccountID", serviceAccountID, "tokenID", tokenID)
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) revokeSecret(ctx context.Context, namespace string, secretName string) error {
+ var secret corev1.Secret
+ err := r.Client.Get(ctx, client.ObjectKey{Name: secretName, Namespace: namespace}, &secret)
+ if err != nil {
+ if kuberr.IsNotFound(err) {
+ logf.FromContext(ctx).Info("token secret not found, skip", "secretName", secretName)
+ return nil
+ }
+ return fmt.Errorf("getting token secret %s: %w", secretName, err)
+ }
+
+ err = r.Client.Delete(ctx, &secret)
+ if err != nil {
+ return fmt.Errorf("deleting token secret %s: %w", secretName, err)
+ }
+
+ return nil
+}
diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml
index 8c760c40d..ff8f886b3 100644
--- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml
+++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml
@@ -9515,6 +9515,10 @@ spec:
items:
type: string
type: array
+ serviceaccounts:
+ items:
+ type: string
+ type: array
stage:
type: string
stageStatus:
diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanaserviceaccounts.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanaserviceaccounts.yaml
new file mode 100644
index 000000000..54c6ff4a9
--- /dev/null
+++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanaserviceaccounts.yaml
@@ -0,0 +1,319 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.16.3
+ name: grafanaserviceaccounts.grafana.integreatly.org
+spec:
+ group: grafana.integreatly.org
+ names:
+ categories:
+ - grafana-operator
+ kind: GrafanaServiceAccount
+ listKind: GrafanaServiceAccountList
+ plural: grafanaserviceaccounts
+ singular: grafanaserviceaccount
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - format: date-time
+ jsonPath: .status.lastResync
+ name: Last resync
+ type: date
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1beta1
+ schema:
+ openAPIV3Schema:
+ description: GrafanaServiceAccount is the Schema for the grafanaserviceaccounts
+ 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:
+ description: GrafanaServiceAccountSpec defines the desired state of a
+ GrafanaServiceAccount.
+ properties:
+ allowCrossNamespaceImport:
+ default: false
+ description: Allow the Operator to match this resource with Grafanas
+ outside the current namespace
+ type: boolean
+ generateTokenSecret:
+ default: true
+ description: |-
+ GenerateTokenSecret, if true, will create one default API token in a Secret if no Tokens are specified.
+ If false, no token is created unless explicitly listed in Tokens.
+ type: boolean
+ instanceSelector:
+ description: Selects Grafana instances for import
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements.
+ The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies
+ to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: spec.instanceSelector is immutable
+ rule: self == oldSelf
+ isDisabled:
+ description: IsDisabled indicates if the service account should be
+ disabled in Grafana.
+ type: boolean
+ name:
+ description: Name is the desired name of the service account in Grafana.
+ type: string
+ permissions:
+ description: Permissions specifies additional Grafana permission grants
+ for existing users or teams on this service account.
+ items:
+ description: GrafanaServiceAccountPermission defines a permission
+ grant for a user or team.
+ properties:
+ permission:
+ description: 'Permission level: Edit or Admin.'
+ enum:
+ - Edit
+ - Admin
+ type: string
+ team:
+ description: Team name to grant permissions to (optional).
+ type: string
+ user:
+ description: User login or email to grant permissions to (optional).
+ type: string
+ required:
+ - permission
+ type: object
+ x-kubernetes-validations:
+ - message: one of user or team must be set
+ rule: (has(self.user) && self.user != '') || (has(self.team) &&
+ self.team != '')
+ - message: user and team cannot both be set
+ rule: '!((has(self.user) && self.user != '''') && (has(self.team)
+ && self.team != ''''))'
+ type: array
+ resyncPeriod:
+ default: 10m0s
+ description: How often the resource is synced, defaults to 10m0s if
+ not set
+ format: duration
+ pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
+ type: string
+ role:
+ description: Role is the Grafana role for the service account (Viewer,
+ Editor, Admin).
+ enum:
+ - Viewer
+ - Editor
+ - Admin
+ type: string
+ tokens:
+ description: Tokens defines API tokens to create for this service
+ account. Each token will be stored in a Kubernetes Secret with the
+ given name.
+ items:
+ description: GrafanaServiceAccountToken describes a token to create.
+ properties:
+ expires:
+ description: Expires is the optional expiration time for the
+ token. After this time, the operator may rotate the token.
+ format: date-time
+ type: string
+ name:
+ description: Name is the name of the Kubernetes Secret (and
+ token identifier in Grafana). The secret will contain the
+ token value.
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ required:
+ - instanceSelector
+ - name
+ - role
+ type: object
+ x-kubernetes-validations:
+ - message: disabling spec.allowCrossNamespaceImport requires a recreate
+ to ensure desired state
+ rule: '!oldSelf.allowCrossNamespaceImport || (oldSelf.allowCrossNamespaceImport
+ && self.allowCrossNamespaceImport)'
+ status:
+ description: GrafanaServiceAccountStatus defines the observed state of
+ a GrafanaServiceAccount.
+ properties:
+ conditions:
+ description: Results when synchonizing resource with Grafana instances
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ instances:
+ description: Instances lists Grafana instances where this service
+ account is applied.
+ items:
+ description: GrafanaServiceAccountInstanceStatus holds status for
+ one Grafana instance.
+ properties:
+ grafanaName:
+ type: string
+ grafanaNamespace:
+ description: GrafanaNamespace and GrafanaName specify which
+ Grafana resource this status record belongs to.
+ type: string
+ serviceAccountID:
+ description: ServiceAccountID is the numeric ID of the service
+ account in this Grafana.
+ format: int64
+ type: integer
+ tokens:
+ description: Tokens is the status of tokens for this service
+ account in Grafana.
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a
+ token created in Grafana.
+ properties:
+ name:
+ description: Name of the token (same as Secret name).
+ type: string
+ secretName:
+ description: |-
+ SecretName is the name of the Kubernetes Secret that stores the actual token value.
+ This may seem redundant if the Secret name usually matches the token's Name,
+ but it's stored explicitly in Status for clarity and future flexibility.
+ type: string
+ tokenId:
+ description: TokenID is the Grafana-assigned ID of the
+ token.
+ format: int64
+ type: integer
+ required:
+ - name
+ - secretName
+ - tokenId
+ type: object
+ type: array
+ required:
+ - grafanaName
+ - grafanaNamespace
+ - serviceAccountID
+ type: object
+ type: array
+ lastResync:
+ description: Last time the resource was synchronized with Grafana
+ instances
+ format: date-time
+ type: string
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/deploy/helm/grafana-operator/files/rbac.yaml b/deploy/helm/grafana-operator/files/rbac.yaml
index 00fcdcda5..da857d64d 100644
--- a/deploy/helm/grafana-operator/files/rbac.yaml
+++ b/deploy/helm/grafana-operator/files/rbac.yaml
@@ -68,6 +68,7 @@ rules:
- grafananotificationpolicyroutes
- grafananotificationtemplates
- grafanas
+ - grafanaserviceaccounts
verbs:
- create
- delete
@@ -90,6 +91,7 @@ rules:
- grafananotificationpolicyroutes/finalizers
- grafananotificationtemplates/finalizers
- grafanas/finalizers
+ - grafanaserviceaccounts/finalizers
verbs:
- update
- apiGroups:
@@ -106,6 +108,7 @@ rules:
- grafananotificationpolicyroutes/status
- grafananotificationtemplates/status
- grafanas/status
+ - grafanaserviceaccounts/status
verbs:
- get
- patch
diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml
index 225a32110..2f9a3f974 100644
--- a/deploy/kustomize/base/crds.yaml
+++ b/deploy/kustomize/base/crds.yaml
@@ -12772,6 +12772,10 @@ spec:
items:
type: string
type: array
+ serviceaccounts:
+ items:
+ type: string
+ type: array
stage:
type: string
stageStatus:
@@ -12786,3 +12790,322 @@ spec:
storage: true
subresources:
status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.16.3
+ name: grafanaserviceaccounts.grafana.integreatly.org
+spec:
+ group: grafana.integreatly.org
+ names:
+ categories:
+ - grafana-operator
+ kind: GrafanaServiceAccount
+ listKind: GrafanaServiceAccountList
+ plural: grafanaserviceaccounts
+ singular: grafanaserviceaccount
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - format: date-time
+ jsonPath: .status.lastResync
+ name: Last resync
+ type: date
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1beta1
+ schema:
+ openAPIV3Schema:
+ description: GrafanaServiceAccount is the Schema for the grafanaserviceaccounts
+ 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:
+ description: GrafanaServiceAccountSpec defines the desired state of a
+ GrafanaServiceAccount.
+ properties:
+ allowCrossNamespaceImport:
+ default: false
+ description: Allow the Operator to match this resource with Grafanas
+ outside the current namespace
+ type: boolean
+ generateTokenSecret:
+ default: true
+ description: |-
+ GenerateTokenSecret, if true, will create one default API token in a Secret if no Tokens are specified.
+ If false, no token is created unless explicitly listed in Tokens.
+ type: boolean
+ instanceSelector:
+ description: Selects Grafana instances for import
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements.
+ The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies
+ to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: spec.instanceSelector is immutable
+ rule: self == oldSelf
+ isDisabled:
+ description: IsDisabled indicates if the service account should be
+ disabled in Grafana.
+ type: boolean
+ name:
+ description: Name is the desired name of the service account in Grafana.
+ type: string
+ permissions:
+ description: Permissions specifies additional Grafana permission grants
+ for existing users or teams on this service account.
+ items:
+ description: GrafanaServiceAccountPermission defines a permission
+ grant for a user or team.
+ properties:
+ permission:
+ description: 'Permission level: Edit or Admin.'
+ enum:
+ - Edit
+ - Admin
+ type: string
+ team:
+ description: Team name to grant permissions to (optional).
+ type: string
+ user:
+ description: User login or email to grant permissions to (optional).
+ type: string
+ required:
+ - permission
+ type: object
+ x-kubernetes-validations:
+ - message: one of user or team must be set
+ rule: (has(self.user) && self.user != '') || (has(self.team) &&
+ self.team != '')
+ - message: user and team cannot both be set
+ rule: '!((has(self.user) && self.user != '''') && (has(self.team)
+ && self.team != ''''))'
+ type: array
+ resyncPeriod:
+ default: 10m0s
+ description: How often the resource is synced, defaults to 10m0s if
+ not set
+ format: duration
+ pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
+ type: string
+ role:
+ description: Role is the Grafana role for the service account (Viewer,
+ Editor, Admin).
+ enum:
+ - Viewer
+ - Editor
+ - Admin
+ type: string
+ tokens:
+ description: Tokens defines API tokens to create for this service
+ account. Each token will be stored in a Kubernetes Secret with the
+ given name.
+ items:
+ description: GrafanaServiceAccountToken describes a token to create.
+ properties:
+ expires:
+ description: Expires is the optional expiration time for the
+ token. After this time, the operator may rotate the token.
+ format: date-time
+ type: string
+ name:
+ description: Name is the name of the Kubernetes Secret (and
+ token identifier in Grafana). The secret will contain the
+ token value.
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ required:
+ - instanceSelector
+ - name
+ - role
+ type: object
+ x-kubernetes-validations:
+ - message: disabling spec.allowCrossNamespaceImport requires a recreate
+ to ensure desired state
+ rule: '!oldSelf.allowCrossNamespaceImport || (oldSelf.allowCrossNamespaceImport
+ && self.allowCrossNamespaceImport)'
+ status:
+ description: GrafanaServiceAccountStatus defines the observed state of
+ a GrafanaServiceAccount.
+ properties:
+ conditions:
+ description: Results when synchonizing resource with Grafana instances
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ instances:
+ description: Instances lists Grafana instances where this service
+ account is applied.
+ items:
+ description: GrafanaServiceAccountInstanceStatus holds status for
+ one Grafana instance.
+ properties:
+ grafanaName:
+ type: string
+ grafanaNamespace:
+ description: GrafanaNamespace and GrafanaName specify which
+ Grafana resource this status record belongs to.
+ type: string
+ serviceAccountID:
+ description: ServiceAccountID is the numeric ID of the service
+ account in this Grafana.
+ format: int64
+ type: integer
+ tokens:
+ description: Tokens is the status of tokens for this service
+ account in Grafana.
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a
+ token created in Grafana.
+ properties:
+ name:
+ description: Name of the token (same as Secret name).
+ type: string
+ secretName:
+ description: |-
+ SecretName is the name of the Kubernetes Secret that stores the actual token value.
+ This may seem redundant if the Secret name usually matches the token's Name,
+ but it's stored explicitly in Status for clarity and future flexibility.
+ type: string
+ tokenId:
+ description: TokenID is the Grafana-assigned ID of the
+ token.
+ format: int64
+ type: integer
+ required:
+ - name
+ - secretName
+ - tokenId
+ type: object
+ type: array
+ required:
+ - grafanaName
+ - grafanaNamespace
+ - serviceAccountID
+ type: object
+ type: array
+ lastResync:
+ description: Last time the resource was synchronized with Grafana
+ instances
+ format: date-time
+ type: string
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/deploy/kustomize/base/role.yaml b/deploy/kustomize/base/role.yaml
index bf20de0f4..9619ab742 100644
--- a/deploy/kustomize/base/role.yaml
+++ b/deploy/kustomize/base/role.yaml
@@ -68,6 +68,7 @@ rules:
- grafananotificationpolicyroutes
- grafananotificationtemplates
- grafanas
+ - grafanaserviceaccounts
verbs:
- create
- delete
@@ -90,6 +91,7 @@ rules:
- grafananotificationpolicyroutes/finalizers
- grafananotificationtemplates/finalizers
- grafanas/finalizers
+ - grafanaserviceaccounts/finalizers
verbs:
- update
- apiGroups:
@@ -106,6 +108,7 @@ rules:
- grafananotificationpolicyroutes/status
- grafananotificationtemplates/status
- grafanas/status
+ - grafanaserviceaccounts/status
verbs:
- get
- patch
diff --git a/docs/docs/api.md b/docs/docs/api.md
index 8436eab98..d50d29039 100644
--- a/docs/docs/api.md
+++ b/docs/docs/api.md
@@ -33,6 +33,8 @@ Resource Types:
- [Grafana](#grafana)
+- [GrafanaServiceAccount](#grafanaserviceaccount)
+
@@ -24814,6 +24816,13 @@ GrafanaStatus defines the observed state of Grafana
Name | +Type | +Description | +Required | +
---|---|---|---|
apiVersion | +string | +grafana.integreatly.org/v1beta1 | +true | +
kind | +string | +GrafanaServiceAccount | +true | +
metadata | +object | +Refer to the Kubernetes API documentation for the fields of the `metadata` field. | +true | +
spec | +object | +
+ GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount. + + Validations: |
+ false | +
status | +object | +
+ GrafanaServiceAccountStatus defines the observed state of a GrafanaServiceAccount. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
instanceSelector | +object | +
+ Selects Grafana instances for import + + Validations: |
+ true | +
name | +string | +
+ Name is the name of the service account in Grafana. + |
+ true | +
role | +enum | +
+ Role is the service account role in Grafana (Viewer, Editor, Admin, etc.). + + Enum: Viewer, Editor, Admin + |
+ true | +
allowCrossNamespaceImport | +boolean | +
+ Allow the Operator to match this resource with Grafanas outside the current namespace + + Default: false + |
+ false | +
generateTokenSecret | +boolean | +
+ GenerateTokenSecret indicates whether the operator should automatically create a Kubernetes Secret
+to store a token for this service account. If true (default), at least one Secret with a token will be created.
+If false, no token is generated unless explicitly defined in Tokens. + + Default: true + |
+ false | +
isDisabled | +boolean | +
+ IsDisabled indicates whether the service account should be disabled in Grafana. + |
+ false | +
permissions | +[]object | +
+ Permissions specifies additional access permissions for users or teams in Grafana
+related to this service account. This aligns with the UI where you can grant specific
+users or groups Edit/Admin permissions on the service account. + |
+ false | +
resyncPeriod | +string | +
+ How often the resource is synced, defaults to 10m0s if not set + + Format: duration + Default: 10m0s + |
+ false | +
tokens | +[]object | +
+ Tokens is the list of tokens to create for this service account. For each token in the list,
+the operator generates a Grafana access token and stores it in a Kubernetes Secret with the specified name.
+If no tokens are specified and GenerateTokenSecret is true, the operator creates a default token
+in a Secret with a default name. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
matchExpressions | +[]object | +
+ matchExpressions is a list of label selector requirements. The requirements are ANDed. + |
+ false | +
matchLabels | +map[string]string | +
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+map is equivalent to an element of matchExpressions, whose key field is "key", the
+operator is "In", and the values array contains only "value". The requirements are ANDed. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
key | +string | +
+ key is the label key that the selector applies to. + |
+ true | +
operator | +string | +
+ operator represents a key's relationship to a set of values.
+Valid operators are In, NotIn, Exists and DoesNotExist. + |
+ true | +
values | +[]string | +
+ values is an array of string values. If the operator is In or NotIn,
+the values array must be non-empty. If the operator is Exists or DoesNotExist,
+the values array must be empty. This array is replaced during a strategic
+merge patch. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
permission | +enum | +
+ Permission is the level of access granted to that user or group for this service account
+(e.g., "Edit" or "Admin"). Depending on the Grafana version, this might map to an RBAC role or other permissions. + + Enum: Viewer, Editor, Admin + |
+ true | +
team | +string | +
+ + |
+ false | +
user | +string | +
+ + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
name | +string | +
+ Name is the name of the Kubernetes Secret in which this token will be stored. + |
+ true | +
expires | +string | +
+ Expires specifies the expiration timestamp (TTL) for this token. If set, the operator
+will rotate or replace the token once the specified expiration time is reached.
+If not set, the token does not expire (defaults to never expire). + + Format: date-time + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
conditions | +[]object | +
+ Results when synchonizing resource with Grafana instances + |
+ false | +
id | +integer | +
+ ID is the numeric identifier of the service account in Grafana. + + Format: int64 + |
+ false | +
lastResync | +string | +
+ Last time the resource was synchronized with Grafana instances + + Format: date-time + |
+ false | +
tokens | +[]object | +
+ Tokens is a list of detailed information for each token created in Grafana. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
lastTransitionTime | +string | +
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + + Format: date-time + |
+ true | +
message | +string | +
+ message is a human readable message indicating details about the transition.
+This may be an empty string. + |
+ true | +
reason | +string | +
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+Producers of specific condition types may define expected values and meanings for this field,
+and whether the values are considered a guaranteed API.
+The value should be a CamelCase string.
+This field may not be empty. + |
+ true | +
status | +enum | +
+ status of the condition, one of True, False, Unknown. + + Enum: True, False, Unknown + |
+ true | +
type | +string | +
+ type of condition in CamelCase or in foo.example.com/CamelCase. + |
+ true | +
observedGeneration | +integer | +
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+with respect to the current state of the instance. + + Format: int64 + Minimum: 0 + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
name | +string | +
+ Name is the name of the token, matching the one specified in .spec.tokens or generated automatically. + |
+ true | +
secretName | +string | +
+ SecretName is the name of the Kubernetes Secret that stores the actual token value (Key). + |
+ true | +
tokenId | +integer | +
+ TokenID is the numeric identifier of the token as returned by Grafana upon creation. + + Format: int64 + |
+ true | +