diff --git a/.gitignore b/.gitignore index bd31e505a..b611c5f27 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ vendor .vscode/ .DS_Store +.mirrord # Audit lab kube-apiserver-audit.log diff --git a/Makefile b/Makefile index d175674fa..257a919ca 100644 --- a/Makefile +++ b/Makefile @@ -211,6 +211,11 @@ deploy-chainsaw: $(KUSTOMIZE) manifests ## Deploy controller to the K8s cluster $(info $(M) running $@) $(KUSTOMIZE) build deploy/kustomize/overlays/chainsaw | kubectl apply --server-side --force-conflicts -f - +.PHONY: deploy-chainsaw-debug +deploy-chainsaw-debug: $(KUSTOMIZE) manifests ## Deploy debug controller (http-echo) to the K8s cluster for mirrord (https://github.com/metalbear-co/mirrord) debugging. + $(info $(M) running $@) + $(KUSTOMIZE) build deploy/kustomize/overlays/chainsaw-debug | kubectl apply --server-side --force-conflicts -f - + .PHONY: undeploy undeploy: $(KUSTOMIZE) ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. $(info $(M) running $@) diff --git a/api/v1beta1/grafana_types.go b/api/v1beta1/grafana_types.go index f11e1aa68..827d35bb2 100644 --- a/api/v1beta1/grafana_types.go +++ b/api/v1beta1/grafana_types.go @@ -157,6 +157,7 @@ type GrafanaStatus struct { ContactPoints NamespacedResourceList `json:"contactPoints,omitempty"` Dashboards NamespacedResourceList `json:"dashboards,omitempty"` Datasources NamespacedResourceList `json:"datasources,omitempty"` + ServiceAccounts NamespacedResourceList `json:"serviceaccounts,omitempty"` Folders NamespacedResourceList `json:"folders,omitempty"` LibraryPanels NamespacedResourceList `json:"libraryPanels,omitempty"` MuteTimings NamespacedResourceList `json:"muteTimings,omitempty"` diff --git a/api/v1beta1/grafanaserviceaccount_types.go b/api/v1beta1/grafanaserviceaccount_types.go new file mode 100644 index 000000000..4336a4199 --- /dev/null +++ b/api/v1beta1/grafanaserviceaccount_types.go @@ -0,0 +1,187 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GrafanaServiceAccountTokenSpec defines a token for a service account +type GrafanaServiceAccountTokenSpec struct { + // Name of the token + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // Expiration date of the token. If not set, the token never expires + // +optional + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Format=date-time + Expires *metav1.Time `json:"expires,omitempty"` + + // Name of the secret to store the token. If not set, a name will be generated + // +optional + // +kubebuilder:validation:MinLength=1 + SecretName string `json:"secretName,omitempty"` +} + +// GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount. +type GrafanaServiceAccountSpec struct { + // How often the resource is synced, defaults to 10m0s if not set + // +optional + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$" + // +kubebuilder:default="10m0s" + // +kubebuilder:validation:XValidation:rule="duration(self) > duration('0s')",message="spec.resyncPeriod must be greater than 0" + ResyncPeriod metav1.Duration `json:"resyncPeriod,omitempty"` + + // Suspend pauses reconciliation of the service account + // +optional + // +kubebuilder:default=false + Suspend bool `json:"suspend,omitempty"` + + // Name of the Grafana instance to create the service account for + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.instanceName is immutable" + InstanceName string `json:"instanceName"` + + // Name of the service account in Grafana + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.name is immutable" + Name string `json:"name"` + + // Role of the service account (Viewer, Editor, Admin) + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=Viewer;Editor;Admin + Role string `json:"role"` + + // Whether the service account is disabled + // +optional + // +kubebuilder:default=false + IsDisabled bool `json:"isDisabled,omitempty"` + + // Tokens to create for the service account + // +optional + // +listType=map + // +listMapKey=name + Tokens []GrafanaServiceAccountTokenSpec `json:"tokens,omitempty"` +} + +// GrafanaServiceAccountSecretStatus describes a Secret created in Kubernetes to store the service account token. +type GrafanaServiceAccountSecretStatus struct { + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` +} + +// GrafanaServiceAccountTokenStatus describes a token created in Grafana. +type GrafanaServiceAccountTokenStatus struct { + Name string `json:"name"` + + // Expiration time of the token + // N.B. There's possible discrepancy with the expiration time in spec + // It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time + Expires *metav1.Time `json:"expires,omitempty"` + + // ID of the token in Grafana + ID int64 `json:"id"` + + // Name of the secret containing the token + Secret *GrafanaServiceAccountSecretStatus `json:"secret,omitempty"` +} + +// GrafanaServiceAccountInfo describes the Grafana service account information. +type GrafanaServiceAccountInfo struct { + Name string `json:"name"` + Login string `json:"login"` + + // ID of the service account in Grafana + ID int64 `json:"id"` + + // Role is the Grafana role for the service account (Viewer, Editor, Admin) + Role string `json:"role"` + + // IsDisabled indicates if the service account is disabled + IsDisabled bool `json:"isDisabled"` + + // Information about tokens + // +optional + Tokens []GrafanaServiceAccountTokenStatus `json:"tokens,omitempty"` +} + +// GrafanaServiceAccountStatus defines the observed state of a GrafanaServiceAccount +type GrafanaServiceAccountStatus struct { + GrafanaCommonStatus `json:",inline"` + + // Info contains the Grafana service account information + Account *GrafanaServiceAccountInfo `json:"account,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// GrafanaServiceAccount is the Schema for the grafanaserviceaccounts API +// +kubebuilder:printcolumn:name="Last resync",type="date",format="date-time",JSONPath=".status.lastResync",description="" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="" +// +kubebuilder:resource:categories={grafana-operator} +type GrafanaServiceAccount struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec GrafanaServiceAccountSpec `json:"spec,omitempty"` + Status GrafanaServiceAccountStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// GrafanaServiceAccountList contains a list of GrafanaServiceAccount +type GrafanaServiceAccountList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []GrafanaServiceAccount `json:"items"` +} + +// Find searches for a GrafanaServiceAccount by namespace/name in the list. +func (in *GrafanaServiceAccountList) Find(namespace, name string) *GrafanaServiceAccount { + for i := range in.Items { + if in.Items[i].Namespace == namespace && in.Items[i].Name == name { + return &in.Items[i] + } + } + + return nil +} + +// MatchNamespace returns the namespace where this service account is defined. +func (in *GrafanaServiceAccount) MatchNamespace() string { + return in.Namespace +} + +// AllowCrossNamespace indicates whether cross-namespace import is allowed for this resource. +func (in *GrafanaServiceAccount) AllowCrossNamespace() bool { + // return in.Spec.AllowCrossNamespaceImport + return false +} + +func (in *GrafanaServiceAccount) CommonStatus() *GrafanaCommonStatus { + return &in.Status.GrafanaCommonStatus +} + +func init() { + SchemeBuilder.Register(&GrafanaServiceAccount{}, &GrafanaServiceAccountList{}) +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 8160ae487..dc5089882 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1740,6 +1740,189 @@ func (in *GrafanaPreferences) DeepCopy() *GrafanaPreferences { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaServiceAccount) DeepCopyInto(out *GrafanaServiceAccount) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccount. +func (in *GrafanaServiceAccount) DeepCopy() *GrafanaServiceAccount { + if in == nil { + return nil + } + out := new(GrafanaServiceAccount) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GrafanaServiceAccount) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaServiceAccountInfo) DeepCopyInto(out *GrafanaServiceAccountInfo) { + *out = *in + if in.Tokens != nil { + in, out := &in.Tokens, &out.Tokens + *out = make([]GrafanaServiceAccountTokenStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountInfo. +func (in *GrafanaServiceAccountInfo) DeepCopy() *GrafanaServiceAccountInfo { + if in == nil { + return nil + } + out := new(GrafanaServiceAccountInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaServiceAccountList) DeepCopyInto(out *GrafanaServiceAccountList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]GrafanaServiceAccount, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountList. +func (in *GrafanaServiceAccountList) DeepCopy() *GrafanaServiceAccountList { + if in == nil { + return nil + } + out := new(GrafanaServiceAccountList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GrafanaServiceAccountList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaServiceAccountSecretStatus) DeepCopyInto(out *GrafanaServiceAccountSecretStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountSecretStatus. +func (in *GrafanaServiceAccountSecretStatus) DeepCopy() *GrafanaServiceAccountSecretStatus { + if in == nil { + return nil + } + out := new(GrafanaServiceAccountSecretStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaServiceAccountSpec) DeepCopyInto(out *GrafanaServiceAccountSpec) { + *out = *in + out.ResyncPeriod = in.ResyncPeriod + if in.Tokens != nil { + in, out := &in.Tokens, &out.Tokens + *out = make([]GrafanaServiceAccountTokenSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountSpec. +func (in *GrafanaServiceAccountSpec) DeepCopy() *GrafanaServiceAccountSpec { + if in == nil { + return nil + } + out := new(GrafanaServiceAccountSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaServiceAccountStatus) DeepCopyInto(out *GrafanaServiceAccountStatus) { + *out = *in + in.GrafanaCommonStatus.DeepCopyInto(&out.GrafanaCommonStatus) + if in.Account != nil { + in, out := &in.Account, &out.Account + *out = new(GrafanaServiceAccountInfo) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountStatus. +func (in *GrafanaServiceAccountStatus) DeepCopy() *GrafanaServiceAccountStatus { + if in == nil { + return nil + } + out := new(GrafanaServiceAccountStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaServiceAccountTokenSpec) DeepCopyInto(out *GrafanaServiceAccountTokenSpec) { + *out = *in + if in.Expires != nil { + in, out := &in.Expires, &out.Expires + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountTokenSpec. +func (in *GrafanaServiceAccountTokenSpec) DeepCopy() *GrafanaServiceAccountTokenSpec { + if in == nil { + return nil + } + out := new(GrafanaServiceAccountTokenSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaServiceAccountTokenStatus) DeepCopyInto(out *GrafanaServiceAccountTokenStatus) { + *out = *in + if in.Expires != nil { + in, out := &in.Expires, &out.Expires + *out = (*in).DeepCopy() + } + if in.Secret != nil { + in, out := &in.Secret, &out.Secret + *out = new(GrafanaServiceAccountSecretStatus) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaServiceAccountTokenStatus. +func (in *GrafanaServiceAccountTokenStatus) DeepCopy() *GrafanaServiceAccountTokenStatus { + if in == nil { + return nil + } + out := new(GrafanaServiceAccountTokenStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaSpec) DeepCopyInto(out *GrafanaSpec) { *out = *in @@ -1846,6 +2029,11 @@ func (in *GrafanaStatus) DeepCopyInto(out *GrafanaStatus) { *out = make(NamespacedResourceList, len(*in)) copy(*out, *in) } + if in.ServiceAccounts != nil { + in, out := &in.ServiceAccounts, &out.ServiceAccounts + *out = make(NamespacedResourceList, len(*in)) + copy(*out, *in) + } if in.Folders != nil { in, out := &in.Folders, &out.Folders *out = make(NamespacedResourceList, len(*in)) diff --git a/config/crd/bases/grafana.integreatly.org_grafanas.yaml b/config/crd/bases/grafana.integreatly.org_grafanas.yaml index a8f752e7d..28efe9bb1 100644 --- a/config/crd/bases/grafana.integreatly.org_grafanas.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafanas.yaml @@ -5186,6 +5186,10 @@ spec: items: type: string type: array + serviceaccounts: + items: + type: string + type: array stage: type: string stageStatus: diff --git a/config/crd/bases/grafana.integreatly.org_grafanaserviceaccounts.yaml b/config/crd/bases/grafana.integreatly.org_grafanaserviceaccounts.yaml new file mode 100644 index 000000000..2240208a8 --- /dev/null +++ b/config/crd/bases/grafana.integreatly.org_grafanaserviceaccounts.yaml @@ -0,0 +1,253 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: grafanaserviceaccounts.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + categories: + - grafana-operator + kind: GrafanaServiceAccount + listKind: GrafanaServiceAccountList + plural: grafanaserviceaccounts + singular: grafanaserviceaccount + scope: Namespaced + versions: + - additionalPrinterColumns: + - format: date-time + jsonPath: .status.lastResync + name: Last resync + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaServiceAccount is the Schema for the grafanaserviceaccounts + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: GrafanaServiceAccountSpec defines the desired state of a + GrafanaServiceAccount. + properties: + instanceName: + description: Name of the Grafana instance to create the service account + for + minLength: 1 + type: string + x-kubernetes-validations: + - message: spec.instanceName is immutable + rule: self == oldSelf + isDisabled: + default: false + description: Whether the service account is disabled + type: boolean + name: + description: Name of the service account in Grafana + minLength: 1 + type: string + x-kubernetes-validations: + - message: spec.name is immutable + rule: self == oldSelf + resyncPeriod: + default: 10m0s + description: How often the resource is synced, defaults to 10m0s if + not set + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + x-kubernetes-validations: + - message: spec.resyncPeriod must be greater than 0 + rule: duration(self) > duration('0s') + role: + description: Role of the service account (Viewer, Editor, Admin) + enum: + - Viewer + - Editor + - Admin + type: string + suspend: + default: false + description: Suspend pauses reconciliation of the service account + type: boolean + tokens: + description: Tokens to create for the service account + items: + description: GrafanaServiceAccountTokenSpec defines a token for + a service account + properties: + expires: + description: Expiration date of the token. If not set, the token + never expires + format: date-time + type: string + name: + description: Name of the token + minLength: 1 + type: string + secretName: + description: Name of the secret to store the token. If not set, + a name will be generated + minLength: 1 + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - instanceName + - name + - role + type: object + status: + description: GrafanaServiceAccountStatus defines the observed state of + a GrafanaServiceAccount + properties: + account: + description: Info contains the Grafana service account information + properties: + id: + description: ID of the service account in Grafana + format: int64 + type: integer + isDisabled: + description: IsDisabled indicates if the service account is disabled + type: boolean + login: + type: string + name: + type: string + role: + description: Role is the Grafana role for the service account + (Viewer, Editor, Admin) + type: string + tokens: + description: Information about tokens + items: + description: GrafanaServiceAccountTokenStatus describes a token + created in Grafana. + properties: + expires: + description: |- + Expiration time of the token + N.B. There's possible discrepancy with the expiration time in spec + It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time + format: date-time + type: string + id: + description: ID of the token in Grafana + format: int64 + type: integer + name: + type: string + secret: + description: Name of the secret containing the token + properties: + name: + type: string + namespace: + type: string + type: object + required: + - id + - name + type: object + type: array + required: + - id + - isDisabled + - login + - name + - role + type: object + conditions: + description: Results when synchonizing resource with Grafana instances + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastResync: + description: Last time the resource was synchronized with Grafana + instances + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 4409f56ca..6f61217b9 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,6 +5,7 @@ resources: - bases/grafana.integreatly.org_grafanas.yaml - bases/grafana.integreatly.org_grafanadashboards.yaml - bases/grafana.integreatly.org_grafanadatasources.yaml +- bases/grafana.integreatly.org_grafanaserviceaccounts.yaml - bases/grafana.integreatly.org_grafanafolders.yaml - bases/grafana.integreatly.org_grafanaalertrulegroups.yaml - bases/grafana.integreatly.org_grafanacontactpoints.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index bf20de0f4..9619ab742 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -68,6 +68,7 @@ rules: - grafananotificationpolicyroutes - grafananotificationtemplates - grafanas + - grafanaserviceaccounts verbs: - create - delete @@ -90,6 +91,7 @@ rules: - grafananotificationpolicyroutes/finalizers - grafananotificationtemplates/finalizers - grafanas/finalizers + - grafanaserviceaccounts/finalizers verbs: - update - apiGroups: @@ -106,6 +108,7 @@ rules: - grafananotificationpolicyroutes/status - grafananotificationtemplates/status - grafanas/status + - grafanaserviceaccounts/status verbs: - get - patch diff --git a/controllers/controller_shared.go b/controllers/controller_shared.go index 03e7b79b1..d81361512 100644 --- a/controllers/controller_shared.go +++ b/controllers/controller_shared.go @@ -484,7 +484,12 @@ func mergeReconcileErrors(sources ...map[string]string) map[string]string { return merged } -func UpdateStatus(ctx context.Context, cl client.Client, cr v1beta1.CommonResource) { +type statusResource interface { + client.Object + CommonStatus() *v1beta1.GrafanaCommonStatus +} + +func UpdateStatus(ctx context.Context, cl client.Client, cr statusResource) { log := logf.FromContext(ctx) cr.CommonStatus().LastResync = metav1.Time{Time: time.Now()} diff --git a/controllers/serviceaccount_controller.go b/controllers/serviceaccount_controller.go new file mode 100644 index 000000000..4fdf4c439 --- /dev/null +++ b/controllers/serviceaccount_controller.go @@ -0,0 +1,836 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package controllers implements Kubernetes controllers for Grafana Operator. +package controllers + +// GrafanaServiceAccountReconciler manages the lifecycle of Grafana service accounts and their tokens. +// +// The controller ensures that service accounts in Grafana match the desired state defined in +// GrafanaServiceAccount custom resources. It handles: +// - Service account creation, updates, and deletion +// - Token lifecycle management with automatic recreation on expiration changes +// - Secure token storage in Kubernetes Secrets +// - Cleanup of orphaned resources +// +// Key architectural decisions: +// - Tokens are immutable in Grafana - any change requires recreation +// - Token names must be unique within a service account (enforced by CRD validation) +// - Secrets use annotations to link them with their corresponding tokens +// - The controller follows eventual consistency model, handling external modifications gracefully +// - All resources are created in the same namespace as the CR for security +// +// The reconciliation process is idempotent and can recover from partial failures or +// external modifications to either Grafana or Kubernetes resources. + +import ( + "context" + "errors" + "fmt" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-openapi/strfmt" + corev1 "k8s.io/api/core/v1" + kuberr "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + genapi "github.com/grafana/grafana-openapi-client-go/client" + "github.com/grafana/grafana-openapi-client-go/client/service_accounts" + "github.com/grafana/grafana-openapi-client-go/models" + "github.com/grafana/grafana-operator/v5/api/v1beta1" + client2 "github.com/grafana/grafana-operator/v5/controllers/client" + model2 "github.com/grafana/grafana-operator/v5/controllers/model" +) + +const ( + conditionServiceAccountSynchronized = "ServiceAccountSynchronized" +) + +// GrafanaServiceAccountReconciler reconciles a GrafanaServiceAccount object. +type GrafanaServiceAccountReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafanaserviceaccounts,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafanaserviceaccounts/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafanaserviceaccounts/finalizers,verbs=update +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete + +// Reconcile synchronizes the actual state (Grafana service accounts and Kubernetes secrets) +// with the desired state defined in the GrafanaServiceAccount CR spec, +// taking into account Kubernetes' eventual consistency model. +// +// The reconciliation process: +// 1. Fetches the GrafanaServiceAccount resource from Kubernetes +// 2. Handles resource deletion (removes service account from Grafana and cleans up secrets) +// 3. Sets up status update handling (deferred) +// 4. Establishes connection to the target Grafana instance +// 5. For active resources - reconciles the actual state with the desired state (creates, updates, removes as needed) +// 6. Updates the resource status with current state and conditions +// 7. Schedules periodic reconciliation based on ResyncPeriod +func (r *GrafanaServiceAccountReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx).WithName("GrafanaServiceAccountReconciler") + ctx = logf.IntoContext(ctx, log) + + // 1. Fetch the GrafanaServiceAccount resource from Kubernetes + cr := &v1beta1.GrafanaServiceAccount{} + + err := r.Get(ctx, req.NamespacedName, cr) + if err != nil { + if kuberr.IsNotFound(err) { + return ctrl.Result{}, nil + } + + return ctrl.Result{}, fmt.Errorf("getting GrafanaServiceAccount %q: %w", req, err) + } + + // 2. Handle resource deletion (removes service account from Grafana and cleans up secrets) + if cr.GetDeletionTimestamp() != nil { + err := r.finalize(ctx, cr) + if err != nil { + return ctrl.Result{}, fmt.Errorf("finalizing GrafanaServiceAccount: %w", err) + } + + err = removeFinalizer(ctx, r.Client, cr) + if err != nil && !kuberr.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("removing finalizer: %w", err) + } + + return ctrl.Result{}, nil + } + + // 3. From here on, we're handling normal reconciliation (not deletion) + defer UpdateStatus(ctx, r.Client, cr) + + // Check if reconciliation is suspended + if cr.Spec.Suspend { + setSuspended(&cr.Status.Conditions, cr.Generation, conditionReasonApplySuspended) + return ctrl.Result{}, nil + } + + removeSuspended(&cr.Status.Conditions) + + // 4. Establish connection to the target Grafana instance + // First, get the Grafana CR + meta.RemoveStatusCondition(&cr.Status.Conditions, conditionServiceAccountSynchronized) + + grafana, err := r.lookupGrafana(ctx, cr) + if err != nil { + setNoMatchingInstancesCondition(&cr.Status.Conditions, cr.Generation, err) + return ctrl.Result{}, err + } + + gClient, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, grafana) + if err != nil { + meta.SetStatusCondition(&cr.Status.Conditions, buildSynchronizedCondition( + "ServiceAccount", + conditionServiceAccountSynchronized, + cr.Generation, + map[string]string{grafana.Name: err.Error()}, + 1, // We always synchronize with a single Grafana instance. + )) + + return ctrl.Result{}, err + } + + removeNoMatchingInstance(&cr.Status.Conditions) + + // 5. For active resources - reconcile the actual state with the desired state (creates, updates, removes as needed) + err = r.reconcileWithInstance(ctx, gClient, cr) + + // 6. Update the resource status with current state and conditions + applyErrors := map[string]string{} + if err != nil { + applyErrors[grafana.Name] = err.Error() + } + + condition := buildSynchronizedCondition( + "ServiceAccount", + conditionServiceAccountSynchronized, + cr.Generation, + applyErrors, + 1, // We always synchronize with a single Grafana instance. + ) + meta.SetStatusCondition(&cr.Status.Conditions, condition) + + if err != nil { + return ctrl.Result{}, fmt.Errorf("reconciling service account: %w", err) + } + + // 7. Schedule periodic reconciliation based on ResyncPeriod + return ctrl.Result{RequeueAfter: cr.Spec.ResyncPeriod.Duration}, nil +} + +// finalize handles the cleanup logic when a GrafanaServiceAccount resource is being deleted. +// It attempts to remove the service account from Grafana and clean up associated secrets. +func (r *GrafanaServiceAccountReconciler) finalize(ctx context.Context, cr *v1beta1.GrafanaServiceAccount) error { + if !controllerutil.ContainsFinalizer(cr, grafanaFinalizer) { + return nil + } + + // Get the Grafana CR for deletion + grafana, err := r.lookupGrafana(ctx, cr) + if err != nil { + return err + } + + gClient, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, grafana) + if err != nil { + return fmt.Errorf("creating Grafana client: %w", err) + } + + _, err = gClient.ServiceAccounts.DeleteServiceAccountWithParams( // nolint:errcheck + service_accounts. + NewDeleteServiceAccountParamsWithContext(ctx). + WithServiceAccountID(cr.Status.Account.ID), + ) + if err != nil { + // ATM, service_accounts.DeleteServiceAccountNotFound doesn't have Is, Unwrap, Unwrap. + // So, we cannot rely only on errors.Is(). + _, ok := err.(*service_accounts.DeleteServiceAccountNotFound) // nolint:errorlint + if ok || errors.Is(err, service_accounts.NewDeleteServiceAccountNotFound()) { + logf.FromContext(ctx).Info("service account not found, skipping removal", + "serviceAccountID", cr.Status.Account.ID, + "serviceAccountName", cr.Spec.Name, + ) + + return nil + } + + // TODO: The operator now deploys Grafana 12.1.0 by default (see controllers/config/operator_constants.go#L6), + // but it may still manage older Grafana instances. + // + // Before Grafana 12.0.2, there was no reliable way to detect a 404 when deleting a service account. + // The API returned 500 instead (see https://github.com/grafana/grafana/issues/106618). + // + // Once we can guarantee all managed instances are >= 12.0.2 we can handle the real 404 explicitly. + // + // Until then, we treat any non-nil error from the delete call as "already removed" and just log it for visibility. + logf.FromContext(ctx).Error(err, "failed to delete service account (may already be deleted)", + "serviceAccountID", cr.Status.Account.ID, + "serviceAccountName", cr.Spec.Name, + ) + // return fmt.Errorf("deleting service account %q: %w", status.SpecID, err) + } + + return nil +} + +// lookupGrafana retrieves the Grafana instance referenced by the GrafanaServiceAccount +// and validates that it's in a ready state for accepting API requests. +func (r *GrafanaServiceAccountReconciler) lookupGrafana( + ctx context.Context, + cr *v1beta1.GrafanaServiceAccount, +) (*v1beta1.Grafana, error) { + var grafana v1beta1.Grafana + + err := r.Get(ctx, client.ObjectKey{ + Namespace: cr.Namespace, + Name: cr.Spec.InstanceName, + }, &grafana) + if err != nil { + return nil, err + } + + // Check if Grafana instance is ready + if grafana.Status.Stage != v1beta1.OperatorStageComplete || grafana.Status.StageStatus != v1beta1.OperatorStageResultSuccess { + return nil, fmt.Errorf("Grafana instance %q is not ready (stage: %q, status: %q)", cr.Spec.InstanceName, grafana.Status.Stage, grafana.Status.StageStatus) // nolint:staticcheck + } + + return &grafana, nil +} + +// reconcileWithInstance performs the core reconciliation logic for active resources. +// +// It orchestrates the complete synchronization process: +// 1. Ensures the service account exists in Grafana (creates if missing) +// 2. Updates service account properties to match the spec +// 3. Manages the lifecycle of authentication tokens and their secrets +func (r *GrafanaServiceAccountReconciler) reconcileWithInstance( + ctx context.Context, + gClient *genapi.GrafanaHTTPAPI, + cr *v1beta1.GrafanaServiceAccount, +) error { + err := r.upsertAccount(ctx, gClient, cr) + if err != nil { + return fmt.Errorf("upserting service account: %w", err) + } + + // Ensure tokens are always sorted for stable ordering + defer func() { + sort.Slice(cr.Status.Account.Tokens, func(i, j int) bool { + return cr.Status.Account.Tokens[i].Name < cr.Status.Account.Tokens[j].Name + }) + }() + + // Phase 1: Prune orphaned secrets and index valid ones + secretsByTokenName, err := r.pruneAndIndexSecrets(ctx, cr) + if err != nil { + return err + } + + // Phase 2: Remove outdated tokens (will be recreated with correct configuration) + err = r.removeOutdatedTokens(ctx, gClient, cr) + if err != nil { + return err + } + + // Phase 3: Validate existing tokens and restore their secret references + err = r.validateAndRestoreTokenSecrets(ctx, gClient, cr, secretsByTokenName) + if err != nil { + return err + } + + // Phase 4: Provision missing tokens + tokensToCreate := r.determineMissingTokens(cr) + + err = r.provisionTokens(ctx, gClient, cr, tokensToCreate, secretsByTokenName) + if err != nil { + return err + } + + if len(cr.Status.Account.Tokens) != 0 { + // Grafana's create token API doesn't return expiration, requiring a separate fetch + return r.populateTokenExpirations(ctx, gClient, cr) + } + + return nil +} + +// convertGrafanaExpiration converts Grafana's strfmt.DateTime to Kubernetes metav1.Time pointer. +// Returns nil if the expiration is zero. +func convertGrafanaExpiration(expiration strfmt.DateTime) *metav1.Time { + if expiration.IsZero() { + return nil + } + + return ptr.To(metav1.NewTime(time.Time(expiration))) +} + +// removeOutdatedTokens removes tokens that are not in the desired spec or have mismatched expiration times. +// These tokens will be recreated later with the correct configuration. +func (r *GrafanaServiceAccountReconciler) removeOutdatedTokens( + ctx context.Context, + gClient *genapi.GrafanaHTTPAPI, + cr *v1beta1.GrafanaServiceAccount, +) error { + // Build map of desired tokens from spec + desiredTokens := make(map[string]v1beta1.GrafanaServiceAccountTokenSpec, len(cr.Spec.Tokens)) + for _, token := range cr.Spec.Tokens { + desiredTokens[token.Name] = token + } + + for i := 0; i < len(cr.Status.Account.Tokens); i++ { + tokenName := cr.Status.Account.Tokens[i].Name + desiredToken, ok := desiredTokens[tokenName] + + needsRecreation := !ok || + !isEqualExpirationTime(desiredToken.Expires, cr.Status.Account.Tokens[i].Expires) + + if needsRecreation { + err := r.removeAccountToken(ctx, gClient, cr.Status.Account.ID, &cr.Status.Account.Tokens[i]) + if err != nil { + return fmt.Errorf("removing service account token %q: %w", tokenName, err) + } + + cr.Status.Account.Tokens = slices.Delete(cr.Status.Account.Tokens, i, i+1) + i-- + } + } + + return nil +} + +// provisionTokens creates the specified tokens in Grafana and ensures their secrets exist in Kubernetes. +// For each token, it creates the token in Grafana and either updates an existing secret or creates a new one. +func (r *GrafanaServiceAccountReconciler) provisionTokens( + ctx context.Context, + gClient *genapi.GrafanaHTTPAPI, + cr *v1beta1.GrafanaServiceAccount, + tokensToCreate []v1beta1.GrafanaServiceAccountTokenSpec, + secretsByTokenName map[string]corev1.Secret, +) error { + for _, tokenSpec := range tokensToCreate { + tokenStatus, tokenKey, err := r.createToken(ctx, gClient, cr.Status.Account.ID, tokenSpec) + if err != nil { + return fmt.Errorf("creating token %q: %w", tokenSpec.Name, err) + } + + // Check what we should do with the secret. + secret, ok := secretsByTokenName[tokenSpec.Name] + if ok { + // The secret already exists, so we can just update it. + renewSecret(&secret, tokenStatus, tokenKey) + + err := r.Update(ctx, &secret) + if err != nil { + return fmt.Errorf("updating token secret %q: %w", secret.Name, err) + } + } else { + // The secret doesn't exist, so we need to create it. + newSecret := buildTokenSecret(ctx, cr, tokenSpec, tokenStatus, tokenKey, r.Scheme) + + err := r.Create(ctx, newSecret) + if err != nil { + return fmt.Errorf("creating token secret %q: %w", newSecret.Name, err) + } + + secret = *newSecret + } + + tokenStatus.Secret = &v1beta1.GrafanaServiceAccountSecretStatus{ + Namespace: secret.Namespace, + Name: secret.Name, + } + + cr.Status.Account.Tokens = append(cr.Status.Account.Tokens, tokenStatus) + } + + return nil +} + +// determineMissingTokens returns a sorted list of tokens that are in the spec but not in the current status. +// These are the tokens that need to be created. +func (r *GrafanaServiceAccountReconciler) determineMissingTokens(cr *v1beta1.GrafanaServiceAccount) []v1beta1.GrafanaServiceAccountTokenSpec { + // Build map of desired tokens from spec + desiredTokens := make(map[string]v1beta1.GrafanaServiceAccountTokenSpec, len(cr.Spec.Tokens)) + for _, token := range cr.Spec.Tokens { + desiredTokens[token.Name] = token + } + + // Remove tokens that already exist in status + for _, token := range cr.Status.Account.Tokens { + delete(desiredTokens, token.Name) + } + + // Convert map to sorted slice for stable ordering + tokensToCreate := make([]v1beta1.GrafanaServiceAccountTokenSpec, 0, len(desiredTokens)) + for _, desiredToken := range desiredTokens { + tokensToCreate = append(tokensToCreate, desiredToken) + } + + // Sort for stable order - makes debugging and testing easier + slices.SortFunc(tokensToCreate, func(a, b v1beta1.GrafanaServiceAccountTokenSpec) int { + return strings.Compare(a.Name, b.Name) + }) + + return tokensToCreate +} + +// validateAndRestoreTokenSecrets removes tokens without corresponding secrets and restores secret references for valid tokens. +// Tokens without secrets will be recreated with new secrets in the next phase. +func (r *GrafanaServiceAccountReconciler) validateAndRestoreTokenSecrets( + ctx context.Context, + gClient *genapi.GrafanaHTTPAPI, + cr *v1beta1.GrafanaServiceAccount, + secretsByTokenName map[string]corev1.Secret, +) error { + for i := 0; i < len(cr.Status.Account.Tokens); i++ { + tokenName := cr.Status.Account.Tokens[i].Name + secret, secretExists := secretsByTokenName[tokenName] + + if !secretExists { + err := r.removeAccountToken(ctx, gClient, cr.Status.Account.ID, &cr.Status.Account.Tokens[i]) + if err != nil { + return fmt.Errorf("removing service account token %q: %w", tokenName, err) + } + + cr.Status.Account.Tokens = slices.Delete(cr.Status.Account.Tokens, i, i+1) + i-- + + continue + } + + // Restore secret reference for valid token + cr.Status.Account.Tokens[i].Secret = &v1beta1.GrafanaServiceAccountSecretStatus{ + Namespace: secret.Namespace, + Name: secret.Name, + } + } + + return nil +} + +// createToken creates a new token in Grafana for the service account. +// Returns the token status, the token key (secret value), and any error. +func (r *GrafanaServiceAccountReconciler) createToken( + ctx context.Context, + gClient *genapi.GrafanaHTTPAPI, + serviceAccountID int64, + tokenSpec v1beta1.GrafanaServiceAccountTokenSpec, +) (v1beta1.GrafanaServiceAccountTokenStatus, []byte, error) { + cmd := models.AddServiceAccountTokenCommand{Name: tokenSpec.Name} + if tokenSpec.Expires != nil { + // Note: We pass potentially negative TTL to Grafana API and let it handle the validation. + // This approach handles edge cases like clock drift, timezone differences, and API processing delays. + // Grafana will reject tokens with invalid TTL values appropriately. + cmd.SecondsToLive = int64(time.Until(tokenSpec.Expires.Time).Seconds()) + } + + createResp, err := gClient.ServiceAccounts.CreateToken( + service_accounts. + NewCreateTokenParamsWithContext(ctx). + WithServiceAccountID(serviceAccountID). + WithBody(&cmd), + ) + if err != nil { + return v1beta1.GrafanaServiceAccountTokenStatus{}, nil, err + } + + tokenStatus := v1beta1.GrafanaServiceAccountTokenStatus{ + Name: createResp.Payload.Name, + ID: createResp.Payload.ID, + } + + return tokenStatus, []byte(createResp.Payload.Key), nil +} + +// populateTokenExpirations fetches token expiration times from Grafana and updates the status. +// This is a workaround for Grafana API limitation where the create token response doesn't include expiration. +func (r *GrafanaServiceAccountReconciler) populateTokenExpirations( + ctx context.Context, + gClient *genapi.GrafanaHTTPAPI, + cr *v1beta1.GrafanaServiceAccount, +) error { + listResp, err := gClient.ServiceAccounts.ListTokensWithParams( + service_accounts. + NewListTokensParamsWithContext(ctx). + WithServiceAccountID(cr.Status.Account.ID), + ) + if err != nil { + return fmt.Errorf("listing tokens to get expirations: %w", err) + } + + // Build a map of token ID to expiration time + expirations := make(map[int64]*metav1.Time, len(listResp.Payload)) + for _, token := range listResp.Payload { + expirations[token.ID] = convertGrafanaExpiration(token.Expiration) + } + + // Update tokens in status with their expiration times + for i := range cr.Status.Account.Tokens { + cr.Status.Account.Tokens[i].Expires = expirations[cr.Status.Account.Tokens[i].ID] + } + + return nil +} + +// pruneAndIndexSecrets removes orphaned secrets (those whose tokens are no longer in the spec) +// and returns a map of remaining valid secrets indexed by token name for efficient lookup. +// It uses the token name annotation to match secrets with their corresponding tokens in the spec. +func (r *GrafanaServiceAccountReconciler) pruneAndIndexSecrets( + ctx context.Context, + cr *v1beta1.GrafanaServiceAccount, +) (map[string]corev1.Secret, error) { + var secrets corev1.SecretList + + err := r.List(ctx, &secrets, + client.InNamespace(cr.Namespace), + client.MatchingLabels(buildSecretLabels(cr)), + ) + if err != nil { + return nil, fmt.Errorf("listing secrets: %w", err) + } + + // Build map of desired tokens for efficient lookup + desiredTokens := make(map[string]*v1beta1.GrafanaServiceAccountTokenSpec, len(cr.Spec.Tokens)) + for i, token := range cr.Spec.Tokens { + desiredTokens[token.Name] = &cr.Spec.Tokens[i] + } + + filtered := make(map[string]corev1.Secret) + + for _, secret := range secrets.Items { + tokenName, ok := extractTokenNameFromSecret(&secret) + if !ok { + continue + } + + token, ok := desiredTokens[tokenName] + if ok && + ((token.SecretName == "" && secret.GenerateName != "") || + (token.SecretName != "" && secret.GenerateName == "" && token.SecretName == secret.Name)) { + // Keep this secret + filtered[tokenName] = secret + } else { + // Delete orphaned secret + err := r.Delete(ctx, &secret) + if err != nil && !kuberr.IsNotFound(err) { + return nil, fmt.Errorf("deleting orphaned secret %q: %w", secret.Name, err) + } + } + } + + return filtered, nil +} + +// upsertAccount ensures a service account exists in Grafana. +func (r *GrafanaServiceAccountReconciler) upsertAccount( + ctx context.Context, + gClient *genapi.GrafanaHTTPAPI, + cr *v1beta1.GrafanaServiceAccount, +) error { + if cr.Status.Account != nil { + update, err := gClient.ServiceAccounts.UpdateServiceAccount( + service_accounts. + NewUpdateServiceAccountParamsWithContext(ctx). + WithServiceAccountID(cr.Status.Account.ID). + WithBody(&models.UpdateServiceAccountForm{ + // The form contains a ServiceAccountID field which is unused in Grafana, so it's ignored here. + Name: cr.Spec.Name, + Role: cr.Spec.Role, + IsDisabled: ptr.To(cr.Spec.IsDisabled), + }), + ) + if err == nil { + cr.Status.Account = &v1beta1.GrafanaServiceAccountInfo{ + ID: update.Payload.Serviceaccount.ID, + Role: update.Payload.Serviceaccount.Role, + IsDisabled: update.Payload.Serviceaccount.IsDisabled, + Name: update.Payload.Serviceaccount.Name, + Login: update.Payload.Serviceaccount.Login, + } + + // Load existing tokens from Grafana + tokenList, err := gClient.ServiceAccounts.ListTokensWithParams( + service_accounts. + NewListTokensParamsWithContext(ctx). + WithServiceAccountID(cr.Status.Account.ID), + ) + if err != nil { + return fmt.Errorf("listing tokens: %w", err) + } + + cr.Status.Account.Tokens = make([]v1beta1.GrafanaServiceAccountTokenStatus, 0, len(tokenList.Payload)) + for _, token := range tokenList.Payload { + if token != nil { + cr.Status.Account.Tokens = append(cr.Status.Account.Tokens, v1beta1.GrafanaServiceAccountTokenStatus{ + ID: token.ID, + Name: token.Name, + Expires: convertGrafanaExpiration(token.Expiration), + }) + } + } + + return nil + } + + // ATM, service_accounts.UpdateServiceAccountNotFound doesn't have Is, Unwrap, Unwrap. + // So, we cannot rely only on errors.Is(). + _, ok := err.(*service_accounts.UpdateServiceAccountNotFound) // nolint:errorlint + if !ok && !errors.Is(err, service_accounts.NewUpdateServiceAccountNotFound()) { + return fmt.Errorf("updating service account: %w", err) + } + + cr.Status.Account = nil + } + + create, err := gClient.ServiceAccounts.CreateServiceAccount( + service_accounts. + NewCreateServiceAccountParamsWithContext(ctx). + WithBody(&models.CreateServiceAccountForm{ + Name: cr.Spec.Name, + Role: cr.Spec.Role, + IsDisabled: cr.Spec.IsDisabled, + }), + ) + if err != nil { + return fmt.Errorf("creating service account: %w", err) + } + + cr.Status.Account = &v1beta1.GrafanaServiceAccountInfo{ + ID: create.Payload.ID, + Role: create.Payload.Role, + IsDisabled: create.Payload.IsDisabled, + Name: create.Payload.Name, + Login: create.Payload.Login, + } + + return nil +} + +func (r *GrafanaServiceAccountReconciler) removeAccountToken( + ctx context.Context, + gClient *genapi.GrafanaHTTPAPI, + serviceAccountID int64, + tokenStatus *v1beta1.GrafanaServiceAccountTokenStatus, +) error { + if tokenStatus.Secret != nil { + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Namespace: tokenStatus.Secret.Namespace, + Name: tokenStatus.Secret.Name, + }} + + err := r.Delete(ctx, secret) + if err != nil && !kuberr.IsNotFound(err) { + return fmt.Errorf("deleting token secret %q: %w", secret.Name, err) + } + + tokenStatus.Secret = nil + } + + _, err := gClient.ServiceAccounts.DeleteTokenWithParams( // nolint:errcheck + service_accounts. + NewDeleteTokenParamsWithContext(ctx). + WithServiceAccountID(serviceAccountID). + WithTokenID(tokenStatus.ID), + ) + if err != nil { + // ATM, service_accounts.DeleteTokenNotFound doesn't have Is, Unwrap, Unwrap. + // So, we cannot rely only on errors.Is(). + _, ok := err.(*service_accounts.DeleteTokenNotFound) // nolint:errorlint + if ok || errors.Is(err, service_accounts.NewDeleteTokenNotFound()) { + return nil + } + + return fmt.Errorf("deleting token: %w", err) + } + + return nil +} + +func isEqualExpirationTime(a, b *metav1.Time) bool { + // Token expiration drift tolerance + const tokenExpirationDrift = 1 * time.Second + + if a == nil && b == nil { + return true + } + + if a == nil || b == nil { + return false + } + + // Grafana API doesn't allow to set expiration time for tokens. Instead of it, + // Grafana accepts TTL then calculates the expiration time against the current time. + // So, we cannot just compare the expiration time with the spec' one. + // Let's assume that two expiration times are equal if they are close enough. + diff := a.Sub(b.Time) + + return diff.Abs() <= tokenExpirationDrift +} + +func buildSecretLabels(cr *v1beta1.GrafanaServiceAccount) map[string]string { + return map[string]string{ + "operator.grafana.com/service-account-instance": cr.Spec.InstanceName, + "operator.grafana.com/service-account-name": cr.Name, + "operator.grafana.com/service-account-uid": string(cr.UID), + } +} + +func generateSecretName(cr *v1beta1.GrafanaServiceAccount, tokenSpec v1beta1.GrafanaServiceAccountTokenSpec) string { + return fmt.Sprintf("%s-%s-%s-", cr.Spec.InstanceName, cr.Spec.Name, tokenSpec.Name) +} + +func extractTokenNameFromSecret(secret *corev1.Secret) (string, bool) { + if secret.Annotations == nil { + return "", false + } + + tokenName, ok := secret.Annotations["operator.grafana.com/service-account-token-name"] + + return tokenName, ok +} + +func buildTokenSecret( + ctx context.Context, + cr *v1beta1.GrafanaServiceAccount, + tokenSpec v1beta1.GrafanaServiceAccountTokenSpec, + tokenStatus v1beta1.GrafanaServiceAccountTokenStatus, + tokenKey []byte, + scheme *runtime.Scheme, +) *corev1.Secret { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tokenSpec.SecretName, + Namespace: cr.Namespace, + Labels: buildSecretLabels(cr), + Annotations: map[string]string{ + "operator.grafana.com/service-account-spec-name": cr.Spec.Name, + "operator.grafana.com/service-account-uid": string(cr.UID), + "operator.grafana.com/service-account-token-name": tokenStatus.Name, + }, + }, + Type: corev1.SecretTypeOpaque, + } + renewSecret(secret, tokenStatus, tokenKey) + + if secret.Name == "" { + secret.GenerateName = generateSecretName(cr, tokenSpec) + } + + if tokenStatus.Expires != nil { + secret.Annotations["operator.grafana.com/service-account-token-expiry"] = tokenStatus.Expires.Format(time.RFC3339) + } + + model2.SetInheritedLabels(secret, cr.Labels) + + if scheme != nil { + err := controllerutil.SetControllerReference(cr, secret, scheme) + if err != nil { + logf.FromContext(ctx).Error(err, "Failed to set controller reference") + } + } + + return secret +} + +func renewSecret( + secret *corev1.Secret, + tokenStatus v1beta1.GrafanaServiceAccountTokenStatus, + tokenKey []byte, +) { + if secret.Data == nil { + secret.Data = map[string][]byte{} + } + + secret.Data["token"] = tokenKey + + if secret.Annotations == nil { + secret.Annotations = map[string]string{} + } + + secret.Annotations["operator.grafana.com/service-account-token-id"] = strconv.FormatInt(tokenStatus.ID, 10) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *GrafanaServiceAccountReconciler) SetupWithManager(mgr ctrl.Manager) error { + // TODO: Consider watching token Secrets for reactive reconciles. + // It'll requeue on Secret create/update/delete, reducing reliance on ResyncPeriod. + // Example: Owns(&corev1.Secret{}, builder.WithPredicates(...)) + return ctrl.NewControllerManagedBy(mgr). + For(&v1beta1.GrafanaServiceAccount{}). + WithEventFilter(predicate.Or( + ignoreStatusUpdates(), + predicate.AnnotationChangedPredicate{}, + )). + WithOptions(controller.Options{RateLimiter: defaultRateLimiter()}). + Complete(r) +} diff --git a/controllers/serviceaccount_controller_test.go b/controllers/serviceaccount_controller_test.go new file mode 100644 index 000000000..be310019e --- /dev/null +++ b/controllers/serviceaccount_controller_test.go @@ -0,0 +1,220 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "time" + + "github.com/grafana/grafana-openapi-client-go/client/service_accounts" + "github.com/grafana/grafana-operator/v5/api/v1beta1" + client2 "github.com/grafana/grafana-operator/v5/controllers/client" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" +) + +var _ = Describe("ServiceAccount Controller: Integration Tests", func() { + Context("When creating service account with token", func() { + It("should create secret and verify Grafana API state", func() { + ctx := context.Background() + const namespace = "default" + const name = "test-sa-with-token" + const secretName = "test-sa-token-secret" // nolint:gosec + + // Create a GrafanaServiceAccount with a token + sa := &v1beta1.GrafanaServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1beta1.GrafanaServiceAccountSpec{ + ResyncPeriod: metav1.Duration{Duration: 10 * time.Minute}, + InstanceName: grafanaName, + Name: "test-account-with-token", + Role: "Admin", + Tokens: []v1beta1.GrafanaServiceAccountTokenSpec{ + { + Name: "test-token", + SecretName: secretName, + }, + }, + }, + } + + Expect(k8sClient.Create(ctx, sa)).Should(Succeed()) + + // Reconcile + r := &GrafanaServiceAccountReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + req := requestFromMeta(sa.ObjectMeta) + _, err := r.Reconcile(ctx, req) + Expect(err).ToNot(HaveOccurred()) + + // Check that the secret was created with correct metadata and data + secret := &corev1.Secret{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: secretName, + Namespace: namespace, + }, secret)).Should(Succeed()) + + Expect(secret).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "ObjectMeta": MatchFields(IgnoreExtras, Fields{ + "Labels": HaveKeyWithValue("operator.grafana.com/service-account-name", name), + "Annotations": HaveKeyWithValue("operator.grafana.com/service-account-token-name", "test-token"), + }), + "Data": HaveKeyWithValue("token", Not(BeEmpty())), + }))) + + // Check the status + updatedSA := &v1beta1.GrafanaServiceAccount{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: namespace, + }, updatedSA)).Should(Succeed()) + + // Verify that ServiceAccountSynchronized condition is set to success + containsEqualCondition(updatedSA.Status.Conditions, metav1.Condition{ + Type: conditionServiceAccountSynchronized, + Reason: conditionReasonApplySuccessful, + }) + + // Verify that tokens are populated in status + Expect(updatedSA.Status.Account).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "Tokens": ConsistOf(MatchFields(IgnoreExtras, Fields{ + "Name": Equal("test-token"), + "Secret": PointTo(MatchFields(IgnoreExtras, Fields{ + "Name": Equal(secretName), + "Namespace": Equal(namespace), + })), + })), + }))) + + // Verify that the service account and token were actually created in Grafana + // Get Grafana client + gClient, err := client2.NewGeneratedGrafanaClient(ctx, k8sClient, externalGrafanaCr) + Expect(err).ToNot(HaveOccurred()) + + // Retrieve the service account from Grafana API + saFromGrafana, err := gClient.ServiceAccounts.RetrieveServiceAccountWithParams( + service_accounts. + NewRetrieveServiceAccountParamsWithContext(ctx). + WithServiceAccountID(updatedSA.Status.Account.ID), + ) + Expect(err).ToNot(HaveOccurred()) + Expect(saFromGrafana.Payload).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "Name": Equal("test-account-with-token"), + "Role": Equal("Admin"), + "IsDisabled": BeFalse(), + }))) + + // Verify that the token exists in Grafana + tokensFromGrafana, err := gClient.ServiceAccounts.ListTokensWithParams( + service_accounts. + NewListTokensParamsWithContext(ctx). + WithServiceAccountID(updatedSA.Status.Account.ID), + ) + Expect(err).ToNot(HaveOccurred()) + Expect(tokensFromGrafana.Payload).To(ConsistOf( + PointTo(MatchFields(IgnoreExtras, Fields{ + "Name": Equal("test-token"), + "ID": Equal(updatedSA.Status.Account.Tokens[0].ID), + })), + )) + }) + }) + + Context("When Grafana instance is not ready", func() { + It("should handle gracefully", func() { + ctx := context.Background() + const namespace = "default" + const name = "test-sa-instance-not-ready" + const grafanaNotReady = "grafana-not-ready" + + // Create a Grafana instance that is not ready + notReadyGrafana := &v1beta1.Grafana{ + ObjectMeta: metav1.ObjectMeta{ + Name: grafanaNotReady, + Namespace: namespace, + }, + Spec: v1beta1.GrafanaSpec{ + Config: map[string]map[string]string{ + "security": { + "admin_user": "admin", + "admin_password": "admin", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, notReadyGrafana)).Should(Succeed()) + + // Update status to be not ready + notReadyGrafana.Status = v1beta1.GrafanaStatus{ + Stage: v1beta1.OperatorStageDeployment, + StageStatus: v1beta1.OperatorStageResultInProgress, + } + Expect(k8sClient.Status().Update(ctx, notReadyGrafana)).Should(Succeed()) + + // Create a GrafanaServiceAccount that references the not-ready instance + sa := &v1beta1.GrafanaServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1beta1.GrafanaServiceAccountSpec{ + ResyncPeriod: metav1.Duration{Duration: 10 * time.Minute}, + InstanceName: grafanaNotReady, + Name: "test-account-not-ready", + Role: "Viewer", + }, + } + + Expect(k8sClient.Create(ctx, sa)).Should(Succeed()) + + // Reconcile + r := &GrafanaServiceAccountReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + req := requestFromMeta(sa.ObjectMeta) + _, err := r.Reconcile(ctx, req) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("is not ready")) + + // Check the status condition + updatedSA := &v1beta1.GrafanaServiceAccount{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: namespace, + }, updatedSA)).Should(Succeed()) + + // Verify that NoMatchingInstance condition is set + containsEqualCondition(updatedSA.Status.Conditions, metav1.Condition{ + Type: conditionNoMatchingInstance, + Reason: "ErrFetchingInstances", + }) + }) + }) +}) diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml index a8f752e7d..28efe9bb1 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanas.yaml @@ -5186,6 +5186,10 @@ spec: items: type: string type: array + serviceaccounts: + items: + type: string + type: array stage: type: string stageStatus: diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanaserviceaccounts.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanaserviceaccounts.yaml new file mode 100644 index 000000000..2240208a8 --- /dev/null +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafanaserviceaccounts.yaml @@ -0,0 +1,253 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: grafanaserviceaccounts.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + categories: + - grafana-operator + kind: GrafanaServiceAccount + listKind: GrafanaServiceAccountList + plural: grafanaserviceaccounts + singular: grafanaserviceaccount + scope: Namespaced + versions: + - additionalPrinterColumns: + - format: date-time + jsonPath: .status.lastResync + name: Last resync + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaServiceAccount is the Schema for the grafanaserviceaccounts + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: GrafanaServiceAccountSpec defines the desired state of a + GrafanaServiceAccount. + properties: + instanceName: + description: Name of the Grafana instance to create the service account + for + minLength: 1 + type: string + x-kubernetes-validations: + - message: spec.instanceName is immutable + rule: self == oldSelf + isDisabled: + default: false + description: Whether the service account is disabled + type: boolean + name: + description: Name of the service account in Grafana + minLength: 1 + type: string + x-kubernetes-validations: + - message: spec.name is immutable + rule: self == oldSelf + resyncPeriod: + default: 10m0s + description: How often the resource is synced, defaults to 10m0s if + not set + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + x-kubernetes-validations: + - message: spec.resyncPeriod must be greater than 0 + rule: duration(self) > duration('0s') + role: + description: Role of the service account (Viewer, Editor, Admin) + enum: + - Viewer + - Editor + - Admin + type: string + suspend: + default: false + description: Suspend pauses reconciliation of the service account + type: boolean + tokens: + description: Tokens to create for the service account + items: + description: GrafanaServiceAccountTokenSpec defines a token for + a service account + properties: + expires: + description: Expiration date of the token. If not set, the token + never expires + format: date-time + type: string + name: + description: Name of the token + minLength: 1 + type: string + secretName: + description: Name of the secret to store the token. If not set, + a name will be generated + minLength: 1 + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - instanceName + - name + - role + type: object + status: + description: GrafanaServiceAccountStatus defines the observed state of + a GrafanaServiceAccount + properties: + account: + description: Info contains the Grafana service account information + properties: + id: + description: ID of the service account in Grafana + format: int64 + type: integer + isDisabled: + description: IsDisabled indicates if the service account is disabled + type: boolean + login: + type: string + name: + type: string + role: + description: Role is the Grafana role for the service account + (Viewer, Editor, Admin) + type: string + tokens: + description: Information about tokens + items: + description: GrafanaServiceAccountTokenStatus describes a token + created in Grafana. + properties: + expires: + description: |- + Expiration time of the token + N.B. There's possible discrepancy with the expiration time in spec + It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time + format: date-time + type: string + id: + description: ID of the token in Grafana + format: int64 + type: integer + name: + type: string + secret: + description: Name of the secret containing the token + properties: + name: + type: string + namespace: + type: string + type: object + required: + - id + - name + type: object + type: array + required: + - id + - isDisabled + - login + - name + - role + type: object + conditions: + description: Results when synchonizing resource with Grafana instances + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastResync: + description: Last time the resource was synchronized with Grafana + instances + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/helm/grafana-operator/files/rbac.yaml b/deploy/helm/grafana-operator/files/rbac.yaml index 00fcdcda5..da857d64d 100644 --- a/deploy/helm/grafana-operator/files/rbac.yaml +++ b/deploy/helm/grafana-operator/files/rbac.yaml @@ -68,6 +68,7 @@ rules: - grafananotificationpolicyroutes - grafananotificationtemplates - grafanas + - grafanaserviceaccounts verbs: - create - delete @@ -90,6 +91,7 @@ rules: - grafananotificationpolicyroutes/finalizers - grafananotificationtemplates/finalizers - grafanas/finalizers + - grafanaserviceaccounts/finalizers verbs: - update - apiGroups: @@ -106,6 +108,7 @@ rules: - grafananotificationpolicyroutes/status - grafananotificationtemplates/status - grafanas/status + - grafanaserviceaccounts/status verbs: - get - patch diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index b8be34be0..deb519799 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -8596,6 +8596,10 @@ spec: items: type: string type: array + serviceaccounts: + items: + type: string + type: array stage: type: string stageStatus: @@ -8610,3 +8614,256 @@ spec: storage: true subresources: status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: grafanaserviceaccounts.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + categories: + - grafana-operator + kind: GrafanaServiceAccount + listKind: GrafanaServiceAccountList + plural: grafanaserviceaccounts + singular: grafanaserviceaccount + scope: Namespaced + versions: + - additionalPrinterColumns: + - format: date-time + jsonPath: .status.lastResync + name: Last resync + type: date + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaServiceAccount is the Schema for the grafanaserviceaccounts + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: GrafanaServiceAccountSpec defines the desired state of a + GrafanaServiceAccount. + properties: + instanceName: + description: Name of the Grafana instance to create the service account + for + minLength: 1 + type: string + x-kubernetes-validations: + - message: spec.instanceName is immutable + rule: self == oldSelf + isDisabled: + default: false + description: Whether the service account is disabled + type: boolean + name: + description: Name of the service account in Grafana + minLength: 1 + type: string + x-kubernetes-validations: + - message: spec.name is immutable + rule: self == oldSelf + resyncPeriod: + default: 10m0s + description: How often the resource is synced, defaults to 10m0s if + not set + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + x-kubernetes-validations: + - message: spec.resyncPeriod must be greater than 0 + rule: duration(self) > duration('0s') + role: + description: Role of the service account (Viewer, Editor, Admin) + enum: + - Viewer + - Editor + - Admin + type: string + suspend: + default: false + description: Suspend pauses reconciliation of the service account + type: boolean + tokens: + description: Tokens to create for the service account + items: + description: GrafanaServiceAccountTokenSpec defines a token for + a service account + properties: + expires: + description: Expiration date of the token. If not set, the token + never expires + format: date-time + type: string + name: + description: Name of the token + minLength: 1 + type: string + secretName: + description: Name of the secret to store the token. If not set, + a name will be generated + minLength: 1 + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - instanceName + - name + - role + type: object + status: + description: GrafanaServiceAccountStatus defines the observed state of + a GrafanaServiceAccount + properties: + account: + description: Info contains the Grafana service account information + properties: + id: + description: ID of the service account in Grafana + format: int64 + type: integer + isDisabled: + description: IsDisabled indicates if the service account is disabled + type: boolean + login: + type: string + name: + type: string + role: + description: Role is the Grafana role for the service account + (Viewer, Editor, Admin) + type: string + tokens: + description: Information about tokens + items: + description: GrafanaServiceAccountTokenStatus describes a token + created in Grafana. + properties: + expires: + description: |- + Expiration time of the token + N.B. There's possible discrepancy with the expiration time in spec + It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time + format: date-time + type: string + id: + description: ID of the token in Grafana + format: int64 + type: integer + name: + type: string + secret: + description: Name of the secret containing the token + properties: + name: + type: string + namespace: + type: string + type: object + required: + - id + - name + type: object + type: array + required: + - id + - isDisabled + - login + - name + - role + type: object + conditions: + description: Results when synchonizing resource with Grafana instances + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastResync: + description: Last time the resource was synchronized with Grafana + instances + format: date-time + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/kustomize/base/role.yaml b/deploy/kustomize/base/role.yaml index bf20de0f4..9619ab742 100644 --- a/deploy/kustomize/base/role.yaml +++ b/deploy/kustomize/base/role.yaml @@ -68,6 +68,7 @@ rules: - grafananotificationpolicyroutes - grafananotificationtemplates - grafanas + - grafanaserviceaccounts verbs: - create - delete @@ -90,6 +91,7 @@ rules: - grafananotificationpolicyroutes/finalizers - grafananotificationtemplates/finalizers - grafanas/finalizers + - grafanaserviceaccounts/finalizers verbs: - update - apiGroups: @@ -106,6 +108,7 @@ rules: - grafananotificationpolicyroutes/status - grafananotificationtemplates/status - grafanas/status + - grafanaserviceaccounts/status verbs: - get - patch diff --git a/deploy/kustomize/overlays/chainsaw-debug/deployment-patch.yaml b/deploy/kustomize/overlays/chainsaw-debug/deployment-patch.yaml new file mode 100644 index 000000000..c29b2e6a8 --- /dev/null +++ b/deploy/kustomize/overlays/chainsaw-debug/deployment-patch.yaml @@ -0,0 +1,24 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana-operator-controller-manager +spec: + template: + spec: + containers: + - name: manager + imagePullPolicy: IfNotPresent + args: + - -listen=:8081 + livenessProbe: + httpGet: + path: / + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 60 + readinessProbe: + httpGet: + path: / + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 60 diff --git a/deploy/kustomize/overlays/chainsaw-debug/kustomization.yaml b/deploy/kustomize/overlays/chainsaw-debug/kustomization.yaml new file mode 100644 index 000000000..880a166cd --- /dev/null +++ b/deploy/kustomize/overlays/chainsaw-debug/kustomization.yaml @@ -0,0 +1,17 @@ +resources: + - ../chainsaw + +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +images: + - name: ko.local/grafana/grafana-operator + newName: hashicorp/http-echo + newTag: latest + +patches: + - path: deployment-patch.yaml + target: + group: apps + kind: Deployment + version: v1 diff --git a/deploy/kustomize/overlays/cluster_scoped/kustomization.yaml b/deploy/kustomize/overlays/cluster_scoped/kustomization.yaml index a68dcae82..f5fc29861 100644 --- a/deploy/kustomize/overlays/cluster_scoped/kustomization.yaml +++ b/deploy/kustomize/overlays/cluster_scoped/kustomization.yaml @@ -1,4 +1,10 @@ namespace: grafana resources: - - ../../base +- ../../base +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +images: +- name: ghcr.io/grafana/grafana-operator + newName: ghcr.io/grafana/grafana-operator + newTag: v5.18.0 diff --git a/docs/docs/api.md b/docs/docs/api.md index 0ac4f5aad..0d15c53f4 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -33,6 +33,8 @@ Resource Types: - [Grafana](#grafana) +- [GrafanaServiceAccount](#grafanaserviceaccount) + @@ -22948,6 +22950,13 @@ GrafanaStatus defines the observed state of Grafana
false + + serviceaccounts + []string + +
+ + false stage string @@ -22978,6 +22987,456 @@ GrafanaStatus defines the observed state of Grafana +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 +[↩ 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.
+
false
statusobject + GrafanaServiceAccountStatus defines the observed state of a GrafanaServiceAccount
+
false
+ + +### GrafanaServiceAccount.spec +[↩ Parent](#grafanaserviceaccount) + + + +GrafanaServiceAccountSpec defines the desired state of a GrafanaServiceAccount. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
instanceNamestring + Name of the Grafana instance to create the service account for
+
+ Validations:
  • self == oldSelf: spec.instanceName is immutable
  • +
    true
    namestring + Name of the service account in Grafana
    +
    + Validations:
  • self == oldSelf: spec.name is immutable
  • +
    true
    roleenum + Role of the service account (Viewer, Editor, Admin)
    +
    + Enum: Viewer, Editor, Admin
    +
    true
    isDisabledboolean + Whether the service account is disabled
    +
    + Default: false
    +
    false
    resyncPeriodstring + How often the resource is synced, defaults to 10m0s if not set
    +
    + Validations:
  • duration(self) > duration('0s'): spec.resyncPeriod must be greater than 0
  • + Default: 10m0s
    +
    false
    suspendboolean + Suspend pauses reconciliation of the service account
    +
    + Default: false
    +
    false
    tokens[]object + Tokens to create for the service account
    +
    false
    + + +### GrafanaServiceAccount.spec.tokens[index] +[↩ Parent](#grafanaserviceaccountspec) + + + +GrafanaServiceAccountTokenSpec defines a token for a service account + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring + Name of the token
    +
    true
    expiresstring + Expiration date of the token. If not set, the token never expires
    +
    + Format: date-time
    +
    false
    secretNamestring + Name of the secret to store the token. If not set, a name will be generated
    +
    false
    + + +### GrafanaServiceAccount.status +[↩ Parent](#grafanaserviceaccount) + + + +GrafanaServiceAccountStatus defines the observed state of a GrafanaServiceAccount + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    accountobject + Info contains the Grafana service account information
    +
    false
    conditions[]object + Results when synchonizing resource with Grafana instances
    +
    false
    lastResyncstring + Last time the resource was synchronized with Grafana instances
    +
    + Format: date-time
    +
    false
    + + +### GrafanaServiceAccount.status.account +[↩ Parent](#grafanaserviceaccountstatus) + + + +Info contains the Grafana service account information + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    idinteger + ID of the service account in Grafana
    +
    + Format: int64
    +
    true
    isDisabledboolean + IsDisabled indicates if the service account is disabled
    +
    true
    loginstring +
    +
    true
    namestring +
    +
    true
    rolestring + Role is the Grafana role for the service account (Viewer, Editor, Admin)
    +
    true
    tokens[]object + Information about tokens
    +
    false
    + + +### GrafanaServiceAccount.status.account.tokens[index] +[↩ Parent](#grafanaserviceaccountstatusaccount) + + + +GrafanaServiceAccountTokenStatus describes a token created in Grafana. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    idinteger + ID of the token in Grafana
    +
    + Format: int64
    +
    true
    namestring +
    +
    true
    expiresstring + Expiration time of the token +N.B. There's possible discrepancy with the expiration time in spec +It happens because Grafana API accepts TTL in seconds then calculates the expiration time against the current time
    +
    + Format: date-time
    +
    false
    secretobject + Name of the secret containing the token
    +
    false
    + + +### GrafanaServiceAccount.status.account.tokens[index].secret +[↩ Parent](#grafanaserviceaccountstatusaccounttokensindex) + + + +Name of the secret containing the token + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring +
    +
    false
    namespacestring +
    +
    false
    + + +### GrafanaServiceAccount.status.conditions[index] +[↩ Parent](#grafanaserviceaccountstatus) + + + Condition contains details for one aspect of the current state of this API Resource. diff --git a/examples/serviceaccounts/resources.yaml b/examples/serviceaccounts/resources.yaml new file mode 100644 index 000000000..fa9dadfcb --- /dev/null +++ b/examples/serviceaccounts/resources.yaml @@ -0,0 +1,14 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: mysa +spec: + name: "my-service-account" + role: "Admin" + isDisabled: false + instanceName: "grafana" + tokens: + - name: "my-token-a" + secretName: thatsfine + expires: 2029-12-31T14:00:00+02:00 + - name: "my-token-b" diff --git a/go.mod b/go.mod index a60f7c833..406cc9455 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/go-openapi/runtime v0.28.0 github.com/go-openapi/strfmt v0.23.0 github.com/google/go-jsonnet v0.21.0 - github.com/grafana/grafana-openapi-client-go v0.0.0-20250617151817-c0f8cbb88d5c + github.com/grafana/grafana-openapi-client-go v0.0.0-20250828163705-969607f81baa github.com/onsi/ginkgo/v2 v2.25.2 github.com/onsi/gomega v1.38.2 github.com/openshift/api v0.0.0-20190924102528-32369d4db2ad @@ -47,7 +47,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/analysis v0.23.0 // indirect - github.com/go-openapi/errors v0.22.1 // indirect + github.com/go-openapi/errors v0.22.2 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect diff --git a/go.sum b/go.sum index 713e5051e..56d801d1d 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,8 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= -github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU= -github.com/go-openapi/errors v0.22.1/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= +github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg= +github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -109,8 +109,8 @@ github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pI github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-openapi-client-go v0.0.0-20250617151817-c0f8cbb88d5c h1:jox7J0BnJmcZJp8lp631u4gjDEoIfpi6O3yrpiXNTtg= -github.com/grafana/grafana-openapi-client-go v0.0.0-20250617151817-c0f8cbb88d5c/go.mod h1:AOzHLStinAJHJmcih1eEbIRImxpT6enYUsZLnnOvhbo= +github.com/grafana/grafana-openapi-client-go v0.0.0-20250828163705-969607f81baa h1:tvPhlrPcD7/e+oWrIQeORR3lrI3bpyzmXM1/5+cs9Xg= +github.com/grafana/grafana-openapi-client-go v0.0.0-20250828163705-969607f81baa/go.mod h1:kPvro06l0pxBDKftPQ9611gaoSNi6IJrcDgiXdN2Nng= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= diff --git a/main.go b/main.go index 1e6ffa3ac..bafb55ee6 100644 --- a/main.go +++ b/main.go @@ -294,6 +294,14 @@ func main() { // nolint:gocyclo os.Exit(1) } + if err = (&controllers.GrafanaServiceAccountReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); 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/010-assert.yaml b/tests/e2e/grafanaserviceaccount/010-assert.yaml new file mode 100644 index 000000000..1f9a7c8a7 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/010-assert.yaml @@ -0,0 +1,13 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + conditions: + - observedGeneration: 1 + reason: ErrFetchingInstances + status: "True" + type: NoMatchingInstance + message: 'error occurred during fetching of instances: Grafana.grafana.integreatly.org + "grafana1" not found' diff --git a/tests/e2e/grafanaserviceaccount/010-precreate-sa.yaml b/tests/e2e/grafanaserviceaccount/010-precreate-sa.yaml new file mode 100644 index 000000000..8a3958897 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/010-precreate-sa.yaml @@ -0,0 +1,9 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +spec: + instanceName: ($INSTANCENAME) + name: sa1 + role: Editor diff --git a/tests/e2e/grafanaserviceaccount/020-assert.yaml b/tests/e2e/grafanaserviceaccount/020-assert.yaml new file mode 100644 index 000000000..c71b43d07 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/020-assert.yaml @@ -0,0 +1,15 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: grafana1 + namespace: ($NAMESPACE) + labels: + test: ($test.metadata.name) +status: + stage: complete + stageStatus: success + (conditions[?type == 'GrafanaReady']): + - message: Grafana reconcile completed + reason: GrafanaReady + status: "True" + type: GrafanaReady diff --git a/tests/e2e/grafanaserviceaccount/020-deploy-grafana.yaml b/tests/e2e/grafanaserviceaccount/020-deploy-grafana.yaml new file mode 100644 index 000000000..d6d7f7719 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/020-deploy-grafana.yaml @@ -0,0 +1,16 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: Grafana +metadata: + name: grafana1 + namespace: ($NAMESPACE) + labels: + test: ($test.metadata.name) +spec: + config: + log: + mode: "console" + auth: + disable_login_form: "false" + security: + admin_user: ($USER) + admin_password: ($PASS) diff --git a/tests/e2e/grafanaserviceaccount/030-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/030-assert-api-response.yaml new file mode 100644 index 000000000..0054f659d --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/030-assert-api-response.yaml @@ -0,0 +1,12 @@ +($serviceaccounts): + totalCount: 1 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/030-assert.yaml b/tests/e2e/grafanaserviceaccount/030-assert.yaml new file mode 100644 index 000000000..b1d7ba7f9 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/030-assert.yaml @@ -0,0 +1,20 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + finalizers: + - operator.grafana.com/finalizer + generation: 1 + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 diff --git a/tests/e2e/grafanaserviceaccount/040-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/040-assert-api-response.yaml new file mode 100644 index 000000000..dac2fee7d --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/040-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/040-assert.yaml b/tests/e2e/grafanaserviceaccount/040-assert.yaml new file mode 100644 index 000000000..957aa13a7 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/040-assert.yaml @@ -0,0 +1,41 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + finalizers: + - operator.grafana.com/finalizer + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + finalizers: + - operator.grafana.com/finalizer + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 diff --git a/tests/e2e/grafanaserviceaccount/040-sa-no-tokens.yaml b/tests/e2e/grafanaserviceaccount/040-sa-no-tokens.yaml new file mode 100644 index 000000000..d931dd8ac --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/040-sa-no-tokens.yaml @@ -0,0 +1,10 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + instanceName: ($INSTANCENAME) + name: sa2 + role: Editor + isDisabled: false diff --git a/tests/e2e/grafanaserviceaccount/050-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/050-assert-api-response.yaml new file mode 100644 index 000000000..28ebaa41e --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/050-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: true + role : Editor + tokens: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/050-assert.yaml b/tests/e2e/grafanaserviceaccount/050-assert.yaml new file mode 100644 index 000000000..2bd16e621 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/050-assert.yaml @@ -0,0 +1,37 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: true + login: sa-1-sa2 + name: sa2 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 2 diff --git a/tests/e2e/grafanaserviceaccount/050-disable-sa.yaml b/tests/e2e/grafanaserviceaccount/050-disable-sa.yaml new file mode 100644 index 000000000..3c383ccae --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/050-disable-sa.yaml @@ -0,0 +1,7 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + isDisabled: true diff --git a/tests/e2e/grafanaserviceaccount/060-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/060-assert-api-response.yaml new file mode 100644 index 000000000..dac2fee7d --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/060-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/060-assert.yaml b/tests/e2e/grafanaserviceaccount/060-assert.yaml new file mode 100644 index 000000000..8fa26b5f6 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/060-assert.yaml @@ -0,0 +1,37 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 3 diff --git a/tests/e2e/grafanaserviceaccount/060-enable-sa.yaml b/tests/e2e/grafanaserviceaccount/060-enable-sa.yaml new file mode 100644 index 000000000..f9a494b2e --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/060-enable-sa.yaml @@ -0,0 +1,7 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + isDisabled: false diff --git a/tests/e2e/grafanaserviceaccount/070-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/070-assert-api-response.yaml new file mode 100644 index 000000000..13bbb6607 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/070-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/070-assert.yaml b/tests/e2e/grafanaserviceaccount/070-assert.yaml new file mode 100644 index 000000000..82a5bca4b --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/070-assert.yaml @@ -0,0 +1,37 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 4 diff --git a/tests/e2e/grafanaserviceaccount/070-change-role.yaml b/tests/e2e/grafanaserviceaccount/070-change-role.yaml new file mode 100644 index 000000000..e316dd559 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/070-change-role.yaml @@ -0,0 +1,7 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + role: Viewer diff --git a/tests/e2e/grafanaserviceaccount/080-assert-api-response-nochanges.yaml b/tests/e2e/grafanaserviceaccount/080-assert-api-response-nochanges.yaml new file mode 100644 index 000000000..13bbb6607 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-assert-api-response-nochanges.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/080-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/080-assert-api-response.yaml new file mode 100644 index 000000000..13bbb6607 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/080-assert-nochanges.yaml b/tests/e2e/grafanaserviceaccount/080-assert-nochanges.yaml new file mode 100644 index 000000000..9ada2c5b6 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-assert-nochanges.yaml @@ -0,0 +1,20 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + suspend: true +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + conditions: + - message: Resource changes are ignored + reason: ApplySuspended + status: "True" + type: Suspended + observedGeneration: 6 diff --git a/tests/e2e/grafanaserviceaccount/080-assert-suspension.yaml b/tests/e2e/grafanaserviceaccount/080-assert-suspension.yaml new file mode 100644 index 000000000..256321c2f --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-assert-suspension.yaml @@ -0,0 +1,20 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + suspend: true +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + conditions: + - message: Resource changes are ignored + reason: ApplySuspended + status: "True" + type: Suspended + observedGeneration: 5 diff --git a/tests/e2e/grafanaserviceaccount/080-assert.yaml b/tests/e2e/grafanaserviceaccount/080-assert.yaml new file mode 100644 index 000000000..e4c20a424 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-assert.yaml @@ -0,0 +1,20 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + suspend: false +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 7 diff --git a/tests/e2e/grafanaserviceaccount/080-modify-suspended-sa.yaml b/tests/e2e/grafanaserviceaccount/080-modify-suspended-sa.yaml new file mode 100644 index 000000000..ef923d85a --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-modify-suspended-sa.yaml @@ -0,0 +1,9 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + suspend: true + role: Admin + isDisabled: true diff --git a/tests/e2e/grafanaserviceaccount/080-resume-sa.yaml b/tests/e2e/grafanaserviceaccount/080-resume-sa.yaml new file mode 100644 index 000000000..7389b1343 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-resume-sa.yaml @@ -0,0 +1,9 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + suspend: false + role: Viewer + isDisabled: false diff --git a/tests/e2e/grafanaserviceaccount/080-suspend-sa.yaml b/tests/e2e/grafanaserviceaccount/080-suspend-sa.yaml new file mode 100644 index 000000000..2e4ef1e0e --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/080-suspend-sa.yaml @@ -0,0 +1,7 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + suspend: true diff --git a/tests/e2e/grafanaserviceaccount/090-add-tokens.yaml b/tests/e2e/grafanaserviceaccount/090-add-tokens.yaml new file mode 100644 index 000000000..e0dadc9e9 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/090-add-tokens.yaml @@ -0,0 +1,15 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + tokens: + - name: token1 + - name: token2 + expires: (time_add($NOW, '24h')) + - name: token3 + expires: (time_add($NOW, '28h')) + secretName: ccc + - name: token4 + secretName: ddd diff --git a/tests/e2e/grafanaserviceaccount/090-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/090-assert-api-response.yaml new file mode 100644 index 000000000..6c01010ae --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/090-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 4 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/090-assert.yaml b/tests/e2e/grafanaserviceaccount/090-assert.yaml new file mode 100644 index 000000000..8efd92d67 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/090-assert.yaml @@ -0,0 +1,154 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + tokens: + - id: 1 + name: token1 + secret: + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + - (time_between(expires, time_utc(time_add($NOW, '23h55m')), time_utc(time_add($NOW, '24h05m')))): true + id: 2 + name: token2 + secret: + (starts_with(name, 'grafana1-sa2-token2')): true + namespace: ($NAMESPACE) + - (time_between(expires, time_utc(time_add($NOW, '27h55m')), time_utc(time_add($NOW, '28h05m')))): true + id: 3 + name: token3 + secret: + name: ccc + namespace: ($NAMESPACE) + - id: 4 + name: token4 + secret: + name: ddd + namespace: ($NAMESPACE) + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: 'True' + type: ServiceAccountSynchronized + observedGeneration: 8 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '1' + operator.grafana.com/service-account-token-name: token1 + generateName: grafana1-sa2-token1- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '2' + operator.grafana.com/service-account-token-name: token2 + generateName: grafana1-sa2-token2- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token2')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '3' + operator.grafana.com/service-account-token-name: token3 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: ccc + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '4' + operator.grafana.com/service-account-token-name: token4 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: ddd + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque diff --git a/tests/e2e/grafanaserviceaccount/100-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/100-assert-api-response.yaml new file mode 100644 index 000000000..6c01010ae --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/100-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 4 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/100-assert.yaml b/tests/e2e/grafanaserviceaccount/100-assert.yaml new file mode 100644 index 000000000..ca67103d6 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/100-assert.yaml @@ -0,0 +1,154 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + tokens: + - id: 1 + name: token1 + secret: + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + - (time_between(expires, time_utc(time_add($NOW, '23h55m')), time_utc(time_add($NOW, '24h05m')))): true + id: 5 + name: token2 + secret: + name: fff + namespace: ($NAMESPACE) + - (time_between(expires, time_utc(time_add($NOW, '27h55m')), time_utc(time_add($NOW, '28h05m')))): true + id: 6 + name: token3 + secret: + name: eee + namespace: ($NAMESPACE) + - id: 7 + name: token4 + secret: + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: 'True' + type: ServiceAccountSynchronized + observedGeneration: 9 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '1' + operator.grafana.com/service-account-token-name: token1 + generateName: grafana1-sa2-token1- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '5' + operator.grafana.com/service-account-token-name: token2 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: fff + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '6' + operator.grafana.com/service-account-token-name: token3 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: eee + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '7' + operator.grafana.com/service-account-token-name: token4 + generateName: grafana1-sa2-token4- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque diff --git a/tests/e2e/grafanaserviceaccount/100-rename-token-secret.yaml b/tests/e2e/grafanaserviceaccount/100-rename-token-secret.yaml new file mode 100644 index 000000000..a165a55ec --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/100-rename-token-secret.yaml @@ -0,0 +1,20 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + instanceName: grafana1 + isDisabled: false + name: sa2 + resyncPeriod: 10m0s + role: Viewer + tokens: + - name: token1 + - name: token2 + secretName: fff + expires: (time_add($NOW, '24h')) + - name: token3 + expires: (time_add($NOW, '28h')) + secretName: eee + - name: token4 diff --git a/tests/e2e/grafanaserviceaccount/110-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/110-assert-api-response.yaml new file mode 100644 index 000000000..6c01010ae --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/110-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 4 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/110-assert.yaml b/tests/e2e/grafanaserviceaccount/110-assert.yaml new file mode 100644 index 000000000..027ac8189 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/110-assert.yaml @@ -0,0 +1,152 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + tokens: + - id: 8 + (time_between(expires, time_utc(time_add($NOW, '19h55m')), time_utc(time_add($NOW, '20h05m')))): true + name: token1 + secret: + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + - (time_between(expires, time_utc(time_add($NOW, '25h55m')), time_utc(time_add($NOW, '26h05m')))): true + id: 9 + name: token2 + secret: + name: fff + namespace: ($NAMESPACE) + - id: 10 + name: token3 + secret: + name: eee + namespace: ($NAMESPACE) + - id: 7 + name: token4 + secret: + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: 'True' + type: ServiceAccountSynchronized + observedGeneration: 10 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '8' + operator.grafana.com/service-account-token-name: token1 + generateName: grafana1-sa2-token1- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '9' + operator.grafana.com/service-account-token-name: token2 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: fff + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '10' + operator.grafana.com/service-account-token-name: token3 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: eee + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-token-name: token4 + generateName: grafana1-sa2-token4- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque diff --git a/tests/e2e/grafanaserviceaccount/110-update-expirations.yaml b/tests/e2e/grafanaserviceaccount/110-update-expirations.yaml new file mode 100644 index 000000000..d0a58a65d --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/110-update-expirations.yaml @@ -0,0 +1,15 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + tokens: + - name: token1 + expires: (time_add($NOW, '20h')) # Add expiration to token1 + - name: token2 + secretName: fff + expires: (time_add($NOW, '26h')) # Update expiration for token2 + - name: token3 + secretName: eee + - name: token4 diff --git a/tests/e2e/grafanaserviceaccount/120-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/120-assert-api-response.yaml new file mode 100644 index 000000000..6c01010ae --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/120-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 4 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/120-assert.yaml b/tests/e2e/grafanaserviceaccount/120-assert.yaml new file mode 100644 index 000000000..a07bd0d29 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/120-assert.yaml @@ -0,0 +1,152 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + tokens: + - id: 11 + name: token1 + secret: + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + - id: 12 + name: token2 + secret: + name: fff + namespace: ($NAMESPACE) + - id: 10 + name: token3 + secret: + name: eee + namespace: ($NAMESPACE) + - id: 7 + name: token4 + secret: + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: 'True' + type: ServiceAccountSynchronized + observedGeneration: 11 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '11' + operator.grafana.com/service-account-token-name: token1 + generateName: grafana1-sa2-token1- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '12' + operator.grafana.com/service-account-token-name: token2 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: fff + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '10' + operator.grafana.com/service-account-token-name: token3 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: eee + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '7' + operator.grafana.com/service-account-token-name: token4 + generateName: grafana1-sa2-token4- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque diff --git a/tests/e2e/grafanaserviceaccount/120-remove-expirations.yaml b/tests/e2e/grafanaserviceaccount/120-remove-expirations.yaml new file mode 100644 index 000000000..fff2491a0 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/120-remove-expirations.yaml @@ -0,0 +1,13 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +spec: + tokens: + - name: token1 + - name: token2 + secretName: fff + - name: token3 + secretName: eee + - name: token4 diff --git a/tests/e2e/grafanaserviceaccount/130-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/130-assert-api-response.yaml new file mode 100644 index 000000000..6c01010ae --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/130-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 4 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/130-assert.yaml b/tests/e2e/grafanaserviceaccount/130-assert.yaml new file mode 100644 index 000000000..91ffbeffc --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/130-assert.yaml @@ -0,0 +1,152 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + tokens: + - id: 11 + name: token1 + secret: + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + - id: 12 + name: token2 + secret: + name: fff + namespace: ($NAMESPACE) + - id: 13 + name: token3 + secret: + name: eee + namespace: ($NAMESPACE) + - id: 7 + name: token4 + secret: + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: 'True' + type: ServiceAccountSynchronized + observedGeneration: 11 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '11' + operator.grafana.com/service-account-token-name: token1 + generateName: grafana1-sa2-token1- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '12' + operator.grafana.com/service-account-token-name: token2 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: fff + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '13' + operator.grafana.com/service-account-token-name: token3 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: eee + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '7' + operator.grafana.com/service-account-token-name: token4 + generateName: grafana1-sa2-token4- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque diff --git a/tests/e2e/grafanaserviceaccount/140-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/140-assert-api-response.yaml new file mode 100644 index 000000000..6c01010ae --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/140-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 3 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 4 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/140-assert.yaml b/tests/e2e/grafanaserviceaccount/140-assert.yaml new file mode 100644 index 000000000..94a7b23af --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/140-assert.yaml @@ -0,0 +1,152 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 3 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + tokens: + - id: 11 + name: token1 + secret: + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + - id: 12 + name: token2 + secret: + name: fff + namespace: ($NAMESPACE) + - id: 13 + name: token3 + secret: + name: eee + namespace: ($NAMESPACE) + - id: 14 + name: token4 + secret: + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: 'True' + type: ServiceAccountSynchronized + observedGeneration: 11 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '11' + operator.grafana.com/service-account-token-name: token1 + generateName: grafana1-sa2-token1- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '12' + operator.grafana.com/service-account-token-name: token2 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: fff + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '13' + operator.grafana.com/service-account-token-name: token3 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: eee + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '14' + operator.grafana.com/service-account-token-name: token4 + generateName: grafana1-sa2-token4- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque diff --git a/tests/e2e/grafanaserviceaccount/150-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/150-assert-api-response.yaml new file mode 100644 index 000000000..77922f3dd --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/150-assert-api-response.yaml @@ -0,0 +1,19 @@ +($serviceaccounts): + totalCount: 2 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + - id: 4 + name: sa2 + login: sa-1-sa2 + orgId: 1 + isDisabled: false + role : Viewer + tokens: 4 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/150-assert.yaml b/tests/e2e/grafanaserviceaccount/150-assert.yaml new file mode 100644 index 000000000..6ad2edb3f --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/150-assert.yaml @@ -0,0 +1,152 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + account: + id: 2 + isDisabled: false + login: sa-1-sa1 + name: sa1 + role: Editor + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: "True" + type: ServiceAccountSynchronized + observedGeneration: 1 +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) +status: + account: + id: 4 + isDisabled: false + login: sa-1-sa2 + name: sa2 + role: Viewer + tokens: + - id: 15 + name: token1 + secret: + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + - id: 16 + name: token2 + secret: + name: fff + namespace: ($NAMESPACE) + - id: 17 + name: token3 + secret: + name: eee + namespace: ($NAMESPACE) + - id: 18 + name: token4 + secret: + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + conditions: + - message: ServiceAccount was successfully applied to 1 instances + reason: ApplySuccessful + status: 'True' + type: ServiceAccountSynchronized + observedGeneration: 11 +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '15' + operator.grafana.com/service-account-token-name: token1 + generateName: grafana1-sa2-token1- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token1')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '16' + operator.grafana.com/service-account-token-name: token2 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: fff + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '17' + operator.grafana.com/service-account-token-name: token3 + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + name: eee + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque +--- +apiVersion: v1 +data: + (length(token) > `0`): true +kind: Secret +metadata: + annotations: + operator.grafana.com/service-account-spec-name: sa2 + operator.grafana.com/service-account-token-id: '18' + operator.grafana.com/service-account-token-name: token4 + generateName: grafana1-sa2-token4- + labels: + app.kubernetes.io/managed-by: grafana-operator + operator.grafana.com/service-account-instance: grafana1 + operator.grafana.com/service-account-name: grafana1-sa2 + (starts_with(name, 'grafana1-sa2-token4')): true + namespace: ($NAMESPACE) + ownerReferences: + - apiVersion: grafana.integreatly.org/v1beta1 + blockOwnerDeletion: true + controller: true + kind: GrafanaServiceAccount + name: grafana1-sa2 +type: Opaque diff --git a/tests/e2e/grafanaserviceaccount/160-assert-api-response.yaml b/tests/e2e/grafanaserviceaccount/160-assert-api-response.yaml new file mode 100644 index 000000000..0054f659d --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/160-assert-api-response.yaml @@ -0,0 +1,12 @@ +($serviceaccounts): + totalCount: 1 + serviceAccounts: + - id: 2 + name: sa1 + login: sa-1-sa1 + orgId: 1 + isDisabled: false + role : Editor + tokens: 0 + page: 1 + perPage: 1000 diff --git a/tests/e2e/grafanaserviceaccount/170-assert.yaml b/tests/e2e/grafanaserviceaccount/170-assert.yaml new file mode 100644 index 000000000..db04dca5f --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/170-assert.yaml @@ -0,0 +1,11 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaServiceAccount +metadata: + name: grafana1-sa1 + namespace: ($NAMESPACE) +status: + conditions: + - observedGeneration: 1 + reason: ErrFetchingInstances + status: 'True' + type: NoMatchingInstance diff --git a/tests/e2e/grafanaserviceaccount/chainsaw-test.yaml b/tests/e2e/grafanaserviceaccount/chainsaw-test.yaml new file mode 100644 index 000000000..3e9091c2f --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/chainsaw-test.yaml @@ -0,0 +1,404 @@ +# 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: grafana-service-accounts +spec: + bindings: + - name: NAMESPACE + value: ($namespace) + - name: USER + value: root + - name: PASS + value: secret + - name: NOW + value: (time_now()) + - name: INSTANCENAME + value: grafana1 + - name: SANAME + value: sa1 + + steps: + - name: Create a service account before Grafana + try: + - apply: { file: 010-precreate-sa.yaml } + - assert: { file: 010-assert.yaml } + + - name: Create Grafana instance + try: + - apply: { file: 020-deploy-grafana.yaml } + - assert: { file: 020-assert.yaml } + + - name: Precreated service account is reconciled + try: + - script: &reconcileSA1 + # Trigger reconciliation + content: > + kubectl annotate -n $NS grafanaserviceaccount grafana1-sa1 reconcile-trigger="$(date +%s)" --overwrite + env: + - name: NS + value: ($NAMESPACE) + check: + ($error == null): true + - assert: { file: 030-assert.yaml } + - script: &fetchSAs + # Check the current state of Grafana via API. + content: > + kubectl exec -n $NS $DEPLOYMENT -- \ + curl --fail --silent --show-error -u $USER:$PASS \ + "http://localhost:3000/api/serviceaccounts/search" + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + outputs: + - name: serviceaccounts + value: (json_parse($stdout)) + check: + ($error == null): true + - assert: { file: 030-assert-api-response.yaml } + + - name: Create a new service account without tokens + try: + - apply: { file: 040-sa-no-tokens.yaml } + - assert: { file: 040-assert.yaml } + - script: *fetchSAs + - assert: { file: 040-assert-api-response.yaml } + + - name: Reconcile service account after external modification + try: + - script: + # Manually modify the service account through Grafana API to simulate external changes. + content: > + kubectl exec -n $NS $DEPLOYMENT -- \ + curl --fail --silent --show-error -X PATCH -u $USER:$PASS \ + -H "Accept: application/json" -H "Content-Type: application/json" -d '{"name": "test", "role": "Viewer", "isDisabled": true}' \ + "http://localhost:3000/api/serviceaccounts/3" + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + outputs: + - name: serviceaccount + value: (json_parse($stdout)) + check: + ($error == null): true + - script: &reconcileSA2 + # Trigger reconciliation + content: > + kubectl annotate -n $NS grafanaserviceaccount grafana1-sa2 reconcile-trigger="$(date +%s)" --overwrite + env: + - name: NS + value: ($NAMESPACE) + check: + ($error == null): true + - script: + # Wait for the operator to restore the original state after manual changes. + content: > + scripts/wait-sa.sh 3 5s 1s '{"name": "sa2", "role": "Editor", "isDisabled": false}' + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + check: + ($error == null): true + - assert: { file: 040-assert.yaml } + - script: *fetchSAs + - assert: { file: 040-assert-api-response.yaml } + + - name: Disable the service account + try: + - patch: { file: 050-disable-sa.yaml } + - assert: { file: 050-assert.yaml } + - script: *fetchSAs + - assert: { file: 050-assert-api-response.yaml } + + - name: Enable the service account + try: + - patch: { file: 060-enable-sa.yaml } + - assert: { file: 060-assert.yaml } + - script: *fetchSAs + - assert: { file: 060-assert-api-response.yaml } + + - name: Change the role of the service account + try: + - patch: { file: 070-change-role.yaml } + - assert: { file: 070-assert.yaml } + - script: *fetchSAs + - assert: { file: 070-assert-api-response.yaml } + + - name: Check suspending reconciliation + try: + # Suspend reconciliation + - patch: { file: 080-suspend-sa.yaml } + - assert: { file: 080-assert-suspension.yaml } + # Modify suspended service account (changes should not apply) + - patch: { file: 080-modify-suspended-sa.yaml } + - assert: { file: 080-assert-nochanges.yaml } + - script: *fetchSAs + - assert: { file: 080-assert-api-response-nochanges.yaml } + # Resume reconciliation + - patch: { file: 080-resume-sa.yaml } + - assert: { file: 080-assert.yaml } + - script: *fetchSAs + - assert: { file: 080-assert-api-response.yaml } + + - name: Add multiple tokens to service account + try: + - patch: { file: 090-add-tokens.yaml } + - assert: { file: 090-assert.yaml } + - script: *fetchSAs + - assert: { file: 090-assert-api-response.yaml } + + - name: Rename token secret + try: + - apply: { file: 100-rename-token-secret.yaml } + - script: *reconcileSA2 + - wait: + apiVersion: v1 + kind: Secret + name: ccc + namespace: ($NAMESPACE) + for: + deletion: {} + - assert: { file: 100-assert.yaml } + - script: *fetchSAs + - assert: { file: 100-assert-api-response.yaml } + + - name: Updating token expirations triggers reconciliation + try: + - patch: { file: 110-update-expirations.yaml } + - assert: + resource: + # Chainsaw cannot correctly compare nil and list in assertions. + # Workaround: wait for generation update, which guarantees changes are applied. + apiVersion: grafana.integreatly.org/v1beta1 + kind: GrafanaServiceAccount + metadata: + name: grafana1-sa2 + namespace: ($NAMESPACE) + status: + conditions: + - observedGeneration: 10 + - assert: { file: 110-assert.yaml } + - script: *fetchSAs + - assert: { file: 110-assert-api-response.yaml } + + - name: Remove token expirations + try: + - apply: { file: 120-remove-expirations.yaml } + - assert: { file: 120-assert.yaml } + - script: *fetchSAs + - assert: { file: 120-assert-api-response.yaml } + + - name: Delete token secret and verify restoration + try: + - delete: + ref: + apiVersion: v1 + kind: Secret + name: eee + namespace: ($NAMESPACE) + - wait: + apiVersion: v1 + kind: Secret + name: eee + namespace: ($NAMESPACE) + for: + deletion: {} + - script: *reconcileSA2 + - assert: { file: 130-assert.yaml } + - script: *fetchSAs + - assert: { file: 130-assert-api-response.yaml } + + - name: Delete token externally and verify restoration + try: + - script: + # Delete token through Grafana API to simulate external deletion + content: > + kubectl exec -n $NS $DEPLOYMENT -- \ + curl --fail --silent --show-error -X DELETE -u $USER:$PASS \ + "http://localhost:3000/api/serviceaccounts/3/tokens/7" + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + check: + ($error == null): true + - script: + content: > + scripts/wait-token-deletion.sh 3 7 5s 1s + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + check: + ($error == null): true + - script: *reconcileSA2 + - assert: { file: 140-assert.yaml } + - script: *fetchSAs + - assert: { file: 140-assert-api-response.yaml } + + - name: Delete service account externally and verify restoration + try: + - script: + # Delete service account through Grafana API to simulate external deletion + content: > + kubectl exec -n $NS $DEPLOYMENT -- \ + curl --fail --silent --show-error -X DELETE -u $USER:$PASS \ + "http://localhost:3000/api/serviceaccounts/3" + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + check: + ($error == null): true + - script: + # Wait for service account to be deleted from Grafana + content: > + scripts/wait-sa-deletion.sh 3 5s 1s + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + check: + ($error == null): true + - script: *reconcileSA2 + - script: + # Wait for the operator to restore the original state after manual changes. + content: > + scripts/wait-sa.sh 4 5s 1s '{"name": "sa2", "role": "Viewer", "isDisabled": false}' + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + check: + ($error == null): true + - assert: { file: 150-assert.yaml } + - script: *fetchSAs + - assert: { file: 150-assert-api-response.yaml } + + - name: Delete service account and ensure cleanup + try: + - delete: + ref: + apiVersion: grafana.integreatly.org/v1beta1 + kind: GrafanaServiceAccount + name: grafana1-sa2 + - wait: + apiVersion: v1 + kind: Secret + selector: operator.grafana.com/service-account-name=grafana1-sa2 + namespace: ($NAMESPACE) + for: + deletion: {} + - wait: + apiVersion: grafana.integreatly.org/v1beta1 + kind: GrafanaServiceAccount + name: grafana1-sa2 + namespace: ($NAMESPACE) + for: + deletion: {} + - script: + # Wait for service account to be deleted from Grafana + content: > + scripts/wait-sa-deletion.sh 3 5s 1s + env: + - name: USER + value: ($USER) + - name: PASS + value: ($PASS) + - name: NS + value: ($NAMESPACE) + - name: DEPLOYMENT + value: deployment/grafana1-deployment + check: + ($error == null): true + - script: *fetchSAs + - assert: { file: 160-assert-api-response.yaml } + + - name: Delete Grafana before service account + try: + - delete: + ref: + apiVersion: grafana.integreatly.org/v1beta1 + kind: Grafana + namespace: ($NAMESPACE) + - wait: + apiVersion: grafana.integreatly.org/v1beta1 + kind: Grafana + name: grafana1 + namespace: ($NAMESPACE) + for: + deletion: {} + - wait: + apiVersion: apps/v1 + kind: Deployment + name: grafana1-deployment + namespace: ($NAMESPACE) + for: + deletion: {} + - script: *reconcileSA1 + - assert: { file: 170-assert.yaml } + + - name: Delete precreated service account + try: + - delete: + ref: + apiVersion: grafana.integreatly.org/v1beta1 + kind: GrafanaServiceAccount + name: grafana1-sa1 + - wait: + apiVersion: v1 + kind: Secret + selector: operator.grafana.com/service-account-name=grafana1-sa1 + namespace: ($NAMESPACE) + for: + deletion: {} + - wait: + apiVersion: grafana.integreatly.org/v1beta1 + kind: GrafanaServiceAccount + name: grafana1-sa1 + namespace: ($NAMESPACE) + for: + deletion: {} diff --git a/tests/e2e/grafanaserviceaccount/scripts/wait-sa-deletion.sh b/tests/e2e/grafanaserviceaccount/scripts/wait-sa-deletion.sh new file mode 100755 index 000000000..43622e539 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/scripts/wait-sa-deletion.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Usage: ./wait-sa-deletion.sh + +SA_ID="$1" +TIMEOUT="$2" +INTERVAL="$3" + +# Convert timeout and interval to seconds (remove 's' suffix if present) +TIMEOUT_SEC=$(echo "$TIMEOUT" | sed 's/s$//') +INTERVAL_SEC=$(echo "$INTERVAL" | sed 's/s$//') + +end_time=$(($(date +%s) + TIMEOUT_SEC)) + +while [ $(date +%s) -lt $end_time ]; do + # Try to fetch the service account + if ! kubectl exec -n "$NS" "$DEPLOYMENT" -- \ + curl -sSf -u "$USER:$PASS" "http://localhost:3000/api/serviceaccounts/$SA_ID" 2>/dev/null; then + # Service account not found (404), which is what we want + echo "Service account $SA_ID has been deleted from Grafana" + exit 0 + fi + sleep "$INTERVAL_SEC" +done + +echo "Timeout waiting for SA $SA_ID to be deleted from Grafana" >&2 +exit 1 diff --git a/tests/e2e/grafanaserviceaccount/scripts/wait-sa.sh b/tests/e2e/grafanaserviceaccount/scripts/wait-sa.sh new file mode 100755 index 000000000..e33de7317 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/scripts/wait-sa.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Usage: ./wait-sa.sh + +SA_ID="$1" +TIMEOUT="$2" +INTERVAL="$3" +EXPECTED="$4" + +# Convert timeout and interval to seconds (remove 's' suffix if present) +TIMEOUT_SEC=$(echo "$TIMEOUT" | sed 's/s$//') +INTERVAL_SEC=$(echo "$INTERVAL" | sed 's/s$//') + +end_time=$(($(date +%s) + TIMEOUT_SEC)) +last_response="" + +while [ $(date +%s) -lt $end_time ]; do + if response=$(kubectl exec -n "$NS" "$DEPLOYMENT" -- \ + curl -sSf -u "$USER:$PASS" "http://localhost:3000/api/serviceaccounts/$SA_ID" 2>/dev/null); then + + last_response="$response" + + # Check if all expected fields match + if echo "$response" | jq --argjson exp "$EXPECTED" ' + . as $curr | $exp | to_entries | + all(.key as $k | .value == $curr[$k])' | grep -q true; then + echo "$response" + exit 0 + fi + fi + sleep "$INTERVAL_SEC" +done + +echo "Timeout waiting for SA $SA_ID to match $EXPECTED" >&2 +if [ -n "$last_response" ]; then + echo "Last state:" >&2 + echo "$last_response" | jq . >&2 +fi +exit 1 diff --git a/tests/e2e/grafanaserviceaccount/scripts/wait-token-deletion.sh b/tests/e2e/grafanaserviceaccount/scripts/wait-token-deletion.sh new file mode 100755 index 000000000..630f50fe1 --- /dev/null +++ b/tests/e2e/grafanaserviceaccount/scripts/wait-token-deletion.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Usage: ./wait-token-deletion.sh + +SA_ID="$1" +TOKEN_ID="$2" +TIMEOUT="$3" +INTERVAL="$4" + +# Convert timeout and interval to seconds (remove 's' suffix if present) +TIMEOUT_SEC=$(echo "$TIMEOUT" | sed 's/s$//') +INTERVAL_SEC=$(echo "$INTERVAL" | sed 's/s$//') + +end_time=$(($(date +%s) + TIMEOUT_SEC)) + +while [ $(date +%s) -lt $end_time ]; do + # List all tokens for the service account and check if our token still exists + response=$(kubectl exec -n "$NS" "$DEPLOYMENT" -- \ + curl -sSf -u "$USER:$PASS" "http://localhost:3000/api/serviceaccounts/$SA_ID/tokens" 2>/dev/null) + + if [ $? -eq 0 ]; then + # Check if the token with TOKEN_ID exists in the response + if ! echo "$response" | jq -e ".[] | select(.id == $TOKEN_ID)" > /dev/null 2>&1; then + # Token not found in the list, which is what we want + echo "Token $TOKEN_ID has been deleted from service account $SA_ID" + exit 0 + fi + else + # Failed to get tokens list, but this might mean the SA itself was deleted + echo "Warning: Failed to list tokens for SA $SA_ID" >&2 + fi + + sleep "$INTERVAL_SEC" +done + +echo "Timeout waiting for token $TOKEN_ID to be deleted from SA $SA_ID" >&2 +exit 1