diff --git a/pkg/services/sqlstore/migrations/ualert/channel.go b/pkg/services/sqlstore/migrations/ualert/channel.go index 3e4d181e4b7..9fcb0bdaf6e 100644 --- a/pkg/services/sqlstore/migrations/ualert/channel.go +++ b/pkg/services/sqlstore/migrations/ualert/channel.go @@ -8,6 +8,7 @@ import ( "regexp" "sort" "strings" + "time" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" @@ -16,6 +17,11 @@ import ( ngModels "github.com/grafana/grafana/pkg/services/ngalert/models" ) +const ( + // DisabledRepeatInterval is a large duration that will be used as a pseudo-disable in case a legacy channel doesn't have SendReminders enabled. + DisabledRepeatInterval = model.Duration(time.Duration(8736) * time.Hour) // 1y +) + type notificationChannel struct { ID int64 `xorm:"id"` OrgID int64 `xorm:"org_id"` @@ -26,6 +32,8 @@ type notificationChannel struct { IsDefault bool `xorm:"is_default"` Settings *simplejson.Json `xorm:"settings"` SecureSettings SecureJsonData `xorm:"secure_settings"` + SendReminder bool `xorm:"send_reminder"` + Frequency model.Duration `xorm:"frequency"` } // channelsPerOrg maps notification channels per organisation @@ -37,6 +45,12 @@ type defaultChannelsPerOrg map[int64][]*notificationChannel // uidOrID for both uid and ID, primarily used for mapping legacy channel to migrated receiver. type uidOrID interface{} +// channelReceiver is a convenience struct that contains a notificationChannel and its corresponding migrated PostableApiReceiver. +type channelReceiver struct { + channel *notificationChannel + receiver *PostableApiReceiver +} + // setupAlertmanagerConfigs creates Alertmanager configs with migrated receivers and routes. func (m *migration) setupAlertmanagerConfigs(rulesPerOrg map[int64]map[*alertRule][]uidOrID) (amConfigsPerOrg, error) { // allChannels: channelUID -> channelConfig @@ -66,7 +80,9 @@ func (m *migration) setupAlertmanagerConfigs(rulesPerOrg map[int64]map[*alertRul continue } - amConfig.AlertmanagerConfig.Receivers = receivers + for _, cr := range receivers { + amConfig.AlertmanagerConfig.Receivers = append(amConfig.AlertmanagerConfig.Receivers, cr.receiver) + } defaultReceivers := make(map[string]struct{}) defaultChannels, ok := defaultChannelsPerOrg[orgID] @@ -85,10 +101,10 @@ func (m *migration) setupAlertmanagerConfigs(rulesPerOrg map[int64]map[*alertRul amConfig.AlertmanagerConfig.Receivers = append(amConfig.AlertmanagerConfig.Receivers, defaultReceiver) } - for _, recv := range receivers { - route, err := createRoute(recv) + for _, cr := range receivers { + route, err := createRoute(cr) if err != nil { - return nil, fmt.Errorf("failed to create route for receiver %s in orgId %d: %w", recv.Name, orgID, err) + return nil, fmt.Errorf("failed to create route for receiver %s in orgId %d: %w", cr.receiver.Name, orgID, err) } amConfigPerOrg[orgID].AlertmanagerConfig.Route.Routes = append(amConfigPerOrg[orgID].AlertmanagerConfig.Route.Routes, route) @@ -141,7 +157,9 @@ func (m *migration) getNotificationChannelMap() (channelsPerOrg, defaultChannels disable_resolve_message, is_default, settings, - secure_settings + secure_settings, + send_reminder, + frequency FROM alert_notification ` @@ -196,8 +214,8 @@ func (m *migration) createNotifier(c *notificationChannel) (*PostableGrafanaRece } // Create one receiver for every unique notification channel. -func (m *migration) createReceivers(allChannels []*notificationChannel) (map[uidOrID]*PostableApiReceiver, []*PostableApiReceiver, error) { - receivers := make([]*PostableApiReceiver, 0, len(allChannels)) +func (m *migration) createReceivers(allChannels []*notificationChannel) (map[uidOrID]*PostableApiReceiver, []channelReceiver, error) { + receivers := make([]channelReceiver, 0, len(allChannels)) receiversMap := make(map[uidOrID]*PostableApiReceiver) set := make(map[string]struct{}) // Used to deduplicate sanitized names. @@ -218,20 +236,23 @@ func (m *migration) createReceivers(allChannels []*notificationChannel) (map[uid set[sanitizedName] = struct{}{} - recv := &PostableApiReceiver{ - Name: sanitizedName, // Channel name is unique within an Org. - GrafanaManagedReceivers: []*PostableGrafanaReceiver{notifier}, + cr := channelReceiver{ + channel: c, + receiver: &PostableApiReceiver{ + Name: sanitizedName, // Channel name is unique within an Org. + GrafanaManagedReceivers: []*PostableGrafanaReceiver{notifier}, + }, } - receivers = append(receivers, recv) + receivers = append(receivers, cr) // Store receivers for creating routes from alert rules later. if c.Uid != "" { - receiversMap[c.Uid] = recv + receiversMap[c.Uid] = cr.receiver } if c.ID != 0 { // In certain circumstances, the alert rule uses ID instead of uid. So, we add this to be able to lookup by ID in case. - receiversMap[c.ID] = recv + receiversMap[c.ID] = cr.receiver } } @@ -240,17 +261,27 @@ func (m *migration) createReceivers(allChannels []*notificationChannel) (map[uid // Create the root-level route with the default receiver. If no new receiver is created specifically for the root-level route, the returned receiver will be nil. func (m *migration) createDefaultRouteAndReceiver(defaultChannels []*notificationChannel) (*PostableApiReceiver, *Route, error) { - var defaultReceiver *PostableApiReceiver - defaultReceiverName := "autogen-contact-point-default" - if len(defaultChannels) != 1 { - // If there are zero or more than one default channels we create a separate contact group that is used only in the root policy. This is to simplify the migrated notification policy structure. - // If we ever allow more than one receiver per route this won't be necessary. - defaultReceiver = &PostableApiReceiver{ - Name: defaultReceiverName, - GrafanaManagedReceivers: []*PostableGrafanaReceiver{}, - } + defaultRoute := &Route{ + Receiver: defaultReceiverName, + Routes: make([]*Route, 0), + GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, // To keep parity with pre-migration notifications. + RepeatInterval: nil, + } + newDefaultReceiver := &PostableApiReceiver{ + Name: defaultReceiverName, + GrafanaManagedReceivers: []*PostableGrafanaReceiver{}, + } + // Return early if there are no default channels + if len(defaultChannels) == 0 { + return newDefaultReceiver, defaultRoute, nil + } + + repeatInterval := DisabledRepeatInterval // If no channels have SendReminders enabled, we will use this large value as a pseudo-disable. + if len(defaultChannels) > 1 { + // If there are more than one default channels we create a separate contact group that is used only in the root policy. This is to simplify the migrated notification policy structure. + // If we ever allow more than one receiver per route this won't be necessary. for _, c := range defaultChannels { // Need to create a new notifier to prevent uid conflict. defaultNotifier, err := m.createNotifier(c) @@ -258,39 +289,51 @@ func (m *migration) createDefaultRouteAndReceiver(defaultChannels []*notificatio return nil, nil, err } - defaultReceiver.GrafanaManagedReceivers = append(defaultReceiver.GrafanaManagedReceivers, defaultNotifier) + newDefaultReceiver.GrafanaManagedReceivers = append(newDefaultReceiver.GrafanaManagedReceivers, defaultNotifier) + + // Choose the lowest send reminder duration from all the notifiers to use for default route. + if c.SendReminder && c.Frequency < repeatInterval { + repeatInterval = c.Frequency + } } } else { // If there is only a single default channel, we don't need a separate receiver to hold it. We can reuse the existing receiver for that single notifier. - defaultReceiverName = defaultChannels[0].Name - } + defaultRoute.Receiver = defaultChannels[0].Name + if defaultChannels[0].SendReminder { + repeatInterval = defaultChannels[0].Frequency + } - defaultRoute := &Route{ - Receiver: defaultReceiverName, - Routes: make([]*Route, 0), - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, // To keep parity with pre-migration notifications. + // No need to create a new receiver. + newDefaultReceiver = nil } + defaultRoute.RepeatInterval = &repeatInterval - return defaultReceiver, defaultRoute, nil + return newDefaultReceiver, defaultRoute, nil } // Create one route per contact point, matching based on ContactLabel. -func createRoute(recv *PostableApiReceiver) (*Route, error) { +func createRoute(cr channelReceiver) (*Route, error) { // We create a regex matcher so that each alert rule need only have a single ContactLabel entry for all contact points it sends to. // For example, if an alert needs to send to contact1 and contact2 it will have ContactLabel=`"contact1","contact2"` and will match both routes looking // for `.*"contact1".*` and `.*"contact2".*`. // We quote and escape here to ensure the regex will correctly match the ContactLabel on the alerts. - name := fmt.Sprintf(`.*%s.*`, regexp.QuoteMeta(quote(recv.Name))) + name := fmt.Sprintf(`.*%s.*`, regexp.QuoteMeta(quote(cr.receiver.Name))) mat, err := labels.NewMatcher(labels.MatchRegexp, ContactLabel, name) if err != nil { return nil, err } + repeatInterval := DisabledRepeatInterval + if cr.channel.SendReminder { + repeatInterval = cr.channel.Frequency + } + return &Route{ - Receiver: recv.Name, + Receiver: cr.receiver.Name, ObjectMatchers: ObjectMatchers{mat}, Continue: true, // We continue so that each sibling contact point route can separately match. + RepeatInterval: &repeatInterval, }, nil } @@ -430,11 +473,12 @@ type PostableApiAlertingConfig struct { } type Route struct { - Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty"` - ObjectMatchers ObjectMatchers `yaml:"object_matchers,omitempty" json:"object_matchers,omitempty"` - Routes []*Route `yaml:"routes,omitempty" json:"routes,omitempty"` - Continue bool `yaml:"continue,omitempty" json:"continue,omitempty"` - GroupByStr []string `yaml:"group_by,omitempty" json:"group_by,omitempty"` + Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty"` + ObjectMatchers ObjectMatchers `yaml:"object_matchers,omitempty" json:"object_matchers,omitempty"` + Routes []*Route `yaml:"routes,omitempty" json:"routes,omitempty"` + Continue bool `yaml:"continue,omitempty" json:"continue,omitempty"` + GroupByStr []string `yaml:"group_by,omitempty" json:"group_by,omitempty"` + RepeatInterval *model.Duration `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty"` } type ObjectMatchers labels.Matchers diff --git a/pkg/services/sqlstore/migrations/ualert/channel_test.go b/pkg/services/sqlstore/migrations/ualert/channel_test.go index fe28db54b65..6bd5635fa56 100644 --- a/pkg/services/sqlstore/migrations/ualert/channel_test.go +++ b/pkg/services/sqlstore/migrations/ualert/channel_test.go @@ -2,6 +2,7 @@ package ualert import ( "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -121,11 +122,13 @@ func TestFilterReceiversForAlert(t *testing.T) { func TestCreateRoute(t *testing.T) { tc := []struct { name string + channel *notificationChannel recv *PostableApiReceiver expected *Route }{ { - name: "when a receiver is passed in, the route should regex match based on quoted name with continue=true", + name: "when a receiver is passed in, the route should regex match based on quoted name with continue=true", + channel: ¬ificationChannel{}, recv: &PostableApiReceiver{ Name: "recv1", }, @@ -135,10 +138,12 @@ func TestCreateRoute(t *testing.T) { Routes: nil, Continue: true, GroupByStr: nil, + RepeatInterval: durationPointer(DisabledRepeatInterval), }, }, { - name: "notification channel should be escaped for regex in the matcher", + name: "notification channel should be escaped for regex in the matcher", + channel: ¬ificationChannel{}, recv: &PostableApiReceiver{ Name: `. ^ $ * + - ? ( ) [ ] { } \ |`, }, @@ -148,13 +153,47 @@ func TestCreateRoute(t *testing.T) { Routes: nil, Continue: true, GroupByStr: nil, + RepeatInterval: durationPointer(DisabledRepeatInterval), + }, + }, + { + name: "when a channel has sendReminder=true, the route should use the frequency in repeat interval", + channel: ¬ificationChannel{SendReminder: true, Frequency: model.Duration(time.Duration(42) * time.Hour)}, + recv: &PostableApiReceiver{ + Name: "recv1", + }, + expected: &Route{ + Receiver: "recv1", + ObjectMatchers: ObjectMatchers{{Type: 2, Name: ContactLabel, Value: `.*"recv1".*`}}, + Routes: nil, + Continue: true, + GroupByStr: nil, + RepeatInterval: durationPointer(model.Duration(time.Duration(42) * time.Hour)), + }, + }, + { + name: "when a channel has sendReminder=false, the route should ignore the frequency in repeat interval and use DisabledRepeatInterval", + channel: ¬ificationChannel{SendReminder: false, Frequency: model.Duration(time.Duration(42) * time.Hour)}, + recv: &PostableApiReceiver{ + Name: "recv1", + }, + expected: &Route{ + Receiver: "recv1", + ObjectMatchers: ObjectMatchers{{Type: 2, Name: ContactLabel, Value: `.*"recv1".*`}}, + Routes: nil, + Continue: true, + GroupByStr: nil, + RepeatInterval: durationPointer(DisabledRepeatInterval), }, }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { - res, err := createRoute(tt.recv) + res, err := createRoute(channelReceiver{ + channel: tt.channel, + receiver: tt.recv, + }) require.NoError(t, err) // Order of nested routes is not guaranteed. @@ -180,13 +219,18 @@ func createNotChannel(t *testing.T, uid string, id int64, name string) *notifica return ¬ificationChannel{Uid: uid, ID: id, Name: name, Settings: simplejson.New()} } +func createNotChannelWithReminder(t *testing.T, uid string, id int64, name string, frequency model.Duration) *notificationChannel { + t.Helper() + return ¬ificationChannel{Uid: uid, ID: id, Name: name, SendReminder: true, Frequency: frequency, Settings: simplejson.New()} +} + func TestCreateReceivers(t *testing.T) { tc := []struct { name string allChannels []*notificationChannel defaultChannels []*notificationChannel expRecvMap map[uidOrID]*PostableApiReceiver - expRecv []*PostableApiReceiver + expRecv []channelReceiver expErr error }{ { @@ -210,14 +254,20 @@ func TestCreateReceivers(t *testing.T) { GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name2"}}, }, }, - expRecv: []*PostableApiReceiver{ + expRecv: []channelReceiver{ { - Name: "name1", - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name1"}}, + channel: createNotChannel(t, "uid1", int64(1), "name1"), + receiver: &PostableApiReceiver{ + Name: "name1", + GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name1"}}, + }, }, { - Name: "name2", - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name2"}}, + channel: createNotChannel(t, "uid2", int64(2), "name2"), + receiver: &PostableApiReceiver{ + Name: "name2", + GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name2"}}, + }, }, }, }, @@ -234,10 +284,13 @@ func TestCreateReceivers(t *testing.T) { GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name_1"}}, }, }, - expRecv: []*PostableApiReceiver{ + expRecv: []channelReceiver{ { - Name: "name_1", - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name_1"}}, + channel: createNotChannel(t, "uid1", int64(1), "name\"1"), + receiver: &PostableApiReceiver{ + Name: "name_1", + GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name_1"}}, + }, }, }, }, @@ -262,14 +315,20 @@ func TestCreateReceivers(t *testing.T) { GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name_1_dba13d"}}, }, }, - expRecv: []*PostableApiReceiver{ + expRecv: []channelReceiver{ { - Name: "name_1", - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name_1"}}, + channel: createNotChannel(t, "uid1", int64(1), "name\"1"), + receiver: &PostableApiReceiver{ + Name: "name_1", + GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name_1"}}, + }, }, { - Name: "name_1_dba13d", - GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name_1_dba13d"}}, + channel: createNotChannel(t, "uid2", int64(2), "name_1"), + receiver: &PostableApiReceiver{ + Name: "name_1_dba13d", + GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name_1_dba13d"}}, + }, }, }, }, @@ -289,7 +348,7 @@ func TestCreateReceivers(t *testing.T) { // We ignore certain fields for the purposes of this test for _, recv := range recvs { - for _, not := range recv.GrafanaManagedReceivers { + for _, not := range recv.receiver.GrafanaManagedReceivers { not.UID = "" not.Settings = nil not.SecureSettings = nil @@ -319,9 +378,27 @@ func TestCreateDefaultRouteAndReceiver(t *testing.T) { GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name1"}, {Name: "name2"}}, }, expRoute: &Route{ - Receiver: "autogen-contact-point-default", - Routes: make([]*Route, 0), - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + Receiver: "autogen-contact-point-default", + Routes: make([]*Route, 0), + GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + RepeatInterval: durationPointer(DisabledRepeatInterval), + }, + }, + { + name: "when given multiple default notification channels migrate them to a single receiver with RepeatInterval set to be the minimum of all channel frequencies", + defaultChannels: []*notificationChannel{ + createNotChannelWithReminder(t, "uid1", int64(1), "name1", model.Duration(42)), + createNotChannelWithReminder(t, "uid2", int64(2), "name2", model.Duration(100000)), + }, + expRecv: &PostableApiReceiver{ + Name: "autogen-contact-point-default", + GrafanaManagedReceivers: []*PostableGrafanaReceiver{{Name: "name1"}, {Name: "name2"}}, + }, + expRoute: &Route{ + Receiver: "autogen-contact-point-default", + Routes: make([]*Route, 0), + GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + RepeatInterval: durationPointer(model.Duration(42)), }, }, { @@ -332,9 +409,10 @@ func TestCreateDefaultRouteAndReceiver(t *testing.T) { GrafanaManagedReceivers: []*PostableGrafanaReceiver{}, }, expRoute: &Route{ - Receiver: "autogen-contact-point-default", - Routes: make([]*Route, 0), - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + Receiver: "autogen-contact-point-default", + Routes: make([]*Route, 0), + GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + RepeatInterval: nil, }, }, { @@ -342,9 +420,21 @@ func TestCreateDefaultRouteAndReceiver(t *testing.T) { defaultChannels: []*notificationChannel{createNotChannel(t, "uid1", int64(1), "name1")}, expRecv: nil, expRoute: &Route{ - Receiver: "name1", - Routes: make([]*Route, 0), - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + Receiver: "name1", + Routes: make([]*Route, 0), + GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + RepeatInterval: durationPointer(DisabledRepeatInterval), + }, + }, + { + name: "when given a single default notification channel with SendReminder=true, use the channels Frequency as the RepeatInterval", + defaultChannels: []*notificationChannel{createNotChannelWithReminder(t, "uid1", int64(1), "name1", model.Duration(42))}, + expRecv: nil, + expRoute: &Route{ + Receiver: "name1", + Routes: make([]*Route, 0), + GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + RepeatInterval: durationPointer(model.Duration(42)), }, }, } @@ -375,3 +465,7 @@ func TestCreateDefaultRouteAndReceiver(t *testing.T) { }) } } + +func durationPointer(d model.Duration) *model.Duration { + return &d +} diff --git a/pkg/services/sqlstore/migrations/ualert/migration_test.go b/pkg/services/sqlstore/migrations/ualert/migration_test.go index 9073feae2ba..03e1d0563b4 100644 --- a/pkg/services/sqlstore/migrations/ualert/migration_test.go +++ b/pkg/services/sqlstore/migrations/ualert/migration_test.go @@ -158,10 +158,11 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "autogen-contact-point-default", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier3", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier3".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, + {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, + {Receiver: "notifier3", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier3".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, + RepeatInterval: nil, }, Receivers: []*ualert.PostableApiReceiver{ {Name: "notifier1", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}, @@ -177,15 +178,16 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "notifier6", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier4", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier4".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier5", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier5".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier6", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier6".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier4", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier4".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, + {Receiver: "notifier5", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier5".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, + {Receiver: "notifier6", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier6".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, + RepeatInterval: durationPointer(ualert.DisabledRepeatInterval), }, Receivers: []*ualert.PostableApiReceiver{ {Name: "notifier4", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier4", Type: "email"}}}, {Name: "notifier5", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier5", Type: "slack"}}}, - {Name: "notifier6", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier6", Type: "opsgenie"}}}, // empty default + {Name: "notifier6", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier6", Type: "opsgenie"}}}, }, }, }, @@ -204,8 +206,9 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "autogen-contact-point-default", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, + RepeatInterval: nil, }, Receivers: []*ualert.PostableApiReceiver{ {Name: "notifier1", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}, @@ -228,8 +231,33 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "notifier1", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, + RepeatInterval: durationPointer(ualert.DisabledRepeatInterval), + }, + Receivers: []*ualert.PostableApiReceiver{ + {Name: "notifier1", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}, + }, + }, + }, + }, + }, + { + name: "when single default channel with SendReminder, use channel Frequency as RepeatInterval", + legacyChannels: []*models.AlertNotification{ + createAlertNotificationWithReminder(t, int64(1), "notifier1", "email", emailSettings, true, true, time.Duration(1)*time.Hour), + }, + alerts: []*models.Alert{}, + expected: map[int64]*ualert.PostableUserConfig{ + int64(1): { + AlertmanagerConfig: ualert.PostableApiAlertingConfig{ + Route: &ualert.Route{ + Receiver: "notifier1", + GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + Routes: []*ualert.Route{ + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(model.Duration(time.Duration(1) * time.Hour))}, + }, + RepeatInterval: durationPointer(model.Duration(time.Duration(1) * time.Hour)), }, Receivers: []*ualert.PostableApiReceiver{ {Name: "notifier1", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}, @@ -252,9 +280,38 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "autogen-contact-point-default", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, + {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, + RepeatInterval: durationPointer(ualert.DisabledRepeatInterval), + }, + Receivers: []*ualert.PostableApiReceiver{ + {Name: "notifier1", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}, + {Name: "notifier2", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier2", Type: "slack"}}}, + {Name: "autogen-contact-point-default", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}, {Name: "notifier2", Type: "slack"}}}, + }, + }, + }, + }, + }, + { + name: "when multiple default channels with SendReminder, use minimum channel frequency as RepeatInterval", + legacyChannels: []*models.AlertNotification{ + createAlertNotificationWithReminder(t, int64(1), "notifier1", "email", emailSettings, true, true, time.Duration(1)*time.Hour), + createAlertNotificationWithReminder(t, int64(1), "notifier2", "slack", slackSettings, true, true, time.Duration(30)*time.Minute), + }, + alerts: []*models.Alert{}, + expected: map[int64]*ualert.PostableUserConfig{ + int64(1): { + AlertmanagerConfig: ualert.PostableApiAlertingConfig{ + Route: &ualert.Route{ + Receiver: "autogen-contact-point-default", + GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, + Routes: []*ualert.Route{ + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(model.Duration(time.Duration(1) * time.Hour))}, + {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(model.Duration(time.Duration(30) * time.Minute))}, + }, + RepeatInterval: durationPointer(model.Duration(time.Duration(30) * time.Minute)), }, Receivers: []*ualert.PostableApiReceiver{ {Name: "notifier1", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}, @@ -280,10 +337,11 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "autogen-contact-point-default", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier3", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier3".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, + {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, + {Receiver: "notifier3", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier3".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, + RepeatInterval: durationPointer(ualert.DisabledRepeatInterval), }, Receivers: []*ualert.PostableApiReceiver{ {Name: "notifier1", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}, @@ -294,36 +352,6 @@ func TestAMConfigMigration(t *testing.T) { }, }, }, - { - name: "when alert has only defaults, don't create route for it", - legacyChannels: []*models.AlertNotification{ - createAlertNotification(t, int64(1), "notifier1", "email", emailSettings, true), // default - createAlertNotification(t, int64(1), "notifier2", "slack", slackSettings, true), // default - }, - alerts: []*models.Alert{ - createAlert(t, int64(1), int64(1), int64(1), "alert1", []string{"notifier1"}), - createAlert(t, int64(1), int64(2), int64(3), "alert2", []string{}), - }, - expected: map[int64]*ualert.PostableUserConfig{ - int64(1): { - AlertmanagerConfig: ualert.PostableApiAlertingConfig{ - Route: &ualert.Route{ - Receiver: "autogen-contact-point-default", - GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, - Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true}, - }, - }, - Receivers: []*ualert.PostableApiReceiver{ - {Name: "notifier1", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}}}, - {Name: "notifier2", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier2", Type: "slack"}}}, - {Name: "autogen-contact-point-default", GrafanaManagedReceivers: []*ualert.PostableGrafanaReceiver{{Name: "notifier1", Type: "email"}, {Name: "notifier2", Type: "slack"}}}, - }, - }, - }, - }, - }, { name: "when alerts share channels, only create one receiver per legacy channel", legacyChannels: []*models.AlertNotification{ @@ -341,8 +369,8 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "autogen-contact-point-default", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, - {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, + {Receiver: "notifier2", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier2".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, }, Receivers: []*ualert.PostableApiReceiver{ @@ -367,7 +395,7 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "autogen-contact-point-default", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, }, Receivers: []*ualert.PostableApiReceiver{ @@ -393,7 +421,7 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "autogen-contact-point-default", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, }, Receivers: []*ualert.PostableApiReceiver{ @@ -420,7 +448,7 @@ func TestAMConfigMigration(t *testing.T) { Receiver: "autogen-contact-point-default", GroupByStr: []string{ngModels.FolderTitleLabel, model.AlertNameLabel}, Routes: []*ualert.Route{ - {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true}, + {Receiver: "notifier1", ObjectMatchers: ualert.ObjectMatchers{{Type: 2, Name: ualert.ContactLabel, Value: `.*"notifier1".*`}}, Routes: nil, Continue: true, RepeatInterval: durationPointer(ualert.DisabledRepeatInterval)}, }, }, Receivers: []*ualert.PostableApiReceiver{ @@ -444,6 +472,7 @@ func TestAMConfigMigration(t *testing.T) { // Order of nested GrafanaManagedReceivers is not guaranteed. cOpt := []cmp.Option{ + cmpopts.IgnoreUnexported(ualert.PostableApiReceiver{}), cmpopts.IgnoreFields(ualert.PostableGrafanaReceiver{}, "UID", "Settings", "SecureSettings"), cmpopts.SortSlices(func(a, b *ualert.PostableGrafanaReceiver) bool { return a.Name < b.Name }), cmpopts.SortSlices(func(a, b *ualert.PostableApiReceiver) bool { return a.Name < b.Name }), @@ -576,8 +605,8 @@ var ( now = time.Now() ) -// createAlertNotification creates a legacy alert notification channel for inserting into the test database. -func createAlertNotification(t *testing.T, orgId int64, uid string, channelType string, settings string, defaultChannel bool) *models.AlertNotification { +// createAlertNotificationWithReminder creates a legacy alert notification channel for inserting into the test database. +func createAlertNotificationWithReminder(t *testing.T, orgId int64, uid string, channelType string, settings string, defaultChannel bool, sendReminder bool, frequency time.Duration) *models.AlertNotification { t.Helper() settingsJson := simplejson.New() if settings != "" { @@ -599,9 +628,16 @@ func createAlertNotification(t *testing.T, orgId int64, uid string, channelType SecureSettings: make(map[string][]byte), Created: now, Updated: now, + SendReminder: sendReminder, + Frequency: frequency, } } +// createAlertNotification creates a legacy alert notification channel for inserting into the test database. +func createAlertNotification(t *testing.T, orgId int64, uid string, channelType string, settings string, defaultChannel bool) *models.AlertNotification { + return createAlertNotificationWithReminder(t, orgId, uid, channelType, settings, defaultChannel, false, time.Duration(0)) +} + // createAlert creates a legacy alert rule for inserting into the test database. func createAlert(t *testing.T, orgId int64, dashboardId int64, panelsId int64, name string, notifierUids []string) *models.Alert { t.Helper() @@ -766,3 +802,7 @@ func getAlertRules(t *testing.T, x *xorm.Engine, orgId int64) []*ngModels.AlertR func boolPointer(b bool) *bool { return &b } + +func durationPointer(d model.Duration) *model.Duration { + return &d +}