From 130a6a05db98fce0cc2e0175a308ba2363d9bf02 Mon Sep 17 00:00:00 2001 From: Steffen Baarsgaard Date: Sun, 2 Nov 2025 17:27:46 +0100 Subject: [PATCH 1/5] fix: Split Route into PartialRoute containing top-level fields Removes a fields from .spec.route preventing users from defining them and ending up with an invalid API request/DTO Additionally, .spec.provenance is deprecated as it seemingly does nothing --- .../grafananotificationpolicy_types.go | 72 ++++--- .../grafananotificationpolicy_types_test.go | 43 +++-- api/v1beta1/zz_generated.deepcopy.go | 76 +++++--- controllers/notificationpolicy_controller.go | 10 +- .../notificationpolicy_controller_test.go | 182 +++++++++++------- 5 files changed, 233 insertions(+), 150 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index fca64ff64..df5b4a9a6 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -29,7 +29,7 @@ type GrafanaNotificationPolicySpec struct { GrafanaCommonSpec `json:",inline"` // Routes for alerts to match against - Route *Route `json:"route"` + Route *PartialRoute `json:"route"` // Whether to enable or disable editing of the notification policy in Grafana UI // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" @@ -37,10 +37,7 @@ type GrafanaNotificationPolicySpec struct { Editable *bool `json:"editable,omitempty"` } -type Route struct { - // continue - Continue bool `json:"continue,omitempty"` - +type PartialRoute struct { // group by GroupBy []string `json:"group_by,omitempty"` @@ -50,23 +47,6 @@ type Route struct { // group wait GroupWait string `json:"group_wait,omitempty"` - // match re - MatchRe models.MatchRegexps `json:"match_re,omitempty"` - - // matchers - Matchers Matchers `json:"matchers,omitempty"` - - // mute time intervals - MuteTimeIntervals []string `json:"mute_time_intervals,omitempty"` - - ActiveTimeIntervals []string `json:"active_time_intervals,omitempty"` - - // object matchers - ObjectMatchers models.ObjectMatchers `json:"object_matchers,omitempty"` - - // provenance - Provenance models.Provenance `json:"provenance,omitempty"` - // receiver // +kubebuilder:validation:MinLength=1 Receiver string `json:"receiver"` @@ -82,6 +62,31 @@ type Route struct { // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Schemaless Routes []*Route `json:"routes,omitempty"` + + // Deprecated: Does nothing + Provenance models.Provenance `json:"provenance,omitempty"` +} + +type Route struct { + PartialRoute `json:",inline"` + + // continue + Continue bool `json:"continue,omitempty"` + + // match re + MatchRe models.MatchRegexps `json:"match_re,omitempty"` + + // matchers + Matchers Matchers `json:"matchers,omitempty"` + + // object matchers + ObjectMatchers models.ObjectMatchers `json:"object_matchers,omitempty"` + + // mute time intervals + MuteTimeIntervals []string `json:"mute_time_intervals,omitempty"` + + // active time intervals + ActiveTimeIntervals []string `json:"active_time_intervals,omitempty"` } type Matcher struct { @@ -124,7 +129,6 @@ func (r *Route) ToModelRoute() *models.Route { MuteTimeIntervals: r.MuteTimeIntervals, ActiveTimeIntervals: r.ActiveTimeIntervals, ObjectMatchers: r.ObjectMatchers, - Provenance: r.Provenance, Receiver: r.Receiver, RepeatInterval: r.RepeatInterval, Routes: make([]*models.Route, len(r.Routes)), @@ -136,15 +140,31 @@ func (r *Route) ToModelRoute() *models.Route { return out } +func (r *PartialRoute) ToModelRoute() *models.Route { + out := &models.Route{ + GroupBy: r.GroupBy, + GroupInterval: r.GroupInterval, + GroupWait: r.GroupWait, + Receiver: r.Receiver, + RepeatInterval: r.RepeatInterval, + Routes: make([]*models.Route, len(r.Routes)), + } + for i, v := range r.Routes { + out.Routes[i] = v.ToModelRoute() + } + + return out +} + // 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 { +func (r *PartialRoute) selectorMutuallyExclusive() bool { return !(r.RouteSelector != nil && len(r.Routes) > 0) //nolint:staticcheck } // 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 { +func (r *PartialRoute) IsRouteSelectorMutuallyExclusive() bool { if !r.selectorMutuallyExclusive() { return false } @@ -160,7 +180,7 @@ func (r *Route) IsRouteSelectorMutuallyExclusive() bool { } // HasRouteSelector checks if the given Route or any of its nested Routes has a RouteSelector -func (r *Route) HasRouteSelector() bool { +func (r *PartialRoute) HasRouteSelector() bool { if r.RouteSelector != nil { return true } diff --git a/api/v1beta1/grafananotificationpolicy_types_test.go b/api/v1beta1/grafananotificationpolicy_types_test.go index c652b9a87..9dce74240 100644 --- a/api/v1beta1/grafananotificationpolicy_types_test.go +++ b/api/v1beta1/grafananotificationpolicy_types_test.go @@ -39,13 +39,10 @@ func newNotificationPolicy(name string, editable *bool) *GrafanaNotificationPoli }, }, }, - Route: &Route{ - Continue: false, - Receiver: "grafana-default-email", - GroupBy: []string{"group_name", "alert_name"}, - MuteTimeIntervals: []string{}, - ActiveTimeIntervals: []string{}, - Routes: []*Route{}, + Route: &PartialRoute{ + Receiver: "grafana-default-email", + GroupBy: []string{"group_name", "alert_name"}, + Routes: []*Route{}, }, }, } @@ -100,24 +97,24 @@ var _ = Describe("NotificationPolicy type", func() { func TestIsRouteSelectorMutuallyExclusive(t *testing.T) { tests := []struct { name string - route *Route + route *PartialRoute expected bool }{ { name: "Empty route", - route: &Route{}, + route: &PartialRoute{}, expected: true, }, { name: "Route with only RouteSelector", - route: &Route{ + route: &PartialRoute{ RouteSelector: &metav1.LabelSelector{}, }, expected: true, }, { name: "Route with only sub-routes", - route: &Route{ + route: &PartialRoute{ Routes: []*Route{ {}, {}, @@ -127,7 +124,7 @@ func TestIsRouteSelectorMutuallyExclusive(t *testing.T) { }, { name: "Route with both RouteSelector and sub-routes", - route: &Route{ + route: &PartialRoute{ RouteSelector: &metav1.LabelSelector{}, Routes: []*Route{ {}, @@ -137,14 +134,18 @@ func TestIsRouteSelectorMutuallyExclusive(t *testing.T) { }, { name: "Nested routes with mutual exclusivity", - route: &Route{ + route: &PartialRoute{ Routes: []*Route{ { - RouteSelector: &metav1.LabelSelector{}, + PartialRoute: PartialRoute{ + RouteSelector: &metav1.LabelSelector{}, + }, }, { - Routes: []*Route{ - {}, + PartialRoute: PartialRoute{ + Routes: []*Route{ + {}, + }, }, }, }, @@ -153,12 +154,14 @@ func TestIsRouteSelectorMutuallyExclusive(t *testing.T) { }, { name: "Nested routes without mutual exclusivity", - route: &Route{ + route: &PartialRoute{ Routes: []*Route{ { - RouteSelector: &metav1.LabelSelector{}, - Routes: []*Route{ - {}, + PartialRoute: PartialRoute{ + RouteSelector: &metav1.LabelSelector{}, + Routes: []*Route{ + {}, + }, }, }, }, diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index e3699833a..1e91811d4 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1619,7 +1619,7 @@ func (in *GrafanaNotificationPolicySpec) DeepCopyInto(out *GrafanaNotificationPo in.GrafanaCommonSpec.DeepCopyInto(&out.GrafanaCommonSpec) if in.Route != nil { in, out := &in.Route, &out.Route - *out = new(Route) + *out = new(PartialRoute) (*in).DeepCopyInto(*out) } if in.Editable != nil { @@ -2363,6 +2363,42 @@ func (in *OperatorReconcileVars) DeepCopy() *OperatorReconcileVars { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PartialRoute) DeepCopyInto(out *PartialRoute) { + *out = *in + if in.GroupBy != nil { + in, out := &in.GroupBy, &out.GroupBy + *out = make([]string, len(*in)) + copy(*out, *in) + } + 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)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(Route) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PartialRoute. +func (in *PartialRoute) DeepCopy() *PartialRoute { + if in == nil { + return nil + } + out := new(PartialRoute) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PersistentVolumeClaimV1) DeepCopyInto(out *PersistentVolumeClaimV1) { *out = *in @@ -2492,11 +2528,7 @@ func (in *Record) DeepCopy() *Record { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Route) DeepCopyInto(out *Route) { *out = *in - if in.GroupBy != nil { - in, out := &in.GroupBy, &out.GroupBy - *out = make([]string, len(*in)) - copy(*out, *in) - } + in.PartialRoute.DeepCopyInto(&out.PartialRoute) if in.MatchRe != nil { in, out := &in.MatchRe, &out.MatchRe *out = make(models.MatchRegexps, len(*in)) @@ -2515,16 +2547,6 @@ func (in *Route) DeepCopyInto(out *Route) { } } } - if in.MuteTimeIntervals != nil { - in, out := &in.MuteTimeIntervals, &out.MuteTimeIntervals - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.ActiveTimeIntervals != nil { - in, out := &in.ActiveTimeIntervals, &out.ActiveTimeIntervals - *out = make([]string, len(*in)) - copy(*out, *in) - } if in.ObjectMatchers != nil { in, out := &in.ObjectMatchers, &out.ObjectMatchers *out = make(models.ObjectMatchers, len(*in)) @@ -2536,21 +2558,15 @@ 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.MuteTimeIntervals != nil { + in, out := &in.MuteTimeIntervals, &out.MuteTimeIntervals + *out = make([]string, len(*in)) + copy(*out, *in) } - if in.Routes != nil { - in, out := &in.Routes, &out.Routes - *out = make([]*Route, len(*in)) - for i := range *in { - if (*in)[i] != nil { - in, out := &(*in)[i], &(*out)[i] - *out = new(Route) - (*in).DeepCopyInto(*out) - } - } + if in.ActiveTimeIntervals != nil { + in, out := &in.ActiveTimeIntervals, &out.ActiveTimeIntervals + *out = make([]string, len(*in)) + copy(*out, *in) } } diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 7251930d7..7e272460b 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -208,9 +208,9 @@ func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Clie // so we can detect loops visitedChilds := make(map[string]bool) - var assembleRoute func(*v1beta1.Route) error + var assembleRoute func(*v1beta1.PartialRoute) error - assembleRoute = func(route *v1beta1.Route) error { + assembleRoute = func(route *v1beta1.PartialRoute) error { if route.RouteSelector != nil { routes, err := getMatchingNotificationPolicyRoutes(ctx, k8sClient, route.RouteSelector, namespace) if err != nil { @@ -236,7 +236,7 @@ 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.PartialRoute); err != nil { return err } @@ -247,7 +247,7 @@ func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Clie } 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 { + if err := assembleRoute(&inlineRoute.PartialRoute); err != nil { return err } @@ -375,7 +375,7 @@ func (r *GrafanaNotificationPolicyReconciler) SetupWithManager(mgr ctrl.Manager) }() // check if notification policy route is valid - if !npr.Spec.Route.IsRouteSelectorMutuallyExclusive() { + if !npr.Spec.PartialRoute.IsRouteSelectorMutuallyExclusive() { setInvalidSpecMutuallyExclusive(&npr.Status.Conditions, npr.Generation) return nil } diff --git a/controllers/notificationpolicy_controller_test.go b/controllers/notificationpolicy_controller_test.go index 67c2d66fb..5a26f53d6 100644 --- a/controllers/notificationpolicy_controller_test.go +++ b/controllers/notificationpolicy_controller_test.go @@ -53,7 +53,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { name: "Simple assembly with one level of routes", notificationPolicy: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", RouteSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{"tier": "first"}, @@ -70,7 +70,9 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "team-A-receiver", + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + }, Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, }, }, @@ -78,14 +80,14 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, want: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", - Routes: []*v1beta1.Route{ - { + Routes: []*v1beta1.Route{{ + PartialRoute: v1beta1.PartialRoute{ Receiver: "team-A-receiver", - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, }, - }, + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + }}, }, }, }, @@ -101,7 +103,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { GrafanaCommonSpec: v1beta1.GrafanaCommonSpec{ AllowCrossNamespaceImport: false, }, - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", RouteSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{"tier": "first"}, @@ -118,8 +120,10 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "team-A-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + }, }, }, }, @@ -131,8 +135,10 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "team-A-receiver-other-namespace", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver-other-namespace", + }, }, }, }, @@ -142,12 +148,14 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Namespace: "default", }, Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", Routes: []*v1beta1.Route{ { - Receiver: "team-A-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + }, }, }, }, @@ -159,7 +167,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { name: "Assembly with nested routes", notificationPolicy: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", RouteSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{"tier": "first"}, @@ -176,10 +184,12 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "team-A-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "second"}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second"}, + }, }, }, }, @@ -192,7 +202,9 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "team-B-receiver", + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-B-receiver", + }, Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, }, }, @@ -200,16 +212,20 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, want: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", Routes: []*v1beta1.Route{ { - Receiver: "team-A-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - Routes: []*v1beta1.Route{ - { - Receiver: "team-B-receiver", - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-B-receiver", + }, + }, }, }, }, @@ -223,21 +239,25 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { name: "Assembly with nested routes and multiple RouteSelectors inside Routes", notificationPolicy: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", Routes: []*v1beta1.Route{ { - Receiver: "team-A-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "second", "team": "A"}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second", "team": "A"}, + }, }, }, { - Receiver: "team-B-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "second", "team": "B"}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-B-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second", "team": "B"}, + }, }, }, }, @@ -253,8 +273,10 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "project-X-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "X", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "project-X-receiver", + }, }, }, }, @@ -266,34 +288,44 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "project-Y-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "Y", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "project-Y-receiver", + }, }, }, }, }, want: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", Routes: []*v1beta1.Route{ { - Receiver: "team-A-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - Routes: []*v1beta1.Route{ - { - Receiver: "project-X-receiver", - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "X", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "X", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "project-X-receiver", + }, + }, }, }, }, { - Receiver: "team-B-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, - Routes: []*v1beta1.Route{ - { - Receiver: "project-Y-receiver", - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "Y", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-B-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "Y", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "project-Y-receiver", + }, + }, }, }, }, @@ -307,7 +339,7 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { name: "Detect loop in routes", notificationPolicy: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", RouteSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{"tier": "first"}, @@ -324,10 +356,12 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "team-A-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "second"}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second"}, + }, }, }, }, @@ -340,10 +374,12 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "team-B-receiver", Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "first"}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-B-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, }, }, }, @@ -391,7 +427,7 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaSuspended, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecSuspended, - Route: &v1beta1.Route{Receiver: "default-receiver"}, + Route: &v1beta1.PartialRoute{Receiver: "default-receiver"}, }, want: metav1.Condition{ Type: conditionSuspended, @@ -403,7 +439,7 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaNoMatchingInstances, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecNoMatchingInstances, - Route: &v1beta1.Route{Receiver: "default-receiver"}, + Route: &v1beta1.PartialRoute{Receiver: "default-receiver"}, }, want: metav1.Condition{ Type: conditionNoMatchingInstance, @@ -416,7 +452,7 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaApplyFailed, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecApplyFailed, - Route: &v1beta1.Route{Receiver: "default-receiver"}, + Route: &v1beta1.PartialRoute{Receiver: "default-receiver"}, }, want: metav1.Condition{ Type: conditionNotificationPolicySynchronized, @@ -429,10 +465,12 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaInvalidSpec, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecInvalidSpec, - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "default-receiver", Routes: []*v1beta1.Route{{ - Receiver: "default-receiver", + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + }, }}, RouteSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{}, @@ -450,7 +488,7 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaSynchronized, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecSynchronized, - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "grafana-default-email", }, }, @@ -489,14 +527,16 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke LoopDetected Condition" MatchLabels: map[string]string{"loop-detected": "test"}, }, }, - Route: &v1beta1.Route{ + Route: &v1beta1.PartialRoute{ Receiver: "grafana-default-email", Routes: []*v1beta1.Route{{ - Receiver: "grafana-default-email", - Matchers: v1beta1.Matchers{{Name: ptr.To("team"), Value: "a", IsEqual: true}}, - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"team-a": "child"}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "grafana-default-email", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team-a": "child"}, + }, }, + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "a", IsEqual: true}}, }}, }, }, @@ -509,10 +549,12 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke LoopDetected Condition" }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "grafana-default-email", - Matchers: v1beta1.Matchers{{Name: ptr.To("team"), Value: "b", IsEqual: true}}, - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"team-b": "child"}, + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "b", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "grafana-default-email", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team-b": "child"}, + }, }, }, }, @@ -525,10 +567,12 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke LoopDetected Condition" }, Spec: v1beta1.GrafanaNotificationPolicyRouteSpec{ Route: v1beta1.Route{ - Receiver: "grafana-default-email", - Matchers: v1beta1.Matchers{{Name: ptr.To("team"), Value: "b", IsEqual: true}}, // Also matches team b - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"team-b": "child"}, + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "b", IsEqual: true}}, // Also matches team b + PartialRoute: v1beta1.PartialRoute{ + Receiver: "grafana-default-email", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team-b": "child"}, + }, }, }, }, From e80f328cf2029f3bd5fae4f741ec90ab46255a8f Mon Sep 17 00:00:00 2001 From: Steffen Baarsgaard Date: Sun, 2 Nov 2025 17:28:05 +0100 Subject: [PATCH 2/5] chore: Generate crds/docs --- ...eatly.org_grafananotificationpolicies.yaml | 51 +--------- ...y.org_grafananotificationpolicyroutes.yaml | 3 +- ...eatly.org_grafananotificationpolicies.yaml | 51 +--------- ...y.org_grafananotificationpolicyroutes.yaml | 3 +- deploy/kustomize/base/crds.yaml | 54 +---------- docs/docs/api.md | 96 +------------------ 6 files changed, 12 insertions(+), 246 deletions(-) diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml index fe6208cfb..0daa917b0 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -120,13 +120,6 @@ spec: route: description: Routes for alerts to match against properties: - active_time_intervals: - items: - type: string - type: array - continue: - description: continue - type: boolean group_by: description: group by items: @@ -138,50 +131,8 @@ spec: 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 + description: 'Deprecated: Does nothing' type: string receiver: description: receiver diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index 1f11c0921..708c2fa3e 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -44,6 +44,7 @@ spec: of GrafanaNotificationPolicyRoute properties: active_time_intervals: + description: active time intervals items: type: string type: array @@ -104,7 +105,7 @@ spec: type: array type: array provenance: - description: provenance + description: 'Deprecated: Does nothing' type: string receiver: description: receiver diff --git a/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicies.yaml b/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicies.yaml index fe6208cfb..0daa917b0 100644 --- a/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -120,13 +120,6 @@ spec: route: description: Routes for alerts to match against properties: - active_time_intervals: - items: - type: string - type: array - continue: - description: continue - type: boolean group_by: description: group by items: @@ -138,50 +131,8 @@ spec: 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 + description: 'Deprecated: Does nothing' type: string receiver: description: receiver diff --git a/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml b/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml index 1f11c0921..708c2fa3e 100644 --- a/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml +++ b/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicyroutes.yaml @@ -44,6 +44,7 @@ spec: of GrafanaNotificationPolicyRoute properties: active_time_intervals: + description: active time intervals items: type: string type: array @@ -104,7 +105,7 @@ spec: type: array type: array provenance: - description: provenance + description: 'Deprecated: Does nothing' type: string receiver: description: receiver diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 3d4921609..1558ac9a9 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -2754,13 +2754,6 @@ spec: route: description: Routes for alerts to match against properties: - active_time_intervals: - items: - type: string - type: array - continue: - description: continue - type: boolean group_by: description: group by items: @@ -2772,50 +2765,8 @@ spec: 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 + description: 'Deprecated: Does nothing' type: string receiver: description: receiver @@ -3018,6 +2969,7 @@ spec: of GrafanaNotificationPolicyRoute properties: active_time_intervals: + description: active time intervals items: type: string type: array @@ -3078,7 +3030,7 @@ spec: type: array type: array provenance: - description: provenance + description: 'Deprecated: Does nothing' type: string receiver: description: receiver diff --git a/docs/docs/api.md b/docs/docs/api.md index a77842094..b82377ea3 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -5370,20 +5370,6 @@ Routes for alerts to match against receiver
true - - active_time_intervals - []string - -
- - false - - continue - boolean - - continue
- - false group_by []string @@ -5405,39 +5391,11 @@ Routes for alerts to match against group wait
false - - match_re - map[string]string - - match re
- - false - - matchers - []object - - matchers
- - false - - mute_time_intervals - []string - - mute time intervals
- - false - - object_matchers - [][]string - - object matchers
- - false provenance string - provenance
+ Deprecated: Does nothing
false @@ -5466,54 +5424,6 @@ mutually exclusive with Routes
-### GrafanaNotificationPolicy.spec.route.matchers[index] -[↩ Parent](#grafananotificationpolicyspecroute) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescriptionRequired
isRegexboolean - is regex
-
true
valuestring - value
-
true
isEqualboolean - is equal
-
false
namestring - name
-
false
- - ### GrafanaNotificationPolicy.spec.route.routeSelector [↩ Parent](#grafananotificationpolicyspecroute) @@ -5797,7 +5707,7 @@ GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificat active_time_intervals []string -
+ active time intervals
false @@ -5860,7 +5770,7 @@ GrafanaNotificationPolicyRouteSpec defines the desired state of GrafanaNotificat provenance string - provenance
+ Deprecated: Does nothing
false From 5521342f226ea3e07fa9f3468df0359b64915153 Mon Sep 17 00:00:00 2001 From: Steffen Baarsgaard Date: Thu, 4 Dec 2025 03:30:13 +0100 Subject: [PATCH 3/5] fix: Make all fields present on root route, but invalidate them CEL rules --- .../grafananotificationpolicy_types.go | 32 ++- .../grafananotificationpolicy_types_test.go | 10 +- api/v1beta1/zz_generated.deepcopy.go | 57 ++++- controllers/notificationpolicy_controller.go | 2 +- .../notificationpolicy_controller_test.go | 236 ++++++++++-------- 5 files changed, 225 insertions(+), 112 deletions(-) diff --git a/api/v1beta1/grafananotificationpolicy_types.go b/api/v1beta1/grafananotificationpolicy_types.go index df5b4a9a6..548b9041b 100644 --- a/api/v1beta1/grafananotificationpolicy_types.go +++ b/api/v1beta1/grafananotificationpolicy_types.go @@ -29,7 +29,7 @@ type GrafanaNotificationPolicySpec struct { GrafanaCommonSpec `json:",inline"` // Routes for alerts to match against - Route *PartialRoute `json:"route"` + Route *RootRoute `json:"route"` // Whether to enable or disable editing of the notification policy in Grafana UI // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" @@ -67,6 +67,34 @@ type PartialRoute struct { Provenance models.Provenance `json:"provenance,omitempty"` } +// +kubebuilder:validation:XValidation:rule="!has(self.continue)", message="continue is invalid on the top level route node" +// +kubebuilder:validation:XValidation:rule="!has(self.match_re)", message="match_re is invalid on the top level route node" +// +kubebuilder:validation:XValidation:rule="!has(self.matchers)", message="matchers is invalid on the top level route node" +// +kubebuilder:validation:XValidation:rule="!has(self.object_matchers)", message="object_matchers is invalid on the top level route node" +// +kubebuilder:validation:XValidation:rule="!has(self.mute_time_intervals)", message="mute_time_intervals is invalid on the top level route node" +// +kubebuilder:validation:XValidation:rule="!has(self.active_time_intervals)", message="active_time_intervals is invalid on the top level route node" +type RootRoute struct { + PartialRoute `json:",inline"` + + // Deprecated: Never worked on the top level route node + Continue bool `json:"continue,omitempty"` + + // Deprecated: Never worked on the top level route node + MatchRe models.MatchRegexps `json:"match_re,omitempty"` + + // Deprecated: Never worked on the top level route node + Matchers Matchers `json:"matchers,omitempty"` + + // Deprecated: Never worked on the top level route node + ObjectMatchers models.ObjectMatchers `json:"object_matchers,omitempty"` + + // Deprecated: Never worked on the top level route node + MuteTimeIntervals []string `json:"mute_time_intervals,omitempty"` + + // Deprecated: Never worked on the top level route node + ActiveTimeIntervals []string `json:"active_time_intervals,omitempty"` +} + type Route struct { PartialRoute `json:",inline"` @@ -140,7 +168,7 @@ func (r *Route) ToModelRoute() *models.Route { return out } -func (r *PartialRoute) ToModelRoute() *models.Route { +func (r *RootRoute) ToModelRoute() *models.Route { out := &models.Route{ GroupBy: r.GroupBy, GroupInterval: r.GroupInterval, diff --git a/api/v1beta1/grafananotificationpolicy_types_test.go b/api/v1beta1/grafananotificationpolicy_types_test.go index 9dce74240..38305b6d9 100644 --- a/api/v1beta1/grafananotificationpolicy_types_test.go +++ b/api/v1beta1/grafananotificationpolicy_types_test.go @@ -39,10 +39,12 @@ func newNotificationPolicy(name string, editable *bool) *GrafanaNotificationPoli }, }, }, - Route: &PartialRoute{ - Receiver: "grafana-default-email", - GroupBy: []string{"group_name", "alert_name"}, - Routes: []*Route{}, + Route: &RootRoute{ + PartialRoute: PartialRoute{ + Receiver: "grafana-default-email", + GroupBy: []string{"group_name", "alert_name"}, + Routes: []*Route{}, + }, }, }, } diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 1e91811d4..2639a56ef 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -1619,7 +1619,7 @@ func (in *GrafanaNotificationPolicySpec) DeepCopyInto(out *GrafanaNotificationPo in.GrafanaCommonSpec.DeepCopyInto(&out.GrafanaCommonSpec) if in.Route != nil { in, out := &in.Route, &out.Route - *out = new(PartialRoute) + *out = new(RootRoute) (*in).DeepCopyInto(*out) } if in.Editable != nil { @@ -2525,6 +2525,61 @@ func (in *Record) DeepCopy() *Record { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RootRoute) DeepCopyInto(out *RootRoute) { + *out = *in + in.PartialRoute.DeepCopyInto(&out.PartialRoute) + if in.MatchRe != nil { + in, out := &in.MatchRe, &out.MatchRe + *out = make(models.MatchRegexps, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Matchers != nil { + in, out := &in.Matchers, &out.Matchers + *out = make(Matchers, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(Matcher) + (*in).DeepCopyInto(*out) + } + } + } + if in.ObjectMatchers != nil { + in, out := &in.ObjectMatchers, &out.ObjectMatchers + *out = make(models.ObjectMatchers, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = make(models.ObjectMatcher, len(*in)) + copy(*out, *in) + } + } + } + if in.MuteTimeIntervals != nil { + in, out := &in.MuteTimeIntervals, &out.MuteTimeIntervals + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ActiveTimeIntervals != nil { + in, out := &in.ActiveTimeIntervals, &out.ActiveTimeIntervals + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RootRoute. +func (in *RootRoute) DeepCopy() *RootRoute { + if in == nil { + return nil + } + out := new(RootRoute) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Route) DeepCopyInto(out *Route) { *out = *in diff --git a/controllers/notificationpolicy_controller.go b/controllers/notificationpolicy_controller.go index 7e272460b..09ac86afc 100644 --- a/controllers/notificationpolicy_controller.go +++ b/controllers/notificationpolicy_controller.go @@ -259,7 +259,7 @@ func assembleNotificationPolicyRoutes(ctx context.Context, k8sClient client.Clie } // Start with Spec.Route - if err := assembleRoute(cr.Spec.Route); err != nil { + if err := assembleRoute(&cr.Spec.Route.PartialRoute); err != nil { return nil, err } diff --git a/controllers/notificationpolicy_controller_test.go b/controllers/notificationpolicy_controller_test.go index 5a26f53d6..952d71c4c 100644 --- a/controllers/notificationpolicy_controller_test.go +++ b/controllers/notificationpolicy_controller_test.go @@ -53,10 +53,12 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { name: "Simple assembly with one level of routes", notificationPolicy: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "first"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, }, }, }, @@ -80,14 +82,16 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, want: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - Routes: []*v1beta1.Route{{ - PartialRoute: v1beta1.PartialRoute{ - Receiver: "team-A-receiver", - }, - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - }}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + Routes: []*v1beta1.Route{{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + }, + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + }}, + }, }, }, }, @@ -103,10 +107,12 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { GrafanaCommonSpec: v1beta1.GrafanaCommonSpec{ AllowCrossNamespaceImport: false, }, - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "first"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, }, }, }, @@ -148,13 +154,15 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { Namespace: "default", }, Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - Routes: []*v1beta1.Route{ - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "team-A-receiver", + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + }, }, }, }, @@ -167,10 +175,12 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { name: "Assembly with nested routes", notificationPolicy: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "first"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, }, }, }, @@ -212,18 +222,20 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, want: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - Routes: []*v1beta1.Route{ - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "team-A-receiver", - Routes: []*v1beta1.Route{ - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "team-B-receiver", + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-B-receiver", + }, }, }, }, @@ -239,24 +251,26 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { name: "Assembly with nested routes and multiple RouteSelectors inside Routes", notificationPolicy: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - Routes: []*v1beta1.Route{ - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "team-A-receiver", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "second", "team": "A"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second", "team": "A"}, + }, }, }, - }, - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "team-B-receiver", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "second", "team": "B"}, + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-B-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "second", "team": "B"}, + }, }, }, }, @@ -298,32 +312,34 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { }, want: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - Routes: []*v1beta1.Route{ - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "team-A-receiver", - Routes: []*v1beta1.Route{ - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "X", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "project-X-receiver", + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-A-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "X", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "project-X-receiver", + }, }, }, }, }, - }, - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "team-B-receiver", - Routes: []*v1beta1.Route{ - { - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "Y", IsEqual: true}}, - PartialRoute: v1beta1.PartialRoute{ - Receiver: "project-Y-receiver", + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "B", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "team-B-receiver", + Routes: []*v1beta1.Route{ + { + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("project"), Value: "Y", IsEqual: true}}, + PartialRoute: v1beta1.PartialRoute{ + Receiver: "project-Y-receiver", + }, }, }, }, @@ -339,10 +355,12 @@ func TestAssembleNotificationPolicyRoutes(t *testing.T) { name: "Detect loop in routes", notificationPolicy: &v1beta1.GrafanaNotificationPolicy{ Spec: v1beta1.GrafanaNotificationPolicySpec{ - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"tier": "first"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"tier": "first"}, + }, }, }, }, @@ -427,7 +445,9 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaSuspended, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecSuspended, - Route: &v1beta1.PartialRoute{Receiver: "default-receiver"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{Receiver: "default-receiver"}, + }, }, want: metav1.Condition{ Type: conditionSuspended, @@ -439,7 +459,9 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaNoMatchingInstances, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecNoMatchingInstances, - Route: &v1beta1.PartialRoute{Receiver: "default-receiver"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{Receiver: "default-receiver"}, + }, }, want: metav1.Condition{ Type: conditionNoMatchingInstance, @@ -452,7 +474,9 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaApplyFailed, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecApplyFailed, - Route: &v1beta1.PartialRoute{Receiver: "default-receiver"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{Receiver: "default-receiver"}, + }, }, want: metav1.Condition{ Type: conditionNotificationPolicySynchronized, @@ -465,15 +489,17 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaInvalidSpec, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecInvalidSpec, - Route: &v1beta1.PartialRoute{ - Receiver: "default-receiver", - Routes: []*v1beta1.Route{{ - PartialRoute: v1beta1.PartialRoute{ - Receiver: "default-receiver", + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + Routes: []*v1beta1.Route{{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "default-receiver", + }, + }}, + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{}, }, - }}, - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{}, }, }, }, @@ -488,8 +514,8 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke Conditions", func() { meta: objectMetaSynchronized, spec: v1beta1.GrafanaNotificationPolicySpec{ GrafanaCommonSpec: commonSpecSynchronized, - Route: &v1beta1.PartialRoute{ - Receiver: "grafana-default-email", + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{Receiver: "grafana-default-email"}, }, }, want: metav1.Condition{ @@ -527,17 +553,19 @@ var _ = Describe("NotificationPolicy Reconciler: Provoke LoopDetected Condition" MatchLabels: map[string]string{"loop-detected": "test"}, }, }, - Route: &v1beta1.PartialRoute{ - Receiver: "grafana-default-email", - Routes: []*v1beta1.Route{{ - PartialRoute: v1beta1.PartialRoute{ - Receiver: "grafana-default-email", - RouteSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"team-a": "child"}, + Route: &v1beta1.RootRoute{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "grafana-default-email", + Routes: []*v1beta1.Route{{ + PartialRoute: v1beta1.PartialRoute{ + Receiver: "grafana-default-email", + RouteSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team-a": "child"}, + }, }, - }, - Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "a", IsEqual: true}}, - }}, + Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "a", IsEqual: true}}, + }}, + }, }, }, } From 45b313b77c605aad261cd44ce439c178749d1a62 Mon Sep 17 00:00:00 2001 From: Steffen Baarsgaard Date: Thu, 4 Dec 2025 03:30:30 +0100 Subject: [PATCH 4/5] chore: Generate manifests/docs --- ...eatly.org_grafananotificationpolicies.yaml | 70 ++++++++++++++ ...eatly.org_grafananotificationpolicies.yaml | 70 ++++++++++++++ deploy/kustomize/base/crds.yaml | 70 ++++++++++++++ docs/docs/api.md | 92 +++++++++++++++++++ 4 files changed, 302 insertions(+) diff --git a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml index 0daa917b0..d671d73a3 100644 --- a/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/config/crd/bases/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -120,6 +120,16 @@ spec: route: description: Routes for alerts to match against properties: + active_time_intervals: + description: 'Deprecated: Never worked on the top level route + node' + items: + type: string + type: array + continue: + description: 'Deprecated: Never worked on the top level route + node' + type: boolean group_by: description: group by items: @@ -131,6 +141,52 @@ spec: group_wait: description: group wait type: string + match_re: + additionalProperties: + type: string + description: 'Deprecated: Never worked on the top level route + node' + type: object + matchers: + description: 'Deprecated: Never worked on the top level route + node' + 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: 'Deprecated: Never worked on the top level route + node' + items: + type: string + type: array + object_matchers: + description: 'Deprecated: Never worked on the top level route + node' + 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: 'Deprecated: Does nothing' type: string @@ -195,6 +251,20 @@ spec: required: - receiver type: object + x-kubernetes-validations: + - message: continue is invalid on the top level route node + rule: '!has(self.continue)' + - message: match_re is invalid on the top level route node + rule: '!has(self.match_re)' + - message: matchers is invalid on the top level route node + rule: '!has(self.matchers)' + - message: object_matchers is invalid on the top level route node + rule: '!has(self.object_matchers)' + - message: mute_time_intervals is invalid on the top level route node + rule: '!has(self.mute_time_intervals)' + - message: active_time_intervals is invalid on the top level route + node + rule: '!has(self.active_time_intervals)' suspend: description: Suspend pauses synchronizing attempts and tells the operator to ignore changes diff --git a/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicies.yaml b/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicies.yaml index 0daa917b0..d671d73a3 100644 --- a/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicies.yaml +++ b/deploy/helm/grafana-operator/files/crds/grafana.integreatly.org_grafananotificationpolicies.yaml @@ -120,6 +120,16 @@ spec: route: description: Routes for alerts to match against properties: + active_time_intervals: + description: 'Deprecated: Never worked on the top level route + node' + items: + type: string + type: array + continue: + description: 'Deprecated: Never worked on the top level route + node' + type: boolean group_by: description: group by items: @@ -131,6 +141,52 @@ spec: group_wait: description: group wait type: string + match_re: + additionalProperties: + type: string + description: 'Deprecated: Never worked on the top level route + node' + type: object + matchers: + description: 'Deprecated: Never worked on the top level route + node' + 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: 'Deprecated: Never worked on the top level route + node' + items: + type: string + type: array + object_matchers: + description: 'Deprecated: Never worked on the top level route + node' + 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: 'Deprecated: Does nothing' type: string @@ -195,6 +251,20 @@ spec: required: - receiver type: object + x-kubernetes-validations: + - message: continue is invalid on the top level route node + rule: '!has(self.continue)' + - message: match_re is invalid on the top level route node + rule: '!has(self.match_re)' + - message: matchers is invalid on the top level route node + rule: '!has(self.matchers)' + - message: object_matchers is invalid on the top level route node + rule: '!has(self.object_matchers)' + - message: mute_time_intervals is invalid on the top level route node + rule: '!has(self.mute_time_intervals)' + - message: active_time_intervals is invalid on the top level route + node + rule: '!has(self.active_time_intervals)' suspend: description: Suspend pauses synchronizing attempts and tells the operator to ignore changes diff --git a/deploy/kustomize/base/crds.yaml b/deploy/kustomize/base/crds.yaml index 1558ac9a9..2d1c0c946 100644 --- a/deploy/kustomize/base/crds.yaml +++ b/deploy/kustomize/base/crds.yaml @@ -2754,6 +2754,16 @@ spec: route: description: Routes for alerts to match against properties: + active_time_intervals: + description: 'Deprecated: Never worked on the top level route + node' + items: + type: string + type: array + continue: + description: 'Deprecated: Never worked on the top level route + node' + type: boolean group_by: description: group by items: @@ -2765,6 +2775,52 @@ spec: group_wait: description: group wait type: string + match_re: + additionalProperties: + type: string + description: 'Deprecated: Never worked on the top level route + node' + type: object + matchers: + description: 'Deprecated: Never worked on the top level route + node' + 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: 'Deprecated: Never worked on the top level route + node' + items: + type: string + type: array + object_matchers: + description: 'Deprecated: Never worked on the top level route + node' + 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: 'Deprecated: Does nothing' type: string @@ -2829,6 +2885,20 @@ spec: required: - receiver type: object + x-kubernetes-validations: + - message: continue is invalid on the top level route node + rule: '!has(self.continue)' + - message: match_re is invalid on the top level route node + rule: '!has(self.match_re)' + - message: matchers is invalid on the top level route node + rule: '!has(self.matchers)' + - message: object_matchers is invalid on the top level route node + rule: '!has(self.object_matchers)' + - message: mute_time_intervals is invalid on the top level route node + rule: '!has(self.mute_time_intervals)' + - message: active_time_intervals is invalid on the top level route + node + rule: '!has(self.active_time_intervals)' suspend: description: Suspend pauses synchronizing attempts and tells the operator to ignore changes diff --git a/docs/docs/api.md b/docs/docs/api.md index b82377ea3..93e3a3a83 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -5227,6 +5227,8 @@ GrafanaNotificationPolicySpec defines the desired state of GrafanaNotificationPo object Routes for alerts to match against
+
+ Validations:
  • !has(self.continue): continue is invalid on the top level route node
  • !has(self.match_re): match_re is invalid on the top level route node
  • !has(self.matchers): matchers is invalid on the top level route node
  • !has(self.object_matchers): object_matchers is invalid on the top level route node
  • !has(self.mute_time_intervals): mute_time_intervals is invalid on the top level route node
  • !has(self.active_time_intervals): active_time_intervals is invalid on the top level route node
  • true @@ -5370,6 +5372,20 @@ Routes for alerts to match against receiver
    true + + active_time_intervals + []string + + Deprecated: Never worked on the top level route node
    + + false + + continue + boolean + + Deprecated: Never worked on the top level route node
    + + false group_by []string @@ -5391,6 +5407,34 @@ Routes for alerts to match against group wait
    false + + match_re + map[string]string + + Deprecated: Never worked on the top level route node
    + + false + + matchers + []object + + Deprecated: Never worked on the top level route node
    + + false + + mute_time_intervals + []string + + Deprecated: Never worked on the top level route node
    + + false + + object_matchers + [][]string + + Deprecated: Never worked on the top level route node
    + + false provenance string @@ -5424,6 +5468,54 @@ mutually exclusive with Routes
    +### GrafanaNotificationPolicy.spec.route.matchers[index] +[↩ Parent](#grafananotificationpolicyspecroute) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    isRegexboolean + is regex
    +
    true
    valuestring + value
    +
    true
    isEqualboolean + is equal
    +
    false
    namestring + name
    +
    false
    + + ### GrafanaNotificationPolicy.spec.route.routeSelector [↩ Parent](#grafananotificationpolicyspecroute) From 48ec9c1115ea4ee461f5ae59c11960321557579a Mon Sep 17 00:00:00 2001 From: Steffen Baarsgaard Date: Thu, 4 Dec 2025 04:02:34 +0100 Subject: [PATCH 5/5] test: Deprecated fields on root route error on apply --- .../grafananotificationpolicy_types_test.go | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/api/v1beta1/grafananotificationpolicy_types_test.go b/api/v1beta1/grafananotificationpolicy_types_test.go index 38305b6d9..e5fdc535f 100644 --- a/api/v1beta1/grafananotificationpolicy_types_test.go +++ b/api/v1beta1/grafananotificationpolicy_types_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/grafana/grafana-openapi-client-go/models" "github.com/grafana/grafana-operator/v5/pkg/ptr" . "github.com/onsi/ginkgo/v2" "github.com/stretchr/testify/assert" @@ -94,6 +95,66 @@ var _ = Describe("NotificationPolicy type", func() { require.Error(t, err) }) }) + + Context("Invalidate root routes when using invalid fields", func() { + ctx := context.Background() + invalidFieldErr := "is invalid on the top level route node" + + It("Invalidate continue", func() { + np := newNotificationPolicy("invalid-route-fields", nil) + np.Spec.Route.Continue = true + + err := k8sClient.Create(ctx, np) + require.Error(t, err) + assert.ErrorContains(t, err, invalidFieldErr) + }) + + It("Invalidate active_time_intervals", func() { + np := newNotificationPolicy("invalid-route-fields", nil) + np.Spec.Route.ActiveTimeIntervals = []string{"any-string"} + + err := k8sClient.Create(ctx, np) + require.Error(t, err) + assert.ErrorContains(t, err, invalidFieldErr) + }) + + It("Invalidate mute_time_intervals", func() { + np := newNotificationPolicy("invalid-route-fields", nil) + np.Spec.Route.MuteTimeIntervals = []string{"any-string"} + + err := k8sClient.Create(ctx, np) + require.Error(t, err) + assert.ErrorContains(t, err, invalidFieldErr) + }) + + It("Invalidate match_re", func() { + np := newNotificationPolicy("invalid-route-fields", nil) + np.Spec.Route.MatchRe = models.MatchRegexps{"match": "string"} + + err := k8sClient.Create(ctx, np) + require.Error(t, err) + assert.ErrorContains(t, err, invalidFieldErr) + }) + + It("Invalidate matchers", func() { + np := newNotificationPolicy("invalid-route-fields", nil) + np.Spec.Route.Matchers = Matchers{&Matcher{}} + // Matchers: v1beta1.Matchers{&v1beta1.Matcher{Name: ptr.To("team"), Value: "A", IsEqual: true}}, + + err := k8sClient.Create(ctx, np) + require.Error(t, err) + assert.ErrorContains(t, err, invalidFieldErr) + }) + + It("Invalidate matchers", func() { + np := newNotificationPolicy("invalid-route-fields", nil) + np.Spec.Route.ObjectMatchers = models.ObjectMatchers{[]string{"any"}} + + err := k8sClient.Create(ctx, np) + require.Error(t, err) + assert.ErrorContains(t, err, invalidFieldErr) + }) + }) }) func TestIsRouteSelectorMutuallyExclusive(t *testing.T) {