diff --git a/.gitignore b/.gitignore
index bd31e505a..b611c5f27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ vendor
.vscode/
.DS_Store
+.mirrord
# Audit lab
kube-apiserver-audit.log
diff --git a/Makefile b/Makefile
index d175674fa..257a919ca 100644
--- a/Makefile
+++ b/Makefile
@@ -211,6 +211,11 @@ deploy-chainsaw: $(KUSTOMIZE) manifests ## Deploy controller to the K8s cluster
$(info $(M) running $@)
$(KUSTOMIZE) build deploy/kustomize/overlays/chainsaw | kubectl apply --server-side --force-conflicts -f -
+.PHONY: deploy-chainsaw-debug
+deploy-chainsaw-debug: $(KUSTOMIZE) manifests ## Deploy debug controller (http-echo) to the K8s cluster for mirrord (https://github.com/metalbear-co/mirrord) debugging.
+ $(info $(M) running $@)
+ $(KUSTOMIZE) build deploy/kustomize/overlays/chainsaw-debug | kubectl apply --server-side --force-conflicts -f -
+
.PHONY: undeploy
undeploy: $(KUSTOMIZE) ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
$(info $(M) running $@)
diff --git a/api/v1beta1/grafana_types.go b/api/v1beta1/grafana_types.go
index f11e1aa68..827d35bb2 100644
--- a/api/v1beta1/grafana_types.go
+++ b/api/v1beta1/grafana_types.go
@@ -157,6 +157,7 @@ type GrafanaStatus struct {
ContactPoints NamespacedResourceList `json:"contactPoints,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"`
MuteTimings NamespacedResourceList `json:"muteTimings,omitempty"`
diff --git a/api/v1beta1/grafanaserviceaccount_types.go b/api/v1beta1/grafanaserviceaccount_types.go
new file mode 100644
index 000000000..4336a4199
--- /dev/null
+++ b/api/v1beta1/grafanaserviceaccount_types.go
@@ -0,0 +1,187 @@
+/*
+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"
+)
+
+// GrafanaServiceAccountTokenSpec defines a token for a service account
+type GrafanaServiceAccountTokenSpec struct {
+ // Name of the token
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ Name string `json:"name"`
+
+ // Expiration date of the token. If not set, the token never expires
+ // +optional
+ // +kubebuilder:validation:Type=string
+ // +kubebuilder:validation:Format=date-time
+ Expires *metav1.Time `json:"expires,omitempty"`
+
+ // Name of the secret to store the token. If not set, a name will be generated
+ // +optional
+ // +kubebuilder:validation:MinLength=1
+ SecretName string `json:"secretName,omitempty"`
+}
+
+// GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount.
+type GrafanaServiceAccountSpec struct {
+ // How often the resource is synced, defaults to 10m0s if not set
+ // +optional
+ // +kubebuilder:validation:Type=string
+ // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$"
+ // +kubebuilder:default="10m0s"
+ // +kubebuilder:validation:XValidation:rule="duration(self) > duration('0s')",message="spec.resyncPeriod must be greater than 0"
+ ResyncPeriod metav1.Duration `json:"resyncPeriod,omitempty"`
+
+ // Suspend pauses reconciliation of the service account
+ // +optional
+ // +kubebuilder:default=false
+ Suspend bool `json:"suspend,omitempty"`
+
+ // Name of the Grafana instance to create the service account for
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.instanceName is immutable"
+ InstanceName string `json:"instanceName"`
+
+ // Name of the service account in Grafana
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.name is immutable"
+ Name string `json:"name"`
+
+ // Role of the service account (Viewer, Editor, Admin)
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:Enum=Viewer;Editor;Admin
+ Role string `json:"role"`
+
+ // Whether the service account is disabled
+ // +optional
+ // +kubebuilder:default=false
+ IsDisabled bool `json:"isDisabled,omitempty"`
+
+ // Tokens to create for the service account
+ // +optional
+ // +listType=map
+ // +listMapKey=name
+ Tokens []GrafanaServiceAccountTokenSpec `json:"tokens,omitempty"`
+}
+
+// GrafanaServiceAccountSecretStatus describes a Secret created in Kubernetes to store the service account token.
+type GrafanaServiceAccountSecretStatus struct {
+ Namespace string `json:"namespace,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+// GrafanaServiceAccountTokenStatus describes a token created in Grafana.
+type GrafanaServiceAccountTokenStatus struct {
+ Name string `json:"name"`
+
+ // Expiration time of the token
+ // N.B. There's possible discrepancy with the expiration time in spec
+ // It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time
+ Expires *metav1.Time `json:"expires,omitempty"`
+
+ // ID of the token in Grafana
+ ID int64 `json:"id"`
+
+ // Name of the secret containing the token
+ Secret *GrafanaServiceAccountSecretStatus `json:"secret,omitempty"`
+}
+
+// GrafanaServiceAccountInfo describes the Grafana service account information.
+type GrafanaServiceAccountInfo struct {
+ Name string `json:"name"`
+ Login string `json:"login"`
+
+ // ID of the service account in Grafana
+ ID int64 `json:"id"`
+
+ // Role is the Grafana role for the service account (Viewer, Editor, Admin)
+ Role string `json:"role"`
+
+ // IsDisabled indicates if the service account is disabled
+ IsDisabled bool `json:"isDisabled"`
+
+ // Information about tokens
+ // +optional
+ Tokens []GrafanaServiceAccountTokenStatus `json:"tokens,omitempty"`
+}
+
+// GrafanaServiceAccountStatus defines the observed state of a GrafanaServiceAccount
+type GrafanaServiceAccountStatus struct {
+ GrafanaCommonStatus `json:",inline"`
+
+ // Info contains the Grafana service account information
+ Account *GrafanaServiceAccountInfo `json:"account,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
+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 i := range in.Items {
+ if in.Items[i].Namespace == namespace && in.Items[i].Name == name {
+ return &in.Items[i]
+ }
+ }
+
+ return nil
+}
+
+// MatchNamespace returns the namespace where this service account is defined.
+func (in *GrafanaServiceAccount) MatchNamespace() string {
+ return in.Namespace
+}
+
+// AllowCrossNamespace indicates whether cross-namespace import is allowed for this resource.
+func (in *GrafanaServiceAccount) AllowCrossNamespace() bool {
+ // return in.Spec.AllowCrossNamespaceImport
+ return false
+}
+
+func (in *GrafanaServiceAccount) CommonStatus() *GrafanaCommonStatus {
+ return &in.Status.GrafanaCommonStatus
+}
+
+func init() {
+ SchemeBuilder.Register(&GrafanaServiceAccount{}, &GrafanaServiceAccountList{})
+}
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index 8160ae487..dc5089882 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -1740,6 +1740,189 @@ 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 *GrafanaServiceAccountInfo) DeepCopyInto(out *GrafanaServiceAccountInfo) {
+ *out = *in
+ if in.Tokens != nil {
+ in, out := &in.Tokens, &out.Tokens
+ *out = make([]GrafanaServiceAccountTokenStatus, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountInfo.
+func (in *GrafanaServiceAccountInfo) DeepCopy() *GrafanaServiceAccountInfo {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountInfo)
+ 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 *GrafanaServiceAccountSecretStatus) DeepCopyInto(out *GrafanaServiceAccountSecretStatus) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountSecretStatus.
+func (in *GrafanaServiceAccountSecretStatus) DeepCopy() *GrafanaServiceAccountSecretStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountSecretStatus)
+ 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
+ out.ResyncPeriod = in.ResyncPeriod
+ if in.Tokens != nil {
+ in, out := &in.Tokens, &out.Tokens
+ *out = make([]GrafanaServiceAccountTokenSpec, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// 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.Account != nil {
+ in, out := &in.Account, &out.Account
+ *out = new(GrafanaServiceAccountInfo)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// 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 *GrafanaServiceAccountTokenSpec) DeepCopyInto(out *GrafanaServiceAccountTokenSpec) {
+ *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 GrafanaServiceAccountTokenSpec.
+func (in *GrafanaServiceAccountTokenSpec) DeepCopy() *GrafanaServiceAccountTokenSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccountTokenSpec)
+ 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
+ if in.Expires != nil {
+ in, out := &in.Expires, &out.Expires
+ *out = (*in).DeepCopy()
+ }
+ if in.Secret != nil {
+ in, out := &in.Secret, &out.Secret
+ *out = new(GrafanaServiceAccountSecretStatus)
+ **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
@@ -1846,6 +2029,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 a8f752e7d..28efe9bb1 100644
--- a/config/crd/bases/grafana.integreatly.org_grafanas.yaml
+++ b/config/crd/bases/grafana.integreatly.org_grafanas.yaml
@@ -5186,6 +5186,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..2240208a8
--- /dev/null
+++ b/config/crd/bases/grafana.integreatly.org_grafanaserviceaccounts.yaml
@@ -0,0 +1,253 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.17.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:
+ instanceName:
+ description: Name of the Grafana instance to create the service account
+ for
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: spec.instanceName is immutable
+ rule: self == oldSelf
+ isDisabled:
+ default: false
+ description: Whether the service account is disabled
+ type: boolean
+ name:
+ description: Name of the service account in Grafana
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: spec.name is immutable
+ rule: self == oldSelf
+ resyncPeriod:
+ default: 10m0s
+ description: How often the resource is synced, defaults to 10m0s if
+ not set
+ pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
+ type: string
+ x-kubernetes-validations:
+ - message: spec.resyncPeriod must be greater than 0
+ rule: duration(self) > duration('0s')
+ role:
+ description: Role of the service account (Viewer, Editor, Admin)
+ enum:
+ - Viewer
+ - Editor
+ - Admin
+ type: string
+ suspend:
+ default: false
+ description: Suspend pauses reconciliation of the service account
+ type: boolean
+ tokens:
+ description: Tokens to create for the service account
+ items:
+ description: GrafanaServiceAccountTokenSpec defines a token for
+ a service account
+ properties:
+ expires:
+ description: Expiration date of the token. If not set, the token
+ never expires
+ format: date-time
+ type: string
+ name:
+ description: Name of the token
+ minLength: 1
+ type: string
+ secretName:
+ description: Name of the secret to store the token. If not set,
+ a name will be generated
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
+ required:
+ - instanceName
+ - name
+ - role
+ type: object
+ status:
+ description: GrafanaServiceAccountStatus defines the observed state of
+ a GrafanaServiceAccount
+ properties:
+ account:
+ description: Info contains the Grafana service account information
+ properties:
+ id:
+ description: ID of the service account in Grafana
+ format: int64
+ type: integer
+ isDisabled:
+ description: IsDisabled indicates if the service account is disabled
+ type: boolean
+ login:
+ type: string
+ name:
+ type: string
+ role:
+ description: Role is the Grafana role for the service account
+ (Viewer, Editor, Admin)
+ type: string
+ tokens:
+ description: Information about tokens
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a token
+ created in Grafana.
+ properties:
+ expires:
+ description: |-
+ Expiration time of the token
+ N.B. There's possible discrepancy with the expiration time in spec
+ It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time
+ format: date-time
+ type: string
+ id:
+ description: ID of the token in Grafana
+ format: int64
+ type: integer
+ name:
+ type: string
+ secret:
+ description: Name of the secret containing the token
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ type: object
+ required:
+ - id
+ - name
+ type: object
+ type: array
+ required:
+ - id
+ - isDisabled
+ - login
+ - name
+ - role
+ type: object
+ 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
+ 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/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/controller_shared.go b/controllers/controller_shared.go
index 03e7b79b1..d81361512 100644
--- a/controllers/controller_shared.go
+++ b/controllers/controller_shared.go
@@ -484,7 +484,12 @@ func mergeReconcileErrors(sources ...map[string]string) map[string]string {
return merged
}
-func UpdateStatus(ctx context.Context, cl client.Client, cr v1beta1.CommonResource) {
+type statusResource interface {
+ client.Object
+ CommonStatus() *v1beta1.GrafanaCommonStatus
+}
+
+func UpdateStatus(ctx context.Context, cl client.Client, cr statusResource) {
log := logf.FromContext(ctx)
cr.CommonStatus().LastResync = metav1.Time{Time: time.Now()}
diff --git a/controllers/serviceaccount_controller.go b/controllers/serviceaccount_controller.go
new file mode 100644
index 000000000..4fdf4c439
--- /dev/null
+++ b/controllers/serviceaccount_controller.go
@@ -0,0 +1,836 @@
+/*
+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 implements Kubernetes controllers for Grafana Operator.
+package controllers
+
+// GrafanaServiceAccountReconciler manages the lifecycle of Grafana service accounts and their tokens.
+//
+// The controller ensures that service accounts in Grafana match the desired state defined in
+// GrafanaServiceAccount custom resources. It handles:
+// - Service account creation, updates, and deletion
+// - Token lifecycle management with automatic recreation on expiration changes
+// - Secure token storage in Kubernetes Secrets
+// - Cleanup of orphaned resources
+//
+// Key architectural decisions:
+// - Tokens are immutable in Grafana - any change requires recreation
+// - Token names must be unique within a service account (enforced by CRD validation)
+// - Secrets use annotations to link them with their corresponding tokens
+// - The controller follows eventual consistency model, handling external modifications gracefully
+// - All resources are created in the same namespace as the CR for security
+//
+// The reconciliation process is idempotent and can recover from partial failures or
+// external modifications to either Grafana or Kubernetes resources.
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-openapi/strfmt"
+ 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/runtime"
+ "k8s.io/utils/ptr"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+
+ genapi "github.com/grafana/grafana-openapi-client-go/client"
+ "github.com/grafana/grafana-openapi-client-go/client/service_accounts"
+ "github.com/grafana/grafana-openapi-client-go/models"
+ "github.com/grafana/grafana-operator/v5/api/v1beta1"
+ client2 "github.com/grafana/grafana-operator/v5/controllers/client"
+ model2 "github.com/grafana/grafana-operator/v5/controllers/model"
+)
+
+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
+// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
+
+// Reconcile synchronizes the actual state (Grafana service accounts and Kubernetes secrets)
+// with the desired state defined in the GrafanaServiceAccount CR spec,
+// taking into account Kubernetes' eventual consistency model.
+//
+// The reconciliation process:
+// 1. Fetches the GrafanaServiceAccount resource from Kubernetes
+// 2. Handles resource deletion (removes service account from Grafana and cleans up secrets)
+// 3. Sets up status update handling (deferred)
+// 4. Establishes connection to the target Grafana instance
+// 5. For active resources - reconciles the actual state with the desired state (creates, updates, removes as needed)
+// 6. Updates the resource status with current state and conditions
+// 7. Schedules periodic reconciliation based on ResyncPeriod
+func (r *GrafanaServiceAccountReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ log := logf.FromContext(ctx).WithName("GrafanaServiceAccountReconciler")
+ ctx = logf.IntoContext(ctx, log)
+
+ // 1. Fetch the GrafanaServiceAccount resource from Kubernetes
+ cr := &v1beta1.GrafanaServiceAccount{}
+
+ err := r.Get(ctx, req.NamespacedName, cr)
+ if err != nil {
+ if kuberr.IsNotFound(err) {
+ return ctrl.Result{}, nil
+ }
+
+ return ctrl.Result{}, fmt.Errorf("getting GrafanaServiceAccount %q: %w", req, err)
+ }
+
+ // 2. Handle resource deletion (removes service account from Grafana and cleans up secrets)
+ if cr.GetDeletionTimestamp() != nil {
+ err := r.finalize(ctx, cr)
+ if err != nil {
+ return ctrl.Result{}, fmt.Errorf("finalizing GrafanaServiceAccount: %w", err)
+ }
+
+ err = removeFinalizer(ctx, r.Client, cr)
+ if err != nil && !kuberr.IsNotFound(err) {
+ return ctrl.Result{}, fmt.Errorf("removing finalizer: %w", err)
+ }
+
+ return ctrl.Result{}, nil
+ }
+
+ // 3. From here on, we're handling normal reconciliation (not deletion)
+ defer UpdateStatus(ctx, r.Client, cr)
+
+ // Check if reconciliation is suspended
+ if cr.Spec.Suspend {
+ setSuspended(&cr.Status.Conditions, cr.Generation, conditionReasonApplySuspended)
+ return ctrl.Result{}, nil
+ }
+
+ removeSuspended(&cr.Status.Conditions)
+
+ // 4. Establish connection to the target Grafana instance
+ // First, get the Grafana CR
+ meta.RemoveStatusCondition(&cr.Status.Conditions, conditionServiceAccountSynchronized)
+
+ grafana, err := r.lookupGrafana(ctx, cr)
+ if err != nil {
+ setNoMatchingInstancesCondition(&cr.Status.Conditions, cr.Generation, err)
+ return ctrl.Result{}, err
+ }
+
+ gClient, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, grafana)
+ if err != nil {
+ meta.SetStatusCondition(&cr.Status.Conditions, buildSynchronizedCondition(
+ "ServiceAccount",
+ conditionServiceAccountSynchronized,
+ cr.Generation,
+ map[string]string{grafana.Name: err.Error()},
+ 1, // We always synchronize with a single Grafana instance.
+ ))
+
+ return ctrl.Result{}, err
+ }
+
+ removeNoMatchingInstance(&cr.Status.Conditions)
+
+ // 5. For active resources - reconcile the actual state with the desired state (creates, updates, removes as needed)
+ err = r.reconcileWithInstance(ctx, gClient, cr)
+
+ // 6. Update the resource status with current state and conditions
+ applyErrors := map[string]string{}
+ if err != nil {
+ applyErrors[grafana.Name] = err.Error()
+ }
+
+ condition := buildSynchronizedCondition(
+ "ServiceAccount",
+ conditionServiceAccountSynchronized,
+ cr.Generation,
+ applyErrors,
+ 1, // We always synchronize with a single Grafana instance.
+ )
+ meta.SetStatusCondition(&cr.Status.Conditions, condition)
+
+ if err != nil {
+ return ctrl.Result{}, fmt.Errorf("reconciling service account: %w", err)
+ }
+
+ // 7. Schedule periodic reconciliation based on ResyncPeriod
+ return ctrl.Result{RequeueAfter: cr.Spec.ResyncPeriod.Duration}, nil
+}
+
+// finalize handles the cleanup logic when a GrafanaServiceAccount resource is being deleted.
+// It attempts to remove the service account from Grafana and clean up associated secrets.
+func (r *GrafanaServiceAccountReconciler) finalize(ctx context.Context, cr *v1beta1.GrafanaServiceAccount) error {
+ if !controllerutil.ContainsFinalizer(cr, grafanaFinalizer) {
+ return nil
+ }
+
+ // Get the Grafana CR for deletion
+ grafana, err := r.lookupGrafana(ctx, cr)
+ if err != nil {
+ return err
+ }
+
+ gClient, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, grafana)
+ if err != nil {
+ return fmt.Errorf("creating Grafana client: %w", err)
+ }
+
+ _, err = gClient.ServiceAccounts.DeleteServiceAccountWithParams( // nolint:errcheck
+ service_accounts.
+ NewDeleteServiceAccountParamsWithContext(ctx).
+ WithServiceAccountID(cr.Status.Account.ID),
+ )
+ if err != nil {
+ // ATM, service_accounts.DeleteServiceAccountNotFound doesn't have Is, Unwrap, Unwrap.
+ // So, we cannot rely only on errors.Is().
+ _, ok := err.(*service_accounts.DeleteServiceAccountNotFound) // nolint:errorlint
+ if ok || errors.Is(err, service_accounts.NewDeleteServiceAccountNotFound()) {
+ logf.FromContext(ctx).Info("service account not found, skipping removal",
+ "serviceAccountID", cr.Status.Account.ID,
+ "serviceAccountName", cr.Spec.Name,
+ )
+
+ return nil
+ }
+
+ // TODO: The operator now deploys Grafana 12.1.0 by default (see controllers/config/operator_constants.go#L6),
+ // but it may still manage older Grafana instances.
+ //
+ // Before Grafana 12.0.2, there was no reliable way to detect a 404 when deleting a service account.
+ // The API returned 500 instead (see https://github.com/grafana/grafana/issues/106618).
+ //
+ // Once we can guarantee all managed instances are >= 12.0.2 we can handle the real 404 explicitly.
+ //
+ // Until then, we treat any non-nil error from the delete call as "already removed" and just log it for visibility.
+ logf.FromContext(ctx).Error(err, "failed to delete service account (may already be deleted)",
+ "serviceAccountID", cr.Status.Account.ID,
+ "serviceAccountName", cr.Spec.Name,
+ )
+ // return fmt.Errorf("deleting service account %q: %w", status.SpecID, err)
+ }
+
+ return nil
+}
+
+// lookupGrafana retrieves the Grafana instance referenced by the GrafanaServiceAccount
+// and validates that it's in a ready state for accepting API requests.
+func (r *GrafanaServiceAccountReconciler) lookupGrafana(
+ ctx context.Context,
+ cr *v1beta1.GrafanaServiceAccount,
+) (*v1beta1.Grafana, error) {
+ var grafana v1beta1.Grafana
+
+ err := r.Get(ctx, client.ObjectKey{
+ Namespace: cr.Namespace,
+ Name: cr.Spec.InstanceName,
+ }, &grafana)
+ if err != nil {
+ return nil, err
+ }
+
+ // Check if Grafana instance is ready
+ if grafana.Status.Stage != v1beta1.OperatorStageComplete || grafana.Status.StageStatus != v1beta1.OperatorStageResultSuccess {
+ return nil, fmt.Errorf("Grafana instance %q is not ready (stage: %q, status: %q)", cr.Spec.InstanceName, grafana.Status.Stage, grafana.Status.StageStatus) // nolint:staticcheck
+ }
+
+ return &grafana, nil
+}
+
+// reconcileWithInstance performs the core reconciliation logic for active resources.
+//
+// It orchestrates the complete synchronization process:
+// 1. Ensures the service account exists in Grafana (creates if missing)
+// 2. Updates service account properties to match the spec
+// 3. Manages the lifecycle of authentication tokens and their secrets
+func (r *GrafanaServiceAccountReconciler) reconcileWithInstance(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+) error {
+ err := r.upsertAccount(ctx, gClient, cr)
+ if err != nil {
+ return fmt.Errorf("upserting service account: %w", err)
+ }
+
+ // Ensure tokens are always sorted for stable ordering
+ defer func() {
+ sort.Slice(cr.Status.Account.Tokens, func(i, j int) bool {
+ return cr.Status.Account.Tokens[i].Name < cr.Status.Account.Tokens[j].Name
+ })
+ }()
+
+ // Phase 1: Prune orphaned secrets and index valid ones
+ secretsByTokenName, err := r.pruneAndIndexSecrets(ctx, cr)
+ if err != nil {
+ return err
+ }
+
+ // Phase 2: Remove outdated tokens (will be recreated with correct configuration)
+ err = r.removeOutdatedTokens(ctx, gClient, cr)
+ if err != nil {
+ return err
+ }
+
+ // Phase 3: Validate existing tokens and restore their secret references
+ err = r.validateAndRestoreTokenSecrets(ctx, gClient, cr, secretsByTokenName)
+ if err != nil {
+ return err
+ }
+
+ // Phase 4: Provision missing tokens
+ tokensToCreate := r.determineMissingTokens(cr)
+
+ err = r.provisionTokens(ctx, gClient, cr, tokensToCreate, secretsByTokenName)
+ if err != nil {
+ return err
+ }
+
+ if len(cr.Status.Account.Tokens) != 0 {
+ // Grafana's create token API doesn't return expiration, requiring a separate fetch
+ return r.populateTokenExpirations(ctx, gClient, cr)
+ }
+
+ return nil
+}
+
+// convertGrafanaExpiration converts Grafana's strfmt.DateTime to Kubernetes metav1.Time pointer.
+// Returns nil if the expiration is zero.
+func convertGrafanaExpiration(expiration strfmt.DateTime) *metav1.Time {
+ if expiration.IsZero() {
+ return nil
+ }
+
+ return ptr.To(metav1.NewTime(time.Time(expiration)))
+}
+
+// removeOutdatedTokens removes tokens that are not in the desired spec or have mismatched expiration times.
+// These tokens will be recreated later with the correct configuration.
+func (r *GrafanaServiceAccountReconciler) removeOutdatedTokens(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+) error {
+ // Build map of desired tokens from spec
+ desiredTokens := make(map[string]v1beta1.GrafanaServiceAccountTokenSpec, len(cr.Spec.Tokens))
+ for _, token := range cr.Spec.Tokens {
+ desiredTokens[token.Name] = token
+ }
+
+ for i := 0; i < len(cr.Status.Account.Tokens); i++ {
+ tokenName := cr.Status.Account.Tokens[i].Name
+ desiredToken, ok := desiredTokens[tokenName]
+
+ needsRecreation := !ok ||
+ !isEqualExpirationTime(desiredToken.Expires, cr.Status.Account.Tokens[i].Expires)
+
+ if needsRecreation {
+ err := r.removeAccountToken(ctx, gClient, cr.Status.Account.ID, &cr.Status.Account.Tokens[i])
+ if err != nil {
+ return fmt.Errorf("removing service account token %q: %w", tokenName, err)
+ }
+
+ cr.Status.Account.Tokens = slices.Delete(cr.Status.Account.Tokens, i, i+1)
+ i--
+ }
+ }
+
+ return nil
+}
+
+// provisionTokens creates the specified tokens in Grafana and ensures their secrets exist in Kubernetes.
+// For each token, it creates the token in Grafana and either updates an existing secret or creates a new one.
+func (r *GrafanaServiceAccountReconciler) provisionTokens(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+ tokensToCreate []v1beta1.GrafanaServiceAccountTokenSpec,
+ secretsByTokenName map[string]corev1.Secret,
+) error {
+ for _, tokenSpec := range tokensToCreate {
+ tokenStatus, tokenKey, err := r.createToken(ctx, gClient, cr.Status.Account.ID, tokenSpec)
+ if err != nil {
+ return fmt.Errorf("creating token %q: %w", tokenSpec.Name, err)
+ }
+
+ // Check what we should do with the secret.
+ secret, ok := secretsByTokenName[tokenSpec.Name]
+ if ok {
+ // The secret already exists, so we can just update it.
+ renewSecret(&secret, tokenStatus, tokenKey)
+
+ err := r.Update(ctx, &secret)
+ if err != nil {
+ return fmt.Errorf("updating token secret %q: %w", secret.Name, err)
+ }
+ } else {
+ // The secret doesn't exist, so we need to create it.
+ newSecret := buildTokenSecret(ctx, cr, tokenSpec, tokenStatus, tokenKey, r.Scheme)
+
+ err := r.Create(ctx, newSecret)
+ if err != nil {
+ return fmt.Errorf("creating token secret %q: %w", newSecret.Name, err)
+ }
+
+ secret = *newSecret
+ }
+
+ tokenStatus.Secret = &v1beta1.GrafanaServiceAccountSecretStatus{
+ Namespace: secret.Namespace,
+ Name: secret.Name,
+ }
+
+ cr.Status.Account.Tokens = append(cr.Status.Account.Tokens, tokenStatus)
+ }
+
+ return nil
+}
+
+// determineMissingTokens returns a sorted list of tokens that are in the spec but not in the current status.
+// These are the tokens that need to be created.
+func (r *GrafanaServiceAccountReconciler) determineMissingTokens(cr *v1beta1.GrafanaServiceAccount) []v1beta1.GrafanaServiceAccountTokenSpec {
+ // Build map of desired tokens from spec
+ desiredTokens := make(map[string]v1beta1.GrafanaServiceAccountTokenSpec, len(cr.Spec.Tokens))
+ for _, token := range cr.Spec.Tokens {
+ desiredTokens[token.Name] = token
+ }
+
+ // Remove tokens that already exist in status
+ for _, token := range cr.Status.Account.Tokens {
+ delete(desiredTokens, token.Name)
+ }
+
+ // Convert map to sorted slice for stable ordering
+ tokensToCreate := make([]v1beta1.GrafanaServiceAccountTokenSpec, 0, len(desiredTokens))
+ for _, desiredToken := range desiredTokens {
+ tokensToCreate = append(tokensToCreate, desiredToken)
+ }
+
+ // Sort for stable order - makes debugging and testing easier
+ slices.SortFunc(tokensToCreate, func(a, b v1beta1.GrafanaServiceAccountTokenSpec) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+
+ return tokensToCreate
+}
+
+// validateAndRestoreTokenSecrets removes tokens without corresponding secrets and restores secret references for valid tokens.
+// Tokens without secrets will be recreated with new secrets in the next phase.
+func (r *GrafanaServiceAccountReconciler) validateAndRestoreTokenSecrets(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+ secretsByTokenName map[string]corev1.Secret,
+) error {
+ for i := 0; i < len(cr.Status.Account.Tokens); i++ {
+ tokenName := cr.Status.Account.Tokens[i].Name
+ secret, secretExists := secretsByTokenName[tokenName]
+
+ if !secretExists {
+ err := r.removeAccountToken(ctx, gClient, cr.Status.Account.ID, &cr.Status.Account.Tokens[i])
+ if err != nil {
+ return fmt.Errorf("removing service account token %q: %w", tokenName, err)
+ }
+
+ cr.Status.Account.Tokens = slices.Delete(cr.Status.Account.Tokens, i, i+1)
+ i--
+
+ continue
+ }
+
+ // Restore secret reference for valid token
+ cr.Status.Account.Tokens[i].Secret = &v1beta1.GrafanaServiceAccountSecretStatus{
+ Namespace: secret.Namespace,
+ Name: secret.Name,
+ }
+ }
+
+ return nil
+}
+
+// createToken creates a new token in Grafana for the service account.
+// Returns the token status, the token key (secret value), and any error.
+func (r *GrafanaServiceAccountReconciler) createToken(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ serviceAccountID int64,
+ tokenSpec v1beta1.GrafanaServiceAccountTokenSpec,
+) (v1beta1.GrafanaServiceAccountTokenStatus, []byte, error) {
+ cmd := models.AddServiceAccountTokenCommand{Name: tokenSpec.Name}
+ if tokenSpec.Expires != nil {
+ // Note: We pass potentially negative TTL to Grafana API and let it handle the validation.
+ // This approach handles edge cases like clock drift, timezone differences, and API processing delays.
+ // Grafana will reject tokens with invalid TTL values appropriately.
+ cmd.SecondsToLive = int64(time.Until(tokenSpec.Expires.Time).Seconds())
+ }
+
+ createResp, err := gClient.ServiceAccounts.CreateToken(
+ service_accounts.
+ NewCreateTokenParamsWithContext(ctx).
+ WithServiceAccountID(serviceAccountID).
+ WithBody(&cmd),
+ )
+ if err != nil {
+ return v1beta1.GrafanaServiceAccountTokenStatus{}, nil, err
+ }
+
+ tokenStatus := v1beta1.GrafanaServiceAccountTokenStatus{
+ Name: createResp.Payload.Name,
+ ID: createResp.Payload.ID,
+ }
+
+ return tokenStatus, []byte(createResp.Payload.Key), nil
+}
+
+// populateTokenExpirations fetches token expiration times from Grafana and updates the status.
+// This is a workaround for Grafana API limitation where the create token response doesn't include expiration.
+func (r *GrafanaServiceAccountReconciler) populateTokenExpirations(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+) error {
+ listResp, err := gClient.ServiceAccounts.ListTokensWithParams(
+ service_accounts.
+ NewListTokensParamsWithContext(ctx).
+ WithServiceAccountID(cr.Status.Account.ID),
+ )
+ if err != nil {
+ return fmt.Errorf("listing tokens to get expirations: %w", err)
+ }
+
+ // Build a map of token ID to expiration time
+ expirations := make(map[int64]*metav1.Time, len(listResp.Payload))
+ for _, token := range listResp.Payload {
+ expirations[token.ID] = convertGrafanaExpiration(token.Expiration)
+ }
+
+ // Update tokens in status with their expiration times
+ for i := range cr.Status.Account.Tokens {
+ cr.Status.Account.Tokens[i].Expires = expirations[cr.Status.Account.Tokens[i].ID]
+ }
+
+ return nil
+}
+
+// pruneAndIndexSecrets removes orphaned secrets (those whose tokens are no longer in the spec)
+// and returns a map of remaining valid secrets indexed by token name for efficient lookup.
+// It uses the token name annotation to match secrets with their corresponding tokens in the spec.
+func (r *GrafanaServiceAccountReconciler) pruneAndIndexSecrets(
+ ctx context.Context,
+ cr *v1beta1.GrafanaServiceAccount,
+) (map[string]corev1.Secret, error) {
+ var secrets corev1.SecretList
+
+ err := r.List(ctx, &secrets,
+ client.InNamespace(cr.Namespace),
+ client.MatchingLabels(buildSecretLabels(cr)),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("listing secrets: %w", err)
+ }
+
+ // Build map of desired tokens for efficient lookup
+ desiredTokens := make(map[string]*v1beta1.GrafanaServiceAccountTokenSpec, len(cr.Spec.Tokens))
+ for i, token := range cr.Spec.Tokens {
+ desiredTokens[token.Name] = &cr.Spec.Tokens[i]
+ }
+
+ filtered := make(map[string]corev1.Secret)
+
+ for _, secret := range secrets.Items {
+ tokenName, ok := extractTokenNameFromSecret(&secret)
+ if !ok {
+ continue
+ }
+
+ token, ok := desiredTokens[tokenName]
+ if ok &&
+ ((token.SecretName == "" && secret.GenerateName != "") ||
+ (token.SecretName != "" && secret.GenerateName == "" && token.SecretName == secret.Name)) {
+ // Keep this secret
+ filtered[tokenName] = secret
+ } else {
+ // Delete orphaned secret
+ err := r.Delete(ctx, &secret)
+ if err != nil && !kuberr.IsNotFound(err) {
+ return nil, fmt.Errorf("deleting orphaned secret %q: %w", secret.Name, err)
+ }
+ }
+ }
+
+ return filtered, nil
+}
+
+// upsertAccount ensures a service account exists in Grafana.
+func (r *GrafanaServiceAccountReconciler) upsertAccount(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.GrafanaServiceAccount,
+) error {
+ if cr.Status.Account != nil {
+ update, err := gClient.ServiceAccounts.UpdateServiceAccount(
+ service_accounts.
+ NewUpdateServiceAccountParamsWithContext(ctx).
+ WithServiceAccountID(cr.Status.Account.ID).
+ WithBody(&models.UpdateServiceAccountForm{
+ // The form contains a ServiceAccountID field which is unused in Grafana, so it's ignored here.
+ Name: cr.Spec.Name,
+ Role: cr.Spec.Role,
+ IsDisabled: ptr.To(cr.Spec.IsDisabled),
+ }),
+ )
+ if err == nil {
+ cr.Status.Account = &v1beta1.GrafanaServiceAccountInfo{
+ ID: update.Payload.Serviceaccount.ID,
+ Role: update.Payload.Serviceaccount.Role,
+ IsDisabled: update.Payload.Serviceaccount.IsDisabled,
+ Name: update.Payload.Serviceaccount.Name,
+ Login: update.Payload.Serviceaccount.Login,
+ }
+
+ // Load existing tokens from Grafana
+ tokenList, err := gClient.ServiceAccounts.ListTokensWithParams(
+ service_accounts.
+ NewListTokensParamsWithContext(ctx).
+ WithServiceAccountID(cr.Status.Account.ID),
+ )
+ if err != nil {
+ return fmt.Errorf("listing tokens: %w", err)
+ }
+
+ cr.Status.Account.Tokens = make([]v1beta1.GrafanaServiceAccountTokenStatus, 0, len(tokenList.Payload))
+ for _, token := range tokenList.Payload {
+ if token != nil {
+ cr.Status.Account.Tokens = append(cr.Status.Account.Tokens, v1beta1.GrafanaServiceAccountTokenStatus{
+ ID: token.ID,
+ Name: token.Name,
+ Expires: convertGrafanaExpiration(token.Expiration),
+ })
+ }
+ }
+
+ return nil
+ }
+
+ // ATM, service_accounts.UpdateServiceAccountNotFound doesn't have Is, Unwrap, Unwrap.
+ // So, we cannot rely only on errors.Is().
+ _, ok := err.(*service_accounts.UpdateServiceAccountNotFound) // nolint:errorlint
+ if !ok && !errors.Is(err, service_accounts.NewUpdateServiceAccountNotFound()) {
+ return fmt.Errorf("updating service account: %w", err)
+ }
+
+ cr.Status.Account = nil
+ }
+
+ create, err := gClient.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 fmt.Errorf("creating service account: %w", err)
+ }
+
+ cr.Status.Account = &v1beta1.GrafanaServiceAccountInfo{
+ ID: create.Payload.ID,
+ Role: create.Payload.Role,
+ IsDisabled: create.Payload.IsDisabled,
+ Name: create.Payload.Name,
+ Login: create.Payload.Login,
+ }
+
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) removeAccountToken(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ serviceAccountID int64,
+ tokenStatus *v1beta1.GrafanaServiceAccountTokenStatus,
+) error {
+ if tokenStatus.Secret != nil {
+ secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
+ Namespace: tokenStatus.Secret.Namespace,
+ Name: tokenStatus.Secret.Name,
+ }}
+
+ err := r.Delete(ctx, secret)
+ if err != nil && !kuberr.IsNotFound(err) {
+ return fmt.Errorf("deleting token secret %q: %w", secret.Name, err)
+ }
+
+ tokenStatus.Secret = nil
+ }
+
+ _, err := gClient.ServiceAccounts.DeleteTokenWithParams( // nolint:errcheck
+ service_accounts.
+ NewDeleteTokenParamsWithContext(ctx).
+ WithServiceAccountID(serviceAccountID).
+ WithTokenID(tokenStatus.ID),
+ )
+ if err != nil {
+ // ATM, service_accounts.DeleteTokenNotFound doesn't have Is, Unwrap, Unwrap.
+ // So, we cannot rely only on errors.Is().
+ _, ok := err.(*service_accounts.DeleteTokenNotFound) // nolint:errorlint
+ if ok || errors.Is(err, service_accounts.NewDeleteTokenNotFound()) {
+ return nil
+ }
+
+ return fmt.Errorf("deleting token: %w", err)
+ }
+
+ return nil
+}
+
+func isEqualExpirationTime(a, b *metav1.Time) bool {
+ // Token expiration drift tolerance
+ const tokenExpirationDrift = 1 * time.Second
+
+ if a == nil && b == nil {
+ return true
+ }
+
+ if a == nil || b == nil {
+ return false
+ }
+
+ // Grafana API doesn't allow to set expiration time for tokens. Instead of it,
+ // Grafana accepts TTL then calculates the expiration time against the current time.
+ // So, we cannot just compare the expiration time with the spec' one.
+ // Let's assume that two expiration times are equal if they are close enough.
+ diff := a.Sub(b.Time)
+
+ return diff.Abs() <= tokenExpirationDrift
+}
+
+func buildSecretLabels(cr *v1beta1.GrafanaServiceAccount) map[string]string {
+ return map[string]string{
+ "operator.grafana.com/service-account-instance": cr.Spec.InstanceName,
+ "operator.grafana.com/service-account-name": cr.Name,
+ "operator.grafana.com/service-account-uid": string(cr.UID),
+ }
+}
+
+func generateSecretName(cr *v1beta1.GrafanaServiceAccount, tokenSpec v1beta1.GrafanaServiceAccountTokenSpec) string {
+ return fmt.Sprintf("%s-%s-%s-", cr.Spec.InstanceName, cr.Spec.Name, tokenSpec.Name)
+}
+
+func extractTokenNameFromSecret(secret *corev1.Secret) (string, bool) {
+ if secret.Annotations == nil {
+ return "", false
+ }
+
+ tokenName, ok := secret.Annotations["operator.grafana.com/service-account-token-name"]
+
+ return tokenName, ok
+}
+
+func buildTokenSecret(
+ ctx context.Context,
+ cr *v1beta1.GrafanaServiceAccount,
+ tokenSpec v1beta1.GrafanaServiceAccountTokenSpec,
+ tokenStatus v1beta1.GrafanaServiceAccountTokenStatus,
+ tokenKey []byte,
+ scheme *runtime.Scheme,
+) *corev1.Secret {
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: tokenSpec.SecretName,
+ Namespace: cr.Namespace,
+ Labels: buildSecretLabels(cr),
+ Annotations: map[string]string{
+ "operator.grafana.com/service-account-spec-name": cr.Spec.Name,
+ "operator.grafana.com/service-account-uid": string(cr.UID),
+ "operator.grafana.com/service-account-token-name": tokenStatus.Name,
+ },
+ },
+ Type: corev1.SecretTypeOpaque,
+ }
+ renewSecret(secret, tokenStatus, tokenKey)
+
+ if secret.Name == "" {
+ secret.GenerateName = generateSecretName(cr, tokenSpec)
+ }
+
+ if tokenStatus.Expires != nil {
+ secret.Annotations["operator.grafana.com/service-account-token-expiry"] = tokenStatus.Expires.Format(time.RFC3339)
+ }
+
+ model2.SetInheritedLabels(secret, cr.Labels)
+
+ if scheme != nil {
+ err := controllerutil.SetControllerReference(cr, secret, scheme)
+ if err != nil {
+ logf.FromContext(ctx).Error(err, "Failed to set controller reference")
+ }
+ }
+
+ return secret
+}
+
+func renewSecret(
+ secret *corev1.Secret,
+ tokenStatus v1beta1.GrafanaServiceAccountTokenStatus,
+ tokenKey []byte,
+) {
+ if secret.Data == nil {
+ secret.Data = map[string][]byte{}
+ }
+
+ secret.Data["token"] = tokenKey
+
+ if secret.Annotations == nil {
+ secret.Annotations = map[string]string{}
+ }
+
+ secret.Annotations["operator.grafana.com/service-account-token-id"] = strconv.FormatInt(tokenStatus.ID, 10)
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *GrafanaServiceAccountReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ // TODO: Consider watching token Secrets for reactive reconciles.
+ // It'll requeue on Secret create/update/delete, reducing reliance on ResyncPeriod.
+ // Example: Owns(&corev1.Secret{}, builder.WithPredicates(...))
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&v1beta1.GrafanaServiceAccount{}).
+ WithEventFilter(predicate.Or(
+ ignoreStatusUpdates(),
+ predicate.AnnotationChangedPredicate{},
+ )).
+ WithOptions(controller.Options{RateLimiter: defaultRateLimiter()}).
+ Complete(r)
+}
diff --git a/controllers/serviceaccount_controller_test.go b/controllers/serviceaccount_controller_test.go
new file mode 100644
index 000000000..be310019e
--- /dev/null
+++ b/controllers/serviceaccount_controller_test.go
@@ -0,0 +1,220 @@
+/*
+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"
+ "time"
+
+ "github.com/grafana/grafana-openapi-client-go/client/service_accounts"
+ "github.com/grafana/grafana-operator/v5/api/v1beta1"
+ client2 "github.com/grafana/grafana-operator/v5/controllers/client"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ . "github.com/onsi/gomega/gstruct"
+)
+
+var _ = Describe("ServiceAccount Controller: Integration Tests", func() {
+ Context("When creating service account with token", func() {
+ It("should create secret and verify Grafana API state", func() {
+ ctx := context.Background()
+ const namespace = "default"
+ const name = "test-sa-with-token"
+ const secretName = "test-sa-token-secret" // nolint:gosec
+
+ // Create a GrafanaServiceAccount with a token
+ sa := &v1beta1.GrafanaServiceAccount{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ },
+ Spec: v1beta1.GrafanaServiceAccountSpec{
+ ResyncPeriod: metav1.Duration{Duration: 10 * time.Minute},
+ InstanceName: grafanaName,
+ Name: "test-account-with-token",
+ Role: "Admin",
+ Tokens: []v1beta1.GrafanaServiceAccountTokenSpec{
+ {
+ Name: "test-token",
+ SecretName: secretName,
+ },
+ },
+ },
+ }
+
+ Expect(k8sClient.Create(ctx, sa)).Should(Succeed())
+
+ // Reconcile
+ r := &GrafanaServiceAccountReconciler{
+ Client: k8sClient,
+ Scheme: k8sClient.Scheme(),
+ }
+
+ req := requestFromMeta(sa.ObjectMeta)
+ _, err := r.Reconcile(ctx, req)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Check that the secret was created with correct metadata and data
+ secret := &corev1.Secret{}
+ Expect(k8sClient.Get(ctx, types.NamespacedName{
+ Name: secretName,
+ Namespace: namespace,
+ }, secret)).Should(Succeed())
+
+ Expect(secret).To(PointTo(MatchFields(IgnoreExtras, Fields{
+ "ObjectMeta": MatchFields(IgnoreExtras, Fields{
+ "Labels": HaveKeyWithValue("operator.grafana.com/service-account-name", name),
+ "Annotations": HaveKeyWithValue("operator.grafana.com/service-account-token-name", "test-token"),
+ }),
+ "Data": HaveKeyWithValue("token", Not(BeEmpty())),
+ })))
+
+ // Check the status
+ updatedSA := &v1beta1.GrafanaServiceAccount{}
+ Expect(k8sClient.Get(ctx, types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, updatedSA)).Should(Succeed())
+
+ // Verify that ServiceAccountSynchronized condition is set to success
+ containsEqualCondition(updatedSA.Status.Conditions, metav1.Condition{
+ Type: conditionServiceAccountSynchronized,
+ Reason: conditionReasonApplySuccessful,
+ })
+
+ // Verify that tokens are populated in status
+ Expect(updatedSA.Status.Account).To(PointTo(MatchFields(IgnoreExtras, Fields{
+ "Tokens": ConsistOf(MatchFields(IgnoreExtras, Fields{
+ "Name": Equal("test-token"),
+ "Secret": PointTo(MatchFields(IgnoreExtras, Fields{
+ "Name": Equal(secretName),
+ "Namespace": Equal(namespace),
+ })),
+ })),
+ })))
+
+ // Verify that the service account and token were actually created in Grafana
+ // Get Grafana client
+ gClient, err := client2.NewGeneratedGrafanaClient(ctx, k8sClient, externalGrafanaCr)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Retrieve the service account from Grafana API
+ saFromGrafana, err := gClient.ServiceAccounts.RetrieveServiceAccountWithParams(
+ service_accounts.
+ NewRetrieveServiceAccountParamsWithContext(ctx).
+ WithServiceAccountID(updatedSA.Status.Account.ID),
+ )
+ Expect(err).ToNot(HaveOccurred())
+ Expect(saFromGrafana.Payload).To(PointTo(MatchFields(IgnoreExtras, Fields{
+ "Name": Equal("test-account-with-token"),
+ "Role": Equal("Admin"),
+ "IsDisabled": BeFalse(),
+ })))
+
+ // Verify that the token exists in Grafana
+ tokensFromGrafana, err := gClient.ServiceAccounts.ListTokensWithParams(
+ service_accounts.
+ NewListTokensParamsWithContext(ctx).
+ WithServiceAccountID(updatedSA.Status.Account.ID),
+ )
+ Expect(err).ToNot(HaveOccurred())
+ Expect(tokensFromGrafana.Payload).To(ConsistOf(
+ PointTo(MatchFields(IgnoreExtras, Fields{
+ "Name": Equal("test-token"),
+ "ID": Equal(updatedSA.Status.Account.Tokens[0].ID),
+ })),
+ ))
+ })
+ })
+
+ Context("When Grafana instance is not ready", func() {
+ It("should handle gracefully", func() {
+ ctx := context.Background()
+ const namespace = "default"
+ const name = "test-sa-instance-not-ready"
+ const grafanaNotReady = "grafana-not-ready"
+
+ // Create a Grafana instance that is not ready
+ notReadyGrafana := &v1beta1.Grafana{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: grafanaNotReady,
+ Namespace: namespace,
+ },
+ Spec: v1beta1.GrafanaSpec{
+ Config: map[string]map[string]string{
+ "security": {
+ "admin_user": "admin",
+ "admin_password": "admin",
+ },
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, notReadyGrafana)).Should(Succeed())
+
+ // Update status to be not ready
+ notReadyGrafana.Status = v1beta1.GrafanaStatus{
+ Stage: v1beta1.OperatorStageDeployment,
+ StageStatus: v1beta1.OperatorStageResultInProgress,
+ }
+ Expect(k8sClient.Status().Update(ctx, notReadyGrafana)).Should(Succeed())
+
+ // Create a GrafanaServiceAccount that references the not-ready instance
+ sa := &v1beta1.GrafanaServiceAccount{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ },
+ Spec: v1beta1.GrafanaServiceAccountSpec{
+ ResyncPeriod: metav1.Duration{Duration: 10 * time.Minute},
+ InstanceName: grafanaNotReady,
+ Name: "test-account-not-ready",
+ Role: "Viewer",
+ },
+ }
+
+ Expect(k8sClient.Create(ctx, sa)).Should(Succeed())
+
+ // Reconcile
+ r := &GrafanaServiceAccountReconciler{
+ Client: k8sClient,
+ Scheme: k8sClient.Scheme(),
+ }
+
+ req := requestFromMeta(sa.ObjectMeta)
+ _, err := r.Reconcile(ctx, req)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("is not ready"))
+
+ // Check the status condition
+ updatedSA := &v1beta1.GrafanaServiceAccount{}
+ Expect(k8sClient.Get(ctx, types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, updatedSA)).Should(Succeed())
+
+ // Verify that NoMatchingInstance condition is set
+ containsEqualCondition(updatedSA.Status.Conditions, metav1.Condition{
+ Type: conditionNoMatchingInstance,
+ Reason: "ErrFetchingInstances",
+ })
+ })
+ })
+})
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 a8f752e7d..28efe9bb1 100644
--- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml
+++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml
@@ -5186,6 +5186,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..2240208a8
--- /dev/null
+++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanaserviceaccounts.yaml
@@ -0,0 +1,253 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.17.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:
+ instanceName:
+ description: Name of the Grafana instance to create the service account
+ for
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: spec.instanceName is immutable
+ rule: self == oldSelf
+ isDisabled:
+ default: false
+ description: Whether the service account is disabled
+ type: boolean
+ name:
+ description: Name of the service account in Grafana
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: spec.name is immutable
+ rule: self == oldSelf
+ resyncPeriod:
+ default: 10m0s
+ description: How often the resource is synced, defaults to 10m0s if
+ not set
+ pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
+ type: string
+ x-kubernetes-validations:
+ - message: spec.resyncPeriod must be greater than 0
+ rule: duration(self) > duration('0s')
+ role:
+ description: Role of the service account (Viewer, Editor, Admin)
+ enum:
+ - Viewer
+ - Editor
+ - Admin
+ type: string
+ suspend:
+ default: false
+ description: Suspend pauses reconciliation of the service account
+ type: boolean
+ tokens:
+ description: Tokens to create for the service account
+ items:
+ description: GrafanaServiceAccountTokenSpec defines a token for
+ a service account
+ properties:
+ expires:
+ description: Expiration date of the token. If not set, the token
+ never expires
+ format: date-time
+ type: string
+ name:
+ description: Name of the token
+ minLength: 1
+ type: string
+ secretName:
+ description: Name of the secret to store the token. If not set,
+ a name will be generated
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
+ required:
+ - instanceName
+ - name
+ - role
+ type: object
+ status:
+ description: GrafanaServiceAccountStatus defines the observed state of
+ a GrafanaServiceAccount
+ properties:
+ account:
+ description: Info contains the Grafana service account information
+ properties:
+ id:
+ description: ID of the service account in Grafana
+ format: int64
+ type: integer
+ isDisabled:
+ description: IsDisabled indicates if the service account is disabled
+ type: boolean
+ login:
+ type: string
+ name:
+ type: string
+ role:
+ description: Role is the Grafana role for the service account
+ (Viewer, Editor, Admin)
+ type: string
+ tokens:
+ description: Information about tokens
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a token
+ created in Grafana.
+ properties:
+ expires:
+ description: |-
+ Expiration time of the token
+ N.B. There's possible discrepancy with the expiration time in spec
+ It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time
+ format: date-time
+ type: string
+ id:
+ description: ID of the token in Grafana
+ format: int64
+ type: integer
+ name:
+ type: string
+ secret:
+ description: Name of the secret containing the token
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ type: object
+ required:
+ - id
+ - name
+ type: object
+ type: array
+ required:
+ - id
+ - isDisabled
+ - login
+ - name
+ - role
+ type: object
+ 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
+ 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 b8be34be0..deb519799 100644
--- a/deploy/kustomize/base/crds.yaml
+++ b/deploy/kustomize/base/crds.yaml
@@ -8596,6 +8596,10 @@ spec:
items:
type: string
type: array
+ serviceaccounts:
+ items:
+ type: string
+ type: array
stage:
type: string
stageStatus:
@@ -8610,3 +8614,256 @@ spec:
storage: true
subresources:
status: {}
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.17.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:
+ instanceName:
+ description: Name of the Grafana instance to create the service account
+ for
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: spec.instanceName is immutable
+ rule: self == oldSelf
+ isDisabled:
+ default: false
+ description: Whether the service account is disabled
+ type: boolean
+ name:
+ description: Name of the service account in Grafana
+ minLength: 1
+ type: string
+ x-kubernetes-validations:
+ - message: spec.name is immutable
+ rule: self == oldSelf
+ resyncPeriod:
+ default: 10m0s
+ description: How often the resource is synced, defaults to 10m0s if
+ not set
+ pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$
+ type: string
+ x-kubernetes-validations:
+ - message: spec.resyncPeriod must be greater than 0
+ rule: duration(self) > duration('0s')
+ role:
+ description: Role of the service account (Viewer, Editor, Admin)
+ enum:
+ - Viewer
+ - Editor
+ - Admin
+ type: string
+ suspend:
+ default: false
+ description: Suspend pauses reconciliation of the service account
+ type: boolean
+ tokens:
+ description: Tokens to create for the service account
+ items:
+ description: GrafanaServiceAccountTokenSpec defines a token for
+ a service account
+ properties:
+ expires:
+ description: Expiration date of the token. If not set, the token
+ never expires
+ format: date-time
+ type: string
+ name:
+ description: Name of the token
+ minLength: 1
+ type: string
+ secretName:
+ description: Name of the secret to store the token. If not set,
+ a name will be generated
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - name
+ x-kubernetes-list-type: map
+ required:
+ - instanceName
+ - name
+ - role
+ type: object
+ status:
+ description: GrafanaServiceAccountStatus defines the observed state of
+ a GrafanaServiceAccount
+ properties:
+ account:
+ description: Info contains the Grafana service account information
+ properties:
+ id:
+ description: ID of the service account in Grafana
+ format: int64
+ type: integer
+ isDisabled:
+ description: IsDisabled indicates if the service account is disabled
+ type: boolean
+ login:
+ type: string
+ name:
+ type: string
+ role:
+ description: Role is the Grafana role for the service account
+ (Viewer, Editor, Admin)
+ type: string
+ tokens:
+ description: Information about tokens
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a token
+ created in Grafana.
+ properties:
+ expires:
+ description: |-
+ Expiration time of the token
+ N.B. There's possible discrepancy with the expiration time in spec
+ It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time
+ format: date-time
+ type: string
+ id:
+ description: ID of the token in Grafana
+ format: int64
+ type: integer
+ name:
+ type: string
+ secret:
+ description: Name of the secret containing the token
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ type: object
+ required:
+ - id
+ - name
+ type: object
+ type: array
+ required:
+ - id
+ - isDisabled
+ - login
+ - name
+ - role
+ type: object
+ 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
+ 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/deploy/kustomize/overlays/chainsaw-debug/deployment-patch.yaml b/deploy/kustomize/overlays/chainsaw-debug/deployment-patch.yaml
new file mode 100644
index 000000000..c29b2e6a8
--- /dev/null
+++ b/deploy/kustomize/overlays/chainsaw-debug/deployment-patch.yaml
@@ -0,0 +1,24 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: grafana-operator-controller-manager
+spec:
+ template:
+ spec:
+ containers:
+ - name: manager
+ imagePullPolicy: IfNotPresent
+ args:
+ - -listen=:8081
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 8081
+ initialDelaySeconds: 5
+ periodSeconds: 60
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 8081
+ initialDelaySeconds: 5
+ periodSeconds: 60
diff --git a/deploy/kustomize/overlays/chainsaw-debug/kustomization.yaml b/deploy/kustomize/overlays/chainsaw-debug/kustomization.yaml
new file mode 100644
index 000000000..880a166cd
--- /dev/null
+++ b/deploy/kustomize/overlays/chainsaw-debug/kustomization.yaml
@@ -0,0 +1,17 @@
+resources:
+ - ../chainsaw
+
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+images:
+ - name: ko.local/grafana/grafana-operator
+ newName: hashicorp/http-echo
+ newTag: latest
+
+patches:
+ - path: deployment-patch.yaml
+ target:
+ group: apps
+ kind: Deployment
+ version: v1
diff --git a/deploy/kustomize/overlays/cluster_scoped/kustomization.yaml b/deploy/kustomize/overlays/cluster_scoped/kustomization.yaml
index a68dcae82..f5fc29861 100644
--- a/deploy/kustomize/overlays/cluster_scoped/kustomization.yaml
+++ b/deploy/kustomize/overlays/cluster_scoped/kustomization.yaml
@@ -1,4 +1,10 @@
namespace: grafana
resources:
- - ../../base
+- ../../base
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+images:
+- name: ghcr.io/grafana/grafana-operator
+ newName: ghcr.io/grafana/grafana-operator
+ newTag: v5.18.0
diff --git a/docs/docs/api.md b/docs/docs/api.md
index 0ac4f5aad..0d15c53f4 100644
--- a/docs/docs/api.md
+++ b/docs/docs/api.md
@@ -33,6 +33,8 @@ Resource Types:
- [Grafana](#grafana)
+- [GrafanaServiceAccount](#grafanaserviceaccount)
+
@@ -22948,6 +22950,13 @@ GrafanaStatus defines the observed state of Grafana
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 | +
---|---|---|---|
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. + |
+ false | +
status | +object | +
+ GrafanaServiceAccountStatus defines the observed state of a GrafanaServiceAccount + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
instanceName | +string | +
+ Name of the Grafana instance to create the service account for + + Validations: |
+ true | +
name | +string | +
+ Name of the service account in Grafana + + Validations: |
+ true | +
role | +enum | +
+ Role of the service account (Viewer, Editor, Admin) + + Enum: Viewer, Editor, Admin + |
+ true | +
isDisabled | +boolean | +
+ Whether the service account is disabled + + Default: false + |
+ false | +
resyncPeriod | +string | +
+ How often the resource is synced, defaults to 10m0s if not set + + Validations: + |
+ false | +
suspend | +boolean | +
+ Suspend pauses reconciliation of the service account + + Default: false + |
+ false | +
tokens | +[]object | +
+ Tokens to create for the service account + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
name | +string | +
+ Name of the token + |
+ true | +
expires | +string | +
+ Expiration date of the token. If not set, the token never expires + + Format: date-time + |
+ false | +
secretName | +string | +
+ Name of the secret to store the token. If not set, a name will be generated + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
account | +object | +
+ Info contains the Grafana service account information + |
+ false | +
conditions | +[]object | +
+ Results when synchonizing resource with Grafana instances + |
+ false | +
lastResync | +string | +
+ Last time the resource was synchronized with Grafana instances + + Format: date-time + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
id | +integer | +
+ ID of the service account in Grafana + + Format: int64 + |
+ true | +
isDisabled | +boolean | +
+ IsDisabled indicates if the service account is disabled + |
+ true | +
login | +string | +
+ + |
+ true | +
name | +string | +
+ + |
+ true | +
role | +string | +
+ Role is the Grafana role for the service account (Viewer, Editor, Admin) + |
+ true | +
tokens | +[]object | +
+ Information about tokens + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
id | +integer | +
+ ID of the token in Grafana + + Format: int64 + |
+ true | +
name | +string | +
+ + |
+ true | +
expires | +string | +
+ Expiration time of the token
+N.B. There's possible discrepancy with the expiration time in spec
+It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time + + Format: date-time + |
+ false | +
secret | +object | +
+ Name of the secret containing the token + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
name | +string | +
+ + |
+ false | +
namespace | +string | +
+ + |
+ false | +