diff --git a/pkg/services/ngalert/provisioning/notification_policies.go b/pkg/services/ngalert/provisioning/notification_policies.go index 1315f64fdd5..09e49c105ac 100644 --- a/pkg/services/ngalert/provisioning/notification_policies.go +++ b/pkg/services/ngalert/provisioning/notification_policies.go @@ -259,6 +259,9 @@ func writeToHash(sum hash.Hash, r *definitions.Route) { for _, matcher := range r.Matchers { writeString(matcher.String()) } + for _, matcher := range r.ObjectMatchers { + writeString(matcher.String()) + } for _, timeInterval := range r.MuteTimeIntervals { writeString(timeInterval) } @@ -269,6 +272,7 @@ func writeToHash(sum hash.Hash, r *definitions.Route) { writeDuration(r.GroupWait) writeDuration(r.GroupInterval) writeDuration(r.RepeatInterval) + writeString(string(r.Provenance)) for _, route := range r.Routes { writeToHash(sum, route) } diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go index 7723765a3b3..a835d1619cc 100644 --- a/pkg/services/ngalert/provisioning/notification_policies_test.go +++ b/pkg/services/ngalert/provisioning/notification_policies_test.go @@ -2,11 +2,16 @@ package provisioning import ( "context" + "encoding/json" "errors" + "reflect" "testing" + "time" "github.com/grafana/alerting/definition" "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/pkg/labels" + "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,13 +27,14 @@ import ( func TestGetPolicyTree(t *testing.T) { orgID := int64(1) rev := getDefaultConfigRevision() - expectedVersion := calculateRouteFingerprint(*rev.Config.AlertmanagerConfig.Route) + expectedRoute := *rev.Config.AlertmanagerConfig.Route + expectedRoute.Provenance = definitions.Provenance(models.ProvenanceAPI) + expectedVersion := calculateRouteFingerprint(expectedRoute) sut, store, prov := createNotificationPolicyServiceSut() store.GetFn = func(ctx context.Context, orgID int64) (*legacy_storage.ConfigRevision, error) { return &rev, nil } - expectedProvenance := models.ProvenanceAPI prov.GetProvenanceFunc = func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) { return models.ProvenanceAPI, nil } @@ -36,11 +42,9 @@ func TestGetPolicyTree(t *testing.T) { tree, version, err := sut.GetPolicyTree(context.Background(), orgID) require.NoError(t, err) - expectedRoute := *rev.Config.AlertmanagerConfig.Route - expectedRoute.Provenance = definitions.Provenance(models.ProvenanceAPI) assert.Equal(t, expectedRoute, tree) assert.Equal(t, expectedVersion, version) - assert.Equal(t, expectedProvenance, models.Provenance(tree.Provenance)) + assert.Equal(t, expectedRoute.Provenance, tree.Provenance) assert.Len(t, store.Calls, 1) assert.Equal(t, "Get", store.Calls[0].Method) @@ -334,6 +338,119 @@ func TestResetPolicyTree(t *testing.T) { }) } +func TestRoute_Fingerprint(t *testing.T) { + // Test that the fingerprint is stable. + mustRegex := func(rg string) config.Regexp { + var regex config.Regexp + require.NoError(t, json.Unmarshal([]byte(rg), ®ex)) + return regex + } + mustMatcher := func(t *testing.T, mt labels.MatchType, lbl, val string) *labels.Matcher { + m, err := labels.NewMatcher(mt, lbl, val) + require.NoError(t, err) + return m + } + baseRouteGen := func() definitions.Route { + return definitions.Route{ + Receiver: "Receiver", + GroupByStr: []string{"GroupByStr1", "GroupByStr2"}, + GroupBy: []model.LabelName{ + "...", + }, + GroupByAll: true, + Match: map[string]string{"Match1": "MatchValue1", "Match2": "MatchValue2"}, + MatchRE: map[string]config.Regexp{ + "MatchRE": mustRegex(`".*"`), + }, + Matchers: config.Matchers{ + mustMatcher(t, labels.MatchNotEqual, "Matchers1", "Matchers1Value"), + mustMatcher(t, labels.MatchEqual, "Matchers2", "Matchers2Value"), + mustMatcher(t, labels.MatchRegexp, "Matchers3", "Matchers3Value"), + }, + ObjectMatchers: definitions.ObjectMatchers{ + mustMatcher(t, labels.MatchNotRegexp, "ObjectMatchers1", "ObjectMatchers1Value"), + mustMatcher(t, labels.MatchRegexp, "ObjectMatchers2", "ObjectMatchers2Value"), + }, + MuteTimeIntervals: []string{"MuteTimeIntervals1", "MuteTimeIntervals2"}, + ActiveTimeIntervals: []string{"ActiveTimeIntervals1", "ActiveTimeIntervals2"}, + Continue: true, + GroupWait: util.Pointer(model.Duration(2 * time.Minute)), + GroupInterval: util.Pointer(model.Duration(5 * time.Minute)), + RepeatInterval: util.Pointer(model.Duration(30 * time.Hour)), + Provenance: definitions.Provenance(models.ProvenanceAPI), + Routes: nil, // Nested routes are not included in the fingerprint test for simplicity. + } + } + + completelyDifferentRoute := definitions.Route{ + Receiver: "Receiver_2", + GroupByStr: []string{"GroupByStr1_2", "GroupByStr2_2"}, + GroupBy: []model.LabelName{ + "other", + }, + GroupByAll: false, + Match: map[string]string{"Match1_2": "MatchValue1", "Match2": "MatchValue2_2"}, + MatchRE: map[string]config.Regexp{ + "MatchRE": mustRegex(`".+"`), + }, + Matchers: config.Matchers{ + mustMatcher(t, labels.MatchNotEqual, "Matchers1_2", "Matchers1Value"), + mustMatcher(t, labels.MatchEqual, "Matchers2", "Matchers2Value_2"), + mustMatcher(t, labels.MatchEqual, "Matchers3", "Matchers3Value"), + }, + ObjectMatchers: definitions.ObjectMatchers{ + mustMatcher(t, labels.MatchNotRegexp, "ObjectMatchers1_2", "ObjectMatchers1Value"), + mustMatcher(t, labels.MatchRegexp, "ObjectMatchers2", "ObjectMatchers2Value_2"), + }, + MuteTimeIntervals: []string{"MuteTimeIntervals1_2", "MuteTimeIntervals2_2"}, + ActiveTimeIntervals: []string{"ActiveTimeIntervals1_2", "ActiveTimeIntervals2_2"}, + Continue: false, + GroupWait: util.Pointer(model.Duration(20 * time.Minute)), + GroupInterval: util.Pointer(model.Duration(50 * time.Minute)), + RepeatInterval: util.Pointer(model.Duration(300 * time.Hour)), + Provenance: definitions.Provenance(models.ProvenanceFile), + Routes: nil, // Nested routes are not included in the fingerprint test for simplicity, recursive fingerprinting is assumed. + } + + t.Run("stable across code changes", func(t *testing.T) { + expectedFingerprint := "7faba12778df93b8" // If this is a valid fingerprint generation change, update the expected value. + assert.Equal(t, expectedFingerprint, calculateRouteFingerprint(baseRouteGen())) + }) + t.Run("unstable across field modification", func(t *testing.T) { + fingerprint := calculateRouteFingerprint(baseRouteGen()) + excludedFields := map[string]struct{}{ + "Routes": {}, + } + + reflectVal := reflect.ValueOf(&completelyDifferentRoute).Elem() + + receiverType := reflect.TypeOf((*definitions.Route)(nil)).Elem() + for i := 0; i < receiverType.NumField(); i++ { + field := receiverType.Field(i).Name + if _, ok := excludedFields[field]; ok { + continue + } + cp := baseRouteGen() + + // Get the current field being modified. + v := reflect.ValueOf(&cp).Elem() + vf := v.Field(i) + + otherField := reflectVal.Field(i) + if reflect.DeepEqual(otherField.Interface(), vf.Interface()) { + assert.Failf(t, "fields are identical", "Route field %s is the same as the original, test does not ensure instability across the field", field) + continue + } + + // Set the field to the value of the completelyDifferentRoute. + vf.Set(otherField) + + f2 := calculateRouteFingerprint(cp) + assert.NotEqualf(t, fingerprint, f2, "Route field %s does not seem to be used in fingerprint", field) + } + }) +} + func createNotificationPolicyServiceSut() (*NotificationPolicyService, *legacy_storage.AlertmanagerConfigStoreFake, *fakes.FakeProvisioningStore) { prov := fakes.NewFakeProvisioningStore() configStore := &legacy_storage.AlertmanagerConfigStoreFake{