diff --git a/.gitignore b/.gitignore
index e1df15327..ad4d7d39d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Binaries for programs and plugins
+__debug_bin*
*.exe
*.exe~
*.dll
@@ -26,6 +27,7 @@ vendor
*~
.vscode/
+.mirrord/
.DS_Store
# Audit lab
diff --git a/api/v1beta1/grafana_types.go b/api/v1beta1/grafana_types.go
index 0c4241fb1..98dbcbbdf 100644
--- a/api/v1beta1/grafana_types.go
+++ b/api/v1beta1/grafana_types.go
@@ -52,6 +52,54 @@ type OperatorReconcileVars struct {
Plugins string
}
+// GrafanaServiceAccountTokenSpec describes a token to create.
+type GrafanaServiceAccountTokenSpec 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"`
+}
+
+// GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount.
+type GrafanaServiceAccountSpec struct {
+ // ID is a kind of unique identifier to distinguish between service accounts if the name is changed.
+ // +kubebuilder:validation:Required
+ ID string `json:"id"`
+
+ // 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 []GrafanaServiceAccountTokenSpec `json:"tokens,omitempty"`
+}
+
+type GrafanaServiceAccounts struct {
+ // Accounts lists Grafana service accounts to manage.
+ // Each service account is uniquely identified by its ID.
+ // +listType=map
+ // +listMapKey=id
+ Accounts []GrafanaServiceAccountSpec `json:"accounts,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"`
+}
+
// GrafanaSpec defines the desired state of Grafana
type GrafanaSpec struct {
// +kubebuilder:pruning:PreserveUnknownFields
@@ -83,6 +131,8 @@ type GrafanaSpec struct {
// DisableDefaultSecurityContext prevents the operator from populating securityContext on deployments
// +kubebuilder:validation:Enum=Pod;Container;All
DisableDefaultSecurityContext string `json:"disableDefaultSecurityContext,omitempty"`
+ // Grafana Service Accounts
+ GrafanaServiceAccounts *GrafanaServiceAccounts `json:"grafanaServiceAccounts,omitempty"`
}
type External struct {
@@ -134,22 +184,68 @@ type GrafanaPreferences struct {
HomeDashboardUID string `json:"homeDashboardUid,omitempty"`
}
+type GrafanaServiceAccountSecretStatus struct {
+ Namespace string `json:"namespace,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+// GrafanaServiceAccountTokenStatus describes a token created in Grafana.
+type GrafanaServiceAccountTokenStatus struct {
+ // Name is the name of the Kubernetes Secret. The secret will contain the token value.
+ Name string `json:"name"`
+
+ // Expires is the expiration time for 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 is the Grafana-assigned ID of the token.
+ ID int64 `json:"tokenId"`
+
+ // Secret is 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.
+ Secret *GrafanaServiceAccountSecretStatus `json:"secret,omitempty"`
+}
+
+// GrafanaServiceAccountStatus holds status for one Grafana instance.
+type GrafanaServiceAccountStatus struct {
+ // SpecID is a kind of unique identifier to distinguish between service accounts if the name is changed.
+ SpecID string `json:"specId"`
+
+ // Name is the name of the service account in Grafana.
+ Name string `json:"name"`
+
+ // ServiceAccountID is the numeric ID of the service account in this Grafana.
+ ServiceAccountID int64 `json:"serviceAccountId"`
+
+ // 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,omitempty"`
+
+ // Tokens is the status of tokens for this service account in Grafana.
+ Tokens []GrafanaServiceAccountTokenStatus `json:"tokens,omitempty"`
+}
+
// 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"`
- AlertRuleGroups NamespacedResourceList `json:"alertRuleGroups,omitempty"`
- ContactPoints NamespacedResourceList `json:"contactPoints,omitempty"`
- Dashboards NamespacedResourceList `json:"dashboards,omitempty"`
- Datasources NamespacedResourceList `json:"datasources,omitempty"`
- Folders NamespacedResourceList `json:"folders,omitempty"`
- LibraryPanels NamespacedResourceList `json:"libraryPanels,omitempty"`
- MuteTimings NamespacedResourceList `json:"muteTimings,omitempty"`
- NotificationTemplates NamespacedResourceList `json:"notificationTemplates,omitempty"`
- Version string `json:"version,omitempty"`
- Conditions []metav1.Condition `json:"conditions,omitempty"`
+ Stage OperatorStageName `json:"stage,omitempty"`
+ StageStatus OperatorStageStatus `json:"stageStatus,omitempty"`
+ LastMessage string `json:"lastMessage,omitempty"`
+ AdminURL string `json:"adminUrl,omitempty"`
+ AlertRuleGroups NamespacedResourceList `json:"alertRuleGroups,omitempty"`
+ ContactPoints NamespacedResourceList `json:"contactPoints,omitempty"`
+ Dashboards NamespacedResourceList `json:"dashboards,omitempty"`
+ Datasources NamespacedResourceList `json:"datasources,omitempty"`
+ Folders NamespacedResourceList `json:"folders,omitempty"`
+ LibraryPanels NamespacedResourceList `json:"libraryPanels,omitempty"`
+ MuteTimings NamespacedResourceList `json:"muteTimings,omitempty"`
+ NotificationTemplates NamespacedResourceList `json:"notificationTemplates,omitempty"`
+ Version string `json:"version,omitempty"`
+ Conditions []metav1.Condition `json:"conditions,omitempty"`
+ GrafanaServiceAccounts []GrafanaServiceAccountStatus `json:"serviceAccounts,omitempty"`
}
// +kubebuilder:object:root=true
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index aa32255c2..10cde08ec 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -1730,6 +1730,130 @@ 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 *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
+ 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
+ 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 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 *GrafanaServiceAccounts) DeepCopyInto(out *GrafanaServiceAccounts) {
+ *out = *in
+ if in.Accounts != nil {
+ in, out := &in.Accounts, &out.Accounts
+ *out = make([]GrafanaServiceAccountSpec, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccounts.
+func (in *GrafanaServiceAccounts) DeepCopy() *GrafanaServiceAccounts {
+ if in == nil {
+ return nil
+ }
+ out := new(GrafanaServiceAccounts)
+ 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
@@ -1801,6 +1925,11 @@ func (in *GrafanaSpec) DeepCopyInto(out *GrafanaSpec) {
*out = new(GrafanaPreferences)
**out = **in
}
+ if in.GrafanaServiceAccounts != nil {
+ in, out := &in.GrafanaServiceAccounts, &out.GrafanaServiceAccounts
+ *out = new(GrafanaServiceAccounts)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaSpec.
@@ -1863,6 +1992,13 @@ func (in *GrafanaStatus) DeepCopyInto(out *GrafanaStatus) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
+ if in.GrafanaServiceAccounts != nil {
+ in, out := &in.GrafanaServiceAccounts, &out.GrafanaServiceAccounts
+ *out = make([]GrafanaServiceAccountStatus, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaStatus.
diff --git a/config/crd/bases/grafana.integreatly.org_grafanas.yaml b/config/crd/bases/grafana.integreatly.org_grafanas.yaml
index 2e97ae726..149de75ac 100644
--- a/config/crd/bases/grafana.integreatly.org_grafanas.yaml
+++ b/config/crd/bases/grafana.integreatly.org_grafanas.yaml
@@ -3841,6 +3841,64 @@ spec:
required:
- url
type: object
+ grafanaServiceAccounts:
+ description: Grafana Service Accounts
+ properties:
+ accounts:
+ description: |-
+ Accounts lists Grafana service accounts to manage.
+ Each service account is uniquely identified by its ID.
+ items:
+ description: GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount.
+ properties:
+ id:
+ description: ID is a kind of unique identifier to distinguish between service accounts if the name is changed.
+ type: string
+ 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
+ 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: GrafanaServiceAccountTokenSpec 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:
+ - id
+ - name
+ - role
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - id
+ x-kubernetes-list-type: map
+ 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
+ type: object
ingress:
description: Ingress sets how the ingress object should look like with your grafana instance.
properties:
@@ -5042,6 +5100,68 @@ spec:
items:
type: string
type: array
+ serviceAccounts:
+ items:
+ description: GrafanaServiceAccountStatus holds status for one Grafana instance.
+ properties:
+ isDisabled:
+ description: IsDisabled indicates if the service account is disabled.
+ type: boolean
+ name:
+ description: Name is the name of the service account in Grafana.
+ type: string
+ role:
+ description: Role is the Grafana role for the service account (Viewer, Editor, Admin).
+ type: string
+ serviceAccountId:
+ description: ServiceAccountID is the numeric ID of the service account in this Grafana.
+ format: int64
+ type: integer
+ specId:
+ description: SpecID is a kind of unique identifier to distinguish between service accounts if the name is changed.
+ type: string
+ tokens:
+ description: Tokens is the status of tokens for this service account in Grafana.
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a token created in Grafana.
+ properties:
+ expires:
+ description: |-
+ Expires is the expiration time for 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
+ name:
+ description: Name is the name of the Kubernetes Secret. The secret will contain the token value.
+ type: string
+ secret:
+ description: |-
+ Secret is 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.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ type: object
+ tokenId:
+ description: ID is the Grafana-assigned ID of the token.
+ format: int64
+ type: integer
+ required:
+ - name
+ - tokenId
+ type: object
+ type: array
+ required:
+ - name
+ - role
+ - serviceAccountId
+ - specId
+ type: object
+ type: array
stage:
type: string
stageStatus:
diff --git a/controllers/contactpoint_controller.go b/controllers/contactpoint_controller.go
index 539298751..93b97612b 100644
--- a/controllers/contactpoint_controller.go
+++ b/controllers/contactpoint_controller.go
@@ -124,6 +124,8 @@ func (r *GrafanaContactPointReconciler) Reconcile(ctx context.Context, req ctrl.
}
func (r *GrafanaContactPointReconciler) reconcileWithInstance(ctx context.Context, instance *grafanav1beta1.Grafana, contactPoint *grafanav1beta1.GrafanaContactPoint, settings *models.JSON) error {
+ origCR := client.MergeFrom(instance.DeepCopy())
+
cl, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, instance)
if err != nil {
return fmt.Errorf("building grafana client: %w", err)
@@ -163,7 +165,7 @@ func (r *GrafanaContactPointReconciler) reconcileWithInstance(ctx context.Contex
// Update grafana instance Status
instance.Status.ContactPoints = instance.Status.ContactPoints.Add(contactPoint.Namespace, contactPoint.Name, applied.UID)
- if err = r.Client.Status().Update(ctx, instance); err != nil {
+ if err = r.Client.Status().Patch(ctx, instance, origCR); err != nil {
return err
}
@@ -216,6 +218,8 @@ func (r *GrafanaContactPointReconciler) finalize(ctx context.Context, contactPoi
}
for _, instance := range instances {
+ origCR := client.MergeFrom(instance.DeepCopy())
+
cl, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, &instance)
if err != nil {
return fmt.Errorf("building grafana client: %w", err)
@@ -227,7 +231,7 @@ func (r *GrafanaContactPointReconciler) finalize(ctx context.Context, contactPoi
}
instance.Status.ContactPoints = instance.Status.ContactPoints.Remove(contactPoint.Namespace, contactPoint.Name)
- if err = r.Client.Status().Update(ctx, &instance); err != nil {
+ if err = r.Client.Status().Patch(ctx, &instance, origCR); err != nil {
return fmt.Errorf("removing contact point from Grafana cr: %w", err)
}
}
diff --git a/controllers/dashboard_controller.go b/controllers/dashboard_controller.go
index 3b4e7f4b3..8f03d34d8 100644
--- a/controllers/dashboard_controller.go
+++ b/controllers/dashboard_controller.go
@@ -199,6 +199,8 @@ func (r *GrafanaDashboardReconciler) finalize(ctx context.Context, cr *v1beta1.G
}
for _, grafana := range instances {
+ origCR := client.MergeFrom(grafana.DeepCopy())
+
found, uid := grafana.Status.Dashboards.Find(cr.Namespace, cr.Name)
if !found {
continue
@@ -259,7 +261,7 @@ func (r *GrafanaDashboardReconciler) finalize(ctx context.Context, cr *v1beta1.G
// Update status of Grafana instance
grafana.Status.Dashboards = grafana.Status.Dashboards.Remove(cr.Namespace, cr.Name)
- err = r.Client.Status().Update(ctx, &grafana)
+ err = r.Client.Status().Patch(ctx, &grafana, origCR)
if err != nil {
return fmt.Errorf("updating grafana cr status %s/%s: %w", grafana.Namespace, grafana.Name, err)
}
@@ -269,6 +271,8 @@ func (r *GrafanaDashboardReconciler) finalize(ctx context.Context, cr *v1beta1.G
}
func (r *GrafanaDashboardReconciler) onDashboardCreated(ctx context.Context, grafana *v1beta1.Grafana, cr *v1beta1.GrafanaDashboard, dashboardModel map[string]any, hash string) error {
+ origCR := client.MergeFrom(grafana.DeepCopy())
+
log := logf.FromContext(ctx)
if grafana.IsExternal() && cr.Spec.Plugins != nil {
return fmt.Errorf("external grafana instances don't support plugins, please remove spec.plugins from your dashboard cr")
@@ -352,7 +356,7 @@ func (r *GrafanaDashboardReconciler) onDashboardCreated(ctx context.Context, gra
}
grafana.Status.Dashboards = grafana.Status.Dashboards.Add(cr.Namespace, cr.Name, uid)
- return r.Client.Status().Update(ctx, grafana)
+ return r.Client.Status().Patch(ctx, grafana, origCR)
}
func (r *GrafanaDashboardReconciler) Exists(client *genapi.GrafanaHTTPAPI, uid string, title string, folderUID string) (string, error) {
diff --git a/controllers/datasource_controller.go b/controllers/datasource_controller.go
index 10881f2f5..225d41fb7 100644
--- a/controllers/datasource_controller.go
+++ b/controllers/datasource_controller.go
@@ -172,6 +172,8 @@ func (r *GrafanaDatasourceReconciler) deleteOldDatasource(ctx context.Context, c
}
for _, grafana := range instances {
+ origCR := client.MergeFrom(grafana.DeepCopy())
+
found, uid := grafana.Status.Datasources.Find(cr.Namespace, cr.Name)
if !found {
continue
@@ -198,7 +200,7 @@ func (r *GrafanaDatasourceReconciler) deleteOldDatasource(ctx context.Context, c
}
grafana.Status.Datasources = grafana.Status.Datasources.Remove(cr.Namespace, cr.Name)
- err = r.Status().Update(ctx, &grafana)
+ err = r.Status().Patch(ctx, &grafana, origCR)
if err != nil {
return err
}
@@ -214,6 +216,8 @@ func (r *GrafanaDatasourceReconciler) finalize(ctx context.Context, cr *v1beta1.
}
for _, grafana := range instances {
+ origCR := client.MergeFrom(grafana.DeepCopy())
+
found, uid := grafana.Status.Datasources.Find(cr.Namespace, cr.Name)
if !found {
continue
@@ -242,7 +246,7 @@ func (r *GrafanaDatasourceReconciler) finalize(ctx context.Context, cr *v1beta1.
}
grafana.Status.Datasources = grafana.Status.Datasources.Remove(cr.Namespace, cr.Name)
- err = r.Status().Update(ctx, &grafana)
+ err = r.Status().Patch(ctx, &grafana, origCR)
if err != nil {
return err
}
@@ -252,6 +256,8 @@ func (r *GrafanaDatasourceReconciler) finalize(ctx context.Context, cr *v1beta1.
}
func (r *GrafanaDatasourceReconciler) onDatasourceCreated(ctx context.Context, grafana *v1beta1.Grafana, cr *v1beta1.GrafanaDatasource, datasource *models.UpdateDataSourceCommand, hash string) error {
+ origCR := client.MergeFrom(grafana.DeepCopy())
+
if grafana.IsExternal() && cr.Spec.Plugins != nil {
return fmt.Errorf("external grafana instances don't support plugins, please remove spec.plugins from your datasource cr")
}
@@ -300,7 +306,7 @@ func (r *GrafanaDatasourceReconciler) onDatasourceCreated(ctx context.Context, g
}
grafana.Status.Datasources = grafana.Status.Datasources.Add(cr.Namespace, cr.Name, datasource.UID)
- return r.Status().Update(ctx, grafana)
+ return r.Status().Patch(ctx, grafana, origCR)
}
func (r *GrafanaDatasourceReconciler) Exists(client *genapi.GrafanaHTTPAPI, uid, name string) (bool, string, error) {
diff --git a/controllers/folder_controller.go b/controllers/folder_controller.go
index f579a85d5..d39451245 100644
--- a/controllers/folder_controller.go
+++ b/controllers/folder_controller.go
@@ -136,6 +136,8 @@ func (r *GrafanaFolderReconciler) finalize(ctx context.Context, folder *grafanav
params := folders.NewDeleteFolderParams().WithForceDeleteRules(&reftrue)
for _, grafana := range instances {
+ origCR := client.MergeFrom(grafana.DeepCopy())
+
found, uid := grafana.Status.Folders.Find(folder.Namespace, folder.Name)
if !found {
continue
@@ -155,7 +157,7 @@ func (r *GrafanaFolderReconciler) finalize(ctx context.Context, folder *grafanav
}
grafana.Status.Folders = grafana.Status.Folders.Remove(folder.Namespace, folder.Name)
- if err = r.Status().Update(ctx, &grafana); err != nil {
+ if err = r.Status().Patch(ctx, &grafana, origCR); err != nil {
return fmt.Errorf("removing Folder from Grafana cr: %w", err)
}
}
@@ -164,6 +166,8 @@ func (r *GrafanaFolderReconciler) finalize(ctx context.Context, folder *grafanav
}
func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *grafanav1beta1.Grafana, cr *grafanav1beta1.GrafanaFolder) error {
+ origCR := client.MergeFrom(grafana.DeepCopy())
+
title := cr.GetTitle()
uid := cr.CustomUIDOrUID()
@@ -196,7 +200,7 @@ func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *
// - the folder was created through dashboard controller
if found, _ := grafana.Status.Folders.Find(cr.Namespace, cr.Name); !found {
grafana.Status.Folders = grafana.Status.Folders.Add(cr.Namespace, cr.Name, uid)
- err = r.Status().Update(ctx, grafana)
+ err = r.Status().Patch(ctx, grafana, origCR)
if err != nil {
return err
}
@@ -233,7 +237,7 @@ func (r *GrafanaFolderReconciler) onFolderCreated(ctx context.Context, grafana *
}
grafana.Status.Folders = grafana.Status.Folders.Add(cr.Namespace, cr.Name, folderResp.Payload.UID)
- err = r.Status().Update(ctx, grafana)
+ err = r.Status().Patch(ctx, grafana, origCR)
if err != nil {
return err
}
diff --git a/controllers/grafana_controller.go b/controllers/grafana_controller.go
index a6c8798ff..9f0014ba1 100644
--- a/controllers/grafana_controller.go
+++ b/controllers/grafana_controller.go
@@ -80,11 +80,12 @@ func (r *GrafanaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
log.Error(err, "error getting grafana cr")
return ctrl.Result{}, err
}
+ origCR := cr.DeepCopy()
metrics.GrafanaReconciles.WithLabelValues(cr.Namespace, cr.Name).Inc()
defer func() {
- if err := r.Status().Update(ctx, cr); err != nil {
+ if err := r.Status().Patch(ctx, cr, client.MergeFrom(origCR)); err != nil {
log.Error(err, "updating status")
}
}()
@@ -144,6 +145,15 @@ func (r *GrafanaReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
LastTransitionTime: metav1.Time{Time: time.Now()},
})
+ {
+ sar := newGrafanaServiceAccountReconciler(r.Client, r.Scheme)
+ err := sar.reconcile(ctx, cr)
+ if err != nil {
+ log.Error(err, "Failed to reconcile grafana service accounts")
+ return ctrl.Result{RequeueAfter: RequeueDelay}, nil
+ }
+ }
+
return ctrl.Result{}, nil
}
@@ -240,6 +250,8 @@ func (r *GrafanaReconciler) syncStatuses(ctx context.Context) error {
// delete resources from grafana statuses that no longer have a CR
statusUpdates := 0
for _, grafana := range grafanas.Items {
+ origCR := grafana.DeepCopy()
+
updateStatus := false
removeMissingCRs(&grafana.Status.AlertRuleGroups, alertRuleGroups, &updateStatus)
@@ -253,7 +265,7 @@ func (r *GrafanaReconciler) syncStatuses(ctx context.Context) error {
if updateStatus {
statusUpdates += 1
- err = r.Client.Status().Update(ctx, &grafana)
+ err = r.Client.Status().Patch(ctx, &grafana, client.MergeFrom(origCR))
if err != nil {
return err
}
diff --git a/controllers/librarypanel_controller.go b/controllers/librarypanel_controller.go
index 4995441fe..e67e10356 100644
--- a/controllers/librarypanel_controller.go
+++ b/controllers/librarypanel_controller.go
@@ -156,6 +156,8 @@ func (r *GrafanaLibraryPanelReconciler) Reconcile(ctx context.Context, req ctrl.
}
func (r *GrafanaLibraryPanelReconciler) reconcileWithInstance(ctx context.Context, instance *v1beta1.Grafana, cr *v1beta1.GrafanaLibraryPanel, model map[string]any, hash string) error {
+ origCR := client.MergeFrom(instance.DeepCopy())
+
if instance.IsInternal() {
err := ReconcilePlugins(ctx, r.Client, r.Scheme, instance, cr.Spec.Plugins, fmt.Sprintf("%v-librarypanel", cr.Name))
if err != nil {
@@ -181,7 +183,7 @@ func (r *GrafanaLibraryPanelReconciler) reconcileWithInstance(ctx context.Contex
defer func() {
instance.Status.LibraryPanels = instance.Status.LibraryPanels.Add(cr.Namespace, cr.Name, uid)
//nolint:errcheck
- _ = r.Client.Status().Update(ctx, instance)
+ _ = r.Client.Status().Patch(ctx, instance, origCR)
}()
resp, err := grafanaClient.LibraryElements.GetLibraryElementByUID(uid)
@@ -237,6 +239,8 @@ func (r *GrafanaLibraryPanelReconciler) finalize(ctx context.Context, libraryPan
}
for _, instance := range instances {
+ origCR := client.MergeFrom(instance.DeepCopy())
+
grafanaClient, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, &instance)
if err != nil {
return err
@@ -264,7 +268,7 @@ func (r *GrafanaLibraryPanelReconciler) finalize(ctx context.Context, libraryPan
}
instance.Status.LibraryPanels = instance.Status.LibraryPanels.Remove(libraryPanel.Namespace, libraryPanel.Name)
- if err = r.Client.Status().Update(ctx, &instance); err != nil {
+ if err = r.Client.Status().Patch(ctx, &instance, origCR); err != nil {
return fmt.Errorf("removing Folder from Grafana cr: %w", err)
}
}
diff --git a/controllers/model/grafana_resources.go b/controllers/model/grafana_resources.go
index f9a59b4d1..35a72b7a7 100644
--- a/controllers/model/grafana_resources.go
+++ b/controllers/model/grafana_resources.go
@@ -1,7 +1,12 @@
package model
import (
+ // sha1 is used to generate a hash of service account token secret names
+ "crypto/sha1" // nolint:gosec
"fmt"
+ "strconv"
+ "strings"
+ "time"
grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1"
routev1 "github.com/openshift/api/route/v1"
@@ -46,6 +51,71 @@ func GetGrafanaAdminSecret(cr *grafanav1beta1.Grafana, scheme *runtime.Scheme) *
return secret
}
+func generateInternalServiceAccountTokenSecretName(grafanaName, serviceAccountSpecID, tokenName string) string {
+ const maxSecretNameLength = 63
+
+ sanitizeK8sName := func(s string) string {
+ s = strings.ToLower(s)
+ s = strings.ReplaceAll(s, "_", "-")
+ return s
+ }
+
+ base := strings.Join([]string{grafanaName, serviceAccountSpecID, 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))
+}
+
+func GetInternalServiceAccountSecret(
+ cr *grafanav1beta1.Grafana,
+ saStatus grafanav1beta1.GrafanaServiceAccountStatus,
+ tokenStatus grafanav1beta1.GrafanaServiceAccountTokenStatus,
+ tokenKey []byte,
+ scheme *runtime.Scheme,
+) *v1.Secret {
+ secret := &v1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: generateInternalServiceAccountTokenSecretName(cr.Name, saStatus.SpecID, tokenStatus.Name),
+ Namespace: cr.Namespace,
+ Labels: map[string]string{
+ "app": "grafana-serviceaccount-token",
+ "grafana.integreatly.org/instance": cr.Name,
+ "grafana.integreatly.org/sa-spec-id": saStatus.SpecID,
+ "grafana.integreatly.org/token-name": tokenStatus.Name,
+ },
+ Annotations: map[string]string{
+ "grafana.integreatly.org/token-id": strconv.FormatInt(tokenStatus.ID, 10),
+ },
+ },
+ Type: v1.SecretTypeOpaque,
+ Data: map[string][]byte{
+ "token": tokenKey,
+ },
+ }
+ if tokenStatus.Expires != nil {
+ secret.Annotations = map[string]string{
+ "grafana.integreatly.org/token-expiry": tokenStatus.Expires.Format(time.RFC3339),
+ }
+ }
+ SetInheritedLabels(secret, cr.Labels)
+
+ if scheme != nil {
+ controllerutil.SetControllerReference(cr, secret, scheme) //nolint:errcheck
+ }
+ return secret
+}
+
func GetGrafanaDataPVC(cr *grafanav1beta1.Grafana, scheme *runtime.Scheme) *v1.PersistentVolumeClaim {
pvc := &v1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
diff --git a/controllers/serviceaccount_controller.go b/controllers/serviceaccount_controller.go
new file mode 100644
index 000000000..d32663f7f
--- /dev/null
+++ b/controllers/serviceaccount_controller.go
@@ -0,0 +1,649 @@
+package controllers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "slices"
+ "sort"
+ "time"
+
+ 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"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+
+ 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 conditionServiceAccountsSynchronized = "ServiceAccountsSynchronized"
+
+func setFailedServiceAccountsCondition(cr *v1beta1.Grafana, message string) {
+ meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
+ Type: conditionServiceAccountsSynchronized,
+ LastTransitionTime: metav1.Time{Time: time.Now()},
+ Status: metav1.ConditionFalse,
+ Reason: conditionApplyFailed,
+ Message: message,
+ })
+}
+
+func setSuccessfulServiceAccountsCondition(cr *v1beta1.Grafana, message string) {
+ meta.SetStatusCondition(&cr.Status.Conditions, metav1.Condition{
+ Type: conditionServiceAccountsSynchronized,
+ LastTransitionTime: metav1.Time{Time: time.Now()},
+ Status: metav1.ConditionTrue,
+ Reason: conditionApplySuccessful,
+ Message: message,
+ })
+}
+
+type GrafanaServiceAccountReconciler struct {
+ client.Client
+ scheme *runtime.Scheme
+}
+
+func newGrafanaServiceAccountReconciler(client client.Client, scheme *runtime.Scheme) *GrafanaServiceAccountReconciler {
+ return &GrafanaServiceAccountReconciler{
+ Client: client,
+ scheme: scheme,
+ }
+}
+
+func (r *GrafanaServiceAccountReconciler) reconcile(ctx context.Context, cr *v1beta1.Grafana) error {
+ if cr.Spec.GrafanaServiceAccounts == nil && cr.Status.GrafanaServiceAccounts == nil {
+ return nil
+ }
+
+ gClient, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, cr)
+ if err != nil {
+ setFailedServiceAccountsCondition(cr, err.Error())
+ return fmt.Errorf("building grafana client: %w", err)
+ }
+
+ err = r.reconcileAccounts(ctx, cr, gClient)
+ if err != nil {
+ setFailedServiceAccountsCondition(cr, err.Error())
+ return fmt.Errorf("reconciling service accounts: %w", err)
+ }
+ setSuccessfulServiceAccountsCondition(cr, "service accounts reconciled")
+
+ if cr.Spec.GrafanaServiceAccounts == nil {
+ // Spec is empty, so we don't need to check periodically the service accounts status.
+ return nil
+ }
+
+ return nil
+}
+
+// syncAccounts checks if the service accounts status in the Grafana CR is up to date
+// with the actual service accounts in Grafana. If there are any discrepancies, it updates the status
+// accordingly. If a service account was removed from Grafana, it removes it from the status.
+func (r *GrafanaServiceAccountReconciler) syncAccounts(
+ ctx context.Context,
+ cr *v1beta1.Grafana,
+ gClient *genapi.GrafanaHTTPAPI,
+) error {
+ ctx = logf.IntoContext(ctx, logf.FromContext(ctx).WithName("GrafanaServiceAccountController"))
+
+ if len(cr.Status.GrafanaServiceAccounts) == 0 {
+ return nil
+ }
+
+ existingAccounts, err := listExistingServiceAccounts(ctx, gClient)
+ if err != nil {
+ return fmt.Errorf("listing service accounts: %w", err)
+ }
+
+ removed := 0
+ for i := 0; i < len(cr.Status.GrafanaServiceAccounts); i++ {
+ existingAccount, ok := existingAccounts[cr.Status.GrafanaServiceAccounts[i].ServiceAccountID]
+ if !ok {
+ // It seems that the service account was removed from Grafana. Let's remove it from the status.
+ cr.Status.GrafanaServiceAccounts = removeFromSlice(cr.Status.GrafanaServiceAccounts, i)
+ removed++
+ i--
+ continue
+ }
+
+ actualizeAccountStatus(&cr.Status.GrafanaServiceAccounts[i], existingAccount)
+ err := r.syncTokens(ctx, gClient, &cr.Status.GrafanaServiceAccounts[i])
+ if err != nil {
+ return fmt.Errorf("syncing tokens for service account %q: %w", cr.Status.GrafanaServiceAccounts[i].SpecID, err)
+ }
+ }
+
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) syncTokens(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ accountStatus *v1beta1.GrafanaServiceAccountStatus,
+) error {
+ tokens, err := listExistingTokens(ctx, gClient, accountStatus.ServiceAccountID)
+ if err != nil {
+ return fmt.Errorf("listing tokens for service account %q: %w", accountStatus.ServiceAccountID, err)
+ }
+
+ for i := 0; i < len(accountStatus.Tokens); i++ {
+ existingToken, ok := tokens[accountStatus.Tokens[i].ID]
+ if !ok {
+ // It seems that the service account token was removed from Grafana. Let's remove it from the status.
+ accountStatus.Tokens = removeFromSlice(accountStatus.Tokens, i)
+ i--
+ continue
+ }
+
+ actualizeTokenStatus(&accountStatus.Tokens[i], existingToken)
+ }
+
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) reconcileAccounts(
+ ctx context.Context,
+ cr *v1beta1.Grafana,
+ gClient *genapi.GrafanaHTTPAPI,
+) error {
+ // Sort the accounts to ensure a stable order.
+ defer func(cr *v1beta1.Grafana) {
+ sort.Slice(cr.Status.GrafanaServiceAccounts, func(i, j int) bool {
+ return cr.Status.GrafanaServiceAccounts[i].SpecID < cr.Status.GrafanaServiceAccounts[j].SpecID
+ })
+ }(cr)
+
+ err := r.syncAccounts(ctx, cr, gClient)
+ if err != nil {
+ return fmt.Errorf("syncing GrafanaServiceAccounts status: %w", err)
+ }
+
+ specMap := map[string]v1beta1.GrafanaServiceAccountSpec{}
+ if cr.Spec.GrafanaServiceAccounts != nil {
+ for _, spec := range cr.Spec.GrafanaServiceAccounts.Accounts {
+ specMap[spec.ID] = spec
+ }
+ }
+
+ // What we want to do is:
+ // 1. Iterate over the existing service accounts in the status.
+ // 2. If a service account is not in the spec anymore, remove it.
+ // 3. If a service account is still in the spec, reconcile it.
+ // 4. Create new service accounts for any remaining specs that aren't in the status.
+
+ // Let's iterate over the existing service accounts in the status.
+ for i := 0; i < len(cr.Status.GrafanaServiceAccounts); i++ {
+ spec, ok := specMap[cr.Status.GrafanaServiceAccounts[i].SpecID]
+ if !ok {
+ // It's not in the spec anymore, so we need to remove it.
+ err := r.removeAccount(ctx, gClient, &cr.Status.GrafanaServiceAccounts[i])
+ if err != nil {
+ return fmt.Errorf("removing service account %q: %w", cr.Status.GrafanaServiceAccounts[i].SpecID, err)
+ }
+ cr.Status.GrafanaServiceAccounts = removeFromSlice(cr.Status.GrafanaServiceAccounts, i)
+ i--
+ continue
+ }
+
+ // The service account is still in the spec, so we need to reconcile it.
+ delete(specMap, cr.Status.GrafanaServiceAccounts[i].SpecID)
+ err := r.reconcileAccount(ctx, gClient, cr, spec, &cr.Status.GrafanaServiceAccounts[i])
+ if err != nil {
+ return fmt.Errorf("reconciling service account %q: %w", cr.Status.GrafanaServiceAccounts[i].SpecID, err)
+ }
+ }
+
+ // We assume that specMap now contains only the service accounts that need to be created.
+ for _, spec := range specMap {
+ newAccount, err := r.createAccount(ctx, gClient, cr, spec)
+ if newAccount != nil {
+ cr.Status.GrafanaServiceAccounts = append(cr.Status.GrafanaServiceAccounts, *newAccount)
+ }
+ if err != nil {
+ return fmt.Errorf("creating service account %q: %w", spec.ID, err)
+ }
+ }
+
+ return nil
+}
+
+// createAccount creates a new service account in Grafana and all related resources such as tokens and secrets.
+// This operation isn't atomic, so can succeed partially. Always check not only the error, but also the returned status.
+// If the service account was created successfully, it will be returned in the status.
+func (r *GrafanaServiceAccountReconciler) createAccount(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.Grafana,
+ spec v1beta1.GrafanaServiceAccountSpec,
+) (*v1beta1.GrafanaServiceAccountStatus, error) {
+ create, err := gClient.ServiceAccounts.CreateServiceAccount(
+ service_accounts.
+ NewCreateServiceAccountParamsWithContext(ctx).
+ WithBody(&models.CreateServiceAccountForm{
+ Name: spec.Name,
+ Role: spec.Role,
+ IsDisabled: spec.IsDisabled,
+ }),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("creating service account %q: %w", spec.ID, err)
+ }
+
+ newAccount := v1beta1.GrafanaServiceAccountStatus{
+ SpecID: spec.ID,
+ ServiceAccountID: create.Payload.ID,
+ Name: create.Payload.Name,
+ Role: create.Payload.Role,
+ IsDisabled: create.Payload.IsDisabled,
+ }
+
+ err = r.reconcileTokens(ctx, gClient, cr, spec, &newAccount)
+ if err != nil {
+ return &newAccount, fmt.Errorf("reconciling service account tokens for %q: %w", newAccount.SpecID, err)
+ }
+
+ return &newAccount, nil
+}
+
+func (r *GrafanaServiceAccountReconciler) removeAccount(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ status *v1beta1.GrafanaServiceAccountStatus,
+) error {
+ // We don't need to remove tokens, because they will be removed automatically.
+ // The only thing we need to do is to remove the secrets.
+ {
+ i := len(status.Tokens) - 1
+ for ; i >= 0; i-- {
+ err := r.removeTokenSecret(ctx, &status.Tokens[i])
+ if err != nil {
+ status.Tokens = status.Tokens[:i+1]
+ return fmt.Errorf("removing token secret %q for service account %q: %w", status.Tokens[i].Name, status.SpecID, err)
+ }
+ }
+ status.Tokens = nil
+ }
+
+ _, err := gClient.ServiceAccounts.DeleteServiceAccountWithParams( // nolint:errcheck
+ service_accounts.
+ NewDeleteServiceAccountParamsWithContext(ctx).
+ WithServiceAccountID(status.ServiceAccountID),
+ )
+ 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", status.ServiceAccountID,
+ "specID", status.SpecID,
+ )
+ return nil
+ }
+
+ // TODO: Grafana Operator currently deployes Grafana 11.3.0 (see controllers/config/operator_constants.go#L6).
+ // Till Grafana 12.0.2 there was no reliable way to detect a 404 when deleting a service account.
+ // The API still returns 500 (see grafana/grafana#106618).
+ //
+ // Once we upgrade to Grafana > 12.0.2 and bump github.com/grafana/grafana-openapi-client-go,
+ // we can handle the real 404 explicitly.
+ //
+ // In the meantime 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",
+ "serviceAccountID", status.ServiceAccountID,
+ "specID", status.SpecID,
+ )
+ // return fmt.Errorf("deleting service account %q: %w", status.SpecID, err)
+ }
+
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) reconcileAccount(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.Grafana,
+ spec v1beta1.GrafanaServiceAccountSpec,
+ status *v1beta1.GrafanaServiceAccountStatus,
+) error {
+ err := r.reconcileTokens(ctx, gClient, cr, spec, status)
+ if err != nil {
+ return fmt.Errorf("reconciling service account tokens for %q: %w", status.SpecID, err)
+ }
+
+ form := patchAccount(spec, *status)
+ if form == nil {
+ return nil
+ }
+
+ update, err := gClient.ServiceAccounts.UpdateServiceAccount(
+ service_accounts.
+ NewUpdateServiceAccountParamsWithContext(ctx).
+ WithServiceAccountID(status.ServiceAccountID).
+ WithBody(form),
+ )
+ if err != nil {
+ return fmt.Errorf("updating service account %q: %w", status.SpecID, err)
+ }
+ status.IsDisabled = update.Payload.Serviceaccount.IsDisabled
+ status.Role = update.Payload.Serviceaccount.Role
+ status.Name = update.Payload.Serviceaccount.Name
+
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) reconcileTokens(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.Grafana,
+ accountSpec v1beta1.GrafanaServiceAccountSpec,
+ accountStatus *v1beta1.GrafanaServiceAccountStatus,
+) error {
+ defer func() {
+ sort.Slice(accountStatus.Tokens, func(i, j int) bool {
+ return accountStatus.Tokens[i].Name < accountStatus.Tokens[j].Name
+ })
+ }()
+
+ tokenSpecs := accountSpec.Tokens
+ if len(tokenSpecs) == 0 && cr.Spec.GrafanaServiceAccounts.GenerateTokenSecret {
+ // If there are no tokens in the spec, we create a default token.
+ tokenSpecs = []v1beta1.GrafanaServiceAccountTokenSpec{
+ {Name: fmt.Sprintf("%s-%s-default-token", cr.Name, accountStatus.SpecID)},
+ }
+ }
+
+ specMap := map[string]v1beta1.GrafanaServiceAccountTokenSpec{}
+ for _, tokenSpec := range tokenSpecs {
+ specMap[tokenSpec.Name] = tokenSpec
+ }
+
+ for i := 0; i < len(accountStatus.Tokens); i++ {
+ tokenSpec, ok := specMap[accountStatus.Tokens[i].Name]
+ if !ok ||
+ (tokenSpec.Expires != nil && accountStatus.Tokens[i].Expires == nil) ||
+ (tokenSpec.Expires == nil && accountStatus.Tokens[i].Expires != nil) ||
+ (tokenSpec.Expires != nil && accountStatus.Tokens[i].Expires != nil &&
+ !isEqualExpirationTime(tokenSpec.Expires, accountStatus.Tokens[i].Expires)) {
+ err := r.removeAccountToken(ctx, gClient, accountStatus, &accountStatus.Tokens[i])
+ if err != nil {
+ return fmt.Errorf("removing service account token %q: %w", accountStatus.Tokens[i].Name, err)
+ }
+ accountStatus.Tokens = removeFromSlice(accountStatus.Tokens, i)
+ i--
+ continue
+ }
+
+ delete(specMap, accountStatus.Tokens[i].Name)
+ }
+
+ for _, tokenSpec := range specMap {
+ newToken, err := r.createAccountToken(ctx, gClient, cr, *accountStatus, tokenSpec)
+ if newToken != nil {
+ accountStatus.Tokens = append(accountStatus.Tokens, *newToken)
+ }
+ if err != nil {
+ return fmt.Errorf("creating service account token %q: %w", tokenSpec.Name, err)
+ }
+ }
+
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) createAccountToken(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ cr *v1beta1.Grafana,
+ accountStatus v1beta1.GrafanaServiceAccountStatus,
+ tokenSpec v1beta1.GrafanaServiceAccountTokenSpec,
+) (*v1beta1.GrafanaServiceAccountTokenStatus, error) {
+ cmd := models.AddServiceAccountTokenCommand{Name: tokenSpec.Name}
+ if tokenSpec.Expires != nil {
+ cmd.SecondsToLive = int64(time.Until(tokenSpec.Expires.Time).Seconds())
+ }
+ createResp, err := gClient.ServiceAccounts.CreateToken(
+ service_accounts.
+ NewCreateTokenParamsWithContext(ctx).
+ WithServiceAccountID(accountStatus.ServiceAccountID).
+ WithBody(&cmd),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("creating token: %w", err)
+ }
+ status := &v1beta1.GrafanaServiceAccountTokenStatus{
+ Name: createResp.Payload.Name,
+ ID: createResp.Payload.ID,
+ }
+
+ // Grafana doesn't return the expiration time in the response.
+ // So, we need to do another request to get it.
+ listResp, err := gClient.ServiceAccounts.ListTokensWithParams(
+ service_accounts.
+ NewListTokensParamsWithContext(ctx).
+ WithServiceAccountID(accountStatus.ServiceAccountID),
+ )
+ if err != nil {
+ return status, fmt.Errorf("listing tokens: %w", err)
+ }
+ var found bool
+ for _, token := range listResp.Payload {
+ if token.ID == createResp.Payload.ID {
+ if !token.Expiration.IsZero() {
+ status.Expires = ptr(metav1.NewTime(time.Time(token.Expiration)))
+ }
+ found = true
+ break
+ }
+ }
+ if !found {
+ return status, fmt.Errorf("token %q not found in the list", createResp.Payload.ID)
+ }
+
+ // The token was created, let's create a secret for it.
+ tokenKey := []byte(createResp.Payload.Key)
+ secret := model2.GetInternalServiceAccountSecret(cr, accountStatus, *status, tokenKey, r.Scheme())
+ err = r.Create(ctx, secret)
+ if err != nil {
+ return status, fmt.Errorf("creating token secret %q: %w", secret.Name, err)
+ }
+ status.Secret = &v1beta1.GrafanaServiceAccountSecretStatus{
+ Namespace: secret.Namespace,
+ Name: secret.Name,
+ }
+
+ return status, nil
+}
+
+func (r *GrafanaServiceAccountReconciler) removeTokenSecret(
+ ctx context.Context,
+ tokenStatus *v1beta1.GrafanaServiceAccountTokenStatus,
+) error {
+ if tokenStatus.Secret == nil {
+ // Nothing to remove.
+ return nil
+ }
+
+ secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
+ Namespace: tokenStatus.Secret.Namespace,
+ Name: tokenStatus.Secret.Name,
+ }}
+ err := r.Delete(ctx, secret)
+ if err != nil {
+ if kuberr.IsNotFound(err) {
+ tokenStatus.Secret = nil
+ return nil
+ }
+ return err
+ }
+ tokenStatus.Secret = nil
+
+ return nil
+}
+
+func (r *GrafanaServiceAccountReconciler) removeAccountToken(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ accountStatus *v1beta1.GrafanaServiceAccountStatus,
+ tokenStatus *v1beta1.GrafanaServiceAccountTokenStatus,
+) error {
+ err := r.removeTokenSecret(ctx, tokenStatus)
+ if err != nil {
+ return err
+ }
+
+ _, err = gClient.ServiceAccounts.DeleteTokenWithParams( // nolint:errcheck
+ service_accounts.
+ NewDeleteTokenParamsWithContext(ctx).
+ WithServiceAccountID(accountStatus.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 actualizeAccountStatus(
+ status *v1beta1.GrafanaServiceAccountStatus,
+ actual models.ServiceAccountDTO,
+) {
+ status.Name = actual.Name
+ status.Role = actual.Role
+ status.IsDisabled = actual.IsDisabled
+}
+
+func actualizeTokenStatus(
+ status *v1beta1.GrafanaServiceAccountTokenStatus,
+ actual models.TokenDTO,
+) {
+ status.Name = actual.Name
+ if actual.Expiration.IsZero() {
+ status.Expires = nil
+ } else {
+ status.Expires = ptr(metav1.NewTime(time.Time(actual.Expiration)))
+ }
+}
+
+func patchAccount(
+ spec v1beta1.GrafanaServiceAccountSpec,
+ status v1beta1.GrafanaServiceAccountStatus,
+) *models.UpdateServiceAccountForm {
+ var hasDiscrepancy bool
+ form := models.UpdateServiceAccountForm{
+ // The form contains a ServiceAccountID field which is unused in Grafana, so it's ignored here.
+ // ServiceAccountID: status.ServiceAccountID,
+ }
+ if status.Name != spec.Name {
+ hasDiscrepancy = true
+ form.Name = spec.Name
+ }
+ if status.Role != spec.Role {
+ hasDiscrepancy = true
+ form.Role = spec.Role
+ }
+ if status.IsDisabled != spec.IsDisabled {
+ hasDiscrepancy = true
+ form.IsDisabled = ptr(spec.IsDisabled)
+ }
+
+ if hasDiscrepancy {
+ return &form
+ }
+ return nil
+}
+
+func listExistingServiceAccounts(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+) (map[int64]models.ServiceAccountDTO, error) {
+ serviceAccounts := map[int64]models.ServiceAccountDTO{}
+
+ var page int64 = 1
+ for {
+ resp, err := gClient.ServiceAccounts.SearchOrgServiceAccountsWithPaging(
+ service_accounts.
+ NewSearchOrgServiceAccountsWithPagingParamsWithContext(ctx).
+ WithPage(&page),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("searching service accounts: %w", err)
+ }
+
+ for _, sa := range resp.Payload.ServiceAccounts {
+ if sa == nil {
+ continue
+ }
+ serviceAccounts[sa.ID] = *sa
+ }
+
+ if resp.Payload.TotalCount <= int64(len(serviceAccounts)) {
+ return serviceAccounts, nil
+ }
+ page++
+ }
+}
+
+func listExistingTokens(
+ ctx context.Context,
+ gClient *genapi.GrafanaHTTPAPI,
+ serviceAccountID int64,
+) (map[int64]models.TokenDTO, error) {
+ resp, err := gClient.ServiceAccounts.ListTokensWithParams(
+ service_accounts.
+ NewListTokensParamsWithContext(ctx).
+ WithServiceAccountID(serviceAccountID),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("listing tokens: %w", err)
+ }
+
+ tokens := map[int64]models.TokenDTO{}
+ for _, token := range resp.Payload {
+ if token == nil {
+ continue
+ }
+ tokens[token.ID] = *token
+ }
+
+ return tokens, nil
+}
+
+func ptr[T any](v T) *T { return &v }
+
+func removeFromSlice[T any](slice []T, idx int) []T {
+ // Keep order stable by using slices.Delete which shifts remaining elements left.
+ // The alternative would be swapping with the last element for O(1) removal.
+ return slices.Delete(slice, idx, idx+1)
+}
+
+func isEqualExpirationTime(a, b *metav1.Time) bool {
+ // 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.
+ const expiresDrift = 1 * time.Second
+
+ if a == nil && b == nil {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ diff := a.Sub(b.Time)
+ return diff.Abs() <= expiresDrift
+}
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 2e97ae726..149de75ac 100644
--- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml
+++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml
@@ -3841,6 +3841,64 @@ spec:
required:
- url
type: object
+ grafanaServiceAccounts:
+ description: Grafana Service Accounts
+ properties:
+ accounts:
+ description: |-
+ Accounts lists Grafana service accounts to manage.
+ Each service account is uniquely identified by its ID.
+ items:
+ description: GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount.
+ properties:
+ id:
+ description: ID is a kind of unique identifier to distinguish between service accounts if the name is changed.
+ type: string
+ 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
+ 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: GrafanaServiceAccountTokenSpec 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:
+ - id
+ - name
+ - role
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - id
+ x-kubernetes-list-type: map
+ 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
+ type: object
ingress:
description: Ingress sets how the ingress object should look like with your grafana instance.
properties:
@@ -5042,6 +5100,68 @@ spec:
items:
type: string
type: array
+ serviceAccounts:
+ items:
+ description: GrafanaServiceAccountStatus holds status for one Grafana instance.
+ properties:
+ isDisabled:
+ description: IsDisabled indicates if the service account is disabled.
+ type: boolean
+ name:
+ description: Name is the name of the service account in Grafana.
+ type: string
+ role:
+ description: Role is the Grafana role for the service account (Viewer, Editor, Admin).
+ type: string
+ serviceAccountId:
+ description: ServiceAccountID is the numeric ID of the service account in this Grafana.
+ format: int64
+ type: integer
+ specId:
+ description: SpecID is a kind of unique identifier to distinguish between service accounts if the name is changed.
+ type: string
+ tokens:
+ description: Tokens is the status of tokens for this service account in Grafana.
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a token created in Grafana.
+ properties:
+ expires:
+ description: |-
+ Expires is the expiration time for 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
+ name:
+ description: Name is the name of the Kubernetes Secret. The secret will contain the token value.
+ type: string
+ secret:
+ description: |-
+ Secret is 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.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ type: object
+ tokenId:
+ description: ID is the Grafana-assigned ID of the token.
+ format: int64
+ type: integer
+ required:
+ - name
+ - tokenId
+ type: object
+ type: array
+ required:
+ - name
+ - role
+ - serviceAccountId
+ - specId
+ type: object
+ type: array
stage:
type: string
stageStatus:
diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml
index ff13b30f6..f031a18b4 100644
--- a/deploy/kustomize/base/crds.yaml
+++ b/deploy/kustomize/base/crds.yaml
@@ -7131,6 +7131,76 @@ spec:
required:
- url
type: object
+ grafanaServiceAccounts:
+ description: Grafana Service Accounts
+ properties:
+ accounts:
+ description: |-
+ Accounts lists Grafana service accounts to manage.
+ Each service account is uniquely identified by its ID.
+ items:
+ description: GrafanaServiceAccountSpec defines the desired state
+ of a GrafanaServiceAccount.
+ properties:
+ id:
+ description: ID is a kind of unique identifier to distinguish
+ between service accounts if the name is changed.
+ type: string
+ 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
+ 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: GrafanaServiceAccountTokenSpec 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:
+ - id
+ - name
+ - role
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - id
+ x-kubernetes-list-type: map
+ 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
+ type: object
ingress:
description: Ingress sets how the ingress object should look like
with your grafana instance.
@@ -8399,6 +8469,76 @@ spec:
items:
type: string
type: array
+ serviceAccounts:
+ items:
+ description: GrafanaServiceAccountStatus holds status for one Grafana
+ instance.
+ properties:
+ isDisabled:
+ description: IsDisabled indicates if the service account is
+ disabled.
+ type: boolean
+ name:
+ description: Name is the name of the service account in Grafana.
+ type: string
+ role:
+ description: Role is the Grafana role for the service account
+ (Viewer, Editor, Admin).
+ type: string
+ serviceAccountId:
+ description: ServiceAccountID is the numeric ID of the service
+ account in this Grafana.
+ format: int64
+ type: integer
+ specId:
+ description: SpecID is a kind of unique identifier to distinguish
+ between service accounts if the name is changed.
+ type: string
+ tokens:
+ description: Tokens is the status of tokens for this service
+ account in Grafana.
+ items:
+ description: GrafanaServiceAccountTokenStatus describes a
+ token created in Grafana.
+ properties:
+ expires:
+ description: |-
+ Expires is the expiration time for 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
+ name:
+ description: Name is the name of the Kubernetes Secret.
+ The secret will contain the token value.
+ type: string
+ secret:
+ description: |-
+ Secret is 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.
+ properties:
+ name:
+ type: string
+ namespace:
+ type: string
+ type: object
+ tokenId:
+ description: ID is the Grafana-assigned ID of the token.
+ format: int64
+ type: integer
+ required:
+ - name
+ - tokenId
+ type: object
+ type: array
+ required:
+ - name
+ - role
+ - serviceAccountId
+ - specId
+ type: object
+ type: array
stage:
type: string
stageStatus:
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 d67484ab2..876f26d84 100644
--- a/docs/docs/api.md
+++ b/docs/docs/api.md
@@ -6249,6 +6249,13 @@ GrafanaSpec defines the desired state of Grafana
External enables you to configure external grafana instances that is not managed by the operator.
Name | +Type | +Description | +Required | +
---|---|---|---|
accounts | +[]object | +
+ Accounts lists Grafana service accounts to manage.
+Each service account is uniquely identified by its ID. + |
+ false | +
generateTokenSecret | +boolean | +
+ 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. + + Default: true + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
id | +string | +
+ ID is a kind of unique identifier to distinguish between service accounts if the name is changed. + |
+ true | +
name | +string | +
+ Name is the desired name of the service account in Grafana. + |
+ true | +
role | +enum | +
+ Role is the Grafana role for the service account (Viewer, Editor, Admin). + + Enum: Viewer, Editor, Admin + |
+ true | +
isDisabled | +boolean | +
+ IsDisabled indicates if the service account should be disabled in Grafana. + |
+ false | +
tokens | +[]object | +
+ Tokens defines API tokens to create for this service account. Each token will be stored in a Kubernetes Secret with the given name. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
name | +string | +
+ Name is the name of the Kubernetes Secret (and token identifier in Grafana). The secret will contain the token value. + |
+ true | +
expires | +string | +
+ Expires is the optional expiration time for the token. After this time, the operator may rotate the token. + + Format: date-time + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
name | +string | +
+ Name is the name of the service account in Grafana. + |
+ true | +
role | +string | +
+ Role is the Grafana role for the service account (Viewer, Editor, Admin). + |
+ true | +
serviceAccountId | +integer | +
+ ServiceAccountID is the numeric ID of the service account in this Grafana. + + Format: int64 + |
+ true | +
specId | +string | +
+ SpecID is a kind of unique identifier to distinguish between service accounts if the name is changed. + |
+ true | +
isDisabled | +boolean | +
+ IsDisabled indicates if the service account is disabled. + |
+ false | +
tokens | +[]object | +
+ Tokens is the status of tokens for this service account in Grafana. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
name | +string | +
+ Name is the name of the Kubernetes Secret. The secret will contain the token value. + |
+ true | +
tokenId | +integer | +
+ ID is the Grafana-assigned ID of the token. + + Format: int64 + |
+ true | +
expires | +string | +
+ Expires is the expiration time for 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 | +
+ Secret is 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. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
name | +string | +
+ + |
+ false | +
namespace | +string | +
+ + |
+ false | +