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