From 10b8a2785b36e528ec1f81621ce002eb0800d328 Mon Sep 17 00:00:00 2001 From: Andrey Karpov Date: Tue, 10 Jun 2025 16:49:47 +0200 Subject: [PATCH 1/2] feat(Grafana): Manage Grafana Service Accounts --- .gitignore | 2 + api/v1beta1/grafana_types.go | 124 +++- api/v1beta1/zz_generated.deepcopy.go | 136 ++++ .../grafana.integreatly.org_grafanas.yaml | 120 ++++ controllers/contactpoint_controller.go | 8 +- controllers/dashboard_controller.go | 8 +- controllers/datasource_controller.go | 12 +- controllers/folder_controller.go | 10 +- controllers/grafana_controller.go | 16 +- controllers/librarypanel_controller.go | 8 +- controllers/model/grafana_resources.go | 72 ++ controllers/serviceaccount_controller.go | 649 ++++++++++++++++++ .../grafana.integreatly.org_grafanas.yaml | 120 ++++ deploy/kustomize/base/crds.yaml | 140 ++++ .../cluster_scoped/kustomization.yaml | 8 +- docs/docs/api.md | 301 ++++++++ .../grafana_serviceaccount/resources.yaml | 38 + go.mod | 9 +- go.sum | 16 +- main.go | 2 +- .../e2e/grafanaserviceaccount/000-assert.yaml | 14 + .../000-deploy-grafana.yaml | 17 + .../010-assert-api-response.yaml | 12 + .../e2e/grafanaserviceaccount/010-assert.yaml | 39 ++ .../010-sa-default-token.yaml | 13 + .../020-assert-api-response.yaml | 12 + .../e2e/grafanaserviceaccount/020-assert.yaml | 39 ++ .../grafanaserviceaccount/020-rename-sa.yaml | 13 + .../030-assert-api-response.yaml | 12 + .../e2e/grafanaserviceaccount/030-assert.yaml | 38 + .../grafanaserviceaccount/030-enable-sa.yaml | 13 + .../e2e/grafanaserviceaccount/030-error.yaml | 9 + .../040-assert-api-response.yaml | 12 + .../e2e/grafanaserviceaccount/040-assert.yaml | 38 + .../040-change-role.yaml | 12 + .../050-assert-api-response.yaml | 17 + .../e2e/grafanaserviceaccount/050-assert.yaml | 107 +++ .../grafanaserviceaccount/050-replace-sa.yaml | 25 + .../060-assert-api-response.yaml | 17 + .../e2e/grafanaserviceaccount/060-assert.yaml | 106 +++ .../060-update-expirations.yaml | 25 + .../070-assert-api-response.yaml | 17 + .../e2e/grafanaserviceaccount/070-assert.yaml | 104 +++ .../070-remove-expirations.yaml | 23 + .../080-assert-api-response.yaml | 4 + .../e2e/grafanaserviceaccount/080-assert.yaml | 14 + .../e2e/grafanaserviceaccount/080-error.yaml | 8 + .../080-remove-all-sa.yaml | 9 + .../grafanaserviceaccount/chainsaw-test.yaml | 151 ++++ 49 files changed, 2678 insertions(+), 41 deletions(-) create mode 100644 controllers/serviceaccount_controller.go create mode 100644 examples/grafana_serviceaccount/resources.yaml create mode 100644 tests/e2e/grafanaserviceaccount/000-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/000-deploy-grafana.yaml create mode 100644 tests/e2e/grafanaserviceaccount/010-assert-api-response.yaml create mode 100644 tests/e2e/grafanaserviceaccount/010-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/010-sa-default-token.yaml create mode 100644 tests/e2e/grafanaserviceaccount/020-assert-api-response.yaml create mode 100644 tests/e2e/grafanaserviceaccount/020-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/020-rename-sa.yaml create mode 100644 tests/e2e/grafanaserviceaccount/030-assert-api-response.yaml create mode 100644 tests/e2e/grafanaserviceaccount/030-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/030-enable-sa.yaml create mode 100644 tests/e2e/grafanaserviceaccount/030-error.yaml create mode 100644 tests/e2e/grafanaserviceaccount/040-assert-api-response.yaml create mode 100644 tests/e2e/grafanaserviceaccount/040-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/040-change-role.yaml create mode 100644 tests/e2e/grafanaserviceaccount/050-assert-api-response.yaml create mode 100644 tests/e2e/grafanaserviceaccount/050-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/050-replace-sa.yaml create mode 100644 tests/e2e/grafanaserviceaccount/060-assert-api-response.yaml create mode 100644 tests/e2e/grafanaserviceaccount/060-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/060-update-expirations.yaml create mode 100644 tests/e2e/grafanaserviceaccount/070-assert-api-response.yaml create mode 100644 tests/e2e/grafanaserviceaccount/070-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/070-remove-expirations.yaml create mode 100644 tests/e2e/grafanaserviceaccount/080-assert-api-response.yaml create mode 100644 tests/e2e/grafanaserviceaccount/080-assert.yaml create mode 100644 tests/e2e/grafanaserviceaccount/080-error.yaml create mode 100644 tests/e2e/grafanaserviceaccount/080-remove-all-sa.yaml create mode 100644 tests/e2e/grafanaserviceaccount/chainsaw-test.yaml 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..e7a96ffce 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,73 @@ 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: MergeAnnotations( + GetCommonLabels(), + 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), + } + } + + 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.
false + + grafanaServiceAccounts + object + + Grafana Service Accounts
+ + false ingress object @@ -19952,6 +19959,137 @@ Use a secret as a reference to give TLS Certificate information +### Grafana.spec.grafanaServiceAccounts +[↩ Parent](#grafanaspec) + + + +Grafana Service Accounts + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accounts[]object + Accounts lists Grafana service accounts to manage. +Each service account is uniquely identified by its ID.
+
false
generateTokenSecretboolean + 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
+ + +### Grafana.spec.grafanaServiceAccounts.accounts[index] +[↩ Parent](#grafanaspecgrafanaserviceaccounts) + + + +GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
idstring + ID is a kind of unique identifier to distinguish between service accounts if the name is changed.
+
true
namestring + Name is the desired name of the service account in Grafana.
+
true
roleenum + Role is the Grafana role for the service account (Viewer, Editor, Admin).
+
+ Enum: Viewer, Editor, Admin
+
true
isDisabledboolean + 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
+ + +### Grafana.spec.grafanaServiceAccounts.accounts[index].tokens[index] +[↩ Parent](#grafanaspecgrafanaserviceaccountsaccountsindex) + + + +GrafanaServiceAccountTokenSpec describes a token to create. + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name is the name of the Kubernetes Secret (and token identifier in Grafana). The secret will contain the token value.
+
true
expiresstring + Expires is the optional expiration time for the token. After this time, the operator may rotate the token.
+
+ Format: date-time
+
false
+ + ### Grafana.spec.ingress [↩ Parent](#grafanaspec) @@ -22385,6 +22523,13 @@ GrafanaStatus defines the observed state of Grafana
false + + serviceAccounts + []object + +
+ + false stage string @@ -22485,3 +22630,159 @@ with respect to the current state of the instance.
false + + +### Grafana.status.serviceAccounts[index] +[↩ Parent](#grafanastatus) + + + +GrafanaServiceAccountStatus holds status for one Grafana instance. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name is the name of the service account in Grafana.
+
true
rolestring + Role is the Grafana role for the service account (Viewer, Editor, Admin).
+
true
serviceAccountIdinteger + ServiceAccountID is the numeric ID of the service account in this Grafana.
+
+ Format: int64
+
true
specIdstring + SpecID is a kind of unique identifier to distinguish between service accounts if the name is changed.
+
true
isDisabledboolean + IsDisabled indicates if the service account is disabled.
+
false
tokens[]object + Tokens is the status of tokens for this service account in Grafana.
+
false
+ + +### Grafana.status.serviceAccounts[index].tokens[index] +[↩ Parent](#grafanastatusserviceaccountsindex) + + + +GrafanaServiceAccountTokenStatus describes a token created in Grafana. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name is the name of the Kubernetes Secret. The secret will contain the token value.
+
true
tokenIdinteger + ID is the Grafana-assigned ID of the token.
+
+ Format: int64
+
true
expiresstring + 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
secretobject + 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
+ + +### Grafana.status.serviceAccounts[index].tokens[index].secret +[↩ Parent](#grafanastatusserviceaccountsindextokensindex) + + + +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. + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring +
+
false
namespacestring +
+
false
diff --git a/examples/grafana_serviceaccount/resources.yaml b/examples/grafana_serviceaccount/resources.yaml new file mode 100644 index 000000000..6b31c1761 --- /dev/null +++ b/examples/grafana_serviceaccount/resources.yaml @@ -0,0 +1,38 @@ +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: mygrafana +spec: + config: + security: + admin_user: root + admin_password: secret + grafanaServiceAccounts: + accounts: + - id: grafana-sa1 + name: grafana-service-account-a + role: Editor + isDisabled: true + tokens: + - name: tokena + expires: "2025-12-31T23:59:59Z" + - name: tokena-2 + expires: "2025-12-30T23:59:59Z" + - name: tokena-3 + expires: "2025-12-29T23:59:59Z" + - name: tokena-4 + expires: "2025-12-26T23:59:59Z" + - id: grafana-saa-2 + name: grafana-service-account2 + role: Admin + isDisabled: false + tokens: + - name: token + expires: "2025-12-31T23:59:59Z" + - name: token2 + expires: "2025-12-30T23:59:59Z" + - name: token3 + expires: "2025-12-29T23:59:59Z" + - name: token4 + expires: "2025-12-26T23:59:59Z" diff --git a/go.mod b/go.mod index 80a144a9c..cdd19507f 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,9 @@ module github.com/grafana/grafana-operator/v5 go 1.24.2 +// https://github.com/grafana/grafana-openapi-client-go/pull/115 +replace github.com/grafana/grafana-openapi-client-go => github.com/ndk/grafana-openapi-client-go v0.0.0-20250625114140-5abf8d2d12df + require ( github.com/KimMachineGun/automemlimit v0.7.3 github.com/bitly/go-simplejson v0.5.1 @@ -28,7 +31,7 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.23.0 // indirect - github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/errors v0.22.1 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/runtime v0.28.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect @@ -64,13 +67,13 @@ require ( github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect diff --git a/go.sum b/go.sum index 8d949d3e4..1f91c96b7 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= -github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= -github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU= +github.com/go-openapi/errors v0.22.1/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -47,8 +47,8 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -71,8 +71,6 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-openapi-client-go v0.0.0-20250108132429-8d7e1f158f65 h1:AnfwjPE8TXJO8CX0Q5PvtzGta9Ls3iRASWVV4jHl4KA= -github.com/grafana/grafana-openapi-client-go v0.0.0-20250108132429-8d7e1f158f65/go.mod h1:hiZnMmXc9KXNUlvkV2BKFsiWuIFF/fF4wGgYWEjBitI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -87,8 +85,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -98,6 +96,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ndk/grafana-openapi-client-go v0.0.0-20250625114140-5abf8d2d12df h1:45orEsQWqmK5BL2KYmp8PBGJqlVxgFu+nE9v28c/MRI= +github.com/ndk/grafana-openapi-client-go v0.0.0-20250625114140-5abf8d2d12df/go.mod h1:AOzHLStinAJHJmcih1eEbIRImxpT6enYUsZLnnOvhbo= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= diff --git a/main.go b/main.go index 67398fe24..8a84bfff8 100644 --- a/main.go +++ b/main.go @@ -227,7 +227,7 @@ func main() { // nolint:gocyclo } if enforceCacheLabelsLevel == cachingLevelSafe { mgrOptions.Client.Cache = &client.CacheOptions{ - DisableFor: []client.Object{&corev1.ConfigMap{}, &corev1.Secret{}}, + DisableFor: []client.Object{&corev1.ConfigMap{}, &corev1.Secret{}, &grafanav1beta1.Grafana{}}, } } } diff --git a/tests/e2e/grafanaserviceaccount/000-assert.yaml b/tests/e2e/grafanaserviceaccount/000-assert.yaml new file mode 100644 index 000000000..86e135a26 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/000-assert.yaml @@ -0,0 +1,14 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + stage: complete + stageStatus: success + (conditions[?type == 'GrafanaReady']): + - message: Grafana reconcile completed + reason: GrafanaReady + status: 'True' + type: GrafanaReady diff --git a/tests/e2e/grafanaserviceaccount/000-deploy-grafana.yaml b/tests/e2e/grafanaserviceaccount/000-deploy-grafana.yaml new file mode 100644 index 000000000..13c82069d --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/000-deploy-grafana.yaml @@ -0,0 +1,17 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + config: + log: + mode: "console" + auth: + disable_login_form: "false" + security: + admin_user: ($USER) + admin_password: ($PASS) + grafanaServiceAccounts: + accounts: diff --git a/tests/e2e/grafanaserviceaccount/010-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/010-assert-api-response.yaml new file mode 100644 index 000000000..2a380e351 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/010-assert-api-response.yaml @@ -0,0 +1,12 @@ +($serviceaccounts): + totalCount: 1 + serviceAccounts: + - id: 2 + name: "grafana-service-account-sa1" + login: "sa-1-grafana-service-account-sa1" + orgId: 1 + isDisabled: true + role : "Editor" + tokens: 1 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/010-assert.yaml b/tests/e2e/grafanaserviceaccount/010-assert.yaml new file mode 100644 index 000000000..b896d7e75 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/010-assert.yaml @@ -0,0 +1,39 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + (conditions[?type == 'ServiceAccountsSynchronized']): + - message: service accounts reconciled + reason: ApplySuccessful + status: 'True' + type: ServiceAccountsSynchronized + serviceAccounts: + - isDisabled: true + name: grafana-service-account-sa1 + role: Editor + serviceAccountId: 2 + specId: grafana-sa1 + tokens: + - name: service-accounts-grafana-sa1-default-token + secret: + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + namespace: ($NAMESPACE) + tokenId: 1 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts diff --git a/tests/e2e/grafanaserviceaccount/010-sa-default-token.yaml b/tests/e2e/grafanaserviceaccount/010-sa-default-token.yaml new file mode 100644 index 000000000..e0dd736cf --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/010-sa-default-token.yaml @@ -0,0 +1,13 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + grafanaServiceAccounts: + accounts: + - id: grafana-sa1 + name: grafana-service-account-sa1 + role: Editor + isDisabled: true diff --git a/tests/e2e/grafanaserviceaccount/020-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/020-assert-api-response.yaml new file mode 100644 index 000000000..1c6784a09 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/020-assert-api-response.yaml @@ -0,0 +1,12 @@ +($serviceaccounts): + totalCount: 1 + serviceAccounts: + - id: 2 + name: "grafana-service-account-sa1-new" + login: "sa-1-grafana-service-account-sa1" + orgId: 1 + isDisabled: true + role : "Editor" + tokens: 1 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/020-assert.yaml b/tests/e2e/grafanaserviceaccount/020-assert.yaml new file mode 100644 index 000000000..264c21ea0 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/020-assert.yaml @@ -0,0 +1,39 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + (conditions[?type == 'ServiceAccountsSynchronized']): + - message: service accounts reconciled + reason: ApplySuccessful + status: 'True' + type: ServiceAccountsSynchronized + serviceAccounts: + - isDisabled: true + name: grafana-service-account-sa1-new + role: Editor + serviceAccountId: 2 + specId: grafana-sa1 + tokens: + - name: service-accounts-grafana-sa1-default-token + secret: + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + namespace: ($NAMESPACE) + tokenId: 1 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts diff --git a/tests/e2e/grafanaserviceaccount/020-rename-sa.yaml b/tests/e2e/grafanaserviceaccount/020-rename-sa.yaml new file mode 100644 index 000000000..cdd2ca44f --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/020-rename-sa.yaml @@ -0,0 +1,13 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + grafanaServiceAccounts: + accounts: + - id: grafana-sa1 + name: grafana-service-account-sa1-new + role: Editor + isDisabled: true diff --git a/tests/e2e/grafanaserviceaccount/030-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/030-assert-api-response.yaml new file mode 100644 index 000000000..175074cee --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/030-assert-api-response.yaml @@ -0,0 +1,12 @@ +($serviceaccounts): + totalCount: 1 + serviceAccounts: + - id: 2 + name: "grafana-service-account-sa1-new" + login: "sa-1-grafana-service-account-sa1" + orgId: 1 + isDisabled: false + role : "Editor" + tokens: 1 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/030-assert.yaml b/tests/e2e/grafanaserviceaccount/030-assert.yaml new file mode 100644 index 000000000..e003a15e9 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/030-assert.yaml @@ -0,0 +1,38 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + (conditions[?type == 'ServiceAccountsSynchronized']): + - message: service accounts reconciled + reason: ApplySuccessful + status: 'True' + type: ServiceAccountsSynchronized + serviceAccounts: + - name: grafana-service-account-sa1-new + role: Editor + serviceAccountId: 2 + specId: grafana-sa1 + tokens: + - name: service-accounts-grafana-sa1-default-token + secret: + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + namespace: ($NAMESPACE) + tokenId: 1 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts diff --git a/tests/e2e/grafanaserviceaccount/030-enable-sa.yaml b/tests/e2e/grafanaserviceaccount/030-enable-sa.yaml new file mode 100644 index 000000000..bdb8c9d23 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/030-enable-sa.yaml @@ -0,0 +1,13 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + grafanaServiceAccounts: + accounts: + - id: grafana-sa1 + name: grafana-service-account-sa1-new + role: Editor + isDisabled: false diff --git a/tests/e2e/grafanaserviceaccount/030-error.yaml b/tests/e2e/grafanaserviceaccount/030-error.yaml new file mode 100644 index 000000000..4a4df60fe --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/030-error.yaml @@ -0,0 +1,9 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + serviceAccounts: + - isDisabled: {} diff --git a/tests/e2e/grafanaserviceaccount/040-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/040-assert-api-response.yaml new file mode 100644 index 000000000..8b822f1d6 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/040-assert-api-response.yaml @@ -0,0 +1,12 @@ +($serviceaccounts): + totalCount: 1 + serviceAccounts: + - id: 2 + name: "grafana-service-account-sa1-new" + login: "sa-1-grafana-service-account-sa1" + orgId: 1 + isDisabled: false + role : "Admin" + tokens: 1 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/040-assert.yaml b/tests/e2e/grafanaserviceaccount/040-assert.yaml new file mode 100644 index 000000000..e4e072c0b --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/040-assert.yaml @@ -0,0 +1,38 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + (conditions[?type == 'ServiceAccountsSynchronized']): + - message: service accounts reconciled + reason: ApplySuccessful + status: 'True' + type: ServiceAccountsSynchronized + serviceAccounts: + - name: grafana-service-account-sa1-new + role: Admin + serviceAccountId: 2 + specId: grafana-sa1 + tokens: + - name: service-accounts-grafana-sa1-default-token + secret: + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + namespace: ($NAMESPACE) + tokenId: 1 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts diff --git a/tests/e2e/grafanaserviceaccount/040-change-role.yaml b/tests/e2e/grafanaserviceaccount/040-change-role.yaml new file mode 100644 index 000000000..762c38e92 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/040-change-role.yaml @@ -0,0 +1,12 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + grafanaServiceAccounts: + accounts: + - id: grafana-sa1 + name: grafana-service-account-sa1-new + role: Admin diff --git a/tests/e2e/grafanaserviceaccount/050-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/050-assert-api-response.yaml new file mode 100644 index 000000000..fd257f335 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/050-assert-api-response.yaml @@ -0,0 +1,17 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - name: "grafana-service-account-sa2" + login: "sa-1-grafana-service-account-sa2" + orgId: 1 + isDisabled: false + role : "Viewer" + tokens: 2 + - name: "grafana-service-account-sa3" + login: "sa-1-grafana-service-account-sa3" + orgId: 1 + isDisabled: true + role: "Admin" + tokens: 2 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/050-assert.yaml b/tests/e2e/grafanaserviceaccount/050-assert.yaml new file mode 100644 index 000000000..2eafb967d --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/050-assert.yaml @@ -0,0 +1,107 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + (conditions[?type == 'ServiceAccountsSynchronized']): + - message: service accounts reconciled + reason: ApplySuccessful + status: 'True' + type: ServiceAccountsSynchronized + serviceAccounts: + - name: grafana-service-account-sa2 + role: Viewer + (serviceAccountId > `2` && serviceAccountId < `5`): true + specId: grafana-sa2 + tokens: + - name: tokena + secret: + name: service-accounts-grafana-sa2-tokena + namespace: ($NAMESPACE) + (tokenId > `1` && tokenId < `6`): true + - name: tokenb + secret: + name: service-accounts-grafana-sa2-tokenb + namespace: ($NAMESPACE) + (tokenId > `1` && tokenId < `6`): true + - name: grafana-service-account-sa3 + role: Admin + isDisabled: true + (serviceAccountId > `2` && serviceAccountId < `5`): true + specId: grafana-sa3 + tokens: + - name: tokenc + secret: + name: service-accounts-grafana-sa3-tokenc + namespace: ($NAMESPACE) + (tokenId > `1` && tokenId < `6`): true + (time_between(expires, time_utc(time_add($NOW, '23h55m')), time_utc(time_add($NOW, '24h05m')))): true + - name: tokend + secret: + name: service-accounts-grafana-sa3-tokend + namespace: ($NAMESPACE) + (tokenId > `1` && tokenId < `6`): true + (time_between(expires, time_utc(time_add($NOW, '27h55m')), time_utc(time_add($NOW, '28h05m')))): true + +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa2-tokena + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa2-tokenb + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa3-tokenc + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa3-tokend + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts diff --git a/tests/e2e/grafanaserviceaccount/050-replace-sa.yaml b/tests/e2e/grafanaserviceaccount/050-replace-sa.yaml new file mode 100644 index 000000000..9b6bc712a --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/050-replace-sa.yaml @@ -0,0 +1,25 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + grafanaServiceAccounts: + accounts: + - id: grafana-sa2 + name: grafana-service-account-sa2 + role: Viewer + isDisabled: false + tokens: + - name: tokena + - name: tokenb + - id: grafana-sa3 + name: grafana-service-account-sa3 + role: Admin + isDisabled: true + tokens: + - name: tokenc + expires: (time_add($NOW, '24h')) + - name: tokend + expires: (time_add($NOW, '28h')) diff --git a/tests/e2e/grafanaserviceaccount/060-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/060-assert-api-response.yaml new file mode 100644 index 000000000..fd257f335 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/060-assert-api-response.yaml @@ -0,0 +1,17 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - name: "grafana-service-account-sa2" + login: "sa-1-grafana-service-account-sa2" + orgId: 1 + isDisabled: false + role : "Viewer" + tokens: 2 + - name: "grafana-service-account-sa3" + login: "sa-1-grafana-service-account-sa3" + orgId: 1 + isDisabled: true + role: "Admin" + tokens: 2 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/060-assert.yaml b/tests/e2e/grafanaserviceaccount/060-assert.yaml new file mode 100644 index 000000000..0d69aa538 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/060-assert.yaml @@ -0,0 +1,106 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + (conditions[?type == 'ServiceAccountsSynchronized']): + - message: service accounts reconciled + reason: ApplySuccessful + status: 'True' + type: ServiceAccountsSynchronized + serviceAccounts: + - name: grafana-service-account-sa2 + role: Viewer + (serviceAccountId > `2` && serviceAccountId < `5`): true + specId: grafana-sa2 + tokens: + - name: tokena + secret: + name: service-accounts-grafana-sa2-tokena + namespace: ($NAMESPACE) + (tokenId > `1` && tokenId < `6`): true + - name: tokenb + secret: + name: service-accounts-grafana-sa2-tokenb + namespace: ($NAMESPACE) + (tokenId > `1` && tokenId < `6`): true + - name: grafana-service-account-sa3 + role: Admin + isDisabled: true + (serviceAccountId > `2` && serviceAccountId < `5`): true + specId: grafana-sa3 + tokens: + - name: tokenc + secret: + name: service-accounts-grafana-sa3-tokenc + namespace: ($NAMESPACE) + (tokenId > `5` && tokenId < `8`): true + (time_between(expires, time_utc(time_add($NOW, '24h55m')), time_utc(time_add($NOW, '25h05m')))): true + - name: tokend + secret: + name: service-accounts-grafana-sa3-tokend + namespace: ($NAMESPACE) + (tokenId > `5` && tokenId < `8`): true + (time_between(expires, time_utc(time_add($NOW, '28h55m')), time_utc(time_add($NOW, '29h05m')))): true +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa2-tokena + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa2-tokenb + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa3-tokenc + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa3-tokend + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts diff --git a/tests/e2e/grafanaserviceaccount/060-update-expirations.yaml b/tests/e2e/grafanaserviceaccount/060-update-expirations.yaml new file mode 100644 index 000000000..1495eb36d --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/060-update-expirations.yaml @@ -0,0 +1,25 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + grafanaServiceAccounts: + accounts: + - id: grafana-sa2 + name: grafana-service-account-sa2 + role: Viewer + isDisabled: false + tokens: + - name: tokena + - name: tokenb + - id: grafana-sa3 + name: grafana-service-account-sa3 + role: Admin + isDisabled: true + tokens: + - name: tokenc + expires: (time_add($NOW, '25h')) + - name: tokend + expires: (time_add($NOW, '29h')) diff --git a/tests/e2e/grafanaserviceaccount/070-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/070-assert-api-response.yaml new file mode 100644 index 000000000..fd257f335 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/070-assert-api-response.yaml @@ -0,0 +1,17 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - name: "grafana-service-account-sa2" + login: "sa-1-grafana-service-account-sa2" + orgId: 1 + isDisabled: false + role : "Viewer" + tokens: 2 + - name: "grafana-service-account-sa3" + login: "sa-1-grafana-service-account-sa3" + orgId: 1 + isDisabled: true + role: "Admin" + tokens: 2 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/070-assert.yaml b/tests/e2e/grafanaserviceaccount/070-assert.yaml new file mode 100644 index 000000000..901a96e4f --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/070-assert.yaml @@ -0,0 +1,104 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + (conditions[?type == 'ServiceAccountsSynchronized']): + - message: service accounts reconciled + reason: ApplySuccessful + status: 'True' + type: ServiceAccountsSynchronized + serviceAccounts: + - name: grafana-service-account-sa2 + role: Viewer + (serviceAccountId > `2` && serviceAccountId < `5`): true + specId: grafana-sa2 + tokens: + - name: tokena + secret: + name: service-accounts-grafana-sa2-tokena + namespace: ($NAMESPACE) + (tokenId > `1` && tokenId < `6`): true + - name: tokenb + secret: + name: service-accounts-grafana-sa2-tokenb + namespace: ($NAMESPACE) + (tokenId > `1` && tokenId < `6`): true + - name: grafana-service-account-sa3 + role: Admin + isDisabled: true + (serviceAccountId > `2` && serviceAccountId < `5`): true + specId: grafana-sa3 + tokens: + - name: tokenc + secret: + name: service-accounts-grafana-sa3-tokenc + namespace: ($NAMESPACE) + (tokenId > `2` && tokenId < `10`): true + - name: tokend + secret: + name: service-accounts-grafana-sa3-tokend + namespace: ($NAMESPACE) + (tokenId > `2` && tokenId < `10`): true +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa2-tokena + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa2-tokenb + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa3-tokenc + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + labels: + app: grafana-serviceaccount-token + name: service-accounts-grafana-sa3-tokend + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: Grafana + name: service-accounts diff --git a/tests/e2e/grafanaserviceaccount/070-remove-expirations.yaml b/tests/e2e/grafanaserviceaccount/070-remove-expirations.yaml new file mode 100644 index 000000000..fccd185c9 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/070-remove-expirations.yaml @@ -0,0 +1,23 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + grafanaServiceAccounts: + accounts: + - id: grafana-sa2 + name: grafana-service-account-sa2 + role: Viewer + isDisabled: false + tokens: + - name: tokena + - name: tokenb + - id: grafana-sa3 + name: grafana-service-account-sa3 + role: Admin + isDisabled: true + tokens: + - name: tokenc + - name: tokend diff --git a/tests/e2e/grafanaserviceaccount/080-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/080-assert-api-response.yaml new file mode 100644 index 000000000..b6768933e --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-assert-api-response.yaml @@ -0,0 +1,4 @@ +($serviceaccounts): + totalCount: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/080-assert.yaml b/tests/e2e/grafanaserviceaccount/080-assert.yaml new file mode 100644 index 000000000..c0a23dcc9 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-assert.yaml @@ -0,0 +1,14 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + stage: complete + stageStatus: success + (conditions[?type == 'ServiceAccountsSynchronized']): + - message: service accounts reconciled + reason: ApplySuccessful + status: 'True' + type: ServiceAccountsSynchronized diff --git a/tests/e2e/grafanaserviceaccount/080-error.yaml b/tests/e2e/grafanaserviceaccount/080-error.yaml new file mode 100644 index 000000000..bde641a3c --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-error.yaml @@ -0,0 +1,8 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +status: + serviceAccounts: {} diff --git a/tests/e2e/grafanaserviceaccount/080-remove-all-sa.yaml b/tests/e2e/grafanaserviceaccount/080-remove-all-sa.yaml new file mode 100644 index 000000000..0b19dd35c --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-remove-all-sa.yaml @@ -0,0 +1,9 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: service-accounts + labels: + test: service-accounts +spec: + grafanaServiceAccounts: + accounts: [] diff --git a/tests/e2e/grafanaserviceaccount/chainsaw-test.yaml b/tests/e2e/grafanaserviceaccount/chainsaw-test.yaml new file mode 100644 index 000000000..b8076c3d7 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/chainsaw-test.yaml @@ -0,0 +1,151 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/chainsaw/main/.schemas/json/test-chainsaw-v1alpha1.json +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: service-accounts +spec: + bindings: + - name: NAMESPACE + value: ($namespace) + - name: USER + value: root + - name: PASS + value: secret + - name: NOW + value: (time_now()) + steps: + - name: Create Grafana instance + try: + - apply: { file: 000-deploy-grafana.yaml } + - assert: { file: 000-assert.yaml } + + - name: Create a new service account with default token + try: + - patch: { file: 010-sa-default-token.yaml } + - assert: { file: 010-assert.yaml } + - script: &fetchSAs + content: > + kubectl exec -n $NS $DEPLOYMENT -- \ + curl --fail --silent --show-error -u $USER:$PASS \ + "http://localhost:3000/api/serviceaccounts/search" + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: (join('/', ['deployment', join('-', [$test.metadata.name, 'deployment'])])) + outputs: + - name: serviceaccounts + value: (json_parse($stdout)) + check: + ($error == null): true + - assert: { file: 010-assert-api-response.yaml } + + - name: Rename the service account + try: + - patch: { file: 020-rename-sa.yaml } + - assert: { file: 020-assert.yaml } + - script: { <<: *fetchSAs } + - assert: { file: 020-assert-api-response.yaml } + + - name: Enable the service account + try: + - patch: { file: 030-enable-sa.yaml } + - assert: { file: 030-assert.yaml } + - error: + timeout: "5s" + file: 030-error.yaml + - script: { <<: *fetchSAs } + - assert: { file: 030-assert-api-response.yaml } + + - name: Change a role of the service account + try: + - patch: { file: 040-change-role.yaml } + - assert: { file: 040-assert.yaml } + - script: { <<: *fetchSAs } + - assert: { file: 040-assert-api-response.yaml } + + - name: Replace previous account with two new ones + try: + - patch: { file: 050-replace-sa.yaml } + - wait: + apiVersion: v1 + kind: Secret + name: service-accounts-grafana-sa1-service-accounts-grafana-sa-069616 + for: + deletion: {} + - assert: { file: 050-assert.yaml } + - script: { <<: *fetchSAs } + - assert: { file: 050-assert-api-response.yaml } + + - name: Update token expirations to trigger recreation when TTL changes + try: + - patch: { file: 060-update-expirations.yaml } + - assert: { file: 060-assert.yaml } + - script: { <<: *fetchSAs } + - assert: { file: 060-assert-api-response.yaml } + + - name: Remove token expirations to trigger recreation when TTL changes + try: + - patch: { file: 070-remove-expirations.yaml } + - assert: { file: 070-assert.yaml } + - script: { <<: *fetchSAs } + - assert: { file: 070-assert-api-response.yaml } + + - name: Remove all service accounts and ensure secrets and status are cleaned up + try: + - patch: { file: 080-remove-all-sa.yaml } + - wait: + apiVersion: v1 + kind: Secret + name: service-accounts-grafana-sa2-tokena + for: + deletion: {} + - wait: + apiVersion: v1 + kind: Secret + name: service-accounts-grafana-sa2-tokenb + for: + deletion: {} + - wait: + apiVersion: v1 + kind: Secret + name: service-accounts-grafana-sa3-tokenc + for: + deletion: {} + - wait: + apiVersion: v1 + kind: Secret + name: service-accounts-grafana-sa3-tokend + for: + deletion: {} + - assert: { file: 080-assert.yaml } + - error: + timeout: "5s" + file: 080-error.yaml + - script: { <<: *fetchSAs } + - assert: { file: 080-assert-api-response.yaml } + + - name: Delete Grafana + try: + - delete: + ref: + apiVersion: grafana.integreatly.org/v1beta1 + kind: Grafana + name: service-accounts + expect: + - match: + apiVersion: grafana.integreatly.org/v1beta1 + kind: Grafana + name: service-accounts + check: + ($error != null): false + - wait: + apiVersion: grafana.integreatly.org/v1beta1 + kind: Grafana + name: service-accounts + for: + deletion: {} From 55b5ef22a07338c55c4cad2fe53aed2057cff5ac Mon Sep 17 00:00:00 2001 From: Andrey Karpov Date: Mon, 7 Jul 2025 08:38:45 +0200 Subject: [PATCH 2/2] wip --- controllers/model/grafana_resources.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/controllers/model/grafana_resources.go b/controllers/model/grafana_resources.go index e7a96ffce..35a72b7a7 100644 --- a/controllers/model/grafana_resources.go +++ b/controllers/model/grafana_resources.go @@ -88,15 +88,12 @@ func GetInternalServiceAccountSecret( ObjectMeta: metav1.ObjectMeta{ Name: generateInternalServiceAccountTokenSecretName(cr.Name, saStatus.SpecID, tokenStatus.Name), Namespace: cr.Namespace, - Labels: MergeAnnotations( - GetCommonLabels(), - 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, - }, - ), + 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), }, @@ -111,6 +108,7 @@ func GetInternalServiceAccountSecret( "grafana.integreatly.org/token-expiry": tokenStatus.Expires.Format(time.RFC3339), } } + SetInheritedLabels(secret, cr.Labels) if scheme != nil { controllerutil.SetControllerReference(cr, secret, scheme) //nolint:errcheck