From 871e2dbfd4b1f2f67f9a487445041f8f1c327b38 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 13 Dec 2024 14:44:49 +0100 Subject: [PATCH 01/39] feat: run operator-sdk create api command Adapted PROJECT to new go module version path and ran: ``` ./bin/operator-sdk create api --group grafana --version v1beta1 --kind GrafanaNotificationPolicyRoute --controller false ``` --- PROJECT | 11 ++- .../grafananotificationpolicyroute_types.go | 64 +++++++++++++ api/v1beta1/zz_generated.deepcopy.go | 89 +++++++++++++++++++ config/crd/kustomization.yaml | 3 + ...on_in_grafananotificationpolicyroutes.yaml | 7 ++ ...ok_in_grafananotificationpolicyroutes.yaml | 16 ++++ ...nanotificationpolicyroute_editor_role.yaml | 31 +++++++ ...nanotificationpolicyroute_viewer_role.yaml | 27 ++++++ ...1beta1_grafananotificationpolicyroute.yaml | 12 +++ config/samples/kustomization.yaml | 1 + ...afananotificationpolicyroute_controller.go | 62 +++++++++++++ main.go | 16 +++- 12 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 api/v1beta1/grafananotificationpolicyroute_types.go create mode 100644 config/crd/patches/cainjection_in_grafananotificationpolicyroutes.yaml create mode 100644 config/crd/patches/webhook_in_grafananotificationpolicyroutes.yaml create mode 100644 config/rbac/grafananotificationpolicyroute_editor_role.yaml create mode 100644 config/rbac/grafananotificationpolicyroute_viewer_role.yaml create mode 100644 config/samples/grafana_v1beta1_grafananotificationpolicyroute.yaml create mode 100644 controllers/grafananotificationpolicyroute_controller.go diff --git a/PROJECT b/PROJECT index d9fba0503..a780ffb7e 100644 --- a/PROJECT +++ b/PROJECT @@ -8,7 +8,7 @@ layout: plugins: manifests.sdk.operatorframework.io/v2: {} projectName: grafana-operator -repo: github.com/grafana/grafana-operator +repo: github.com/grafana/grafana-operator/v5 resources: - api: crdVersion: v1 @@ -64,4 +64,13 @@ resources: kind: GrafanaContactPoint path: github.com/grafana/grafana-operator/api/v1beta1 version: v1beta1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: integreatly.org + group: grafana + kind: GrafanaNotificationPolicyRoute + path: github.com/grafana/grafana-operator/v5/api/v1beta1 + version: v1beta1 version: "3" diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go new file mode 100644 index 000000000..2099f3295 --- /dev/null +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -0,0 +1,64 @@ +/* +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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute +type GrafanaNotificationPolicyRouteSpec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file + + // Foo is an example field of GrafanaNotificationPolicyRoute. Edit grafananotificationpolicyroute_types.go to remove/update + Foo string `json:"foo,omitempty"` +} + +// GrafanaNotificationPolicyRouteStatus defines the observed state of GrafanaNotificationPolicyRoute +type GrafanaNotificationPolicyRouteStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// GrafanaNotificationPolicyRoute is the Schema for the grafananotificationpolicyroutes API +type GrafanaNotificationPolicyRoute struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec GrafanaNotificationPolicyRouteSpec `json:"spec,omitempty"` + Status GrafanaNotificationPolicyRouteStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// GrafanaNotificationPolicyRouteList contains a list of GrafanaNotificationPolicyRoute +type GrafanaNotificationPolicyRouteList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []GrafanaNotificationPolicyRoute `json:"items"` +} + +func init() { + SchemeBuilder.Register(&GrafanaNotificationPolicyRoute{}, &GrafanaNotificationPolicyRouteList{}) +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 82321b190..c40b3ad01 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1314,6 +1314,95 @@ func (in *GrafanaNotificationPolicyList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaNotificationPolicyRoute) DeepCopyInto(out *GrafanaNotificationPolicyRoute) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyRoute. +func (in *GrafanaNotificationPolicyRoute) DeepCopy() *GrafanaNotificationPolicyRoute { + if in == nil { + return nil + } + out := new(GrafanaNotificationPolicyRoute) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GrafanaNotificationPolicyRoute) 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 *GrafanaNotificationPolicyRouteList) DeepCopyInto(out *GrafanaNotificationPolicyRouteList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]GrafanaNotificationPolicyRoute, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyRouteList. +func (in *GrafanaNotificationPolicyRouteList) DeepCopy() *GrafanaNotificationPolicyRouteList { + if in == nil { + return nil + } + out := new(GrafanaNotificationPolicyRouteList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GrafanaNotificationPolicyRouteList) 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 *GrafanaNotificationPolicyRouteSpec) DeepCopyInto(out *GrafanaNotificationPolicyRouteSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyRouteSpec. +func (in *GrafanaNotificationPolicyRouteSpec) DeepCopy() *GrafanaNotificationPolicyRouteSpec { + if in == nil { + return nil + } + out := new(GrafanaNotificationPolicyRouteSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GrafanaNotificationPolicyRouteStatus) DeepCopyInto(out *GrafanaNotificationPolicyRouteStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyRouteStatus. +func (in *GrafanaNotificationPolicyRouteStatus) DeepCopy() *GrafanaNotificationPolicyRouteStatus { + if in == nil { + return nil + } + out := new(GrafanaNotificationPolicyRouteStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaNotificationPolicySpec) DeepCopyInto(out *GrafanaNotificationPolicySpec) { *out = *in diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index f4bac468c..9b1fb9235 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_grafananotificationpolicyroutes.yaml #+kubebuilder:scaffold:crdkustomizeresource patchesStrategicMerge: @@ -20,6 +21,7 @@ patchesStrategicMerge: #- patches/webhook_in_grafanafolders.yaml #- patches/webhook_in_grafanaalertrulegroups.yaml #- patches/webhook_in_grafanacontactpoints.yaml +#- patches/webhook_in_grafananotificationpolicyroutes.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -30,6 +32,7 @@ patchesStrategicMerge: #- patches/cainjection_in_grafanafolders.yaml #- patches/cainjection_in_grafanaalertrulegroups.yaml #- patches/cainjection_in_grafanacontactpoints.yaml +#- patches/cainjection_in_grafananotificationpolicyroutes.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_grafananotificationpolicyroutes.yaml b/config/crd/patches/cainjection_in_grafananotificationpolicyroutes.yaml new file mode 100644 index 000000000..206291a42 --- /dev/null +++ b/config/crd/patches/cainjection_in_grafananotificationpolicyroutes.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) + name: grafananotificationpolicyroutes.grafana.integreatly.org diff --git a/config/crd/patches/webhook_in_grafananotificationpolicyroutes.yaml b/config/crd/patches/webhook_in_grafananotificationpolicyroutes.yaml new file mode 100644 index 000000000..becdddd91 --- /dev/null +++ b/config/crd/patches/webhook_in_grafananotificationpolicyroutes.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: grafananotificationpolicyroutes.grafana.integreatly.org +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/grafananotificationpolicyroute_editor_role.yaml b/config/rbac/grafananotificationpolicyroute_editor_role.yaml new file mode 100644 index 000000000..fda08ad26 --- /dev/null +++ b/config/rbac/grafananotificationpolicyroute_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit grafananotificationpolicyroutes. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: grafananotificationpolicyroute-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: grafana-operator + app.kubernetes.io/part-of: grafana-operator + app.kubernetes.io/managed-by: kustomize + name: grafananotificationpolicyroute-editor-role +rules: +- apiGroups: + - grafana.integreatly.org + resources: + - grafananotificationpolicyroutes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - grafana.integreatly.org + resources: + - grafananotificationpolicyroutes/status + verbs: + - get diff --git a/config/rbac/grafananotificationpolicyroute_viewer_role.yaml b/config/rbac/grafananotificationpolicyroute_viewer_role.yaml new file mode 100644 index 000000000..84c2c110d --- /dev/null +++ b/config/rbac/grafananotificationpolicyroute_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view grafananotificationpolicyroutes. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: grafananotificationpolicyroute-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: grafana-operator + app.kubernetes.io/part-of: grafana-operator + app.kubernetes.io/managed-by: kustomize + name: grafananotificationpolicyroute-viewer-role +rules: +- apiGroups: + - grafana.integreatly.org + resources: + - grafananotificationpolicyroutes + verbs: + - get + - list + - watch +- apiGroups: + - grafana.integreatly.org + resources: + - grafananotificationpolicyroutes/status + verbs: + - get diff --git a/config/samples/grafana_v1beta1_grafananotificationpolicyroute.yaml b/config/samples/grafana_v1beta1_grafananotificationpolicyroute.yaml new file mode 100644 index 000000000..2bb4d053c --- /dev/null +++ b/config/samples/grafana_v1beta1_grafananotificationpolicyroute.yaml @@ -0,0 +1,12 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicyRoute +metadata: + labels: + app.kubernetes.io/name: grafananotificationpolicyroute + app.kubernetes.io/instance: grafananotificationpolicyroute-sample + app.kubernetes.io/part-of: grafana-operator + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/created-by: grafana-operator + name: grafananotificationpolicyroute-sample +spec: + # TODO(user): Add fields here diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index ae30bd4a7..91b5c26c1 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -7,4 +7,5 @@ resources: - grafana_v1beta1_grafanaalertrulegroup.yaml - grafana_v1beta1_grafanacontactpoint.yaml - grafana_v1beta1_grafananotificationpolicy.yaml +- grafana_v1beta1_grafananotificationpolicyroute.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/controllers/grafananotificationpolicyroute_controller.go b/controllers/grafananotificationpolicyroute_controller.go new file mode 100644 index 000000000..a4d56454b --- /dev/null +++ b/controllers/grafananotificationpolicyroute_controller.go @@ -0,0 +1,62 @@ +/* +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" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" +) + +// GrafanaNotificationPolicyRouteReconciler reconciles a GrafanaNotificationPolicyRoute object +type GrafanaNotificationPolicyRouteReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the GrafanaNotificationPolicyRoute object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.1/pkg/reconcile +func (r *GrafanaNotificationPolicyRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + _ = log.FromContext(ctx) + + // TODO(user): your logic here + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *GrafanaNotificationPolicyRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&grafanav1beta1.GrafanaNotificationPolicyRoute{}). + Complete(r) +} diff --git a/main.go b/main.go index 38544f3e4..e078d4968 100644 --- a/main.go +++ b/main.go @@ -41,10 +41,6 @@ import ( // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" - grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" - "github.com/grafana/grafana-operator/v5/controllers" - "github.com/grafana/grafana-operator/v5/controllers/autodetect" - "github.com/grafana/grafana-operator/v5/embeds" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -52,6 +48,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" + "github.com/grafana/grafana-operator/v5/controllers" + "github.com/grafana/grafana-operator/v5/controllers/autodetect" + "github.com/grafana/grafana-operator/v5/embeds" //+kubebuilder:scaffold:imports ) @@ -252,6 +253,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "GrafanaNotificationPolicy") os.Exit(1) } + if err = (&controllers.GrafanaNotificationPolicyRouteReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "GrafanaNotificationPolicyRoute") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { From ed9d2fa58799df7e5980d7445bde505d4b0b0070 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 13 Dec 2024 15:12:40 +0100 Subject: [PATCH 02/39] feat: add new fields to GrafanaNotificationPolicyRoute spec --- api/v1beta1/grafananotificationpolicyroute_types.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go index 2099f3295..897daae0e 100644 --- a/api/v1beta1/grafananotificationpolicyroute_types.go +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -28,8 +28,17 @@ type GrafanaNotificationPolicyRouteSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - // Foo is an example field of GrafanaNotificationPolicyRoute. Edit grafananotificationpolicyroute_types.go to remove/update - Foo string `json:"foo,omitempty"` + // Selects Grafana instances for import + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.instanceSelector is immutable" + InstanceSelector *metav1.LabelSelector `json:"instanceSelector"` + + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=100 + // +optional + Priority *int8 `json:"priority,omitempty"` + + // Route for alerts to match against + Route *Route `json:"route"` } // GrafanaNotificationPolicyRouteStatus defines the observed state of GrafanaNotificationPolicyRoute From 0bedeb5d3afc35b752c0052907bf82ecba889ac7 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 13 Dec 2024 15:13:04 +0100 Subject: [PATCH 03/39] chore: run make manifests && make generate --- api/v1beta1/zz_generated.deepcopy.go | 17 +- ...y.org_grafananotificationpolicyroutes.yaml | 181 ++++++++++++++++++ config/rbac/role.yaml | 3 + ...y.org_grafananotificationpolicyroutes.yaml | 181 ++++++++++++++++++ deploy/helm/grafana-operator/files/rbac.yaml | 3 + deploy/kustomize/base/crds.yaml | 181 ++++++++++++++++++ deploy/kustomize/base/role.yaml | 3 + 7 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml create mode 100644 deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index c40b3ad01..da265e782 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1319,7 +1319,7 @@ func (in *GrafanaNotificationPolicyRoute) DeepCopyInto(out *GrafanaNotificationP *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) out.Status = in.Status } @@ -1376,6 +1376,21 @@ func (in *GrafanaNotificationPolicyRouteList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaNotificationPolicyRouteSpec) DeepCopyInto(out *GrafanaNotificationPolicyRouteSpec) { *out = *in + if in.InstanceSelector != nil { + in, out := &in.InstanceSelector, &out.InstanceSelector + *out = new(metav1.LabelSelector) + (*in).DeepCopyInto(*out) + } + if in.Priority != nil { + in, out := &in.Priority, &out.Priority + *out = new(int8) + **out = **in + } + if in.Route != nil { + in, out := &in.Route, &out.Route + *out = new(Route) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyRouteSpec. diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml new file mode 100644 index 000000000..d92df7e08 --- /dev/null +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -0,0 +1,181 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: grafananotificationpolicyroutes.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + kind: GrafanaNotificationPolicyRoute + listKind: GrafanaNotificationPolicyRouteList + plural: grafananotificationpolicyroutes + singular: grafananotificationpolicyroute + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaNotificationPolicyRoute is the Schema for the grafananotificationpolicyroutes + 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: GrafanaNotificationPolicyRouteSpec defines the desired state + of GrafanaNotificationPolicyRoute + properties: + 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 + priority: + maximum: 100 + minimum: 1 + type: integer + route: + description: Route for alerts to match against + properties: + continue: + description: continue + type: boolean + group_by: + description: group by + items: + type: string + type: array + group_interval: + description: group interval + type: string + group_wait: + description: group wait + type: string + match_re: + additionalProperties: + type: string + description: match re + type: object + matchers: + description: matchers + items: + properties: + isEqual: + description: is equal + type: boolean + isRegex: + description: is regex + type: boolean + name: + description: name + type: string + value: + description: value + type: string + required: + - isRegex + - value + type: object + type: array + mute_time_intervals: + description: mute time intervals + items: + type: string + type: array + object_matchers: + description: object matchers + items: + description: |- + ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. + + swagger:model ObjectMatcher + items: + type: string + type: array + type: array + provenance: + description: provenance + type: string + receiver: + description: receiver + type: string + repeat_interval: + description: repeat interval + type: string + routes: + description: routes + x-kubernetes-preserve-unknown-fields: true + type: object + required: + - instanceSelector + - route + type: object + status: + description: GrafanaNotificationPolicyRouteStatus defines the observed + state of GrafanaNotificationPolicyRoute + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 3fd4b79dd..ab6df695d 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -63,6 +63,7 @@ rules: - grafanadatasources - grafanafolders - grafananotificationpolicies + - grafananotificationpolicyroutes - grafanas verbs: - create @@ -81,6 +82,7 @@ rules: - grafanadatasources/finalizers - grafanafolders/finalizers - grafananotificationpolicies/finalizers + - grafananotificationpolicyroutes/finalizers - grafanas/finalizers verbs: - update @@ -93,6 +95,7 @@ rules: - grafanadatasources/status - grafanafolders/status - grafananotificationpolicies/status + - grafananotificationpolicyroutes/status - grafanas/status verbs: - get diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml new file mode 100644 index 000000000..d92df7e08 --- /dev/null +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -0,0 +1,181 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: grafananotificationpolicyroutes.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + kind: GrafanaNotificationPolicyRoute + listKind: GrafanaNotificationPolicyRouteList + plural: grafananotificationpolicyroutes + singular: grafananotificationpolicyroute + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaNotificationPolicyRoute is the Schema for the grafananotificationpolicyroutes + 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: GrafanaNotificationPolicyRouteSpec defines the desired state + of GrafanaNotificationPolicyRoute + properties: + 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 + priority: + maximum: 100 + minimum: 1 + type: integer + route: + description: Route for alerts to match against + properties: + continue: + description: continue + type: boolean + group_by: + description: group by + items: + type: string + type: array + group_interval: + description: group interval + type: string + group_wait: + description: group wait + type: string + match_re: + additionalProperties: + type: string + description: match re + type: object + matchers: + description: matchers + items: + properties: + isEqual: + description: is equal + type: boolean + isRegex: + description: is regex + type: boolean + name: + description: name + type: string + value: + description: value + type: string + required: + - isRegex + - value + type: object + type: array + mute_time_intervals: + description: mute time intervals + items: + type: string + type: array + object_matchers: + description: object matchers + items: + description: |- + ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. + + swagger:model ObjectMatcher + items: + type: string + type: array + type: array + provenance: + description: provenance + type: string + receiver: + description: receiver + type: string + repeat_interval: + description: repeat interval + type: string + routes: + description: routes + x-kubernetes-preserve-unknown-fields: true + type: object + required: + - instanceSelector + - route + type: object + status: + description: GrafanaNotificationPolicyRouteStatus defines the observed + state of GrafanaNotificationPolicyRoute + 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..47cfb9f02 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 + - grafananotificationpolicyroutes - grafanas verbs: - create @@ -81,6 +82,7 @@ rules: - grafanadatasources/finalizers - grafanafolders/finalizers - grafananotificationpolicies/finalizers + - grafananotificationpolicyroutes/finalizers - grafanas/finalizers verbs: - update @@ -93,6 +95,7 @@ rules: - grafanadatasources/status - grafanafolders/status - grafananotificationpolicies/status + - grafananotificationpolicyroutes/status - grafanas/status verbs: - get diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 025b8b262..5a002b0c8 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1846,6 +1846,187 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: grafananotificationpolicyroutes.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + kind: GrafanaNotificationPolicyRoute + listKind: GrafanaNotificationPolicyRouteList + plural: grafananotificationpolicyroutes + singular: grafananotificationpolicyroute + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaNotificationPolicyRoute is the Schema for the grafananotificationpolicyroutes + 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: GrafanaNotificationPolicyRouteSpec defines the desired state + of GrafanaNotificationPolicyRoute + properties: + 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 + priority: + maximum: 100 + minimum: 1 + type: integer + route: + description: Route for alerts to match against + properties: + continue: + description: continue + type: boolean + group_by: + description: group by + items: + type: string + type: array + group_interval: + description: group interval + type: string + group_wait: + description: group wait + type: string + match_re: + additionalProperties: + type: string + description: match re + type: object + matchers: + description: matchers + items: + properties: + isEqual: + description: is equal + type: boolean + isRegex: + description: is regex + type: boolean + name: + description: name + type: string + value: + description: value + type: string + required: + - isRegex + - value + type: object + type: array + mute_time_intervals: + description: mute time intervals + items: + type: string + type: array + object_matchers: + description: object matchers + items: + description: |- + ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. + + swagger:model ObjectMatcher + items: + type: string + type: array + type: array + provenance: + description: provenance + type: string + receiver: + description: receiver + type: string + repeat_interval: + description: repeat interval + type: string + routes: + description: routes + x-kubernetes-preserve-unknown-fields: true + type: object + required: + - instanceSelector + - route + type: object + status: + description: GrafanaNotificationPolicyRouteStatus defines the observed + state of GrafanaNotificationPolicyRoute + 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..ab6df695d 100644 --- a/deploy/kustomize/base/role.yaml +++ b/deploy/kustomize/base/role.yaml @@ -63,6 +63,7 @@ rules: - grafanadatasources - grafanafolders - grafananotificationpolicies + - grafananotificationpolicyroutes - grafanas verbs: - create @@ -81,6 +82,7 @@ rules: - grafanadatasources/finalizers - grafanafolders/finalizers - grafananotificationpolicies/finalizers + - grafananotificationpolicyroutes/finalizers - grafanas/finalizers verbs: - update @@ -93,6 +95,7 @@ rules: - grafanadatasources/status - grafanafolders/status - grafananotificationpolicies/status + - grafananotificationpolicyroutes/status - grafanas/status verbs: - get From cdcdde8239ae183e546d0121c2380cf045aaa685 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 17 Dec 2024 11:25:40 +0100 Subject: [PATCH 04/39] feat: add DiscoveredRoutes to GrafanaNotificationPolicy status --- api/v1beta1/grafananotificationpolicy_types.go | 2 ++ api/v1beta1/zz_generated.deepcopy.go | 9 +++++++++ ...fana.integreatly.org_grafananotificationpolicies.yaml | 4 ++++ ...fana.integreatly.org_grafananotificationpolicies.yaml | 4 ++++ deploy/kustomize/base/crds.yaml | 4 ++++ 5 files changed, 23 insertions(+) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index 2a11ada30..a6ab8e4b6 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -129,6 +129,8 @@ func (r *Route) ToModelRoute() *models.Route { // GrafanaNotificationPolicyStatus defines the observed state of GrafanaNotificationPolicy type GrafanaNotificationPolicyStatus struct { Conditions []metav1.Condition `json:"conditions"` + + DiscoveredRoutes *[]string `json:"discoveredRoutes,omitempty"` } //+kubebuilder:object:root=true diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index da265e782..98bbe44b1 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1454,6 +1454,15 @@ func (in *GrafanaNotificationPolicyStatus) DeepCopyInto(out *GrafanaNotification (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.DiscoveredRoutes != nil { + in, out := &in.DiscoveredRoutes, &out.DiscoveredRoutes + *out = new([]string) + if **in != nil { + in, out := *in, *out + *out = make([]string, len(*in)) + copy(*out, *in) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyStatus. diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml index f7cb57d81..6a210c04a 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -250,6 +250,10 @@ spec: - type type: object type: array + discoveredRoutes: + items: + type: string + type: array required: - conditions type: object diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml index f7cb57d81..6a210c04a 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -250,6 +250,10 @@ spec: - type type: object type: array + discoveredRoutes: + items: + type: string + type: array required: - conditions type: object diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 5a002b0c8..8f91abeb3 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1835,6 +1835,10 @@ spec: - type type: object type: array + discoveredRoutes: + items: + type: string + type: array required: - conditions type: object From b7ddf8d474189514ee4f4a3887db3f48a972e1da Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 17 Dec 2024 12:13:55 +0100 Subject: [PATCH 05/39] refactor: switch to RouteSelector instead of InstanceSelector - during the reconcile loop in notificationpolicy_controller.go, we have to fetch all matching GrafanaNotificationPolicyRoutes for the currently reconciled GrafanaNotificationPolicy - this can be very easily achieved with a routeSelector, which will be a Kubernetes LabelSelector - if we would go with instanceSelector, we would have to fetch all available GrafanaNotificationPolicyRoutes and then do some filtering afterwards, to see if the instanceSelector matches, which would be both more inefficient and more complex --- api/v1beta1/grafananotificationpolicy_types.go | 3 +++ api/v1beta1/grafananotificationpolicyroute_types.go | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index a6ab8e4b6..5beaf8e10 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -28,6 +28,9 @@ import ( type GrafanaNotificationPolicySpec struct { GrafanaCommonSpec `json:",inline"` + // Selects GrafanaNotificationPolicyRoutes to merge in when specified + RouteSelector *metav1.LabelSelector `json:"routeSelector,omitempty"` + // Routes for alerts to match against Route *Route `json:"route"` diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go index 897daae0e..1b67988e5 100644 --- a/api/v1beta1/grafananotificationpolicyroute_types.go +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -28,10 +28,6 @@ type GrafanaNotificationPolicyRouteSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - // Selects Grafana instances for import - // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.instanceSelector is immutable" - InstanceSelector *metav1.LabelSelector `json:"instanceSelector"` - // +kubebuilder:validation:Minimum=1 // +kubebuilder:validation:Maximum=100 // +optional From 10b3ca722d55bfcd1744ddda052c5d7f079b8f89 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 17 Dec 2024 12:19:30 +0100 Subject: [PATCH 06/39] chore: run make --- api/v1beta1/zz_generated.deepcopy.go | 10 +- ...eatly.org_grafananotificationpolicies.yaml | 47 +++++++++ ...y.org_grafananotificationpolicyroutes.yaml | 50 ---------- ...eatly.org_grafananotificationpolicies.yaml | 47 +++++++++ ...y.org_grafananotificationpolicyroutes.yaml | 50 ---------- deploy/kustomize/base/crds.yaml | 97 +++++++++---------- 6 files changed, 146 insertions(+), 155 deletions(-) diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 98bbe44b1..2d62fb204 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1376,11 +1376,6 @@ func (in *GrafanaNotificationPolicyRouteList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaNotificationPolicyRouteSpec) DeepCopyInto(out *GrafanaNotificationPolicyRouteSpec) { *out = *in - if in.InstanceSelector != nil { - in, out := &in.InstanceSelector, &out.InstanceSelector - *out = new(metav1.LabelSelector) - (*in).DeepCopyInto(*out) - } if in.Priority != nil { in, out := &in.Priority, &out.Priority *out = new(int8) @@ -1422,6 +1417,11 @@ func (in *GrafanaNotificationPolicyRouteStatus) DeepCopy() *GrafanaNotificationP func (in *GrafanaNotificationPolicySpec) DeepCopyInto(out *GrafanaNotificationPolicySpec) { *out = *in in.GrafanaCommonSpec.DeepCopyInto(&out.GrafanaCommonSpec) + if in.RouteSelector != nil { + in, out := &in.RouteSelector, &out.RouteSelector + *out = new(metav1.LabelSelector) + (*in).DeepCopyInto(*out) + } if in.Route != nil { in, out := &in.Route, &out.Route *out = new(Route) diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml index 6a210c04a..ce8b4d858 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -182,6 +182,53 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge in when + specified + 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 required: - instanceSelector - route diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index d92df7e08..c84cc0ef9 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -41,55 +41,6 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - 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 priority: maximum: 100 minimum: 1 @@ -167,7 +118,6 @@ spec: x-kubernetes-preserve-unknown-fields: true type: object required: - - instanceSelector - route type: object status: diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml index 6a210c04a..ce8b4d858 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -182,6 +182,53 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge in when + specified + 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 required: - instanceSelector - route diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index d92df7e08..c84cc0ef9 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -41,55 +41,6 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - 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 priority: maximum: 100 minimum: 1 @@ -167,7 +118,6 @@ spec: x-kubernetes-preserve-unknown-fields: true type: object required: - - instanceSelector - route type: object status: diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 8f91abeb3..91e59b303 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1767,6 +1767,53 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge in when + specified + 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 required: - instanceSelector - route @@ -1890,55 +1937,6 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - 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 priority: maximum: 100 minimum: 1 @@ -2016,7 +2014,6 @@ spec: x-kubernetes-preserve-unknown-fields: true type: object required: - - instanceSelector - route type: object status: From 775a1a2384c16b6fc133ed8f675b2828c907d79a Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 17 Dec 2024 15:58:15 +0100 Subject: [PATCH 07/39] feat: implement merging of NotificationPolicyRoutes --- .../grafananotificationpolicyroute_types.go | 57 +++++++++++++++++ controllers/notificationpolicy_controller.go | 62 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go index 1b67988e5..779a88c24 100644 --- a/api/v1beta1/grafananotificationpolicyroute_types.go +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -17,6 +17,9 @@ limitations under the License. package v1beta1 import ( + "fmt" + "sort" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -64,6 +67,60 @@ type GrafanaNotificationPolicyRouteList struct { Items []GrafanaNotificationPolicyRoute `json:"items"` } +// Implement sort.Interface for GrafanaNotificationPolicyRouteList + +func (l GrafanaNotificationPolicyRouteList) Len() int { + return len(l.Items) +} + +func (l GrafanaNotificationPolicyRouteList) Less(i, j int) bool { + iPriority := l.Items[i].Spec.Priority + jPriority := l.Items[j].Spec.Priority + + // If both priorities are nil, maintain original order + if iPriority == nil && jPriority == nil { + return i < j + } + + // Nil priorities are considered lower (come later) + if iPriority == nil { + return false + } + if jPriority == nil { + return true + } + + // Compare non-nil priorities + return *iPriority < *jPriority +} + +func (l GrafanaNotificationPolicyRouteList) Swap(i, j int) { + l.Items[i], l.Items[j] = l.Items[j], l.Items[i] +} + +// SortByPriority sorts the list by Priority +// Priority can be 1-100 or nil, with nil being the lowest priority 100 +func (l *GrafanaNotificationPolicyRouteList) SortByPriority() { + sort.Sort(l) +} + +// StatusDiscoveredRoutes returns the list of discovered routes using the namespace and name +// Used to display all discovered routes in the GrafanaNotificationPolicy status +func (l *GrafanaNotificationPolicyRouteList) StatusDiscoveredRoutes() []string { + sort.Sort(l) + + discoveredRoutes := make([]string, len(l.Items)) + for i, route := range l.Items { + priority := "nil" + if route.Spec.Priority != nil { + priority = fmt.Sprintf("%d", *route.Spec.Priority) + } + discoveredRoutes[i] = fmt.Sprintf("%s/%s (priority: %s)", route.Namespace, route.Name, priority) + } + + return discoveredRoutes +} + func init() { SchemeBuilder.Register(&GrafanaNotificationPolicyRoute{}, &GrafanaNotificationPolicyRouteList{}) } diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index d831f03eb..f91fc19f9 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -22,6 +22,7 @@ import ( 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/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" @@ -33,6 +34,7 @@ import ( "github.com/go-logr/logr" "github.com/grafana/grafana-openapi-client-go/client/provisioning" + "github.com/grafana/grafana-operator/v5/api/v1beta1" grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" client2 "github.com/grafana/grafana-operator/v5/controllers/client" ) @@ -52,6 +54,7 @@ type GrafanaNotificationPolicyReconciler struct { //+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicies/status,verbs=get;update;patch //+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicies/finalizers,verbs=update +// TODO listen for updates on GrafanaNotificationPolicyRoutes // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // TODO(user): Modify the Reconcile function to compare the state specified by @@ -123,6 +126,24 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req removeNoMatchingInstance(¬ificationPolicy.Status.Conditions) + var matchingNotificationPolicyRoutes *v1beta1.GrafanaNotificationPolicyRouteList + if notificationPolicy.Spec.RouteSelector != nil { + var namespace *string + if notificationPolicy.Spec.AllowCrossNamespaceImport != nil && !*notificationPolicy.Spec.AllowCrossNamespaceImport { + ns := notificationPolicy.GetObjectMeta().GetNamespace() + namespace = &ns + } + matchingNotificationPolicyRoutes, err = getMatchingNotificationPolicyRoutes(ctx, r.Client, notificationPolicy.Spec.RouteSelector, namespace) + if err != nil { + r.Log.Error(err, "failed to get matching GrafanaNotificationPolicyRoutes") + return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to get matching GrafanaNotificationPolicyRoutes: %w", err) + } + } + + if matchingNotificationPolicyRoutes != nil { + notificationPolicy = mergeNotificationPolicyRoutesWithRouteList(notificationPolicy, matchingNotificationPolicyRoutes) + } + applyErrors := make(map[string]string) appliedCount := 0 for _, grafana := range instances.Items { @@ -151,9 +172,30 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req return ctrl.Result{}, fmt.Errorf("failed to apply to all instances: %v", applyErrors) } + discoveredRoutes := matchingNotificationPolicyRoutes.StatusDiscoveredRoutes() + if len(discoveredRoutes) > 0 { + notificationPolicy.Status.DiscoveredRoutes = &discoveredRoutes + } + return ctrl.Result{RequeueAfter: notificationPolicy.Spec.ResyncPeriod.Duration}, nil } +// mergeNotificationPolicyRoutesWithRouteList merges a list of GrafanaNotificationPolicyRoutes into the +// spec.Route.Routes of a GrafanaNotificationPolicy following the specified priorities on the Routes +func mergeNotificationPolicyRoutesWithRouteList(notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, notificationPolicyRouteList *grafanav1beta1.GrafanaNotificationPolicyRouteList) *grafanav1beta1.GrafanaNotificationPolicy { + if notificationPolicyRouteList == nil { + return notificationPolicy + } + + notificationPolicyRouteList.SortByPriority() + + for _, route := range notificationPolicyRouteList.Items { + notificationPolicy.Spec.Route.Routes = append(notificationPolicy.Spec.Route.Routes, route.Spec.Route) + } + + return notificationPolicy +} + func (r *GrafanaNotificationPolicyReconciler) reconcileWithInstance(ctx context.Context, instance *grafanav1beta1.Grafana, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) error { cl, err := client2.NewGeneratedGrafanaClient(ctx, r.Client, instance) if err != nil { @@ -246,3 +288,23 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) WithEventFilter(ignoreStatusUpdates()). Complete(r) } + +// getMatchingNotificationPolicyRoutes retrieves all GrafanaNotificationPolicyRoutes for the given labelSelector +// results will be limited to namespace when specified +func getMatchingNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, labelSelector *metav1.LabelSelector, namespace *string) (*v1beta1.GrafanaNotificationPolicyRouteList, error) { + if labelSelector == nil { + return nil, nil + } + + var list v1beta1.GrafanaNotificationPolicyRouteList + opts := []client.ListOption{ + client.MatchingLabels(labelSelector.MatchLabels), + } + + if namespace != nil { + opts = append(opts, client.InNamespace(*namespace)) + } + + err := k8sClient.List(ctx, &list, opts...) + return &list, err +} From 051688f730ff4e0bc077a439979bd8fd21295274 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 17 Dec 2024 16:23:12 +0100 Subject: [PATCH 08/39] test: add test for mergeNotificationPolicyRoutesWithRouteList --- .../notificationpolicy_controller_test.go | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 controllers/notificationpolicy_controller_test.go diff --git a/controllers/notificationpolicy_controller_test.go b/controllers/notificationpolicy_controller_test.go new file mode 100644 index 000000000..b0ea5989d --- /dev/null +++ b/controllers/notificationpolicy_controller_test.go @@ -0,0 +1,263 @@ +/* +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 ( + "reflect" + "testing" + + grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" +) + +func Test_mergeNotificationPolicyRoutesWithRouteList(t *testing.T) { + type args struct { + notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy + notificationPolicyRouteList *grafanav1beta1.GrafanaNotificationPolicyRouteList + } + tests := []struct { + name string + args args + want *grafanav1beta1.GrafanaNotificationPolicy + }{ + { + name: "Merge with empty route list", + args: args{ + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{}, + }, + }, + }, + notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{}, + }, + want: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{}, + }, + }, + }, + }, + { + name: "Merge into nil route list", + args: args{ + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: nil, + }, + }, + }, + notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{}, + }, + want: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: nil, + }, + }, + }, + }, + { + name: "Merge with un-ordered non-empty route list", + args: args{ + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{}, + }, + }, + }, + notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{ + Items: []grafanav1beta1.GrafanaNotificationPolicyRoute{ + { + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "team-B-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, + }, + Priority: int8P(2), + }, + }, + { + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + }, + Priority: int8P(1), + }, + }, + }, + }, + }, + want: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{ + { + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + }, + { + Receiver: "team-B-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, + }, + }, + }, + }, + }, + }, + { + name: "Merge with existing routes in GrafanaNotificationPolicy, existing routes ordered first", + args: args{ + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{ + { + Receiver: "existing-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "critical", IsEqual: true}}, + }, + }, + }, + }, + }, + notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{ + Items: []grafanav1beta1.GrafanaNotificationPolicyRoute{ + { + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "new-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "C", IsEqual: true}}, + }, + Priority: int8P(1), + }, + }, + }, + }, + }, + want: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{ + { + Receiver: "existing-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "critical", IsEqual: true}}, + }, + { + Receiver: "new-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "C", IsEqual: true}}, + }, + }, + }, + }, + }, + }, + { + name: "Merge with multiple routes, nil priority has least priority", + args: args{ + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{}, + }, + }, + }, + notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{ + Items: []grafanav1beta1.GrafanaNotificationPolicyRoute{ + { + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "low-priority", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "info", IsEqual: true}}, + }, + Priority: nil, + }, + }, + { + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "high-priority", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "critical", IsEqual: true}}, + }, + Priority: int8P(1), + }, + }, + { + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "medium-priority", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "warning", IsEqual: true}}, + }, + Priority: int8P(2), + }, + }, + }, + }, + }, + want: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{ + { + Receiver: "high-priority", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "critical", IsEqual: true}}, + }, + { + Receiver: "medium-priority", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "warning", IsEqual: true}}, + }, + { + Receiver: "low-priority", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "info", IsEqual: true}}, + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeNotificationPolicyRoutesWithRouteList(tt.args.notificationPolicy, tt.args.notificationPolicyRouteList) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("mergeNotificationPolicyRoutesWithRouteList() = %v, want %v", got, tt.want) + } + }) + } +} + +func stringP(s string) *string { + return &s +} + +func int8P(i int) *int8 { + i8 := int8(i) + return &i8 +} From 7c8458eae0b86ab68ae7d14eedb5af3c41070ee4 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 17 Dec 2024 16:58:19 +0100 Subject: [PATCH 09/39] chore: add samples for local GrafanaNotificationPolicyRoute testing --- .../default/grafana-notification-policy.yaml | 86 +++++++++++++++++++ .../kind/resources/default/kustomization.yaml | 1 + 2 files changed, 87 insertions(+) create mode 100644 hack/kind/resources/default/grafana-notification-policy.yaml diff --git a/hack/kind/resources/default/grafana-notification-policy.yaml b/hack/kind/resources/default/grafana-notification-policy.yaml new file mode 100644 index 000000000..8220175fe --- /dev/null +++ b/hack/kind/resources/default/grafana-notification-policy.yaml @@ -0,0 +1,86 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicy +metadata: + name: grafananotificationpolicy-sample +spec: + instanceSelector: + matchLabels: + dashboards: "grafana" + routeSelector: + matchLabels: + dynamicroute: "grafana" + route: + receiver: grafana-default-email + group_by: + - grafana_folder + - alertname + routes: + - receiver: grafana-default-email + object_matchers: + - - team + - = + - a + - - inline + - = + - first + - receiver: grafana-default-email + object_matchers: + - - team + - = + - b + - - inline + - = + - second +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicyRoute +metadata: + name: dynamic-c + labels: + dynamicroute: "grafana" +spec: + priority: 1 + route: + receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - c + - - priority + - = + - "1" +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicyRoute +metadata: + name: dynamic-d + labels: + dynamicroute: "grafana" +spec: + priority: 2 + route: + receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - d + - - priority + - = + - "2" +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicyRoute +metadata: + name: dynamic-e + labels: + dynamicroute: "grafana" +spec: + route: + receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - e + - - priority + - = + - none diff --git a/hack/kind/resources/default/kustomization.yaml b/hack/kind/resources/default/kustomization.yaml index d536d4b04..73106c8f7 100644 --- a/hack/kind/resources/default/kustomization.yaml +++ b/hack/kind/resources/default/kustomization.yaml @@ -3,6 +3,7 @@ resources: - grafana-dashboard.yaml - grafana-datasource.yaml - grafana-contactpoint.yaml + - grafana-notification-policy.yaml - grafana-datasource-thanos.yaml - secret.yaml From 068bf860badef8006042e83c0dca725870ac24da Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Wed, 18 Dec 2024 10:38:39 +0100 Subject: [PATCH 10/39] feat: setup ownerReferences for GrafanaNotificationPolicyRoutes --- controllers/notificationpolicy_controller.go | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index f91fc19f9..fb8e89d80 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -26,6 +26,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -142,6 +143,12 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req if matchingNotificationPolicyRoutes != nil { notificationPolicy = mergeNotificationPolicyRoutesWithRouteList(notificationPolicy, matchingNotificationPolicyRoutes) + + err := r.ensureOwnerReferencesForNotificationPolicyRouteList(ctx, notificationPolicy, matchingNotificationPolicyRoutes) + if err != nil { + r.Log.Error(err, "failed to set owner reference on GrafanaNotificationPolicyRoutes") + return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to set owner reference on GrafanaNotificationPolicyRoutes: %w", err) + } } applyErrors := make(map[string]string) @@ -180,6 +187,24 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req return ctrl.Result{RequeueAfter: notificationPolicy.Spec.ResyncPeriod.Duration}, nil } +func (r *GrafanaNotificationPolicyReconciler) ensureOwnerReferencesForNotificationPolicyRouteList(ctx context.Context, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, notificationPolicyRoutes *grafanav1beta1.GrafanaNotificationPolicyRouteList) error { + if notificationPolicy == nil || notificationPolicyRoutes == nil { + return nil + } + + for i := range notificationPolicyRoutes.Items { + route := ¬ificationPolicyRoutes.Items[i] + err := controllerutil.SetOwnerReference(notificationPolicy, route, r.Scheme) + if err != nil { + return err + } + if err := r.Update(ctx, route); err != nil { + return err + } + } + return nil +} + // mergeNotificationPolicyRoutesWithRouteList merges a list of GrafanaNotificationPolicyRoutes into the // spec.Route.Routes of a GrafanaNotificationPolicy following the specified priorities on the Routes func mergeNotificationPolicyRoutesWithRouteList(notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, notificationPolicyRouteList *grafanav1beta1.GrafanaNotificationPolicyRouteList) *grafanav1beta1.GrafanaNotificationPolicy { @@ -285,6 +310,7 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) } return requests })). + Owns(&grafanav1beta1.GrafanaNotificationPolicyRoute{}, builder.MatchEveryOwner). WithEventFilter(ignoreStatusUpdates()). Complete(r) } From 5a70f840f415d3b5ad7743d04f44fd0c01568200 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Wed, 18 Dec 2024 11:46:45 +0100 Subject: [PATCH 11/39] feat: replace owner references with watches to support cross-namespace The GrafanaNotificationPolicy Controller now watches GrafanaNoticationPolicyRoutes instead of using ownerReferences, as ownerReferences do not support cross-namespace references. We now also emit a event on the GrafanaNotificationPolicyRoute to indicate that it has been merged into a specific policy. --- .../grafananotificationpolicy_types.go | 5 ++ controllers/notificationpolicy_controller.go | 86 +++++++++++++------ .../default/grafana-notification-policy.yaml | 5 ++ main.go | 5 +- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index 5beaf8e10..dddda7663 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -153,6 +153,11 @@ func (np *GrafanaNotificationPolicy) NamespacedResource() string { return fmt.Sprintf("%v/%v/%v", np.ObjectMeta.Namespace, np.ObjectMeta.Name, np.ObjectMeta.UID) } +// IsCrossNamespaceImportAllowed returns true when cross namespace imports are allowed +func (np *GrafanaNotificationPolicy) IsCrossNamespaceImportAllowed() bool { + return np.Spec.AllowCrossNamespaceImport != nil && *np.Spec.AllowCrossNamespaceImport +} + //+kubebuilder:object:root=true // GrafanaNotificationPolicyList contains a list of GrafanaNotificationPolicy diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index fb8e89d80..c23b17631 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -20,13 +20,15 @@ import ( "context" "fmt" + corev1 "k8s.io/api/core/v1" kuberr "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -47,8 +49,9 @@ const ( // GrafanaNotificationPolicyReconciler reconciles a GrafanaNotificationPolicy object type GrafanaNotificationPolicyReconciler struct { client.Client - Log logr.Logger - Scheme *runtime.Scheme + Log logr.Logger + Scheme *runtime.Scheme + Recorder record.EventRecorder } //+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicies,verbs=get;list;watch;create;update;patch;delete @@ -130,7 +133,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req var matchingNotificationPolicyRoutes *v1beta1.GrafanaNotificationPolicyRouteList if notificationPolicy.Spec.RouteSelector != nil { var namespace *string - if notificationPolicy.Spec.AllowCrossNamespaceImport != nil && !*notificationPolicy.Spec.AllowCrossNamespaceImport { + if !notificationPolicy.IsCrossNamespaceImportAllowed() { ns := notificationPolicy.GetObjectMeta().GetNamespace() namespace = &ns } @@ -143,12 +146,6 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req if matchingNotificationPolicyRoutes != nil { notificationPolicy = mergeNotificationPolicyRoutesWithRouteList(notificationPolicy, matchingNotificationPolicyRoutes) - - err := r.ensureOwnerReferencesForNotificationPolicyRouteList(ctx, notificationPolicy, matchingNotificationPolicyRoutes) - if err != nil { - r.Log.Error(err, "failed to set owner reference on GrafanaNotificationPolicyRoutes") - return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to set owner reference on GrafanaNotificationPolicyRoutes: %w", err) - } } applyErrors := make(map[string]string) @@ -184,25 +181,11 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req notificationPolicy.Status.DiscoveredRoutes = &discoveredRoutes } - return ctrl.Result{RequeueAfter: notificationPolicy.Spec.ResyncPeriod.Duration}, nil -} - -func (r *GrafanaNotificationPolicyReconciler) ensureOwnerReferencesForNotificationPolicyRouteList(ctx context.Context, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, notificationPolicyRoutes *grafanav1beta1.GrafanaNotificationPolicyRouteList) error { - if notificationPolicy == nil || notificationPolicyRoutes == nil { - return nil + if err := r.recordMergedEventForNotificationPolicyRoutes(ctx, notificationPolicy, matchingNotificationPolicyRoutes); err != nil { + r.Log.Error(err, "failed to add merged events to routes") } - for i := range notificationPolicyRoutes.Items { - route := ¬ificationPolicyRoutes.Items[i] - err := controllerutil.SetOwnerReference(notificationPolicy, route, r.Scheme) - if err != nil { - return err - } - if err := r.Update(ctx, route); err != nil { - return err - } - } - return nil + return ctrl.Result{RequeueAfter: notificationPolicy.Spec.ResyncPeriod.Duration}, nil } // mergeNotificationPolicyRoutesWithRouteList merges a list of GrafanaNotificationPolicyRoutes into the @@ -310,7 +293,41 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) } return requests })). - Owns(&grafanav1beta1.GrafanaNotificationPolicyRoute{}, builder.MatchEveryOwner). + Watches(&grafanav1beta1.GrafanaNotificationPolicyRoute{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + // resync all notification policies that have a routeSelector that matches the routes labels + nps := &grafanav1beta1.GrafanaNotificationPolicyList{} + if err := r.List(ctx, nps); err != nil { + r.Log.Error(err, "failed to fetch notification policies for watch mapping") + return nil + } + requests := []reconcile.Request{} + for _, np := range nps.Items { + if np.Spec.RouteSelector == nil { + continue + } + + if np.GetNamespace() != o.GetNamespace() && !np.IsCrossNamespaceImportAllowed() { + continue + } + + selector, err := metav1.LabelSelectorAsSelector(np.Spec.RouteSelector) + if err != nil { + r.Log.Error(err, "failed to create selector from RouteSelector") + continue + } + + if selector.Matches(labels.Set(o.GetLabels())) { + requests = append(requests, + reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: np.Name, + Namespace: np.Namespace, + }, + }) + } + } + return requests + })). WithEventFilter(ignoreStatusUpdates()). Complete(r) } @@ -334,3 +351,16 @@ func getMatchingNotificationPolicyRoutes(ctx context.Context, k8sClient client.C err := k8sClient.List(ctx, &list, opts...) return &list, err } + +// recordMergedEventForNotificationPolicyRoutes emits a merged event to all matched notification policy routes +func (r *GrafanaNotificationPolicyReconciler) recordMergedEventForNotificationPolicyRoutes(ctx context.Context, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, routes *v1beta1.GrafanaNotificationPolicyRouteList) error { + if notificationPolicy == nil || routes == nil { + return nil + } + + for i := range routes.Items { + route := &routes.Items[i] + r.Recorder.Event(route, corev1.EventTypeNormal, "Merged", fmt.Sprintf("Route merged into NotificationPolicy %s/%s", notificationPolicy.GetNamespace(), notificationPolicy.GetName())) + } + return nil +} diff --git a/hack/kind/resources/default/grafana-notification-policy.yaml b/hack/kind/resources/default/grafana-notification-policy.yaml index 8220175fe..8f20417d4 100644 --- a/hack/kind/resources/default/grafana-notification-policy.yaml +++ b/hack/kind/resources/default/grafana-notification-policy.yaml @@ -3,6 +3,7 @@ kind: GrafanaNotificationPolicy metadata: name: grafananotificationpolicy-sample spec: + allowCrossNamespaceImport: true instanceSelector: matchLabels: dashboards: "grafana" @@ -36,6 +37,7 @@ apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaNotificationPolicyRoute metadata: name: dynamic-c + namespace: grafana-crds labels: dynamicroute: "grafana" spec: @@ -43,6 +45,9 @@ spec: route: receiver: grafana-default-email object_matchers: + - - crossNamespace + - = + - "true" - - dynamic - = - c diff --git a/main.go b/main.go index e078d4968..0649114db 100644 --- a/main.go +++ b/main.go @@ -247,8 +247,9 @@ func main() { os.Exit(1) } if err = (&controllers.GrafanaNotificationPolicyReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("GrafanaNotificationPolicy"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "GrafanaNotificationPolicy") os.Exit(1) From 3ed60f5720811ad7832f9c195af588ffee2576e2 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 20 Dec 2024 13:16:47 +0100 Subject: [PATCH 12/39] docs: add docs and example for new NotificationPolicyRoute --- docs/docs/alerting/notification-policies.md | 65 ++++++++++++++++++ examples/notification-policy/routes.yaml | 74 +++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 examples/notification-policy/routes.yaml diff --git a/docs/docs/alerting/notification-policies.md b/docs/docs/alerting/notification-policies.md index 2436f63e9..180f9ff16 100644 --- a/docs/docs/alerting/notification-policies.md +++ b/docs/docs/alerting/notification-policies.md @@ -9,7 +9,72 @@ For a complete explanation on notification policies, see the [upstream Grafana d If you already know which contact point an alert should send to, you can directly set the [`receivers`]({{% relref "/docs/api/#grafanaalertrulegroupspecrulesindexnotificationsettings" %}}) property on the alert rule. {{% /alert %}} +## Simple Notification Policy The following snippet shows an example notification policy routing to the `operations` or `security` team based on the `team` label. {{< readfile file="../examples/notification-policy/resources.yaml" code="true" lang="yaml" >}} + +## Dynamic Notification Policy Routes + +There might be scenarios where you can not define the entire notification policy in a single place and you have to assemble it from multiple resouces. +In this case, you can use the `spec.routeSelector` in combination with multiple `GrafanaNotificationPolicyRoute` resources. + +All `GrafanaNotificationPolicyRoute` resources will then be discovered based on the label selector defined in `spec.routeSelector`. +In case `spec.allowCrossNamespaceImport` is enabled, matching routes will be fetched from all namespaces. +Otherwise only routes from the same namespace as the `GrafanaNotificationPolicy` will be discovered. + +All discovered routes will then get appended to the `spec.route.routes[]` of the `GrafanaNotificationPolicy` based on the priority defined in the `GrafanaNotificationPolicyRoute`. +Priorities can be in the range 1-100 with `1` being merged first and `100` last. If no priority is specified, it is treated as a priority of `100`. + +The following shows an example of how dynamic routes will get merged. + +{{< readfile file="../examples/notification-policy/routes.yaml" code="true" lang="yaml" >}} + +The resulting Notification Policy will be the following: + +```yaml +apiVersion: 1 +policies: + - orgId: 1 + receiver: grafana-default-email + group_by: + - grafana_folder + - alertname + routes: + - receiver: grafana-default-email + object_matchers: + - - inline + - = + - first + - - team + - = + - a + - receiver: grafana-default-email + object_matchers: + - - inline + - = + - second + - - team + - = + - b + - receiver: grafana-default-email + object_matchers: + - - crossNamespace + - = + - "true" + - - dynamic + - = + - c + - - priority + - = + - "1" + - receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - d + - - priority + - = + - "2" +``` diff --git a/examples/notification-policy/routes.yaml b/examples/notification-policy/routes.yaml new file mode 100644 index 000000000..b063ad3c8 --- /dev/null +++ b/examples/notification-policy/routes.yaml @@ -0,0 +1,74 @@ +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicy +metadata: + name: grafananotificationpolicy-sample +spec: + allowCrossNamespaceImport: true + instanceSelector: + matchLabels: + dashboards: "grafana" + routeSelector: + matchLabels: + dynamicroute: "grafana" + route: + receiver: grafana-default-email + group_by: + - grafana_folder + - alertname + routes: + - receiver: grafana-default-email + object_matchers: + - - team + - = + - a + - - inline + - = + - first + - receiver: grafana-default-email + object_matchers: + - - team + - = + - b + - - inline + - = + - second +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicyRoute +metadata: + name: dynamic-c + namespace: grafana-crds + labels: + dynamicroute: "grafana" +spec: + priority: 1 + route: + receiver: grafana-default-email + object_matchers: + - - crossNamespace + - = + - "true" + - - dynamic + - = + - c + - - priority + - = + - "1" +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicyRoute +metadata: + name: dynamic-d + labels: + dynamicroute: "grafana" +spec: + priority: 2 + route: + receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - d + - - priority + - = + - "2" From f7854d8cf6030e376b6d91a52e08cecf1b875ce0 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Mon, 20 Jan 2025 11:52:09 +0100 Subject: [PATCH 13/39] refactor: move routeSelector to route object, make it mutually exclusive --- .../grafananotificationpolicy_types.go | 7 +- api/v1beta1/zz_generated.deepcopy.go | 10 +- ...eatly.org_grafananotificationpolicies.yaml | 97 ++++++------ ...y.org_grafananotificationpolicyroutes.yaml | 50 ++++++ controllers/notificationpolicy_controller.go | 8 +- ...eatly.org_grafananotificationpolicies.yaml | 97 ++++++------ ...y.org_grafananotificationpolicyroutes.yaml | 50 ++++++ deploy/kustomize/base/crds.yaml | 147 ++++++++++++------ 8 files changed, 313 insertions(+), 153 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index dddda7663..4a75d60e1 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -28,9 +28,6 @@ import ( type GrafanaNotificationPolicySpec struct { GrafanaCommonSpec `json:",inline"` - // Selects GrafanaNotificationPolicyRoutes to merge in when specified - RouteSelector *metav1.LabelSelector `json:"routeSelector,omitempty"` - // Routes for alerts to match against Route *Route `json:"route"` @@ -40,6 +37,7 @@ type GrafanaNotificationPolicySpec struct { Editable *bool `json:"editable,omitempty"` } +// +kubebuilder:validation:XValidation:rule="has(self.routeSelector) != has(self.routes)", message="routeSelector and routes are mutually exclusive" type Route struct { // continue Continue bool `json:"continue,omitempty"` @@ -74,6 +72,9 @@ type Route struct { // repeat interval RepeatInterval string `json:"repeat_interval,omitempty"` + // Selects GrafanaNotificationPolicyRoutes to merge in when specified + RouteSelector *metav1.LabelSelector `json:"routeSelector,omitempty"` + // routes // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Schemaless diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 2d62fb204..03335bbab 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1417,11 +1417,6 @@ func (in *GrafanaNotificationPolicyRouteStatus) DeepCopy() *GrafanaNotificationP func (in *GrafanaNotificationPolicySpec) DeepCopyInto(out *GrafanaNotificationPolicySpec) { *out = *in in.GrafanaCommonSpec.DeepCopyInto(&out.GrafanaCommonSpec) - if in.RouteSelector != nil { - in, out := &in.RouteSelector, &out.RouteSelector - *out = new(metav1.LabelSelector) - (*in).DeepCopyInto(*out) - } if in.Route != nil { in, out := &in.Route, &out.Route *out = new(Route) @@ -1979,6 +1974,11 @@ func (in *Route) DeepCopyInto(out *Route) { } } } + if in.RouteSelector != nil { + in, out := &in.RouteSelector, &out.RouteSelector + *out = new(metav1.LabelSelector) + (*in).DeepCopyInto(*out) + } if in.Routes != nil { in, out := &in.Routes, &out.Routes *out = make([]*Route, len(*in)) diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml index ce8b4d858..289b092c0 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -178,57 +178,60 @@ spec: repeat_interval: description: repeat interval type: string - routes: - description: routes - x-kubernetes-preserve-unknown-fields: true - type: object - routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge in when - specified - 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: + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge + in when specified + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. + 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 - 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. + 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 + routes: + description: routes + x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: routeSelector and routes are mutually exclusive + rule: has(self.routeSelector) != has(self.routes) required: - instanceSelector - route diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index c84cc0ef9..85ca07d4d 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -113,10 +113,60 @@ spec: repeat_interval: description: repeat interval type: string + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge + in when specified + 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 routes: description: routes x-kubernetes-preserve-unknown-fields: true type: object + x-kubernetes-validations: + - message: routeSelector and routes are mutually exclusive + rule: has(self.routeSelector) != has(self.routes) required: - route type: object diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index c23b17631..348e913eb 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -131,13 +131,13 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req removeNoMatchingInstance(¬ificationPolicy.Status.Conditions) var matchingNotificationPolicyRoutes *v1beta1.GrafanaNotificationPolicyRouteList - if notificationPolicy.Spec.RouteSelector != nil { + if notificationPolicy.Spec.Route.RouteSelector != nil { var namespace *string if !notificationPolicy.IsCrossNamespaceImportAllowed() { ns := notificationPolicy.GetObjectMeta().GetNamespace() namespace = &ns } - matchingNotificationPolicyRoutes, err = getMatchingNotificationPolicyRoutes(ctx, r.Client, notificationPolicy.Spec.RouteSelector, namespace) + matchingNotificationPolicyRoutes, err = getMatchingNotificationPolicyRoutes(ctx, r.Client, notificationPolicy.Spec.Route.RouteSelector, namespace) if err != nil { r.Log.Error(err, "failed to get matching GrafanaNotificationPolicyRoutes") return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to get matching GrafanaNotificationPolicyRoutes: %w", err) @@ -302,7 +302,7 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) } requests := []reconcile.Request{} for _, np := range nps.Items { - if np.Spec.RouteSelector == nil { + if np.Spec.Route.RouteSelector == nil { continue } @@ -310,7 +310,7 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) continue } - selector, err := metav1.LabelSelectorAsSelector(np.Spec.RouteSelector) + selector, err := metav1.LabelSelectorAsSelector(np.Spec.Route.RouteSelector) if err != nil { r.Log.Error(err, "failed to create selector from RouteSelector") continue diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml index ce8b4d858..289b092c0 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -178,57 +178,60 @@ spec: repeat_interval: description: repeat interval type: string - routes: - description: routes - x-kubernetes-preserve-unknown-fields: true - type: object - routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge in when - specified - 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: + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge + in when specified + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. + 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 - 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. + 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 + routes: + description: routes + x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: routeSelector and routes are mutually exclusive + rule: has(self.routeSelector) != has(self.routes) required: - instanceSelector - route diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index c84cc0ef9..85ca07d4d 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -113,10 +113,60 @@ spec: repeat_interval: description: repeat interval type: string + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge + in when specified + 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 routes: description: routes x-kubernetes-preserve-unknown-fields: true type: object + x-kubernetes-validations: + - message: routeSelector and routes are mutually exclusive + rule: has(self.routeSelector) != has(self.routes) required: - route type: object diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 91e59b303..c9e5fc68c 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1763,57 +1763,60 @@ spec: repeat_interval: description: repeat interval type: string - routes: - description: routes - x-kubernetes-preserve-unknown-fields: true - type: object - routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge in when - specified - 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: + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge + in when specified + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. + 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 - 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. + 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 + routes: + description: routes + x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: routeSelector and routes are mutually exclusive + rule: has(self.routeSelector) != has(self.routes) required: - instanceSelector - route @@ -2009,10 +2012,60 @@ spec: repeat_interval: description: repeat interval type: string + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge + in when specified + 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 routes: description: routes x-kubernetes-preserve-unknown-fields: true type: object + x-kubernetes-validations: + - message: routeSelector and routes are mutually exclusive + rule: has(self.routeSelector) != has(self.routes) required: - route type: object From 72bda03cc00ec684790db0a4af8e008206122d3f Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Mon, 20 Jan 2025 16:59:21 +0100 Subject: [PATCH 14/39] refactor: implement new assembleNotificationPolicyRoutes logic --- controllers/notificationpolicy_controller.go | 83 +++-- .../notificationpolicy_controller_test.go | 301 ++++++++---------- go.mod | 1 + 3 files changed, 200 insertions(+), 185 deletions(-) diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 348e913eb..6d2cadca1 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -130,24 +130,20 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req removeNoMatchingInstance(¬ificationPolicy.Status.Conditions) - var matchingNotificationPolicyRoutes *v1beta1.GrafanaNotificationPolicyRouteList + var mergedRoutes *v1beta1.GrafanaNotificationPolicyRouteList if notificationPolicy.Spec.Route.RouteSelector != nil { var namespace *string if !notificationPolicy.IsCrossNamespaceImportAllowed() { ns := notificationPolicy.GetObjectMeta().GetNamespace() namespace = &ns } - matchingNotificationPolicyRoutes, err = getMatchingNotificationPolicyRoutes(ctx, r.Client, notificationPolicy.Spec.Route.RouteSelector, namespace) + notificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, notificationPolicy) if err != nil { - r.Log.Error(err, "failed to get matching GrafanaNotificationPolicyRoutes") - return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to get matching GrafanaNotificationPolicyRoutes: %w", err) + r.Log.Error(err, "failed to assemble GrafanaNotificationPolicy using routeSelectors") + return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) } } - if matchingNotificationPolicyRoutes != nil { - notificationPolicy = mergeNotificationPolicyRoutesWithRouteList(notificationPolicy, matchingNotificationPolicyRoutes) - } - applyErrors := make(map[string]string) appliedCount := 0 for _, grafana := range instances.Items { @@ -176,32 +172,77 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req return ctrl.Result{}, fmt.Errorf("failed to apply to all instances: %v", applyErrors) } - discoveredRoutes := matchingNotificationPolicyRoutes.StatusDiscoveredRoutes() - if len(discoveredRoutes) > 0 { - notificationPolicy.Status.DiscoveredRoutes = &discoveredRoutes + if mergedRoutes != nil && len(mergedRoutes.Items) > 0 { + status := mergedRoutes.StatusDiscoveredRoutes() + notificationPolicy.Status.DiscoveredRoutes = &status } - if err := r.recordMergedEventForNotificationPolicyRoutes(ctx, notificationPolicy, matchingNotificationPolicyRoutes); err != nil { + if err := r.recordMergedEventForNotificationPolicyRoutes(ctx, notificationPolicy, mergedRoutes); err != nil { r.Log.Error(err, "failed to add merged events to routes") } return ctrl.Result{RequeueAfter: notificationPolicy.Spec.ResyncPeriod.Duration}, nil } -// mergeNotificationPolicyRoutesWithRouteList merges a list of GrafanaNotificationPolicyRoutes into the -// spec.Route.Routes of a GrafanaNotificationPolicy following the specified priorities on the Routes -func mergeNotificationPolicyRoutesWithRouteList(notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, notificationPolicyRouteList *grafanav1beta1.GrafanaNotificationPolicyRouteList) *grafanav1beta1.GrafanaNotificationPolicy { - if notificationPolicyRouteList == nil { - return notificationPolicy +// assembleNotificationPolicyRoutes iterates over all routeSelectors transitively. +// returns an assembled GrafanaNotificationPolicy as well as a list of all merged routes. +// it ensures that there are no reference loops when discovering routes via labelSelectors +func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, namespace *string, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) (*grafanav1beta1.GrafanaNotificationPolicy, *v1beta1.GrafanaNotificationPolicyRouteList, error) { + if notificationPolicy.Spec.Route.RouteSelector == nil { + return notificationPolicy, nil, nil } - notificationPolicyRouteList.SortByPriority() + assembledPolicy := notificationPolicy.DeepCopy() + assembledPolicy.Spec.Route.Routes = []*grafanav1beta1.Route{} + + mergedRoutes := v1beta1.GrafanaNotificationPolicyRouteList{} + // visitedGlobal keeps track of all routes that have been appened to mergedRoutes + // so we can record a status update for them later + visitedGlobal := make(map[string]bool) + + // visitedChilds keeps track of all downstream routes to detect loops + visitedChilds := make(map[string]bool) + + var dfs func(*metav1.LabelSelector) error + dfs = func(selector *metav1.LabelSelector) error { + routes, err := getMatchingNotificationPolicyRoutes(ctx, k8sClient, selector, namespace) + if err != nil { + return fmt.Errorf("failed to get matching routes: %w", err) + } + + for i := range routes.Items { + route := &routes.Items[i] + key := fmt.Sprintf("%s/%s", route.Namespace, route.Name) + + if visitedChilds[key] { + return fmt.Errorf("loop detected in notification policy routes: %s", key) + } + + if !visitedGlobal[key] { + mergedRoutes.Items = append(mergedRoutes.Items, *route) + } + visitedGlobal[key] = true + + visitedChilds[key] = true + assembledPolicy.Spec.Route.Routes = append(assembledPolicy.Spec.Route.Routes, route.Spec.Route) + + if route.Spec.Route.RouteSelector != nil { + if err := dfs(route.Spec.Route.RouteSelector); err != nil { + return err + } + } + + delete(visitedChilds, key) + } + + return nil + } - for _, route := range notificationPolicyRouteList.Items { - notificationPolicy.Spec.Route.Routes = append(notificationPolicy.Spec.Route.Routes, route.Spec.Route) + if err := dfs(notificationPolicy.Spec.Route.RouteSelector); err != nil { + return nil, nil, err } - return notificationPolicy + return assembledPolicy, &mergedRoutes, nil } func (r *GrafanaNotificationPolicyReconciler) reconcileWithInstance(ctx context.Context, instance *grafanav1beta1.Grafana, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) error { diff --git a/controllers/notificationpolicy_controller_test.go b/controllers/notificationpolicy_controller_test.go index b0ea5989d..1f88b3952 100644 --- a/controllers/notificationpolicy_controller_test.go +++ b/controllers/notificationpolicy_controller_test.go @@ -17,96 +17,64 @@ limitations under the License. package controllers import ( + "context" "reflect" "testing" grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) -func Test_mergeNotificationPolicyRoutesWithRouteList(t *testing.T) { - type args struct { - notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy - notificationPolicyRouteList *grafanav1beta1.GrafanaNotificationPolicyRouteList +func routesToRuntimeObjects(routes []grafanav1beta1.GrafanaNotificationPolicyRoute) []runtime.Object { + objects := make([]runtime.Object, len(routes)) + for i := range routes { + objects[i] = &routes[i] } + return objects +} + +func stringP(s string) *string { + return &s +} + +func int8P(i int) *int8 { + i8 := int8(i) + return &i8 +} + +func TestAssembleNotificationPolicyRoutes(t *testing.T) { tests := []struct { - name string - args args - want *grafanav1beta1.GrafanaNotificationPolicy + name string + notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy + existingRoutes []grafanav1beta1.GrafanaNotificationPolicyRoute + want *grafanav1beta1.GrafanaNotificationPolicy + wantErr bool }{ { - name: "Merge with empty route list", - args: args{ - notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ - Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ - Route: &grafanav1beta1.Route{ - Receiver: "default-receiver", - Routes: []*grafanav1beta1.Route{}, - }, - }, - }, - notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{}, - }, - want: &grafanav1beta1.GrafanaNotificationPolicy{ + name: "Simple assembly with one level of routes", + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ Route: &grafanav1beta1.Route{ Receiver: "default-receiver", - Routes: []*grafanav1beta1.Route{}, - }, - }, - }, - }, - { - name: "Merge into nil route list", - args: args{ - notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ - Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ - Route: &grafanav1beta1.Route{ - Receiver: "default-receiver", - Routes: nil, + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, }, }, }, - notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{}, }, - want: &grafanav1beta1.GrafanaNotificationPolicy{ - Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ - Route: &grafanav1beta1.Route{ - Receiver: "default-receiver", - Routes: nil, + existingRoutes: []grafanav1beta1.GrafanaNotificationPolicyRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "default", + Labels: map[string]string{"tier": "first"}, }, - }, - }, - }, - { - name: "Merge with un-ordered non-empty route list", - args: args{ - notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ - Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ Route: &grafanav1beta1.Route{ - Receiver: "default-receiver", - Routes: []*grafanav1beta1.Route{}, - }, - }, - }, - notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{ - Items: []grafanav1beta1.GrafanaNotificationPolicyRoute{ - { - Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ - Receiver: "team-B-receiver", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, - }, - Priority: int8P(2), - }, - }, - { - Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ - Receiver: "team-A-receiver", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, - }, - Priority: int8P(1), - }, + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, }, }, }, @@ -115,46 +83,59 @@ func Test_mergeNotificationPolicyRoutesWithRouteList(t *testing.T) { Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ Route: &grafanav1beta1.Route{ Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, Routes: []*grafanav1beta1.Route{ { Receiver: "team-A-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, }, - { - Receiver: "team-B-receiver", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, - }, }, }, }, }, + wantErr: false, }, { - name: "Merge with existing routes in GrafanaNotificationPolicy, existing routes ordered first", - args: args{ - notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ - Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + name: "Assembly with nested routes", + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, + }, + }, + }, + existingRoutes: []grafanav1beta1.GrafanaNotificationPolicyRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "default", + Labels: map[string]string{"tier": "first"}, + }, + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ Route: &grafanav1beta1.Route{ - Receiver: "default-receiver", - Routes: []*grafanav1beta1.Route{ - { - Receiver: "existing-receiver", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "critical", IsEqual: true}}, - }, + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second"}, }, }, }, }, - notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{ - Items: []grafanav1beta1.GrafanaNotificationPolicyRoute{ - { - Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ - Receiver: "new-receiver", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "C", IsEqual: true}}, - }, - Priority: int8P(1), - }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + Labels: map[string]string{"tier": "second"}, + }, + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "team-B-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, }, }, }, @@ -163,101 +144,93 @@ func Test_mergeNotificationPolicyRoutesWithRouteList(t *testing.T) { Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ Route: &grafanav1beta1.Route{ Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, Routes: []*grafanav1beta1.Route{ { - Receiver: "existing-receiver", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "critical", IsEqual: true}}, - }, - { - Receiver: "new-receiver", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "C", IsEqual: true}}, + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second"}, + }, + Routes: []*grafanav1beta1.Route{ + { + Receiver: "team-B-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, + }, + }, }, }, }, }, }, + wantErr: false, }, { - name: "Merge with multiple routes, nil priority has least priority", - args: args{ - notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ - Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ - Route: &grafanav1beta1.Route{ - Receiver: "default-receiver", - Routes: []*grafanav1beta1.Route{}, + name: "Detect loop in routes", + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, }, }, }, - notificationPolicyRouteList: &grafanav1beta1.GrafanaNotificationPolicyRouteList{ - Items: []grafanav1beta1.GrafanaNotificationPolicyRoute{ - { - Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ - Receiver: "low-priority", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "info", IsEqual: true}}, - }, - Priority: nil, - }, - }, - { - Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ - Receiver: "high-priority", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "critical", IsEqual: true}}, - }, - Priority: int8P(1), - }, - }, - { - Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ - Receiver: "medium-priority", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "warning", IsEqual: true}}, - }, - Priority: int8P(2), + }, + existingRoutes: []grafanav1beta1.GrafanaNotificationPolicyRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "default", + Labels: map[string]string{"tier": "first"}, + }, + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second"}, }, }, }, }, - }, - want: &grafanav1beta1.GrafanaNotificationPolicy{ - Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ - Route: &grafanav1beta1.Route{ - Receiver: "default-receiver", - Routes: []*grafanav1beta1.Route{ - { - Receiver: "high-priority", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "critical", IsEqual: true}}, - }, - { - Receiver: "medium-priority", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "warning", IsEqual: true}}, - }, - { - Receiver: "low-priority", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("severity"), Value: "info", IsEqual: true}}, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + Labels: map[string]string{"tier": "second"}, + }, + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "team-B-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, }, }, }, }, }, + want: nil, + wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := mergeNotificationPolicyRoutesWithRouteList(tt.args.notificationPolicy, tt.args.notificationPolicyRouteList) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("mergeNotificationPolicyRoutesWithRouteList() = %v, want %v", got, tt.want) + ctx := context.Background() + client := fake.NewClientBuilder().WithRuntimeObjects(routesToRuntimeObjects(tt.existingRoutes)...).Build() + + gotPolicy, _, err := assembleNotificationPolicyRoutes(ctx, client, nil, tt.notificationPolicy) + if (err != nil) != tt.wantErr { + t.Errorf("assembleNotificationPolicyRoutes() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(gotPolicy, tt.want) { + t.Errorf("assembleNotificationPolicyRoutes() = %v, want %v", gotPolicy, tt.want) } }) } } - -func stringP(s string) *string { - return &s -} - -func int8P(i int) *int8 { - i8 := int8(i) - return &i8 -} diff --git a/go.mod b/go.mod index 97aa59a72..0fcda4111 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/tools v0.26.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect ) require ( From d0a7de642508c9c8972e5930505b4e159a46c487 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 21 Jan 2025 10:56:19 +0100 Subject: [PATCH 15/39] refactor: implement new dynamic route assembly and add tests --- .../grafananotificationpolicy_types.go | 1 - .../grafananotificationpolicyroute_types.go | 57 ------ ...eatly.org_grafananotificationpolicies.yaml | 3 - ...y.org_grafananotificationpolicyroutes.yaml | 3 - controllers/notificationpolicy_controller.go | 178 ++++++++++++------ .../notificationpolicy_controller_test.go | 110 +++++++++-- ...eatly.org_grafananotificationpolicies.yaml | 3 - ...y.org_grafananotificationpolicyroutes.yaml | 3 - deploy/kustomize/base/crds.yaml | 6 - .../default/grafana-notification-policy.yaml | 26 +-- 10 files changed, 222 insertions(+), 168 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index 4a75d60e1..afa2c0f38 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -37,7 +37,6 @@ type GrafanaNotificationPolicySpec struct { Editable *bool `json:"editable,omitempty"` } -// +kubebuilder:validation:XValidation:rule="has(self.routeSelector) != has(self.routes)", message="routeSelector and routes are mutually exclusive" type Route struct { // continue Continue bool `json:"continue,omitempty"` diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go index 779a88c24..1b67988e5 100644 --- a/api/v1beta1/grafananotificationpolicyroute_types.go +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -17,9 +17,6 @@ limitations under the License. package v1beta1 import ( - "fmt" - "sort" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -67,60 +64,6 @@ type GrafanaNotificationPolicyRouteList struct { Items []GrafanaNotificationPolicyRoute `json:"items"` } -// Implement sort.Interface for GrafanaNotificationPolicyRouteList - -func (l GrafanaNotificationPolicyRouteList) Len() int { - return len(l.Items) -} - -func (l GrafanaNotificationPolicyRouteList) Less(i, j int) bool { - iPriority := l.Items[i].Spec.Priority - jPriority := l.Items[j].Spec.Priority - - // If both priorities are nil, maintain original order - if iPriority == nil && jPriority == nil { - return i < j - } - - // Nil priorities are considered lower (come later) - if iPriority == nil { - return false - } - if jPriority == nil { - return true - } - - // Compare non-nil priorities - return *iPriority < *jPriority -} - -func (l GrafanaNotificationPolicyRouteList) Swap(i, j int) { - l.Items[i], l.Items[j] = l.Items[j], l.Items[i] -} - -// SortByPriority sorts the list by Priority -// Priority can be 1-100 or nil, with nil being the lowest priority 100 -func (l *GrafanaNotificationPolicyRouteList) SortByPriority() { - sort.Sort(l) -} - -// StatusDiscoveredRoutes returns the list of discovered routes using the namespace and name -// Used to display all discovered routes in the GrafanaNotificationPolicy status -func (l *GrafanaNotificationPolicyRouteList) StatusDiscoveredRoutes() []string { - sort.Sort(l) - - discoveredRoutes := make([]string, len(l.Items)) - for i, route := range l.Items { - priority := "nil" - if route.Spec.Priority != nil { - priority = fmt.Sprintf("%d", *route.Spec.Priority) - } - discoveredRoutes[i] = fmt.Sprintf("%s/%s (priority: %s)", route.Namespace, route.Name, priority) - } - - return discoveredRoutes -} - func init() { SchemeBuilder.Register(&GrafanaNotificationPolicyRoute{}, &GrafanaNotificationPolicyRouteList{}) } diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml index 289b092c0..2e077b770 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -229,9 +229,6 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-validations: - - message: routeSelector and routes are mutually exclusive - rule: has(self.routeSelector) != has(self.routes) required: - instanceSelector - route diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index 85ca07d4d..b1117cb51 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -164,9 +164,6 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-validations: - - message: routeSelector and routes are mutually exclusive - rule: has(self.routeSelector) != has(self.routes) required: - route type: object diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 6d2cadca1..ae90fd825 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -58,7 +58,6 @@ type GrafanaNotificationPolicyReconciler struct { //+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicies/status,verbs=get;update;patch //+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicies/finalizers,verbs=update -// TODO listen for updates on GrafanaNotificationPolicyRoutes // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // TODO(user): Modify the Reconcile function to compare the state specified by @@ -130,14 +129,17 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req removeNoMatchingInstance(¬ificationPolicy.Status.Conditions) - var mergedRoutes *v1beta1.GrafanaNotificationPolicyRouteList - if notificationPolicy.Spec.Route.RouteSelector != nil { + var mergedRoutes []*v1beta1.GrafanaNotificationPolicyRoute + assembledNotificationPolicy := notificationPolicy.DeepCopy() + + if notificationPolicy.Spec.Route.RouteSelector != nil || hasRouteSelector(notificationPolicy.Spec.Route) { var namespace *string if !notificationPolicy.IsCrossNamespaceImportAllowed() { ns := notificationPolicy.GetObjectMeta().GetNamespace() namespace = &ns } - notificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, notificationPolicy) + assembledNotificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, assembledNotificationPolicy) + r.Log.Info("assembled notification policy routes", "mergedRoutes", mergedRoutes) if err != nil { r.Log.Error(err, "failed to assemble GrafanaNotificationPolicy using routeSelectors") return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) @@ -161,7 +163,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req continue } - err := r.reconcileWithInstance(ctx, &grafana, notificationPolicy) + err := r.reconcileWithInstance(ctx, &grafana, assembledNotificationPolicy) if err != nil { applyErrors[fmt.Sprintf("%s/%s", grafana.Namespace, grafana.Name)] = err.Error() } @@ -172,8 +174,8 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req return ctrl.Result{}, fmt.Errorf("failed to apply to all instances: %v", applyErrors) } - if mergedRoutes != nil && len(mergedRoutes.Items) > 0 { - status := mergedRoutes.StatusDiscoveredRoutes() + if mergedRoutes != nil && len(mergedRoutes) > 0 { + status := statusDiscoveredRoutes(mergedRoutes) notificationPolicy.Status.DiscoveredRoutes = &status } @@ -187,62 +189,71 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req // assembleNotificationPolicyRoutes iterates over all routeSelectors transitively. // returns an assembled GrafanaNotificationPolicy as well as a list of all merged routes. // it ensures that there are no reference loops when discovering routes via labelSelectors -func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, namespace *string, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) (*grafanav1beta1.GrafanaNotificationPolicy, *v1beta1.GrafanaNotificationPolicyRouteList, error) { - if notificationPolicy.Spec.Route.RouteSelector == nil { - return notificationPolicy, nil, nil - } +func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, namespace *string, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) (*grafanav1beta1.GrafanaNotificationPolicy, []*v1beta1.GrafanaNotificationPolicyRoute, error) { assembledPolicy := notificationPolicy.DeepCopy() - assembledPolicy.Spec.Route.Routes = []*grafanav1beta1.Route{} + mergedRoutes := []*v1beta1.GrafanaNotificationPolicyRoute{} - mergedRoutes := v1beta1.GrafanaNotificationPolicyRouteList{} - // visitedGlobal keeps track of all routes that have been appened to mergedRoutes + // visitedGlobal keeps track of all routes that have been appended to mergedRoutes // so we can record a status update for them later visitedGlobal := make(map[string]bool) - // visitedChilds keeps track of all downstream routes to detect loops + // visitedChilds keeps track of all routes that have been visited on the current path + // so we can detect loops visitedChilds := make(map[string]bool) - var dfs func(*metav1.LabelSelector) error - dfs = func(selector *metav1.LabelSelector) error { - routes, err := getMatchingNotificationPolicyRoutes(ctx, k8sClient, selector, namespace) - if err != nil { - return fmt.Errorf("failed to get matching routes: %w", err) - } + var assembleRoute func(*grafanav1beta1.Route) error + assembleRoute = func(route *grafanav1beta1.Route) error { + if route.RouteSelector != nil { + routes, err := getMatchingNotificationPolicyRoutes(ctx, k8sClient, route.RouteSelector, namespace) + if err != nil { + return fmt.Errorf("failed to get matching routes: %w", err) + } - for i := range routes.Items { - route := &routes.Items[i] - key := fmt.Sprintf("%s/%s", route.Namespace, route.Name) + // Replace the RouteSelector with matched routes + route.RouteSelector = nil + for i := range routes.Items { + matchedRoute := &routes.Items[i] + key := fmt.Sprintf("%s/%s", matchedRoute.Namespace, matchedRoute.Name) - if visitedChilds[key] { - return fmt.Errorf("loop detected in notification policy routes: %s", key) - } + if _, exists := visitedGlobal[key]; !exists { + mergedRoutes = append(mergedRoutes, matchedRoute) + visitedGlobal[key] = true + } - if !visitedGlobal[key] { - mergedRoutes.Items = append(mergedRoutes.Items, *route) - } - visitedGlobal[key] = true + if _, exists := visitedChilds[key]; exists { + return fmt.Errorf("loop detected, visited %s before", key) + } + visitedChilds[key] = true - visitedChilds[key] = true - assembledPolicy.Spec.Route.Routes = append(assembledPolicy.Spec.Route.Routes, route.Spec.Route) + // Recursively assemble the matched route + if err := assembleRoute(matchedRoute.Spec.Route); err != nil { + return err + } + + delete(visitedChilds, key) - if route.Spec.Route.RouteSelector != nil { - if err := dfs(route.Spec.Route.RouteSelector); err != nil { + route.Routes = append(route.Routes, matchedRoute.Spec.Route) + } + } else { + // if no RouteSelector is specified, process inline routes, as they are mutually exclusive + for i, inlineRoute := range route.Routes { + if err := assembleRoute(inlineRoute); err != nil { return err } + route.Routes[i] = inlineRoute } - - delete(visitedChilds, key) } return nil } - if err := dfs(notificationPolicy.Spec.Route.RouteSelector); err != nil { + // Start with Spec.Route + if err := assembleRoute(assembledPolicy.Spec.Route); err != nil { return nil, nil, err } - return assembledPolicy, &mergedRoutes, nil + return assembledPolicy, mergedRoutes, nil } func (r *GrafanaNotificationPolicyReconciler) reconcileWithInstance(ctx context.Context, instance *grafanav1beta1.Grafana, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) error { @@ -343,7 +354,7 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) } requests := []reconcile.Request{} for _, np := range nps.Items { - if np.Spec.Route.RouteSelector == nil { + if !hasRouteSelector(np.Spec.Route) { continue } @@ -351,20 +362,24 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) continue } - selector, err := metav1.LabelSelectorAsSelector(np.Spec.Route.RouteSelector) - if err != nil { - r.Log.Error(err, "failed to create selector from RouteSelector") - continue - } - - if selector.Matches(labels.Set(o.GetLabels())) { - requests = append(requests, - reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: np.Name, - Namespace: np.Namespace, - }, - }) + allRouteSelectors := getRouteSelectors(np.Spec.Route) + + for _, routeSelector := range allRouteSelectors { + selector, err := metav1.LabelSelectorAsSelector(routeSelector) + if err != nil { + r.Log.Error(err, "failed to create selector from RouteSelector") + continue + } + + if selector.Matches(labels.Set(o.GetLabels())) { + requests = append(requests, + reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: np.Name, + Namespace: np.Namespace, + }, + }) + } } } return requests @@ -394,14 +409,63 @@ func getMatchingNotificationPolicyRoutes(ctx context.Context, k8sClient client.C } // recordMergedEventForNotificationPolicyRoutes emits a merged event to all matched notification policy routes -func (r *GrafanaNotificationPolicyReconciler) recordMergedEventForNotificationPolicyRoutes(ctx context.Context, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, routes *v1beta1.GrafanaNotificationPolicyRouteList) error { +func (r *GrafanaNotificationPolicyReconciler) recordMergedEventForNotificationPolicyRoutes(ctx context.Context, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, routes []*v1beta1.GrafanaNotificationPolicyRoute) error { if notificationPolicy == nil || routes == nil { return nil } - for i := range routes.Items { - route := &routes.Items[i] + for _, route := range routes { r.Recorder.Event(route, corev1.EventTypeNormal, "Merged", fmt.Sprintf("Route merged into NotificationPolicy %s/%s", notificationPolicy.GetNamespace(), notificationPolicy.GetName())) } return nil } + +// hasRouteSelector checks if the given Route or any of its nested Routes has a RouteSelector +func hasRouteSelector(route *grafanav1beta1.Route) bool { + if route == nil { + return false + } + + if route.RouteSelector != nil { + return true + } + + for _, nestedRoute := range route.Routes { + if hasRouteSelector(nestedRoute) { + return true + } + } + + return false +} + +// getRouteSelectors returns a list of all route selectors specified on a notification policy +// in either the Route.RouteSelector or any of its Routes +func getRouteSelectors(route *grafanav1beta1.Route) []*metav1.LabelSelector { + if route == nil { + return nil + } + + var selectors []*metav1.LabelSelector + + if route.RouteSelector != nil { + selectors = append(selectors, route.RouteSelector) + } + + for _, nestedRoute := range route.Routes { + selectors = append(selectors, getRouteSelectors(nestedRoute)...) + } + + return selectors +} + +// statusDiscoveredRoutes returns the list of discovered routes using the namespace and name +// Used to display all discovered routes in the GrafanaNotificationPolicy status +func statusDiscoveredRoutes(routes []*v1beta1.GrafanaNotificationPolicyRoute) []string { + discoveredRoutes := make([]string, len(routes)) + for i, route := range routes { + discoveredRoutes[i] = fmt.Sprintf("%s/%s", route.Namespace, route.Name) + } + + return discoveredRoutes +} diff --git a/controllers/notificationpolicy_controller_test.go b/controllers/notificationpolicy_controller_test.go index 1f88b3952..7b8847241 100644 --- a/controllers/notificationpolicy_controller_test.go +++ b/controllers/notificationpolicy_controller_test.go @@ -18,10 +18,10 @@ package controllers import ( "context" - "reflect" "testing" grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" + "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -83,9 +83,6 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ Route: &grafanav1beta1.Route{ Receiver: "default-receiver", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "first"}, - }, Routes: []*grafanav1beta1.Route{ { Receiver: "team-A-receiver", @@ -144,20 +141,98 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ Route: &grafanav1beta1.Route{ Receiver: "default-receiver", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "first"}, + Routes: []*grafanav1beta1.Route{ + { + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + Routes: []*grafanav1beta1.Route{ + { + Receiver: "team-B-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, + }, + }, + }, }, + }, + }, + }, + wantErr: false, + }, + { + name: "Assembly with nested routes and multiple RouteSelectors inside Routes", + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", Routes: []*grafanav1beta1.Route{ { Receiver: "team-A-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "second"}, + MatchLabels: map[string]string{"tier": "second", "team": "A"}, }, + }, + { + Receiver: "team-B-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second", "team": "B"}, + }, + }, + }, + }, + }, + }, + existingRoutes: []grafanav1beta1.GrafanaNotificationPolicyRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "default", + Labels: map[string]string{"tier": "second", "team": "A"}, + }, + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "project-X-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("project"), Value: "X", IsEqual: true}}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "default", + Labels: map[string]string{"tier": "second", "team": "B"}, + }, + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: &grafanav1beta1.Route{ + Receiver: "project-Y-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("project"), Value: "Y", IsEqual: true}}, + }, + }, + }, + }, + want: &grafanav1beta1.GrafanaNotificationPolicy{ + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{ + { + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, Routes: []*grafanav1beta1.Route{ { - Receiver: "team-B-receiver", - Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, + Receiver: "project-X-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("project"), Value: "X", IsEqual: true}}, + }, + }, + }, + { + Receiver: "team-B-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, + Routes: []*grafanav1beta1.Route{ + { + Receiver: "project-Y-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("project"), Value: "Y", IsEqual: true}}, }, }, }, @@ -213,23 +288,22 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, }, }, - want: nil, wantErr: true, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - client := fake.NewClientBuilder().WithRuntimeObjects(routesToRuntimeObjects(tt.existingRoutes)...).Build() + s := runtime.NewScheme() + _ = grafanav1beta1.AddToScheme(s) + client := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(routesToRuntimeObjects(tt.existingRoutes)...).Build() gotPolicy, _, err := assembleNotificationPolicyRoutes(ctx, client, nil, tt.notificationPolicy) - if (err != nil) != tt.wantErr { - t.Errorf("assembleNotificationPolicyRoutes() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(gotPolicy, tt.want) { - t.Errorf("assembleNotificationPolicyRoutes() = %v, want %v", gotPolicy, tt.want) + if tt.wantErr { + assert.Error(t, err, "assembleNotificationPolicyRoutes() should return an error") + } else { + assert.NoError(t, err, "assembleNotificationPolicyRoutes() should not return an error") + assert.Equal(t, tt.want, gotPolicy, "assembleNotificationPolicyRoutes() returned unexpected policy") } }) } diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml index 289b092c0..2e077b770 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -229,9 +229,6 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-validations: - - message: routeSelector and routes are mutually exclusive - rule: has(self.routeSelector) != has(self.routes) required: - instanceSelector - route diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index 85ca07d4d..b1117cb51 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -164,9 +164,6 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-validations: - - message: routeSelector and routes are mutually exclusive - rule: has(self.routeSelector) != has(self.routes) required: - route type: object diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index c9e5fc68c..45da37219 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1814,9 +1814,6 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-validations: - - message: routeSelector and routes are mutually exclusive - rule: has(self.routeSelector) != has(self.routes) required: - instanceSelector - route @@ -2063,9 +2060,6 @@ spec: description: routes x-kubernetes-preserve-unknown-fields: true type: object - x-kubernetes-validations: - - message: routeSelector and routes are mutually exclusive - rule: has(self.routeSelector) != has(self.routes) required: - route type: object diff --git a/hack/kind/resources/default/grafana-notification-policy.yaml b/hack/kind/resources/default/grafana-notification-policy.yaml index 8f20417d4..a79dea548 100644 --- a/hack/kind/resources/default/grafana-notification-policy.yaml +++ b/hack/kind/resources/default/grafana-notification-policy.yaml @@ -7,9 +7,6 @@ spec: instanceSelector: matchLabels: dashboards: "grafana" - routeSelector: - matchLabels: - dynamicroute: "grafana" route: receiver: grafana-default-email group_by: @@ -24,6 +21,9 @@ spec: - - inline - = - first + routeSelector: + matchLabels: + team-a: "child" - receiver: grafana-default-email object_matchers: - - team @@ -39,9 +39,8 @@ metadata: name: dynamic-c namespace: grafana-crds labels: - dynamicroute: "grafana" + team-a: "child" spec: - priority: 1 route: receiver: grafana-default-email object_matchers: @@ -51,34 +50,30 @@ spec: - - dynamic - = - c - - - priority - - = - - "1" + routeSelector: + matchLabels: + team-c: "child" --- apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaNotificationPolicyRoute metadata: name: dynamic-d labels: - dynamicroute: "grafana" + team-c: "child" spec: - priority: 2 route: receiver: grafana-default-email object_matchers: - - dynamic - = - d - - - priority - - = - - "2" --- apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaNotificationPolicyRoute metadata: name: dynamic-e labels: - dynamicroute: "grafana" + team-a: "child" spec: route: receiver: grafana-default-email @@ -86,6 +81,3 @@ spec: - - dynamic - = - e - - - priority - - = - - none From 95a1fd88dbe1b2c6535aeb06666c5a69dfb8feb3 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 21 Jan 2025 10:58:21 +0100 Subject: [PATCH 16/39] refactor: remove priority --- api/v1beta1/grafananotificationpolicyroute_types.go | 5 ----- api/v1beta1/zz_generated.deepcopy.go | 5 ----- ...fana.integreatly.org_grafananotificationpolicyroutes.yaml | 4 ---- ...fana.integreatly.org_grafananotificationpolicyroutes.yaml | 4 ---- deploy/kustomize/base/crds.yaml | 4 ---- 5 files changed, 22 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go index 1b67988e5..60525d3e4 100644 --- a/api/v1beta1/grafananotificationpolicyroute_types.go +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -28,11 +28,6 @@ type GrafanaNotificationPolicyRouteSpec struct { // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - // +kubebuilder:validation:Minimum=1 - // +kubebuilder:validation:Maximum=100 - // +optional - Priority *int8 `json:"priority,omitempty"` - // Route for alerts to match against Route *Route `json:"route"` } diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 03335bbab..d294b7df8 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1376,11 +1376,6 @@ func (in *GrafanaNotificationPolicyRouteList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaNotificationPolicyRouteSpec) DeepCopyInto(out *GrafanaNotificationPolicyRouteSpec) { *out = *in - if in.Priority != nil { - in, out := &in.Priority, &out.Priority - *out = new(int8) - **out = **in - } if in.Route != nil { in, out := &in.Route, &out.Route *out = new(Route) diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index b1117cb51..43a2333e0 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -41,10 +41,6 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - priority: - maximum: 100 - minimum: 1 - type: integer route: description: Route for alerts to match against properties: diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index b1117cb51..43a2333e0 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -41,10 +41,6 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - priority: - maximum: 100 - minimum: 1 - type: integer route: description: Route for alerts to match against properties: diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 45da37219..f7b04e17c 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1937,10 +1937,6 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - priority: - maximum: 100 - minimum: 1 - type: integer route: description: Route for alerts to match against properties: From 9787fe5ad05f93f3c9a59c7ac66dfb560f0dd6a8 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Tue, 21 Jan 2025 14:59:35 +0100 Subject: [PATCH 17/39] chore: re-run make manifest and generate, fix go mods --- .../grafananotificationpolicy_types.go | 6 +- api/v1beta1/zz_generated.deepcopy.go | 34 +- ...eatly.org_grafananotificationpolicies.yaml | 5 +- ...eatly.org_grafananotificationpolicies.yaml | 5 +- deploy/kustomize/base/crds.yaml | 351 +++++++++--------- go.mod | 3 - 6 files changed, 208 insertions(+), 196 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index 6da4a0475..e1efdebe8 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -131,7 +131,7 @@ func (r *Route) ToModelRoute() *models.Route { // GrafanaNotificationPolicyStatus defines the observed state of GrafanaNotificationPolicy type GrafanaNotificationPolicyStatus struct { - GrafanaCommonStatus + GrafanaCommonStatus `json:",inline"` DiscoveredRoutes *[]string `json:"discoveredRoutes,omitempty"` } @@ -147,7 +147,7 @@ type GrafanaNotificationPolicy struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec GrafanaNotificationPolicySpec `json:"spec,omitempty"` + Spec GrafanaNotificationPolicySpec `json:"spec,omitempty"` Status GrafanaNotificationPolicyStatus `json:"status,omitempty"` } @@ -157,7 +157,7 @@ func (np *GrafanaNotificationPolicy) NamespacedResource() string { // IsCrossNamespaceImportAllowed returns true when cross namespace imports are allowed func (np *GrafanaNotificationPolicy) IsCrossNamespaceImportAllowed() bool { - return np.Spec.AllowCrossNamespaceImport != nil && *np.Spec.AllowCrossNamespaceImport + return np.Spec.AllowCrossNamespaceImport } //+kubebuilder:object:root=true diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 73504d7f7..fbf8cfab2 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1394,6 +1394,31 @@ 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 *GrafanaNotificationPolicyStatus) DeepCopyInto(out *GrafanaNotificationPolicyStatus) { + *out = *in + in.GrafanaCommonStatus.DeepCopyInto(&out.GrafanaCommonStatus) + if in.DiscoveredRoutes != nil { + in, out := &in.DiscoveredRoutes, &out.DiscoveredRoutes + *out = new([]string) + if **in != nil { + in, out := *in, *out + *out = make([]string, len(*in)) + copy(*out, *in) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyStatus. +func (in *GrafanaNotificationPolicyStatus) DeepCopy() *GrafanaNotificationPolicyStatus { + if in == nil { + return nil + } + out := new(GrafanaNotificationPolicyStatus) + in.DeepCopyInto(out) + 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 @@ -1433,15 +1458,6 @@ func (in *GrafanaNotificationTemplateList) DeepCopyInto(out *GrafanaNotification (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.DiscoveredRoutes != nil { - in, out := &in.DiscoveredRoutes, &out.DiscoveredRoutes - *out = new([]string) - if **in != nil { - in, out := *in, *out - *out = make([]string, len(*in)) - copy(*out, *in) - } - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationTemplateList. diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml index dc978cd4a..4731a2874 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -251,7 +251,8 @@ spec: rule: '!oldSelf.allowCrossNamespaceImport || (oldSelf.allowCrossNamespaceImport && self.allowCrossNamespaceImport)' status: - description: The most recent observed state of a Grafana resource + description: GrafanaNotificationPolicyStatus defines the observed state + of GrafanaNotificationPolicy properties: conditions: description: Results when synchonizing resource with Grafana instances @@ -314,8 +315,6 @@ spec: items: type: string type: array - required: - - conditions lastResync: description: Last time the resource was synchronized with Grafana instances diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml index dc978cd4a..4731a2874 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -251,7 +251,8 @@ spec: rule: '!oldSelf.allowCrossNamespaceImport || (oldSelf.allowCrossNamespaceImport && self.allowCrossNamespaceImport)' status: - description: The most recent observed state of a Grafana resource + description: GrafanaNotificationPolicyStatus defines the observed state + of GrafanaNotificationPolicy properties: conditions: description: Results when synchonizing resource with Grafana instances @@ -314,8 +315,6 @@ spec: items: type: string type: array - required: - - conditions lastResync: description: Last time the resource was synchronized with Grafana instances diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index f510446e2..0a6022579 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1945,7 +1945,8 @@ spec: rule: '!oldSelf.allowCrossNamespaceImport || (oldSelf.allowCrossNamespaceImport && self.allowCrossNamespaceImport)' status: - description: The most recent observed state of a Grafana resource + description: GrafanaNotificationPolicyStatus defines the observed state + of GrafanaNotificationPolicy properties: conditions: description: Results when synchonizing resource with Grafana instances @@ -2022,6 +2023,180 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.3 + name: grafananotificationpolicyroutes.grafana.integreatly.org +spec: + group: grafana.integreatly.org + names: + kind: GrafanaNotificationPolicyRoute + listKind: GrafanaNotificationPolicyRouteList + plural: grafananotificationpolicyroutes + singular: grafananotificationpolicyroute + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: GrafanaNotificationPolicyRoute is the Schema for the grafananotificationpolicyroutes + 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: GrafanaNotificationPolicyRouteSpec defines the desired state + of GrafanaNotificationPolicyRoute + properties: + route: + description: Route for alerts to match against + properties: + continue: + description: continue + type: boolean + group_by: + description: group by + items: + type: string + type: array + group_interval: + description: group interval + type: string + group_wait: + description: group wait + type: string + match_re: + additionalProperties: + type: string + description: match re + type: object + matchers: + description: matchers + items: + properties: + isEqual: + description: is equal + type: boolean + isRegex: + description: is regex + type: boolean + name: + description: name + type: string + value: + description: value + type: string + required: + - isRegex + - value + type: object + type: array + mute_time_intervals: + description: mute time intervals + items: + type: string + type: array + object_matchers: + description: object matchers + items: + description: |- + ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. + + swagger:model ObjectMatcher + items: + type: string + type: array + type: array + provenance: + description: provenance + type: string + receiver: + description: receiver + type: string + repeat_interval: + description: repeat interval + type: string + routeSelector: + description: Selects GrafanaNotificationPolicyRoutes to merge + in when specified + 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 + routes: + description: routes + x-kubernetes-preserve-unknown-fields: true + type: object + required: + - route + type: object + status: + description: GrafanaNotificationPolicyRouteStatus defines the observed + state of GrafanaNotificationPolicyRoute + 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 @@ -2232,180 +2407,6 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition -metadata: - annotations: - controller-gen.kubebuilder.io/version: v0.16.3 - name: grafananotificationpolicyroutes.grafana.integreatly.org -spec: - group: grafana.integreatly.org - names: - kind: GrafanaNotificationPolicyRoute - listKind: GrafanaNotificationPolicyRouteList - plural: grafananotificationpolicyroutes - singular: grafananotificationpolicyroute - scope: Namespaced - versions: - - name: v1beta1 - schema: - openAPIV3Schema: - description: GrafanaNotificationPolicyRoute is the Schema for the grafananotificationpolicyroutes - 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: GrafanaNotificationPolicyRouteSpec defines the desired state - of GrafanaNotificationPolicyRoute - properties: - route: - description: Route for alerts to match against - properties: - continue: - description: continue - type: boolean - group_by: - description: group by - items: - type: string - type: array - group_interval: - description: group interval - type: string - group_wait: - description: group wait - type: string - match_re: - additionalProperties: - type: string - description: match re - type: object - matchers: - description: matchers - items: - properties: - isEqual: - description: is equal - type: boolean - isRegex: - description: is regex - type: boolean - name: - description: name - type: string - value: - description: value - type: string - required: - - isRegex - - value - type: object - type: array - mute_time_intervals: - description: mute time intervals - items: - type: string - type: array - object_matchers: - description: object matchers - items: - description: |- - ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. - - swagger:model ObjectMatcher - items: - type: string - type: array - type: array - provenance: - description: provenance - type: string - receiver: - description: receiver - type: string - repeat_interval: - description: repeat interval - type: string - routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge - in when specified - 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 - routes: - description: routes - x-kubernetes-preserve-unknown-fields: true - type: object - required: - - route - type: object - status: - description: GrafanaNotificationPolicyRouteStatus defines the observed - state of GrafanaNotificationPolicyRoute - 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/go.mod b/go.mod index 659110fd2..8ab64ebac 100644 --- a/go.mod +++ b/go.mod @@ -45,9 +45,6 @@ require ( go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/tools v0.26.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/tools v0.28.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect From d4238b216914251f2da25d9e05c9c3c6af72cf00 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Wed, 22 Jan 2025 11:48:22 +0100 Subject: [PATCH 18/39] feat: add status condition for mutual exclusivity check - adds validation for ensuring routes and routeSelector are mutual exclusive - updates both GrafanaNotificationPolicy and GrafanaNotificationPolicyRoute status conditions accordingly --- .../grafananotificationpolicy_types.go | 21 +++++ .../grafananotificationpolicy_types_test.go | 82 +++++++++++++++++++ .../grafananotificationpolicyroute_types.go | 10 +-- api/v1beta1/zz_generated.deepcopy.go | 17 +--- ...y.org_grafananotificationpolicyroutes.yaml | 66 ++++++++++++++- controllers/controller_shared.go | 13 +++ controllers/notificationpolicy_controller.go | 31 +++++-- ...y.org_grafananotificationpolicyroutes.yaml | 66 ++++++++++++++- deploy/kustomize/base/crds.yaml | 66 ++++++++++++++- 9 files changed, 336 insertions(+), 36 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index e1efdebe8..23fc8250e 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -129,6 +129,27 @@ func (r *Route) ToModelRoute() *models.Route { return out } +// isMutuallyExclusive checks if a single route satisfies the mutual exclusivity constraint +func isMutuallyExclusive(r *Route) bool { + return !(r.RouteSelector != nil && len(r.Routes) > 0) +} + +// IsRouteSelectorMutuallyExclusive returns true when the route and all its sub-routes +// satisfy the constraint of routes and routeSelector being mutually exclusive +func (r *Route) IsRouteSelectorMutuallyExclusive() bool { + if !isMutuallyExclusive(r) { + return false + } + + // Recursively check all child routes + for _, childRoute := range r.Routes { + if !childRoute.IsRouteSelectorMutuallyExclusive() { + return false + } + } + return true +} + // GrafanaNotificationPolicyStatus defines the observed state of GrafanaNotificationPolicy type GrafanaNotificationPolicyStatus struct { GrafanaCommonStatus `json:",inline"` diff --git a/api/v1beta1/grafananotificationpolicy_types_test.go b/api/v1beta1/grafananotificationpolicy_types_test.go index d7d43fe78..8a3d22fb0 100644 --- a/api/v1beta1/grafananotificationpolicy_types_test.go +++ b/api/v1beta1/grafananotificationpolicy_types_test.go @@ -2,9 +2,11 @@ package v1beta1 import ( "context" + "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -74,3 +76,83 @@ var _ = Describe("NotificationPolicy type", func() { }) }) }) + +func TestIsRouteSelectorMutuallyExclusive(t *testing.T) { + tests := []struct { + name string + route *Route + expected bool + }{ + { + name: "Empty route", + route: &Route{}, + expected: true, + }, + { + name: "Route with only RouteSelector", + route: &Route{ + RouteSelector: &metav1.LabelSelector{}, + }, + expected: true, + }, + { + name: "Route with only sub-routes", + route: &Route{ + Routes: []*Route{ + {}, + {}, + }, + }, + expected: true, + }, + { + name: "Route with both RouteSelector and sub-routes", + route: &Route{ + RouteSelector: &metav1.LabelSelector{}, + Routes: []*Route{ + {}, + }, + }, + expected: false, + }, + { + name: "Nested routes with mutual exclusivity", + route: &Route{ + Routes: []*Route{ + { + RouteSelector: &metav1.LabelSelector{}, + }, + { + Routes: []*Route{ + {}, + }, + }, + }, + }, + expected: true, + }, + { + name: "Nested routes without mutual exclusivity", + route: &Route{ + Routes: []*Route{ + { + RouteSelector: &metav1.LabelSelector{}, + Routes: []*Route{ + {}, + }, + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.route.IsRouteSelectorMutuallyExclusive() + if result != tt.expected { + t.Errorf("IsRouteSelectorMutuallyExclusive() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go index 60525d3e4..83ebc24ed 100644 --- a/api/v1beta1/grafananotificationpolicyroute_types.go +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -32,12 +32,6 @@ type GrafanaNotificationPolicyRouteSpec struct { Route *Route `json:"route"` } -// GrafanaNotificationPolicyRouteStatus defines the observed state of GrafanaNotificationPolicyRoute -type GrafanaNotificationPolicyRouteStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file -} - //+kubebuilder:object:root=true //+kubebuilder:subresource:status @@ -46,8 +40,8 @@ type GrafanaNotificationPolicyRoute struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec GrafanaNotificationPolicyRouteSpec `json:"spec,omitempty"` - Status GrafanaNotificationPolicyRouteStatus `json:"status,omitempty"` + Spec GrafanaNotificationPolicyRouteSpec `json:"spec,omitempty"` + Status GrafanaCommonStatus `json:"status,omitempty"` } //+kubebuilder:object:root=true diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index fbf8cfab2..8418e07a9 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1280,7 +1280,7 @@ func (in *GrafanaNotificationPolicyRoute) DeepCopyInto(out *GrafanaNotificationP out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyRoute. @@ -1353,21 +1353,6 @@ func (in *GrafanaNotificationPolicyRouteSpec) DeepCopy() *GrafanaNotificationPol return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GrafanaNotificationPolicyRouteStatus) DeepCopyInto(out *GrafanaNotificationPolicyRouteStatus) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyRouteStatus. -func (in *GrafanaNotificationPolicyRouteStatus) DeepCopy() *GrafanaNotificationPolicyRouteStatus { - if in == nil { - return nil - } - out := new(GrafanaNotificationPolicyRouteStatus) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaNotificationPolicySpec) DeepCopyInto(out *GrafanaNotificationPolicySpec) { *out = *in diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index 43a2333e0..f9005210c 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -164,8 +164,70 @@ spec: - route type: object status: - description: GrafanaNotificationPolicyRouteStatus defines the observed - state of GrafanaNotificationPolicyRoute + 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 diff --git a/controllers/controller_shared.go b/controllers/controller_shared.go index 3a8f08e74..1dbf6a57f 100644 --- a/controllers/controller_shared.go +++ b/controllers/controller_shared.go @@ -235,6 +235,19 @@ func setNoMatchingInstancesCondition(conditions *[]metav1.Condition, generation }) } +func setRoutesIgnoredDueToRouteSelectorCondition(conditions *[]metav1.Condition, generation int64) { + meta.SetStatusCondition(conditions, metav1.Condition{ + Type: conditionRoutesIgnoredDueToRouteSelector, + Status: metav1.ConditionTrue, + ObservedGeneration: generation, + Reason: "BothRoutesAndRouteSelectorSpecified", + Message: "Dynamically matched definitions from routeSelector will take precedence and the routes field is ignored", + LastTransitionTime: metav1.Time{ + Time: time.Now(), + }, + }) +} + func removeNoMatchingInstance(conditions *[]metav1.Condition) { meta.RemoveStatusCondition(conditions, conditionNoMatchingInstance) } diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 25679d228..8de30a1cf 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -44,8 +44,9 @@ import ( ) const ( - conditionNotificationPolicySynchronized = "NotificationPolicySynchronized" - annotationAppliedNotificationPolicy = "operator.grafana.com/applied-notificationpolicy" + conditionNotificationPolicySynchronized = "NotificationPolicySynchronized" + conditionRoutesIgnoredDueToRouteSelector = "RoutesIgnoredDueToRouteSelector" + annotationAppliedNotificationPolicy = "operator.grafana.com/applied-notificationpolicy" ) // GrafanaNotificationPolicyReconciler reconciles a GrafanaNotificationPolicy object @@ -157,15 +158,21 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req return ctrl.Result{}, fmt.Errorf("failed to apply to all instances: %v", applyErrors) } - if mergedRoutes != nil && len(mergedRoutes) > 0 { + if len(mergedRoutes) > 0 { status := statusDiscoveredRoutes(mergedRoutes) notificationPolicy.Status.DiscoveredRoutes = &status } - if err := r.recordMergedEventForNotificationPolicyRoutes(ctx, notificationPolicy, mergedRoutes); err != nil { + if err := r.updateNotificationPolicyRoutesStatus(ctx, notificationPolicy, mergedRoutes); err != nil { r.Log.Error(err, "failed to add merged events to routes") } + if notificationPolicy.Spec.Route.IsRouteSelectorMutuallyExclusive() { + meta.RemoveStatusCondition(¬ificationPolicy.Status.Conditions, conditionRoutesIgnoredDueToRouteSelector) + } else { + setRoutesIgnoredDueToRouteSelectorCondition(¬ificationPolicy.Status.Conditions, notificationPolicy.Generation) + } + condition := buildSynchronizedCondition("Notification Policy", conditionNotificationPolicySynchronized, notificationPolicy.Generation, applyErrors, len(instances)) meta.SetStatusCondition(¬ificationPolicy.Status.Conditions, condition) @@ -202,6 +209,13 @@ func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Clie matchedRoute := &routes.Items[i] key := fmt.Sprintf("%s/%s", matchedRoute.Namespace, matchedRoute.Name) + // validate constraints + if matchedRoute.Spec.Route.IsRouteSelectorMutuallyExclusive() { + meta.RemoveStatusCondition(&matchedRoute.Status.Conditions, conditionRoutesIgnoredDueToRouteSelector) + } else { + setRoutesIgnoredDueToRouteSelectorCondition(&matchedRoute.Status.Conditions, matchedRoute.Generation) + } + if _, exists := visitedGlobal[key]; !exists { mergedRoutes = append(mergedRoutes, matchedRoute) visitedGlobal[key] = true @@ -386,14 +400,19 @@ func getMatchingNotificationPolicyRoutes(ctx context.Context, k8sClient client.C return &list, err } -// recordMergedEventForNotificationPolicyRoutes emits a merged event to all matched notification policy routes -func (r *GrafanaNotificationPolicyReconciler) recordMergedEventForNotificationPolicyRoutes(ctx context.Context, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, routes []*v1beta1.GrafanaNotificationPolicyRoute) error { +// updateNotificationPolicyRoutesStatus sets status conditions and emits a merged event to all matched notification policy routes +func (r *GrafanaNotificationPolicyReconciler) updateNotificationPolicyRoutesStatus(ctx context.Context, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy, routes []*v1beta1.GrafanaNotificationPolicyRoute) error { if notificationPolicy == nil || routes == nil { return nil } for _, route := range routes { r.Recorder.Event(route, corev1.EventTypeNormal, "Merged", fmt.Sprintf("Route merged into NotificationPolicy %s/%s", notificationPolicy.GetNamespace(), notificationPolicy.GetName())) + + // Update the status of the route in case conditions have been set + if err := r.Status().Update(ctx, route); err != nil { + return fmt.Errorf("failed to update status for route %s/%s: %w", route.Namespace, route.Name, err) + } } return nil } diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index 43a2333e0..f9005210c 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -164,8 +164,70 @@ spec: - route type: object status: - description: GrafanaNotificationPolicyRouteStatus defines the observed - state of GrafanaNotificationPolicyRoute + 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 diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 0a6022579..09b7e3370 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -2186,8 +2186,70 @@ spec: - route type: object status: - description: GrafanaNotificationPolicyRouteStatus defines the observed - state of GrafanaNotificationPolicyRoute + 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 From c7a9b8d5084375ac7cfc5785ecf00a45a9b6a7ba Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Wed, 22 Jan 2025 13:52:04 +0100 Subject: [PATCH 19/39] docs: update examples and docs related to NotificationPolicies --- controllers/notificationpolicy_controller.go | 1 - docs/docs/alerting/notification-policies.md | 53 +++++++++++--------- examples/notification-policy/routes.yaml | 33 +++++++----- 3 files changed, 51 insertions(+), 36 deletions(-) diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 8de30a1cf..bc20d8797 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -131,7 +131,6 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req namespace = &ns } assembledNotificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, assembledNotificationPolicy) - r.Log.Info("assembled notification policy routes", "mergedRoutes", mergedRoutes) if err != nil { r.Log.Error(err, "failed to assemble GrafanaNotificationPolicy using routeSelectors") return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) diff --git a/docs/docs/alerting/notification-policies.md b/docs/docs/alerting/notification-policies.md index 180f9ff16..8171818f0 100644 --- a/docs/docs/alerting/notification-policies.md +++ b/docs/docs/alerting/notification-policies.md @@ -18,14 +18,20 @@ The following snippet shows an example notification policy routing to the `opera ## Dynamic Notification Policy Routes There might be scenarios where you can not define the entire notification policy in a single place and you have to assemble it from multiple resouces. -In this case, you can use the `spec.routeSelector` in combination with multiple `GrafanaNotificationPolicyRoute` resources. +In this case, you can use the `spec.route.routeSelector` in combination with multiple `GrafanaNotificationPolicyRoute` resources. -All `GrafanaNotificationPolicyRoute` resources will then be discovered based on the label selector defined in `spec.routeSelector`. +The `routeSelector` can be specified in any `route` object, including the list of `spec.route.routes`. + +All `GrafanaNotificationPolicyRoute` resources will then be discovered based on the label selector defined in `spec.route.routeSelector`. In case `spec.allowCrossNamespaceImport` is enabled, matching routes will be fetched from all namespaces. Otherwise only routes from the same namespace as the `GrafanaNotificationPolicy` will be discovered. -All discovered routes will then get appended to the `spec.route.routes[]` of the `GrafanaNotificationPolicy` based on the priority defined in the `GrafanaNotificationPolicyRoute`. -Priorities can be in the range 1-100 with `1` being merged first and `100` last. If no priority is specified, it is treated as a priority of `100`. +All discovered routes will then get set on the `spec.route.routes[]` of the `GrafanaNotificationPolicy`. + +{{% alert title="Note" color="secondary" %}} +The `spec.route.routes` and `spec.route.routeSelector` fields are mutually exclusive. +When both fields are specified, the `routeSelector` takes precedence and overrides anything defined in `routes`. +{{% /alert %}} The following shows an example of how dynamic routes will get merged. @@ -50,6 +56,26 @@ policies: - - team - = - a + routes: + - receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - e + - receiver: grafana-default-email + object_matchers: + - - crossNamespace + - = + - "true" + - - dynamic + - = + - c + routes: + - receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - d - receiver: grafana-default-email object_matchers: - - inline @@ -58,23 +84,4 @@ policies: - - team - = - b - - receiver: grafana-default-email - object_matchers: - - - crossNamespace - - = - - "true" - - - dynamic - - = - - c - - - priority - - = - - "1" - - receiver: grafana-default-email - object_matchers: - - - dynamic - - = - - d - - - priority - - = - - "2" ``` diff --git a/examples/notification-policy/routes.yaml b/examples/notification-policy/routes.yaml index b063ad3c8..a79dea548 100644 --- a/examples/notification-policy/routes.yaml +++ b/examples/notification-policy/routes.yaml @@ -7,9 +7,6 @@ spec: instanceSelector: matchLabels: dashboards: "grafana" - routeSelector: - matchLabels: - dynamicroute: "grafana" route: receiver: grafana-default-email group_by: @@ -24,6 +21,9 @@ spec: - - inline - = - first + routeSelector: + matchLabels: + team-a: "child" - receiver: grafana-default-email object_matchers: - - team @@ -39,9 +39,8 @@ metadata: name: dynamic-c namespace: grafana-crds labels: - dynamicroute: "grafana" + team-a: "child" spec: - priority: 1 route: receiver: grafana-default-email object_matchers: @@ -51,24 +50,34 @@ spec: - - dynamic - = - c - - - priority - - = - - "1" + routeSelector: + matchLabels: + team-c: "child" --- apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaNotificationPolicyRoute metadata: name: dynamic-d labels: - dynamicroute: "grafana" + team-c: "child" spec: - priority: 2 route: receiver: grafana-default-email object_matchers: - - dynamic - = - d - - - priority +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaNotificationPolicyRoute +metadata: + name: dynamic-e + labels: + team-a: "child" +spec: + route: + receiver: grafana-default-email + object_matchers: + - - dynamic - = - - "2" + - e From 01ab6b1862e21a28ff57097971d9e0c84fce5698 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 14:26:55 +0100 Subject: [PATCH 20/39] refactor: convert to member func and rename to selectorMutuallyExclusive --- api/v1beta1/grafananotificationpolicy_types.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index 23fc8250e..ed3f63a2e 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -129,15 +129,16 @@ func (r *Route) ToModelRoute() *models.Route { return out } -// isMutuallyExclusive checks if a single route satisfies the mutual exclusivity constraint -func isMutuallyExclusive(r *Route) bool { +// selectorMutuallyExclusive checks if a single route satisfies the mutual exclusivity constraint +// for checking the entire route including nested routes, use IsRouteSelectorMutuallyExclusive +func (r *Route) selectorMutuallyExclusive() bool { return !(r.RouteSelector != nil && len(r.Routes) > 0) } // IsRouteSelectorMutuallyExclusive returns true when the route and all its sub-routes // satisfy the constraint of routes and routeSelector being mutually exclusive func (r *Route) IsRouteSelectorMutuallyExclusive() bool { - if !isMutuallyExclusive(r) { + if !r.selectorMutuallyExclusive() { return false } From 03dd5a9be2f5f90a3c835bd2983b17a7c32ff635 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 14:29:41 +0100 Subject: [PATCH 21/39] chore: mention mutual exclusivity in comments --- api/v1beta1/grafananotificationpolicy_types.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index ed3f63a2e..d65dbf9ec 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -71,10 +71,11 @@ type Route struct { // repeat interval RepeatInterval string `json:"repeat_interval,omitempty"` - // Selects GrafanaNotificationPolicyRoutes to merge in when specified + // selects GrafanaNotificationPolicyRoutes to merge in when specified + // mutually exclusive with Routes RouteSelector *metav1.LabelSelector `json:"routeSelector,omitempty"` - // routes + // routes, mutually exclusive with RouteSelector // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Schemaless Routes []*Route `json:"routes,omitempty"` From 9cb305d2e6f473cc03dda55a39642bd46f7bf554 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 14:51:37 +0100 Subject: [PATCH 22/39] test: fix test setup order and kind args --- Makefile | 2 +- hack/kind/start-kind.sh | 32 ++++++++++++++++---------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index dcf0d200b..ef07b431a 100644 --- a/Makefile +++ b/Makefile @@ -256,7 +256,7 @@ bundle/redhat: bundle # e2e .PHONY: e2e-kind e2e-kind: -ifeq (,$(shell kind get clusters $(KIND_CLUSTER_NAME))) +ifeq (,$(shell kind get clusters )) $(KIND) --kubeconfig="${KUBECONFIG}" create cluster --image=kindest/node:v$(ENVTEST_K8S_VERSION) --config tests/e2e/kind.yaml endif diff --git a/hack/kind/start-kind.sh b/hack/kind/start-kind.sh index f8cb5ec85..482f1f0de 100755 --- a/hack/kind/start-kind.sh +++ b/hack/kind/start-kind.sh @@ -12,7 +12,7 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) # Make sure there is no current cluster echo "Delete existing cluster" ${KIND} --kubeconfig="${KUBECONFIG}" delete cluster --name "${KIND_CLUSTER_NAME}" || - echo "There was no existing cluster" + echo "There was no existing cluster" # Start kind cluster echo "" @@ -20,12 +20,12 @@ echo "###############################" echo "# 1. Start kind cluster #" echo "###############################" ${KIND} --kubeconfig "${KUBECONFIG}" create cluster \ - --name "${KIND_CLUSTER_NAME}" \ - --wait 120s \ - --config="${SCRIPT_DIR}/resources/cluster.yaml" + --name "${KIND_CLUSTER_NAME}" \ + --wait 120s \ + --config="${SCRIPT_DIR}/resources/cluster.yaml" kubectl --kubeconfig "${KUBECONFIG}" \ - label ns default grafana=grafana + label ns default grafana=grafana # Install ingress-nginx echo "" @@ -33,12 +33,12 @@ echo "###############################" echo "# 2. Install ingress-nginx #" echo "###############################" kubectl --kubeconfig="${KUBECONFIG}" \ - apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml + apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml kubectl --kubeconfig="${KUBECONFIG}" \ - -n ingress-nginx \ - wait deploy ingress-nginx-controller \ - --for condition=Available \ - --timeout=90s + -n ingress-nginx \ + wait deploy ingress-nginx-controller \ + --for condition=Available \ + --timeout=90s # Will install the CRD:s echo "" @@ -54,14 +54,14 @@ echo "" echo "###############################" echo "# 4. Install grafana objects #" echo "###############################" -kubectl --kubeconfig="${KUBECONFIG}" \ - apply -k "${SCRIPT_DIR}/resources/default/" - # Create an extra namespace for CRDs kubectl --kubeconfig "${KUBECONFIG}" \ - create ns "${CRD_NS}" + create ns "${CRD_NS}" kubectl --kubeconfig "${KUBECONFIG}" \ - label ns "${CRD_NS}" grafanacrd=grafana --overwrite + label ns "${CRD_NS}" grafanacrd=grafana --overwrite + +kubectl --kubeconfig="${KUBECONFIG}" \ + apply -k "${SCRIPT_DIR}/resources/default/" # Setup a grafana objects in specific ns echo "" @@ -69,7 +69,7 @@ echo "##########################################" echo "# 5. Install grafana objects in ${CRD_NS}" echo "##########################################" kubectl -n "${CRD_NS}" --kubeconfig="${KUBECONFIG}" \ - apply -k "${SCRIPT_DIR}/resources/crd-ns/" + apply -k "${SCRIPT_DIR}/resources/crd-ns/" echo "" echo "##########################################" From ebdf7ed8b371df743dbdf9d07d8bf4c7001f9034 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 14:52:00 +0100 Subject: [PATCH 23/39] refactor: inline Route object --- .../grafananotificationpolicyroute_types.go | 2 +- api/v1beta1/zz_generated.deepcopy.go | 6 +- ...eatly.org_grafananotificationpolicies.yaml | 7 +- ...y.org_grafananotificationpolicyroutes.yaml | 213 +++++++++-------- controllers/notificationpolicy_controller.go | 4 +- .../notificationpolicy_controller_test.go | 14 +- ...eatly.org_grafananotificationpolicies.yaml | 7 +- ...y.org_grafananotificationpolicyroutes.yaml | 213 +++++++++-------- deploy/kustomize/base/crds.yaml | 220 +++++++++--------- examples/notification-policy/routes.yaml | 45 ++-- .../default/grafana-notification-policy.yaml | 45 ++-- 11 files changed, 377 insertions(+), 399 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go index 83ebc24ed..cb84a47d7 100644 --- a/api/v1beta1/grafananotificationpolicyroute_types.go +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -29,7 +29,7 @@ type GrafanaNotificationPolicyRouteSpec struct { // Important: Run "make" to regenerate code after modifying this file // Route for alerts to match against - Route *Route `json:"route"` + Route `json:",inline"` } //+kubebuilder:object:root=true diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 8418e07a9..3a45642cc 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1336,11 +1336,7 @@ func (in *GrafanaNotificationPolicyRouteList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GrafanaNotificationPolicyRouteSpec) DeepCopyInto(out *GrafanaNotificationPolicyRouteSpec) { *out = *in - if in.Route != nil { - in, out := &in.Route, &out.Route - *out = new(Route) - (*in).DeepCopyInto(*out) - } + in.Route.DeepCopyInto(&out.Route) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrafanaNotificationPolicyRouteSpec. diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml index 4731a2874..7724549fb 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -188,8 +188,9 @@ spec: description: repeat interval type: string routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge - in when specified + description: |- + selects GrafanaNotificationPolicyRoutes to merge in when specified + mutually exclusive with Routes properties: matchExpressions: description: matchExpressions is a list of label selector @@ -235,7 +236,7 @@ spec: type: object x-kubernetes-map-type: atomic routes: - description: routes + description: routes, mutually exclusive with RouteSelector x-kubernetes-preserve-unknown-fields: true type: object required: diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index f9005210c..f3b996083 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -41,127 +41,122 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - route: - description: Route for alerts to match against - properties: - continue: - description: continue - type: boolean - group_by: - description: group by - items: + continue: + description: continue + type: boolean + group_by: + description: group by + items: + type: string + type: array + group_interval: + description: group interval + type: string + group_wait: + description: group wait + type: string + match_re: + additionalProperties: + type: string + description: match re + type: object + matchers: + description: matchers + items: + properties: + isEqual: + description: is equal + type: boolean + isRegex: + description: is regex + type: boolean + name: + description: name type: string - type: array - group_interval: - description: group interval - type: string - group_wait: - description: group wait - type: string - match_re: - additionalProperties: + value: + description: value type: string - description: match re - type: object - matchers: - description: matchers + required: + - isRegex + - value + type: object + type: array + mute_time_intervals: + description: mute time intervals + items: + type: string + type: array + object_matchers: + description: object matchers + items: + description: |- + ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. + + swagger:model ObjectMatcher + items: + type: string + type: array + type: array + provenance: + description: provenance + type: string + receiver: + description: receiver + type: string + repeat_interval: + description: repeat interval + type: string + routeSelector: + description: |- + selects GrafanaNotificationPolicyRoutes to merge in when specified + mutually exclusive with Routes + 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: - isEqual: - description: is equal - type: boolean - isRegex: - description: is regex - type: boolean - name: - description: name + key: + description: key is the label key that the selector applies + to. type: string - value: - description: value + 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: - - isRegex - - value + - key + - operator type: object type: array - mute_time_intervals: - description: mute time intervals - items: + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: type: string - type: array - object_matchers: - description: object matchers - items: - description: |- - ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. - - swagger:model ObjectMatcher - items: - type: string - type: array - type: array - provenance: - description: provenance - type: string - receiver: - description: receiver - type: string - repeat_interval: - description: repeat interval - type: string - routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge - in when specified - 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 + 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 - x-kubernetes-map-type: atomic - routes: - description: routes - x-kubernetes-preserve-unknown-fields: true type: object - required: - - route + x-kubernetes-map-type: atomic + routes: + description: routes, mutually exclusive with RouteSelector + x-kubernetes-preserve-unknown-fields: true type: object status: description: The most recent observed state of a Grafana resource diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index bc20d8797..ca05433aa 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -226,13 +226,13 @@ func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Clie visitedChilds[key] = true // Recursively assemble the matched route - if err := assembleRoute(matchedRoute.Spec.Route); err != nil { + if err := assembleRoute(&matchedRoute.Spec.Route); err != nil { return err } delete(visitedChilds, key) - route.Routes = append(route.Routes, matchedRoute.Spec.Route) + route.Routes = append(route.Routes, &matchedRoute.Spec.Route) } } else { // if no RouteSelector is specified, process inline routes, as they are mutually exclusive diff --git a/controllers/notificationpolicy_controller_test.go b/controllers/notificationpolicy_controller_test.go index 7b8847241..e183f77e4 100644 --- a/controllers/notificationpolicy_controller_test.go +++ b/controllers/notificationpolicy_controller_test.go @@ -72,7 +72,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Labels: map[string]string{"tier": "first"}, }, Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ + Route: grafanav1beta1.Route{ Receiver: "team-A-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, }, @@ -114,7 +114,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Labels: map[string]string{"tier": "first"}, }, Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ + Route: grafanav1beta1.Route{ Receiver: "team-A-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, RouteSelector: &metav1.LabelSelector{ @@ -130,7 +130,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Labels: map[string]string{"tier": "second"}, }, Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ + Route: grafanav1beta1.Route{ Receiver: "team-B-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, }, @@ -191,7 +191,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Labels: map[string]string{"tier": "second", "team": "A"}, }, Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ + Route: grafanav1beta1.Route{ Receiver: "project-X-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("project"), Value: "X", IsEqual: true}}, }, @@ -204,7 +204,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Labels: map[string]string{"tier": "second", "team": "B"}, }, Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ + Route: grafanav1beta1.Route{ Receiver: "project-Y-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("project"), Value: "Y", IsEqual: true}}, }, @@ -262,7 +262,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Labels: map[string]string{"tier": "first"}, }, Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ + Route: grafanav1beta1.Route{ Receiver: "team-A-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, RouteSelector: &metav1.LabelSelector{ @@ -278,7 +278,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Labels: map[string]string{"tier": "second"}, }, Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ - Route: &grafanav1beta1.Route{ + Route: grafanav1beta1.Route{ Receiver: "team-B-receiver", Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "B", IsEqual: true}}, RouteSelector: &metav1.LabelSelector{ diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml index 4731a2874..7724549fb 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -188,8 +188,9 @@ spec: description: repeat interval type: string routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge - in when specified + description: |- + selects GrafanaNotificationPolicyRoutes to merge in when specified + mutually exclusive with Routes properties: matchExpressions: description: matchExpressions is a list of label selector @@ -235,7 +236,7 @@ spec: type: object x-kubernetes-map-type: atomic routes: - description: routes + description: routes, mutually exclusive with RouteSelector x-kubernetes-preserve-unknown-fields: true type: object required: diff --git a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index f9005210c..f3b996083 100644 --- a/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/deploy/helm/grafana-operator/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -41,127 +41,122 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - route: - description: Route for alerts to match against - properties: - continue: - description: continue - type: boolean - group_by: - description: group by - items: + continue: + description: continue + type: boolean + group_by: + description: group by + items: + type: string + type: array + group_interval: + description: group interval + type: string + group_wait: + description: group wait + type: string + match_re: + additionalProperties: + type: string + description: match re + type: object + matchers: + description: matchers + items: + properties: + isEqual: + description: is equal + type: boolean + isRegex: + description: is regex + type: boolean + name: + description: name type: string - type: array - group_interval: - description: group interval - type: string - group_wait: - description: group wait - type: string - match_re: - additionalProperties: + value: + description: value type: string - description: match re - type: object - matchers: - description: matchers + required: + - isRegex + - value + type: object + type: array + mute_time_intervals: + description: mute time intervals + items: + type: string + type: array + object_matchers: + description: object matchers + items: + description: |- + ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. + + swagger:model ObjectMatcher + items: + type: string + type: array + type: array + provenance: + description: provenance + type: string + receiver: + description: receiver + type: string + repeat_interval: + description: repeat interval + type: string + routeSelector: + description: |- + selects GrafanaNotificationPolicyRoutes to merge in when specified + mutually exclusive with Routes + 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: - isEqual: - description: is equal - type: boolean - isRegex: - description: is regex - type: boolean - name: - description: name + key: + description: key is the label key that the selector applies + to. type: string - value: - description: value + 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: - - isRegex - - value + - key + - operator type: object type: array - mute_time_intervals: - description: mute time intervals - items: + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: type: string - type: array - object_matchers: - description: object matchers - items: - description: |- - ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. - - swagger:model ObjectMatcher - items: - type: string - type: array - type: array - provenance: - description: provenance - type: string - receiver: - description: receiver - type: string - repeat_interval: - description: repeat interval - type: string - routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge - in when specified - 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 + 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 - x-kubernetes-map-type: atomic - routes: - description: routes - x-kubernetes-preserve-unknown-fields: true type: object - required: - - route + x-kubernetes-map-type: atomic + routes: + description: routes, mutually exclusive with RouteSelector + x-kubernetes-preserve-unknown-fields: true type: object status: description: The most recent observed state of a Grafana resource diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 09b7e3370..3ea5ed500 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -1882,8 +1882,9 @@ spec: description: repeat interval type: string routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge - in when specified + description: |- + selects GrafanaNotificationPolicyRoutes to merge in when specified + mutually exclusive with Routes properties: matchExpressions: description: matchExpressions is a list of label selector @@ -1929,7 +1930,7 @@ spec: type: object x-kubernetes-map-type: atomic routes: - description: routes + description: routes, mutually exclusive with RouteSelector x-kubernetes-preserve-unknown-fields: true type: object required: @@ -2063,127 +2064,122 @@ spec: description: GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificationPolicyRoute properties: - route: - description: Route for alerts to match against - properties: - continue: - description: continue - type: boolean - group_by: - description: group by - items: + continue: + description: continue + type: boolean + group_by: + description: group by + items: + type: string + type: array + group_interval: + description: group interval + type: string + group_wait: + description: group wait + type: string + match_re: + additionalProperties: + type: string + description: match re + type: object + matchers: + description: matchers + items: + properties: + isEqual: + description: is equal + type: boolean + isRegex: + description: is regex + type: boolean + name: + description: name type: string - type: array - group_interval: - description: group interval - type: string - group_wait: - description: group wait - type: string - match_re: - additionalProperties: + value: + description: value type: string - description: match re - type: object - matchers: - description: matchers + required: + - isRegex + - value + type: object + type: array + mute_time_intervals: + description: mute time intervals + items: + type: string + type: array + object_matchers: + description: object matchers + items: + description: |- + ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. + + swagger:model ObjectMatcher + items: + type: string + type: array + type: array + provenance: + description: provenance + type: string + receiver: + description: receiver + type: string + repeat_interval: + description: repeat interval + type: string + routeSelector: + description: |- + selects GrafanaNotificationPolicyRoutes to merge in when specified + mutually exclusive with Routes + 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: - isEqual: - description: is equal - type: boolean - isRegex: - description: is regex - type: boolean - name: - description: name + key: + description: key is the label key that the selector applies + to. type: string - value: - description: value + 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: - - isRegex - - value + - key + - operator type: object type: array - mute_time_intervals: - description: mute time intervals - items: + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: type: string - type: array - object_matchers: - description: object matchers - items: - description: |- - ObjectMatcher ObjectMatcher is a matcher that can be used to filter alerts. - - swagger:model ObjectMatcher - items: - type: string - type: array - type: array - provenance: - description: provenance - type: string - receiver: - description: receiver - type: string - repeat_interval: - description: repeat interval - type: string - routeSelector: - description: Selects GrafanaNotificationPolicyRoutes to merge - in when specified - 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 + 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 - x-kubernetes-map-type: atomic - routes: - description: routes - x-kubernetes-preserve-unknown-fields: true type: object - required: - - route + x-kubernetes-map-type: atomic + routes: + description: routes, mutually exclusive with RouteSelector + x-kubernetes-preserve-unknown-fields: true type: object status: description: The most recent observed state of a Grafana resource diff --git a/examples/notification-policy/routes.yaml b/examples/notification-policy/routes.yaml index a79dea548..53c88d270 100644 --- a/examples/notification-policy/routes.yaml +++ b/examples/notification-policy/routes.yaml @@ -41,18 +41,17 @@ metadata: labels: team-a: "child" spec: - route: - receiver: grafana-default-email - object_matchers: - - - crossNamespace - - = - - "true" - - - dynamic - - = - - c - routeSelector: - matchLabels: - team-c: "child" + receiver: grafana-default-email + object_matchers: + - - crossNamespace + - = + - "true" + - - dynamic + - = + - c + routeSelector: + matchLabels: + team-c: "child" --- apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaNotificationPolicyRoute @@ -61,12 +60,11 @@ metadata: labels: team-c: "child" spec: - route: - receiver: grafana-default-email - object_matchers: - - - dynamic - - = - - d + receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - d --- apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaNotificationPolicyRoute @@ -75,9 +73,8 @@ metadata: labels: team-a: "child" spec: - route: - receiver: grafana-default-email - object_matchers: - - - dynamic - - = - - e + receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - e diff --git a/hack/kind/resources/default/grafana-notification-policy.yaml b/hack/kind/resources/default/grafana-notification-policy.yaml index a79dea548..53c88d270 100644 --- a/hack/kind/resources/default/grafana-notification-policy.yaml +++ b/hack/kind/resources/default/grafana-notification-policy.yaml @@ -41,18 +41,17 @@ metadata: labels: team-a: "child" spec: - route: - receiver: grafana-default-email - object_matchers: - - - crossNamespace - - = - - "true" - - - dynamic - - = - - c - routeSelector: - matchLabels: - team-c: "child" + receiver: grafana-default-email + object_matchers: + - - crossNamespace + - = + - "true" + - - dynamic + - = + - c + routeSelector: + matchLabels: + team-c: "child" --- apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaNotificationPolicyRoute @@ -61,12 +60,11 @@ metadata: labels: team-c: "child" spec: - route: - receiver: grafana-default-email - object_matchers: - - - dynamic - - = - - d + receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - d --- apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaNotificationPolicyRoute @@ -75,9 +73,8 @@ metadata: labels: team-a: "child" spec: - route: - receiver: grafana-default-email - object_matchers: - - - dynamic - - = - - e + receiver: grafana-default-email + object_matchers: + - - dynamic + - = + - e From d97de637dd8c82cb9fdd8e558d5c5a5c359b0a05 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 14:57:57 +0100 Subject: [PATCH 24/39] refactor: remove controller for GrafanaNotificationPolicyRoute --- PROJECT | 2 +- ...afananotificationpolicyroute_controller.go | 62 ------------------- controllers/notificationpolicy_controller.go | 3 + 3 files changed, 4 insertions(+), 63 deletions(-) delete mode 100644 controllers/grafananotificationpolicyroute_controller.go diff --git a/PROJECT b/PROJECT index a780ffb7e..c6c165485 100644 --- a/PROJECT +++ b/PROJECT @@ -67,7 +67,7 @@ resources: - api: crdVersion: v1 namespaced: true - controller: true + controller: false domain: integreatly.org group: grafana kind: GrafanaNotificationPolicyRoute diff --git a/controllers/grafananotificationpolicyroute_controller.go b/controllers/grafananotificationpolicyroute_controller.go deleted file mode 100644 index a4d56454b..000000000 --- a/controllers/grafananotificationpolicyroute_controller.go +++ /dev/null @@ -1,62 +0,0 @@ -/* -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" - - "k8s.io/apimachinery/pkg/runtime" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" - - grafanav1beta1 "github.com/grafana/grafana-operator/v5/api/v1beta1" -) - -// GrafanaNotificationPolicyRouteReconciler reconciles a GrafanaNotificationPolicyRoute object -type GrafanaNotificationPolicyRouteReconciler struct { - client.Client - Scheme *runtime.Scheme -} - -//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes/finalizers,verbs=update - -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the GrafanaNotificationPolicyRoute object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.14.1/pkg/reconcile -func (r *GrafanaNotificationPolicyRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = log.FromContext(ctx) - - // TODO(user): your logic here - - return ctrl.Result{}, nil -} - -// SetupWithManager sets up the controller with the Manager. -func (r *GrafanaNotificationPolicyRouteReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&grafanav1beta1.GrafanaNotificationPolicyRoute{}). - Complete(r) -} diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index ca05433aa..6d7b51f3f 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -60,6 +60,9 @@ type GrafanaNotificationPolicyReconciler struct { //+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicies,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicies/status,verbs=get;update;patch //+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicies/finalizers,verbs=update +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=grafana.integreatly.org,resources=grafananotificationpolicyroutes/finalizers,verbs=update func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { r.Log = log.FromContext(ctx).WithName("GrafanaNotificationPolicyReconciler") From c8b41c9bc6664f3a70cb3ba99111b077ab928168 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 15:07:45 +0100 Subject: [PATCH 25/39] refactor: implement NamespacedResource for NotificationPolicyRoute --- api/v1beta1/grafananotificationpolicyroute_types.go | 6 ++++++ controllers/notificationpolicy_controller.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/api/v1beta1/grafananotificationpolicyroute_types.go b/api/v1beta1/grafananotificationpolicyroute_types.go index cb84a47d7..bb6dd5401 100644 --- a/api/v1beta1/grafananotificationpolicyroute_types.go +++ b/api/v1beta1/grafananotificationpolicyroute_types.go @@ -17,6 +17,8 @@ limitations under the License. package v1beta1 import ( + "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -44,6 +46,10 @@ type GrafanaNotificationPolicyRoute struct { Status GrafanaCommonStatus `json:"status,omitempty"` } +func (r *GrafanaNotificationPolicyRoute) NamespacedResource() string { + return fmt.Sprintf("%v/%v/%v", r.ObjectMeta.Namespace, r.ObjectMeta.Name, r.ObjectMeta.UID) +} + //+kubebuilder:object:root=true // GrafanaNotificationPolicyRouteList contains a list of GrafanaNotificationPolicyRoute diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 6d7b51f3f..b974ae3e6 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -209,7 +209,7 @@ func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Clie route.RouteSelector = nil for i := range routes.Items { matchedRoute := &routes.Items[i] - key := fmt.Sprintf("%s/%s", matchedRoute.Namespace, matchedRoute.Name) + key := matchedRoute.NamespacedResource() // validate constraints if matchedRoute.Spec.Route.IsRouteSelectorMutuallyExclusive() { From 6f6e696af28ca98075b43158ff9e6ccc166f6b0d Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 15:13:43 +0100 Subject: [PATCH 26/39] docs: switch to example image of rendered routes --- .../alerting/dynamic-notification-policy.png | Bin 0 -> 102837 bytes docs/docs/alerting/notification-policies.md | 47 +----------------- 2 files changed, 1 insertion(+), 46 deletions(-) create mode 100644 docs/docs/alerting/dynamic-notification-policy.png diff --git a/docs/docs/alerting/dynamic-notification-policy.png b/docs/docs/alerting/dynamic-notification-policy.png new file mode 100644 index 0000000000000000000000000000000000000000..0d4959f8c5d66e048f9aba253f8b72d2632ec236 GIT binary patch literal 102837 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 z
  • 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 VcszMzg-8WF^mUB1%QPJ@{|0gFfX)B_ literal 0 HcmV?d00001 diff --git a/docs/docs/alerting/notification-policies.md b/docs/docs/alerting/notification-policies.md index 8171818f0..19822c186 100644 --- a/docs/docs/alerting/notification-policies.md +++ b/docs/docs/alerting/notification-policies.md @@ -39,49 +39,4 @@ The following shows an example of how dynamic routes will get merged. The resulting Notification Policy will be the following: -```yaml -apiVersion: 1 -policies: - - orgId: 1 - receiver: grafana-default-email - group_by: - - grafana_folder - - alertname - routes: - - receiver: grafana-default-email - object_matchers: - - - inline - - = - - first - - - team - - = - - a - routes: - - receiver: grafana-default-email - object_matchers: - - - dynamic - - = - - e - - receiver: grafana-default-email - object_matchers: - - - crossNamespace - - = - - "true" - - - dynamic - - = - - c - routes: - - receiver: grafana-default-email - object_matchers: - - - dynamic - - = - - d - - receiver: grafana-default-email - object_matchers: - - - inline - - = - - second - - - team - - = - - b -``` +![Dynamic notification policy tree after applying the example routes](./dynamic-notification-policy.png) From 9fe352455346cfc3f888c0cd369167a01b65d229 Mon Sep 17 00:00:00 2001 From: msvechla Date: Fri, 24 Jan 2025 15:14:37 +0100 Subject: [PATCH 27/39] Update docs/docs/alerting/notification-policies.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dominik Süß --- docs/docs/alerting/notification-policies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/alerting/notification-policies.md b/docs/docs/alerting/notification-policies.md index 19822c186..d73978d4d 100644 --- a/docs/docs/alerting/notification-policies.md +++ b/docs/docs/alerting/notification-policies.md @@ -20,7 +20,7 @@ The following snippet shows an example notification policy routing to the `opera There might be scenarios where you can not define the entire notification policy in a single place and you have to assemble it from multiple resouces. In this case, you can use the `spec.route.routeSelector` in combination with multiple `GrafanaNotificationPolicyRoute` resources. -The `routeSelector` can be specified in any `route` object, including the list of `spec.route.routes`. +Both `GrafanaNotificationPolicy` and `GrafanaNotificationPolicyRoute` objects support the `routeSelector` field. All `GrafanaNotificationPolicyRoute` resources will then be discovered based on the label selector defined in `spec.route.routeSelector`. In case `spec.allowCrossNamespaceImport` is enabled, matching routes will be fetched from all namespaces. From 85c4e4d32a9dabde1a60bd494767a79b9fdebb93 Mon Sep 17 00:00:00 2001 From: msvechla Date: Fri, 24 Jan 2025 15:14:42 +0100 Subject: [PATCH 28/39] Update docs/docs/alerting/notification-policies.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dominik Süß --- docs/docs/alerting/notification-policies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/alerting/notification-policies.md b/docs/docs/alerting/notification-policies.md index d73978d4d..3a5960d6f 100644 --- a/docs/docs/alerting/notification-policies.md +++ b/docs/docs/alerting/notification-policies.md @@ -26,7 +26,7 @@ All `GrafanaNotificationPolicyRoute` resources will then be discovered based on In case `spec.allowCrossNamespaceImport` is enabled, matching routes will be fetched from all namespaces. Otherwise only routes from the same namespace as the `GrafanaNotificationPolicy` will be discovered. -All discovered routes will then get set on the `spec.route.routes[]` of the `GrafanaNotificationPolicy`. +The discovered routes are then used when applying the notification policy. {{% alert title="Note" color="secondary" %}} The `spec.route.routes` and `spec.route.routeSelector` fields are mutually exclusive. From 3fe566d53af859e40ddd09fba31adce95692d6df Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 15:36:41 +0100 Subject: [PATCH 29/39] fix --- main.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/main.go b/main.go index d6748a05b..96d67ee6b 100644 --- a/main.go +++ b/main.go @@ -257,13 +257,6 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "GrafanaNotificationPolicy") os.Exit(1) } - if err = (&controllers.GrafanaNotificationPolicyRouteReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "GrafanaNotificationPolicyRoute") - os.Exit(1) - } if err = (&controllers.GrafanaNotificationTemplateReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), From f9247636f4f3a487978dfa704c7e3195cef2fe87 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 15:40:05 +0100 Subject: [PATCH 30/39] refactor: always sync applyErrors status condition on defer --- controllers/notificationpolicy_controller.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index b974ae3e6..71371c74f 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -47,6 +47,7 @@ const ( conditionNotificationPolicySynchronized = "NotificationPolicySynchronized" conditionRoutesIgnoredDueToRouteSelector = "RoutesIgnoredDueToRouteSelector" annotationAppliedNotificationPolicy = "operator.grafana.com/applied-notificationpolicy" + globalApplyError = "global" ) // GrafanaNotificationPolicyReconciler reconciles a GrafanaNotificationPolicy object @@ -67,6 +68,9 @@ type GrafanaNotificationPolicyReconciler struct { func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { r.Log = log.FromContext(ctx).WithName("GrafanaNotificationPolicyReconciler") + applyErrors := make(map[string]string) + var instances []grafanav1beta1.Grafana + notificationPolicy := &grafanav1beta1.GrafanaNotificationPolicy{} err := r.Client.Get(ctx, client.ObjectKey{ Namespace: req.Namespace, @@ -93,6 +97,9 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req } defer func() { + condition := buildSynchronizedCondition("Notification Policy", conditionNotificationPolicySynchronized, notificationPolicy.Generation, applyErrors, len(instances)) + meta.SetStatusCondition(¬ificationPolicy.Status.Conditions, condition) + notificationPolicy.Status.LastResync = metav1.Time{Time: time.Now()} if err := r.Client.Status().Update(ctx, notificationPolicy); err != nil { r.Log.Error(err, "updating status") @@ -108,7 +115,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req } }() - instances, err := GetScopedMatchingInstances(r.Log, ctx, r.Client, notificationPolicy) + instances, err = GetScopedMatchingInstances(r.Log, ctx, r.Client, notificationPolicy) if err != nil { setNoMatchingInstancesCondition(¬ificationPolicy.Status.Conditions, notificationPolicy.Generation, err) meta.RemoveStatusCondition(¬ificationPolicy.Status.Conditions, conditionNotificationPolicySynchronized) @@ -135,12 +142,11 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req } assembledNotificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, assembledNotificationPolicy) if err != nil { - r.Log.Error(err, "failed to assemble GrafanaNotificationPolicy using routeSelectors") - return ctrl.Result{RequeueAfter: RequeueDelay}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) + applyErrors[globalApplyError] = err.Error() + return ctrl.Result{Requeue: false}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) } } - applyErrors := make(map[string]string) for _, grafana := range instances { // can be removed in go 1.22+ grafana := grafana From 942a0b57db58b8dfa8945b4f8a1548c9cc13c5fd Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 24 Jan 2025 15:41:00 +0100 Subject: [PATCH 31/39] refactor: always sync all GrafanaNotificationPolicy on route changes related GrafanaNotificationPolicies can now no longer easily retrieved by comparing labels, as routes can be referenced transitively. Therefore we simply sync all related policies now. --- controllers/notificationpolicy_controller.go | 47 +++----------------- 1 file changed, 7 insertions(+), 40 deletions(-) diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 71371c74f..52d6a1a37 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -25,7 +25,6 @@ import ( kuberr "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" @@ -362,25 +361,13 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) continue } - allRouteSelectors := getRouteSelectors(np.Spec.Route) - - for _, routeSelector := range allRouteSelectors { - selector, err := metav1.LabelSelectorAsSelector(routeSelector) - if err != nil { - r.Log.Error(err, "failed to create selector from RouteSelector") - continue - } - - if selector.Matches(labels.Set(o.GetLabels())) { - requests = append(requests, - reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: np.Name, - Namespace: np.Namespace, - }, - }) - } - } + requests = append(requests, + reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: np.Name, + Namespace: np.Namespace, + }, + }) } return requests })). @@ -444,26 +431,6 @@ func hasRouteSelector(route *grafanav1beta1.Route) bool { return false } -// getRouteSelectors returns a list of all route selectors specified on a notification policy -// in either the Route.RouteSelector or any of its Routes -func getRouteSelectors(route *grafanav1beta1.Route) []*metav1.LabelSelector { - if route == nil { - return nil - } - - var selectors []*metav1.LabelSelector - - if route.RouteSelector != nil { - selectors = append(selectors, route.RouteSelector) - } - - for _, nestedRoute := range route.Routes { - selectors = append(selectors, getRouteSelectors(nestedRoute)...) - } - - return selectors -} - // statusDiscoveredRoutes returns the list of discovered routes using the namespace and name // Used to display all discovered routes in the GrafanaNotificationPolicy status func statusDiscoveredRoutes(routes []*v1beta1.GrafanaNotificationPolicyRoute) []string { From feeee4127443390f81c795979ab0042ec256aeda Mon Sep 17 00:00:00 2001 From: msvechla Date: Fri, 24 Jan 2025 15:49:38 +0100 Subject: [PATCH 32/39] Update docs/docs/alerting/notification-policies.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dominik Süß --- docs/docs/alerting/notification-policies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/alerting/notification-policies.md b/docs/docs/alerting/notification-policies.md index 3a5960d6f..a2d76e324 100644 --- a/docs/docs/alerting/notification-policies.md +++ b/docs/docs/alerting/notification-policies.md @@ -18,7 +18,7 @@ The following snippet shows an example notification policy routing to the `opera ## Dynamic Notification Policy Routes There might be scenarios where you can not define the entire notification policy in a single place and you have to assemble it from multiple resouces. -In this case, you can use the `spec.route.routeSelector` in combination with multiple `GrafanaNotificationPolicyRoute` resources. +In this case, you can use the `routeSelector` field in combination with multiple `GrafanaNotificationPolicyRoute` resources. Both `GrafanaNotificationPolicy` and `GrafanaNotificationPolicyRoute` objects support the `routeSelector` field. From b79554d897d3a591ab0c6849b98d685a948cc45f Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Mon, 27 Jan 2025 09:25:35 +0100 Subject: [PATCH 33/39] refactor: remove duplicate IsCrossNamespaceImportAllowed --- api/v1beta1/grafananotificationpolicy_types.go | 5 ----- controllers/notificationpolicy_controller.go | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index d65dbf9ec..14c2e84b5 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -178,11 +178,6 @@ func (np *GrafanaNotificationPolicy) NamespacedResource() string { return fmt.Sprintf("%v/%v/%v", np.ObjectMeta.Namespace, np.ObjectMeta.Name, np.ObjectMeta.UID) } -// IsCrossNamespaceImportAllowed returns true when cross namespace imports are allowed -func (np *GrafanaNotificationPolicy) IsCrossNamespaceImportAllowed() bool { - return np.Spec.AllowCrossNamespaceImport -} - //+kubebuilder:object:root=true // GrafanaNotificationPolicyList contains a list of GrafanaNotificationPolicy diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 52d6a1a37..007a67dca 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -135,7 +135,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req if notificationPolicy.Spec.Route.RouteSelector != nil || hasRouteSelector(notificationPolicy.Spec.Route) { var namespace *string - if !notificationPolicy.IsCrossNamespaceImportAllowed() { + if !notificationPolicy.AllowCrossNamespace() { ns := notificationPolicy.GetObjectMeta().GetNamespace() namespace = &ns } @@ -357,7 +357,7 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) continue } - if np.GetNamespace() != o.GetNamespace() && !np.IsCrossNamespaceImportAllowed() { + if np.GetNamespace() != o.GetNamespace() && !np.AllowCrossNamespace() { continue } From 6a702e77dfc7cee10930a5433fa5ac78733ff27d Mon Sep 17 00:00:00 2001 From: msvechla Date: Mon, 27 Jan 2025 09:28:51 +0100 Subject: [PATCH 34/39] Update controllers/notificationpolicy_controller.go Co-authored-by: Steffen Baarsgaard --- controllers/notificationpolicy_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 007a67dca..291aafd5c 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -142,7 +142,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req assembledNotificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, assembledNotificationPolicy) if err != nil { applyErrors[globalApplyError] = err.Error() - return ctrl.Result{Requeue: false}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) + return ctrl.Result{}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) } } From 974edc8a59861f976db556807680c1852fa2ae8b Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Mon, 27 Jan 2025 09:34:03 +0100 Subject: [PATCH 35/39] refactor: move hasRouteSelector to struct function --- .../grafananotificationpolicy_types.go | 15 ++++++++++++ controllers/notificationpolicy_controller.go | 23 ++----------------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index 14c2e84b5..fcea58679 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -152,6 +152,21 @@ func (r *Route) IsRouteSelectorMutuallyExclusive() bool { return true } +// HasRouteSelector checks if the given Route or any of its nested Routes has a RouteSelector +func (r *Route) HasRouteSelector() bool { + if r.RouteSelector != nil { + return true + } + + for _, nestedRoute := range r.Routes { + if nestedRoute.HasRouteSelector() { + return true + } + } + + return false +} + // GrafanaNotificationPolicyStatus defines the observed state of GrafanaNotificationPolicy type GrafanaNotificationPolicyStatus struct { GrafanaCommonStatus `json:",inline"` diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 291aafd5c..62f140ffb 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -133,7 +133,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req var mergedRoutes []*v1beta1.GrafanaNotificationPolicyRoute assembledNotificationPolicy := notificationPolicy.DeepCopy() - if notificationPolicy.Spec.Route.RouteSelector != nil || hasRouteSelector(notificationPolicy.Spec.Route) { + if notificationPolicy.Spec.Route.RouteSelector != nil || notificationPolicy.Spec.Route.HasRouteSelector() { var namespace *string if !notificationPolicy.AllowCrossNamespace() { ns := notificationPolicy.GetObjectMeta().GetNamespace() @@ -353,7 +353,7 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) } requests := []reconcile.Request{} for _, np := range nps.Items { - if !hasRouteSelector(np.Spec.Route) { + if !np.Spec.Route.HasRouteSelector() { continue } @@ -412,25 +412,6 @@ func (r *GrafanaNotificationPolicyReconciler) updateNotificationPolicyRoutesStat return nil } -// hasRouteSelector checks if the given Route or any of its nested Routes has a RouteSelector -func hasRouteSelector(route *grafanav1beta1.Route) bool { - if route == nil { - return false - } - - if route.RouteSelector != nil { - return true - } - - for _, nestedRoute := range route.Routes { - if hasRouteSelector(nestedRoute) { - return true - } - } - - return false -} - // statusDiscoveredRoutes returns the list of discovered routes using the namespace and name // Used to display all discovered routes in the GrafanaNotificationPolicy status func statusDiscoveredRoutes(routes []*v1beta1.GrafanaNotificationPolicyRoute) []string { From 36c1bede6b24d1c353a0c0917eecd6f497095a1e Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Mon, 27 Jan 2025 12:25:47 +0100 Subject: [PATCH 36/39] refactor: set invalid spec on mutual exclusivity constraint not met --- controllers/controller_shared.go | 13 --- controllers/notificationpolicy_controller.go | 96 ++++++++++++++------ 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/controllers/controller_shared.go b/controllers/controller_shared.go index 1dbf6a57f..3a8f08e74 100644 --- a/controllers/controller_shared.go +++ b/controllers/controller_shared.go @@ -235,19 +235,6 @@ func setNoMatchingInstancesCondition(conditions *[]metav1.Condition, generation }) } -func setRoutesIgnoredDueToRouteSelectorCondition(conditions *[]metav1.Condition, generation int64) { - meta.SetStatusCondition(conditions, metav1.Condition{ - Type: conditionRoutesIgnoredDueToRouteSelector, - Status: metav1.ConditionTrue, - ObservedGeneration: generation, - Reason: "BothRoutesAndRouteSelectorSpecified", - Message: "Dynamically matched definitions from routeSelector will take precedence and the routes field is ignored", - LastTransitionTime: metav1.Time{ - Time: time.Now(), - }, - }) -} - func removeNoMatchingInstance(conditions *[]metav1.Condition) { meta.RemoveStatusCondition(conditions, conditionNoMatchingInstance) } diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 62f140ffb..88e267332 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -114,6 +114,13 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req } }() + // check if spec is valid + if !notificationPolicy.Spec.Route.IsRouteSelectorMutuallyExclusive() { + setInvalidSpecMutuallyExclusive(¬ificationPolicy.Status.Conditions, notificationPolicy.Generation) + return ctrl.Result{}, nil + } + removeInvalidSpec(¬ificationPolicy.Status.Conditions) + instances, err = GetScopedMatchingInstances(r.Log, ctx, r.Client, notificationPolicy) if err != nil { setNoMatchingInstancesCondition(¬ificationPolicy.Status.Conditions, notificationPolicy.Generation, err) @@ -131,7 +138,6 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req r.Log.Info("found matching Grafana instances for notificationPolicy", "count", len(instances)) var mergedRoutes []*v1beta1.GrafanaNotificationPolicyRoute - assembledNotificationPolicy := notificationPolicy.DeepCopy() if notificationPolicy.Spec.Route.RouteSelector != nil || notificationPolicy.Spec.Route.HasRouteSelector() { var namespace *string @@ -139,7 +145,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req ns := notificationPolicy.GetObjectMeta().GetNamespace() namespace = &ns } - assembledNotificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, assembledNotificationPolicy) + notificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, notificationPolicy) if err != nil { applyErrors[globalApplyError] = err.Error() return ctrl.Result{}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) @@ -156,7 +162,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req continue } - err := r.reconcileWithInstance(ctx, &grafana, assembledNotificationPolicy) + err := r.reconcileWithInstance(ctx, &grafana, notificationPolicy) if err != nil { applyErrors[fmt.Sprintf("%s/%s", grafana.Namespace, grafana.Name)] = err.Error() } @@ -174,12 +180,6 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req r.Log.Error(err, "failed to add merged events to routes") } - if notificationPolicy.Spec.Route.IsRouteSelectorMutuallyExclusive() { - meta.RemoveStatusCondition(¬ificationPolicy.Status.Conditions, conditionRoutesIgnoredDueToRouteSelector) - } else { - setRoutesIgnoredDueToRouteSelectorCondition(¬ificationPolicy.Status.Conditions, notificationPolicy.Generation) - } - condition := buildSynchronizedCondition("Notification Policy", conditionNotificationPolicySynchronized, notificationPolicy.Generation, applyErrors, len(instances)) meta.SetStatusCondition(¬ificationPolicy.Status.Conditions, condition) @@ -191,7 +191,6 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req // it ensures that there are no reference loops when discovering routes via labelSelectors func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, namespace *string, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) (*grafanav1beta1.GrafanaNotificationPolicy, []*v1beta1.GrafanaNotificationPolicyRoute, error) { - assembledPolicy := notificationPolicy.DeepCopy() mergedRoutes := []*v1beta1.GrafanaNotificationPolicyRoute{} // visitedGlobal keeps track of all routes that have been appended to mergedRoutes @@ -212,17 +211,10 @@ func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Clie // Replace the RouteSelector with matched routes route.RouteSelector = nil - for i := range routes.Items { - matchedRoute := &routes.Items[i] + for i := range routes { + matchedRoute := &routes[i] key := matchedRoute.NamespacedResource() - // validate constraints - if matchedRoute.Spec.Route.IsRouteSelectorMutuallyExclusive() { - meta.RemoveStatusCondition(&matchedRoute.Status.Conditions, conditionRoutesIgnoredDueToRouteSelector) - } else { - setRoutesIgnoredDueToRouteSelectorCondition(&matchedRoute.Status.Conditions, matchedRoute.Generation) - } - if _, exists := visitedGlobal[key]; !exists { mergedRoutes = append(mergedRoutes, matchedRoute) visitedGlobal[key] = true @@ -256,11 +248,11 @@ func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Clie } // Start with Spec.Route - if err := assembleRoute(assembledPolicy.Spec.Route); err != nil { + if err := assembleRoute(notificationPolicy.Spec.Route); err != nil { return nil, nil, err } - return assembledPolicy, mergedRoutes, nil + return notificationPolicy, mergedRoutes, nil } func (r *GrafanaNotificationPolicyReconciler) reconcileWithInstance(ctx context.Context, instance *grafanav1beta1.Grafana, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) error { @@ -345,19 +337,38 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) return requests })). Watches(&grafanav1beta1.GrafanaNotificationPolicyRoute{}, handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, o client.Object) []reconcile.Request { + npr, ok := o.(*grafanav1beta1.GrafanaNotificationPolicyRoute) + if !ok { + r.Log.Error(fmt.Errorf("expected object to be NotificationPolicyRoute"), "skipping resource") + } + + defer func() { + // update the status + if err := r.Client.Status().Update(ctx, npr); err != nil { + r.Log.Error(err, "updating NotificationPolicyRoute status") + } + }() + + // check if notification policy route is valid + if !npr.Spec.Route.IsRouteSelectorMutuallyExclusive() { + setInvalidSpecMutuallyExclusive(&npr.Status.Conditions, npr.Generation) + return nil + } + removeInvalidSpec(&npr.Status.Conditions) + // resync all notification policies that have a routeSelector that matches the routes labels - nps := &grafanav1beta1.GrafanaNotificationPolicyList{} - if err := r.List(ctx, nps); err != nil { + npList := &grafanav1beta1.GrafanaNotificationPolicyList{} + if err := r.List(ctx, npList); err != nil { r.Log.Error(err, "failed to fetch notification policies for watch mapping") return nil } requests := []reconcile.Request{} - for _, np := range nps.Items { + for _, np := range npList.Items { if !np.Spec.Route.HasRouteSelector() { continue } - if np.GetNamespace() != o.GetNamespace() && !np.AllowCrossNamespace() { + if np.GetNamespace() != npr.GetNamespace() && !np.AllowCrossNamespace() { continue } @@ -375,9 +386,9 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) Complete(r) } -// getMatchingNotificationPolicyRoutes retrieves all GrafanaNotificationPolicyRoutes for the given labelSelector -// results will be limited to namespace when specified -func getMatchingNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, labelSelector *metav1.LabelSelector, namespace *string) (*v1beta1.GrafanaNotificationPolicyRouteList, error) { +// getMatchingNotificationPolicyRoutes retrieves all valid GrafanaNotificationPolicyRoutes for the given labelSelector +// results will be limited to namespace when specified and excludes routes with invalidSpec status condition +func getMatchingNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, labelSelector *metav1.LabelSelector, namespace *string) ([]grafanav1beta1.GrafanaNotificationPolicyRoute, error) { if labelSelector == nil { return nil, nil } @@ -392,7 +403,29 @@ func getMatchingNotificationPolicyRoutes(ctx context.Context, k8sClient client.C } err := k8sClient.List(ctx, &list, opts...) - return &list, err + if err != nil { + return nil, err + } + + // Filter out routes with invalidSpec status condition + validRoutes := make([]v1beta1.GrafanaNotificationPolicyRoute, 0, len(list.Items)) + for _, route := range list.Items { + if !hasInvalidSpecCondition(route.Status.Conditions) { + validRoutes = append(validRoutes, route) + } + } + + return validRoutes, nil +} + +// hasInvalidSpecCondition checks if the given conditions contain an invalidSpec condition +func hasInvalidSpecCondition(conditions []metav1.Condition) bool { + for _, condition := range conditions { + if condition.Type == conditionInvalidSpec && condition.Status == metav1.ConditionTrue { + return true + } + } + return false } // updateNotificationPolicyRoutesStatus sets status conditions and emits a merged event to all matched notification policy routes @@ -422,3 +455,8 @@ func statusDiscoveredRoutes(routes []*v1beta1.GrafanaNotificationPolicyRoute) [] return discoveredRoutes } + +// setInvalidSpecMutuallyExclusive sets the invalid spec condition due to the routeSelector being mutually exclusive with routes +func setInvalidSpecMutuallyExclusive(conditions *[]metav1.Condition, generation int64) { + setInvalidSpec(conditions, generation, "FieldsMutuallyExclusive", "RouteSelector and Routes are mutually exclusive") +} From ad310b12614e85b04d683358c753d80391f09a68 Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Mon, 27 Jan 2025 13:22:42 +0100 Subject: [PATCH 37/39] lint: fix test lint issues --- controllers/notificationpolicy_controller_test.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/controllers/notificationpolicy_controller_test.go b/controllers/notificationpolicy_controller_test.go index e183f77e4..7f29019c3 100644 --- a/controllers/notificationpolicy_controller_test.go +++ b/controllers/notificationpolicy_controller_test.go @@ -39,11 +39,6 @@ func stringP(s string) *string { return &s } -func int8P(i int) *int8 { - i8 := int8(i) - return &i8 -} - func TestAssembleNotificationPolicyRoutes(t *testing.T) { tests := []struct { name string @@ -295,7 +290,8 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() s := runtime.NewScheme() - _ = grafanav1beta1.AddToScheme(s) + err := grafanav1beta1.AddToScheme(s) + assert.NoError(t, err, "adding scheme") client := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(routesToRuntimeObjects(tt.existingRoutes)...).Build() gotPolicy, _, err := assembleNotificationPolicyRoutes(ctx, client, nil, tt.notificationPolicy) From b313c10c6070d03118fb7b237e4aefc8c1a82b7a Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Mon, 27 Jan 2025 13:34:11 +0100 Subject: [PATCH 38/39] refactor: move namespace detection to assembleNotificationPolicyRoutes --- controllers/notificationpolicy_controller.go | 14 ++-- .../notificationpolicy_controller_test.go | 66 ++++++++++++++++++- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 88e267332..cd3f72f00 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -140,12 +140,7 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req var mergedRoutes []*v1beta1.GrafanaNotificationPolicyRoute if notificationPolicy.Spec.Route.RouteSelector != nil || notificationPolicy.Spec.Route.HasRouteSelector() { - var namespace *string - if !notificationPolicy.AllowCrossNamespace() { - ns := notificationPolicy.GetObjectMeta().GetNamespace() - namespace = &ns - } - notificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, namespace, notificationPolicy) + notificationPolicy, mergedRoutes, err = assembleNotificationPolicyRoutes(ctx, r.Client, notificationPolicy) if err != nil { applyErrors[globalApplyError] = err.Error() return ctrl.Result{}, fmt.Errorf("failed to assemble GrafanaNotificationPolicy using routeSelectors: %w", err) @@ -190,7 +185,12 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req // returns an assembled GrafanaNotificationPolicy as well as a list of all merged routes. // it ensures that there are no reference loops when discovering routes via labelSelectors -func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, namespace *string, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) (*grafanav1beta1.GrafanaNotificationPolicy, []*v1beta1.GrafanaNotificationPolicyRoute, error) { +func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Client, notificationPolicy *grafanav1beta1.GrafanaNotificationPolicy) (*grafanav1beta1.GrafanaNotificationPolicy, []*v1beta1.GrafanaNotificationPolicyRoute, error) { + var namespace *string + if !notificationPolicy.AllowCrossNamespace() { + ns := notificationPolicy.GetObjectMeta().GetNamespace() + namespace = &ns + } mergedRoutes := []*v1beta1.GrafanaNotificationPolicyRoute{} // visitedGlobal keeps track of all routes that have been appended to mergedRoutes diff --git a/controllers/notificationpolicy_controller_test.go b/controllers/notificationpolicy_controller_test.go index 7f29019c3..a342006d4 100644 --- a/controllers/notificationpolicy_controller_test.go +++ b/controllers/notificationpolicy_controller_test.go @@ -89,6 +89,70 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, wantErr: false, }, + { + name: "Ignore routes from other namespace when cross-namespace import is not allowed", + notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + GrafanaCommonSpec: grafanav1beta1.GrafanaCommonSpec{ + AllowCrossNamespaceImport: false, + }, + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, + }, + }, + }, + existingRoutes: []grafanav1beta1.GrafanaNotificationPolicyRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-1", + Namespace: "default", + Labels: map[string]string{"tier": "first"}, + }, + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: grafanav1beta1.Route{ + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "route-2", + Namespace: "other-namespace", + Labels: map[string]string{"tier": "first"}, + }, + Spec: grafanav1beta1.GrafanaNotificationPolicyRouteSpec{ + Route: grafanav1beta1.Route{ + Receiver: "team-A-receiver-other-namespace", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + }, + }, + }, + }, + want: &grafanav1beta1.GrafanaNotificationPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: grafanav1beta1.GrafanaNotificationPolicySpec{ + Route: &grafanav1beta1.Route{ + Receiver: "default-receiver", + Routes: []*grafanav1beta1.Route{ + { + Receiver: "team-A-receiver", + Matchers: grafanav1beta1.Matchers{&grafanav1beta1.Matcher{Name: stringP("team"), Value: "A", IsEqual: true}}, + }, + }, + }, + }, + }, + wantErr: false, + }, { name: "Assembly with nested routes", notificationPolicy: &grafanav1beta1.GrafanaNotificationPolicy{ @@ -294,7 +358,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { assert.NoError(t, err, "adding scheme") client := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(routesToRuntimeObjects(tt.existingRoutes)...).Build() - gotPolicy, _, err := assembleNotificationPolicyRoutes(ctx, client, nil, tt.notificationPolicy) + gotPolicy, _, err := assembleNotificationPolicyRoutes(ctx, client, tt.notificationPolicy) if tt.wantErr { assert.Error(t, err, "assembleNotificationPolicyRoutes() should return an error") } else { From 0c550a7d009a3f871e4418f4ede9570aef6c702b Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Mon, 27 Jan 2025 15:53:36 +0100 Subject: [PATCH 39/39] refactor: remove duplicate status condition set, already done on defer --- controllers/notificationpolicy_controller.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index cd3f72f00..816a5dac9 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -175,9 +175,6 @@ func (r *GrafanaNotificationPolicyReconciler) Reconcile(ctx context.Context, req r.Log.Error(err, "failed to add merged events to routes") } - condition := buildSynchronizedCondition("Notification Policy", conditionNotificationPolicySynchronized, notificationPolicy.Generation, applyErrors, len(instances)) - meta.SetStatusCondition(¬ificationPolicy.Status.Conditions, condition) - return ctrl.Result{RequeueAfter: notificationPolicy.Spec.ResyncPeriod.Duration}, nil }