diff --git a/api/v1beta1/grafananotificationtemplate_types.go b/api/v1beta1/grafananotificationtemplate_types.go new file mode 100644 index 000000000..fdc3e0cdb --- /dev/null +++ b/api/v1beta1/grafananotificationtemplate_types.go @@ -0,0 +1,84 @@ +/* +Copyright 2022. + +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 ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GrafanaNotificationTemplateSpec defines the desired state of GrafanaNotificationTemplate +// +kubebuilder:validation:XValidation:rule="((!has(oldSelf.editable) && !has(self.editable)) || (has(oldSelf.editable) && has(self.editable)))", message="spec.editable is immutable" +type GrafanaNotificationTemplateSpec struct { + GrafanaCommonSpec `json:",inline"` + + // Template name + Name string `json:"name"` + + // Template content + Template string `json:"template,omitempty"` + + // Whether to enable or disable editing of the notification template in Grafana UI + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.editable is immutable" + // +optional + Editable *bool `json:"editable,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// GrafanaNotificationTemplate is the Schema for the GrafanaNotificationTemplate API +// +kubebuilder:resource:categories={grafana-operator} +type GrafanaNotificationTemplate struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec GrafanaNotificationTemplateSpec `json:"spec,omitempty"` + Status GrafanaCommonStatus `json:"status,omitempty"` +} + +var _ CommonResource = (*GrafanaNotificationTemplate)(nil) + +func (in *GrafanaNotificationTemplate) MatchLabels() *metav1.LabelSelector { + return in.Spec.InstanceSelector +} + +func (in *GrafanaNotificationTemplate) MatchNamespace() string { + return in.ObjectMeta.Namespace +} + +func (in *GrafanaNotificationTemplate) AllowCrossNamespace() bool { + return in.Spec.AllowCrossNamespaceImport +} + +func (np *GrafanaNotificationTemplate) NamespacedResource() string { + return fmt.Sprintf("%v/%v/%v", np.ObjectMeta.Namespace, np.ObjectMeta.Name, np.ObjectMeta.UID) +} + +//+kubebuilder:object:root=true + +// GrafanaNotificationTemplateList contains a list of GrafanaNotificationTemplate +type GrafanaNotificationTemplateList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []GrafanaNotificationTemplate `json:"items"` +} + +func init() { + SchemeBuilder.Register(&GrafanaNotificationTemplate{}, &GrafanaNotificationTemplateList{}) +} diff --git a/api/v1beta1/grafananotificationtemplate_types_test.go b/api/v1beta1/grafananotificationtemplate_types_test.go new file mode 100644 index 000000000..5eaf07fb1 --- /dev/null +++ b/api/v1beta1/grafananotificationtemplate_types_test.go @@ -0,0 +1,72 @@ +package v1beta1 + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newNotificationTemplate(name string, editable *bool) *GrafanaNotificationTemplate { + return &GrafanaNotificationTemplate{ + TypeMeta: v1.TypeMeta{ + APIVersion: APIVersion, + Kind: "GrafanaNotificationTemplate", + }, + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: GrafanaNotificationTemplateSpec{ + Editable: editable, + GrafanaCommonSpec: GrafanaCommonSpec{ + InstanceSelector: &v1.LabelSelector{ + MatchLabels: map[string]string{ + "test": "notificationtemplate", + }, + }, + }, + Name: name, + Template: "mock template", + }, + } +} + +var _ = Describe("NotificationTemplate type", func() { + Context("Ensure NotificationTemplate spec.editable is immutable", func() { + ctx := context.Background() + refTrue := true + refFalse := false + + It("Should block adding editable field when missing", func() { + notificationtemplate := newNotificationTemplate("missing-editable", nil) + By("Create new NotificationTemplate without editable") + Expect(k8sClient.Create(ctx, notificationtemplate)).To(Succeed()) + + By("Adding a editable") + notificationtemplate.Spec.Editable = &refTrue + Expect(k8sClient.Update(ctx, notificationtemplate)).To(HaveOccurred()) + }) + + It("Should block removing editable field when set", func() { + notificationtemplate := newNotificationTemplate("existing-editable", &refTrue) + By("Creating NotificationTemplate with existing editable") + Expect(k8sClient.Create(ctx, notificationtemplate)).To(Succeed()) + + By("And setting editable to ''") + notificationtemplate.Spec.Editable = nil + Expect(k8sClient.Update(ctx, notificationtemplate)).To(HaveOccurred()) + }) + + It("Should block changing value of editable", func() { + notificationtemplate := newNotificationTemplate("removing-editable", &refTrue) + By("Create new NotificationTemplate with existing editable") + Expect(k8sClient.Create(ctx, notificationtemplate)).To(Succeed()) + + By("Changing the existing editable") + notificationtemplate.Spec.Editable = &refFalse + Expect(k8sClient.Update(ctx, notificationtemplate)).To(HaveOccurred()) + }) + }) +}) diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 3ec1c658c..f661b1bbd 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1300,6 +1300,86 @@ func (in *GrafanaNotificationPolicySpec) DeepCopy() *GrafanaNotificationPolicySp return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaNotificationTemplate) DeepCopyInto(out *GrafanaNotificationTemplate) { + *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 GrafanaNotificationTemplate. +func (in *GrafanaNotificationTemplate) DeepCopy() *GrafanaNotificationTemplate { + if in == nil { + return nil + } + out := new(GrafanaNotificationTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GrafanaNotificationTemplate) 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 *GrafanaNotificationTemplateList) DeepCopyInto(out *GrafanaNotificationTemplateList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]GrafanaNotificationTemplate, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationTemplateList. +func (in *GrafanaNotificationTemplateList) DeepCopy() *GrafanaNotificationTemplateList { + if in == nil { + return nil + } + out := new(GrafanaNotificationTemplateList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GrafanaNotificationTemplateList) 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 *GrafanaNotificationTemplateSpec) DeepCopyInto(out *GrafanaNotificationTemplateSpec) { + *out = *in + in.GrafanaCommonSpec.DeepCopyInto(&out.GrafanaCommonSpec) + if in.Editable != nil { + in, out := &in.Editable, &out.Editable + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationTemplateSpec. +func (in *GrafanaNotificationTemplateSpec) DeepCopy() *GrafanaNotificationTemplateSpec { + if in == nil { + return nil + } + out := new(GrafanaNotificationTemplateSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaPlugin) DeepCopyInto(out *GrafanaPlugin) { *out = *in diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationtemplates.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationtemplates.yaml new file mode 100644 index 000000000..9dced99e0 --- /dev/null +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationtemplates.yaml @@ -0,0 +1,198 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: grafananotificationtemplates.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + categories: + - grafana-operator + kind: GrafanaNotificationTemplate + listKind: GrafanaNotificationTemplateList + plural: grafananotificationtemplates + singular: grafananotificationtemplate + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaNotificationTemplate is the Schema for the GrafanaNotificationTemplate + 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: GrafanaNotificationTemplateSpec defines the desired state + of GrafanaNotificationTemplate + properties: + allowCrossNamespaceImport: + default: false + description: Allow the Operator to match this resource with Grafanas + outside the current namespace + type: boolean + editable: + description: Whether to enable or disable editing of the notification + template in Grafana UI + type: boolean + x-kubernetes-validations: + - message: spec.editable is immutable + rule: self == oldSelf + instanceSelector: + description: Selects Grafana instances for import + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: spec.instanceSelector is immutable + rule: self == oldSelf + name: + description: Template name + type: string + resyncPeriod: + default: 10m0s + description: How often the resource is synced, defaults to 10m0s if + not set + format: duration + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + template: + description: Template content + type: string + required: + - instanceSelector + - name + type: object + x-kubernetes-validations: + - message: spec.editable is immutable + rule: ((!has(oldSelf.editable) && !has(self.editable)) || (has(oldSelf.editable) + && has(self.editable))) + status: + description: The most recent observed state of a Grafana resource + properties: + conditions: + description: Results when synchonizing resource with Grafana instances + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + 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 f4bac468c..49b06ad37 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -9,6 +9,7 @@ resources: - bases/grafana.integreatly.org_grafanaalertrulegroups.yaml - bases/grafana.integreatly.org_grafanacontactpoints.yaml - bases/grafana.integreatly.org_grafananotificationpolicies.yaml +- bases/grafana.integreatly.org_grafananotificationtemplates.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 3fd4b79dd..b51df1806 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -63,6 +63,7 @@ rules: - grafanadatasources - grafanafolders - grafananotificationpolicies + - grafananotificationtemplates - grafanas verbs: - create @@ -81,6 +82,7 @@ rules: - grafanadatasources/finalizers - grafanafolders/finalizers - grafananotificationpolicies/finalizers + - grafananotificationtemplates/finalizers - grafanas/finalizers verbs: - update @@ -93,6 +95,7 @@ rules: - grafanadatasources/status - grafanafolders/status - grafananotificationpolicies/status + - grafananotificationtemplates/status - grafanas/status verbs: - get diff --git a/controllers/controller_shared.go b/controllers/controller_shared.go index 9663b7960..1300e18f0 100644 --- a/controllers/controller_shared.go +++ b/controllers/controller_shared.go @@ -29,9 +29,14 @@ import ( const grafanaFinalizer = "operator.grafana.com/finalizer" const ( + // condition types conditionNoMatchingInstance = "NoMatchingInstance" conditionNoMatchingFolder = "NoMatchingFolder" conditionInvalidSpec = "InvalidSpec" + + // condition reasons + conditionApplySuccessful = "ApplySuccessful" + conditionApplyFailed = "ApplyFailed" ) //+kubebuilder:rbac:groups=coordination.k8s.io,resources=leases,verbs=get;list;watch;create;update;patch;delete @@ -253,7 +258,7 @@ func setNoMatchingInstancesCondition(conditions *[]metav1.Condition, generation } meta.SetStatusCondition(conditions, metav1.Condition{ Type: conditionNoMatchingInstance, - Status: "True", + Status: metav1.ConditionTrue, ObservedGeneration: generation, Reason: reason, Message: message, @@ -266,7 +271,7 @@ func setNoMatchingInstancesCondition(conditions *[]metav1.Condition, generation func setNoMatchingInstance(conditions *[]metav1.Condition, generation int64, reason, message string) { meta.SetStatusCondition(conditions, metav1.Condition{ Type: conditionNoMatchingInstance, - Status: "True", + Status: metav1.ConditionTrue, ObservedGeneration: generation, LastTransitionTime: metav1.Time{ Time: time.Now(), @@ -283,7 +288,7 @@ func removeNoMatchingInstance(conditions *[]metav1.Condition) { func setNoMatchingFolder(conditions *[]metav1.Condition, generation int64, reason, message string) { meta.SetStatusCondition(conditions, metav1.Condition{ Type: conditionNoMatchingFolder, - Status: "True", + Status: metav1.ConditionTrue, ObservedGeneration: generation, LastTransitionTime: metav1.Time{ Time: time.Now(), @@ -300,7 +305,7 @@ func removeNoMatchingFolder(conditions *[]metav1.Condition) { func setInvalidSpec(conditions *[]metav1.Condition, generation int64, reason, message string) { meta.SetStatusCondition(conditions, metav1.Condition{ Type: conditionInvalidSpec, - Status: "True", + Status: metav1.ConditionTrue, ObservedGeneration: generation, LastTransitionTime: metav1.Time{ Time: time.Now(), @@ -333,12 +338,12 @@ func buildSynchronizedCondition(resource string, syncType string, generation int } if len(applyErrors) == 0 { - condition.Status = "True" - condition.Reason = "ApplySuccessful" + condition.Status = metav1.ConditionTrue + condition.Reason = conditionApplySuccessful condition.Message = fmt.Sprintf("%s was successfully applied to %d instances", resource, total) } else { - condition.Status = "False" - condition.Reason = "ApplyFailed" + condition.Status = metav1.ConditionFalse + condition.Reason = conditionApplyFailed var sb strings.Builder for i, err := range applyErrors { diff --git a/controllers/grafanaalertrulegroup_controller.go b/controllers/grafanaalertrulegroup_controller.go index ab730b489..9fc075367 100644 --- a/controllers/grafanaalertrulegroup_controller.go +++ b/controllers/grafanaalertrulegroup_controller.go @@ -160,7 +160,7 @@ func (r *GrafanaAlertRuleGroupReconciler) reconcileWithInstance(ctx context.Cont return fmt.Errorf("building grafana client: %w", err) } - trueRef := "true" + trueRef := "true" //nolint:goconst editable := true if group.Spec.Editable != nil && !*group.Spec.Editable { editable = false diff --git a/controllers/grafanacontactpoint_controller.go b/controllers/grafanacontactpoint_controller.go index 1340b447b..5493ff5ad 100644 --- a/controllers/grafanacontactpoint_controller.go +++ b/controllers/grafanacontactpoint_controller.go @@ -148,12 +148,12 @@ func (r *GrafanaContactPointReconciler) Reconcile(ctx context.Context, req ctrl. } if len(applyErrors) == 0 { - condition.Status = "True" - condition.Reason = "ApplySuccessful" + condition.Status = metav1.ConditionTrue + condition.Reason = conditionApplySuccessful condition.Message = fmt.Sprintf("Contact point was successfully applied to %d instances", len(instances)) } else { - condition.Status = "False" - condition.Reason = "ApplyFailed" + condition.Status = metav1.ConditionFalse + condition.Reason = conditionApplyFailed var sb strings.Builder for i, err := range applyErrors { diff --git a/controllers/grafananotificationtemplate_controller.go b/controllers/grafananotificationtemplate_controller.go new file mode 100644 index 000000000..b5f32983a --- /dev/null +++ b/controllers/grafananotificationtemplate_controller.go @@ -0,0 +1,197 @@ +/* +Copyright 2022. + +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" + "fmt" + "time" + + 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" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/go-logr/logr" + "github.com/grafana/grafana-openapi-client-go/client/provisioning" + "github.com/grafana/grafana-openapi-client-go/models" + grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" + client2 "github.com/grafana/grafana-operator/v5/controllers/client" +) + +const ( + conditionNotificationTemplateSynchronized = "NotificationTemplateSynchronized" +) + +// GrafanaNotificationTemplateReconciler reconciles a GrafanaNotificationTemplate object +type GrafanaNotificationTemplateReconciler struct { + client.Client + Log logr.Logger + Scheme *runtime.Scheme +} + +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationtemplates,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationtemplates/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationtemplates/finalizers,verbs=update + +func (r *GrafanaNotificationTemplateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + controllerLog := log.FromContext(ctx).WithName("GrafanaNotificationTemplateReconciler") + r.Log = controllerLog + + notificationTemplate := &grafanav1beta1.GrafanaNotificationTemplate{} + err := r.Client.Get(ctx, client.ObjectKey{ + Namespace: req.Namespace, + Name: req.Name, + }, notificationTemplate) + if err != nil { + if kuberr.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("failed to get GrafanaNotificationTemplate: %w", err) + } + + if notificationTemplate.GetDeletionTimestamp() != nil { + // Check if resource needs clean up + if controllerutil.ContainsFinalizer(notificationTemplate, grafanaFinalizer) { + if err := r.finalize(ctx, notificationTemplate); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to finalize GrafanaNotificationTemplate: %w", err) + } + if err := removeFinalizer(ctx, r.Client, notificationTemplate); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to remove finalizer: %w", err) + } + } + return ctrl.Result{}, nil + } + + defer func() { + notificationTemplate.Status.LastResync = metav1.Time{Time: time.Now()} + if err := r.Client.Status().Update(ctx, notificationTemplate); err != nil { + r.Log.Error(err, "updating status") + } + if meta.IsStatusConditionTrue(notificationTemplate.Status.Conditions, conditionNoMatchingInstance) { + if err := removeFinalizer(ctx, r.Client, notificationTemplate); err != nil { + r.Log.Error(err, "failed to remove finalizer") + } + } else { + if err := addFinalizer(ctx, r.Client, notificationTemplate); err != nil { + r.Log.Error(err, "failed to set finalizer") + } + } + }() + + instances, err := GetScopedMatchingInstances(controllerLog, ctx, r.Client, notificationTemplate) + if err != nil { + setNoMatchingInstancesCondition(¬ificationTemplate.Status.Conditions, notificationTemplate.Generation, err) + meta.RemoveStatusCondition(¬ificationTemplate.Status.Conditions, conditionNotificationTemplateSynchronized) + return ctrl.Result{}, fmt.Errorf("could not find matching instances: %w", err) + } + + if len(instances) == 0 { + setNoMatchingInstancesCondition(¬ificationTemplate.Status.Conditions, notificationTemplate.Generation, err) + meta.RemoveStatusCondition(¬ificationTemplate.Status.Conditions, conditionNotificationTemplateSynchronized) + return ctrl.Result{RequeueAfter: RequeueDelay}, nil + } + + removeNoMatchingInstance(¬ificationTemplate.Status.Conditions) + controllerLog.Info("found matching Grafana instances for notification template", "count", len(instances)) + + applyErrors := make(map[string]string) + for _, grafana := range instances { + // can be removed in go 1.22+ + grafana := grafana + + err := r.reconcileWithInstance(ctx, &grafana, notificationTemplate) + if err != nil { + applyErrors[fmt.Sprintf("%s/%s", grafana.Namespace, grafana.Name)] = err.Error() + } + } + if len(applyErrors) > 0 { + return ctrl.Result{}, fmt.Errorf("failed to apply to all instances: %v", applyErrors) + } + + condition := buildSynchronizedCondition("Notification template", conditionNotificationTemplateSynchronized, notificationTemplate.Generation, applyErrors, len(instances)) + meta.SetStatusCondition(¬ificationTemplate.Status.Conditions, condition) + return ctrl.Result{RequeueAfter: notificationTemplate.Spec.ResyncPeriod.Duration}, nil +} + +func (r *GrafanaNotificationTemplateReconciler) reconcileWithInstance(ctx context.Context, instance *grafanav1beta1.Grafana, notificationTemplate *grafanav1beta1.GrafanaNotificationTemplate) error { + cl, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, instance) + if err != nil { + return fmt.Errorf("building grafana client: %w", err) + } + + trueRef := "true" //nolint:goconst + editable := true + if notificationTemplate.Spec.Editable != nil && !*notificationTemplate.Spec.Editable { + editable = false + } + + var updatedNT models.NotificationTemplateContent + updatedNT.Template = notificationTemplate.Spec.Template + params := provisioning.NewPutTemplateParams().WithName(notificationTemplate.Spec.Name).WithBody(&updatedNT) + if editable { + params.SetXDisableProvenance(&trueRef) + } + _, err = cl.Provisioning.PutTemplate(params) //nolint:errcheck + if err != nil { + return fmt.Errorf("creating or updating notification template: %w", err) + } + return nil +} + +func (r *GrafanaNotificationTemplateReconciler) finalize(ctx context.Context, notificationTemplate *grafanav1beta1.GrafanaNotificationTemplate) error { + r.Log.Info("Finalizing GrafanaNotificationTemplate") + + instances, err := GetAllMatchingInstances(ctx, r.Client, notificationTemplate) + if err != nil { + return fmt.Errorf("fetching instances: %w", err) + } + for _, i := range instances { + instance := i + if err := r.removeFromInstance(ctx, &instance, notificationTemplate); err != nil { + return fmt.Errorf("removing notification template from instance: %w", err) + } + } + + return nil +} + +func (r *GrafanaNotificationTemplateReconciler) removeFromInstance(ctx context.Context, instance *grafanav1beta1.Grafana, notificationTemplate *grafanav1beta1.GrafanaNotificationTemplate) error { + cl, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, instance) + if err != nil { + return fmt.Errorf("building grafana client: %w", err) + } + + _, err = cl.Provisioning.DeleteTemplate(notificationTemplate.Spec.Name) //nolint:errcheck + if err != nil { + return fmt.Errorf("deleting notification template: %w", err) + } + + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *GrafanaNotificationTemplateReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&grafanav1beta1.GrafanaNotificationTemplate{}). + WithEventFilter(ignoreStatusUpdates()). + Complete(r) +} diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationtemplates.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationtemplates.yaml new file mode 100644 index 000000000..9dced99e0 --- /dev/null +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationtemplates.yaml @@ -0,0 +1,198 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: grafananotificationtemplates.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + categories: + - grafana-operator + kind: GrafanaNotificationTemplate + listKind: GrafanaNotificationTemplateList + plural: grafananotificationtemplates + singular: grafananotificationtemplate + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaNotificationTemplate is the Schema for the GrafanaNotificationTemplate + 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: GrafanaNotificationTemplateSpec defines the desired state + of GrafanaNotificationTemplate + properties: + allowCrossNamespaceImport: + default: false + description: Allow the Operator to match this resource with Grafanas + outside the current namespace + type: boolean + editable: + description: Whether to enable or disable editing of the notification + template in Grafana UI + type: boolean + x-kubernetes-validations: + - message: spec.editable is immutable + rule: self == oldSelf + instanceSelector: + description: Selects Grafana instances for import + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: spec.instanceSelector is immutable + rule: self == oldSelf + name: + description: Template name + type: string + resyncPeriod: + default: 10m0s + description: How often the resource is synced, defaults to 10m0s if + not set + format: duration + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + template: + description: Template content + type: string + required: + - instanceSelector + - name + type: object + x-kubernetes-validations: + - message: spec.editable is immutable + rule: ((!has(oldSelf.editable) && !has(self.editable)) || (has(oldSelf.editable) + && has(self.editable))) + status: + description: The most recent observed state of a Grafana resource + properties: + conditions: + description: Results when synchonizing resource with Grafana instances + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + 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 0de1b535b..6b01728b5 100644 --- a/deploy/helm/grafana-operator/files/rbac.yaml +++ b/deploy/helm/grafana-operator/files/rbac.yaml @@ -63,6 +63,7 @@ rules: - grafanadatasources - grafanafolders - grafananotificationpolicies + - grafananotificationtemplates - grafanas verbs: - create @@ -81,6 +82,7 @@ rules: - grafanadatasources/finalizers - grafanafolders/finalizers - grafananotificationpolicies/finalizers + - grafananotificationtemplates/finalizers - grafanas/finalizers verbs: - update @@ -93,6 +95,7 @@ rules: - grafanadatasources/status - grafanafolders/status - grafananotificationpolicies/status + - grafananotificationtemplates/status - grafanas/status verbs: - get diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 177242e55..0bd451c9d 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1919,6 +1919,204 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: grafananotificationtemplates.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + categories: + - grafana-operator + kind: GrafanaNotificationTemplate + listKind: GrafanaNotificationTemplateList + plural: grafananotificationtemplates + singular: grafananotificationtemplate + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaNotificationTemplate is the Schema for the GrafanaNotificationTemplate + 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: GrafanaNotificationTemplateSpec defines the desired state + of GrafanaNotificationTemplate + properties: + allowCrossNamespaceImport: + default: false + description: Allow the Operator to match this resource with Grafanas + outside the current namespace + type: boolean + editable: + description: Whether to enable or disable editing of the notification + template in Grafana UI + type: boolean + x-kubernetes-validations: + - message: spec.editable is immutable + rule: self == oldSelf + instanceSelector: + description: Selects Grafana instances for import + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: spec.instanceSelector is immutable + rule: self == oldSelf + name: + description: Template name + type: string + resyncPeriod: + default: 10m0s + description: How often the resource is synced, defaults to 10m0s if + not set + format: duration + pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ + type: string + template: + description: Template content + type: string + required: + - instanceSelector + - name + type: object + x-kubernetes-validations: + - message: spec.editable is immutable + rule: ((!has(oldSelf.editable) && !has(self.editable)) || (has(oldSelf.editable) + && has(self.editable))) + status: + description: The most recent observed state of a Grafana resource + properties: + conditions: + description: Results when synchonizing resource with Grafana instances + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + 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: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.3 diff --git a/deploy/kustomize/base/role.yaml b/deploy/kustomize/base/role.yaml index 3fd4b79dd..b51df1806 100644 --- a/deploy/kustomize/base/role.yaml +++ b/deploy/kustomize/base/role.yaml @@ -63,6 +63,7 @@ rules: - grafanadatasources - grafanafolders - grafananotificationpolicies + - grafananotificationtemplates - grafanas verbs: - create @@ -81,6 +82,7 @@ rules: - grafanadatasources/finalizers - grafanafolders/finalizers - grafananotificationpolicies/finalizers + - grafananotificationtemplates/finalizers - grafanas/finalizers verbs: - update @@ -93,6 +95,7 @@ rules: - grafanadatasources/status - grafanafolders/status - grafananotificationpolicies/status + - grafananotificationtemplates/status - grafanas/status verbs: - get diff --git a/docs/docs/api.md b/docs/docs/api.md index b77cdf249..eab5e70c7 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -23,6 +23,8 @@ Resource Types: - [GrafanaNotificationPolicy](#grafananotificationpolicy) +- [GrafanaNotificationTemplate](#grafananotificationtemplate) + - [Grafana](#grafana) @@ -3669,6 +3671,327 @@ The most recent observed state of a Grafana resource +Condition contains details for one aspect of the current state of this API Resource. + +
Name | +Type | +Description | +Required | +
---|---|---|---|
lastTransitionTime | +string | +
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + + Format: date-time + |
+ true | +
message | +string | +
+ message is a human readable message indicating details about the transition.
+This may be an empty string. + |
+ true | +
reason | +string | +
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+Producers of specific condition types may define expected values and meanings for this field,
+and whether the values are considered a guaranteed API.
+The value should be a CamelCase string.
+This field may not be empty. + |
+ true | +
status | +enum | +
+ status of the condition, one of True, False, Unknown. + + Enum: True, False, Unknown + |
+ true | +
type | +string | +
+ type of condition in CamelCase or in foo.example.com/CamelCase. + |
+ true | +
observedGeneration | +integer | +
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+with respect to the current state of the instance. + + Format: int64 + Minimum: 0 + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
apiVersion | +string | +grafana.integreatly.org/v1beta1 | +true | +
kind | +string | +GrafanaNotificationTemplate | +true | +
metadata | +object | +Refer to the Kubernetes API documentation for the fields of the `metadata` field. | +true | +
spec | +object | +
+ GrafanaNotificationTemplateSpec defines the desired state of GrafanaNotificationTemplate + + Validations: |
+ false | +
status | +object | +
+ The most recent observed state of a Grafana resource + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
instanceSelector | +object | +
+ Selects Grafana instances for import + + Validations: |
+ true | +
name | +string | +
+ Template name + |
+ true | +
allowCrossNamespaceImport | +boolean | +
+ Allow the Operator to match this resource with Grafanas outside the current namespace + + Default: false + |
+ false | +
editable | +boolean | +
+ Whether to enable or disable editing of the notification template in Grafana UI + + Validations: |
+ false | +
resyncPeriod | +string | +
+ How often the resource is synced, defaults to 10m0s if not set + + Format: duration + Default: 10m0s + |
+ false | +
template | +string | +
+ Template content + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
matchExpressions | +[]object | +
+ matchExpressions is a list of label selector requirements. The requirements are ANDed. + |
+ false | +
matchLabels | +map[string]string | +
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+map is equivalent to an element of matchExpressions, whose key field is "key", the
+operator is "In", and the values array contains only "value". The requirements are ANDed. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
key | +string | +
+ key is the label key that the selector applies to. + |
+ true | +
operator | +string | +
+ operator represents a key's relationship to a set of values.
+Valid operators are In, NotIn, Exists and DoesNotExist. + |
+ true | +
values | +[]string | +
+ values is an array of string values. If the operator is In or NotIn,
+the values array must be non-empty. If the operator is Exists or DoesNotExist,
+the values array must be empty. This array is replaced during a strategic
+merge patch. + |
+ false | +
Name | +Type | +Description | +Required | +
---|---|---|---|
conditions | +[]object | +
+ Results when synchonizing resource with Grafana instances + |
+ false | +
lastResync | +string | +
+ Last time the resource was synchronized with Grafana instances + + Format: date-time + |
+ false | +