diff --git a/conf/provisioning/notifiers/sample.yaml b/conf/provisioning/notifiers/sample.yaml new file mode 100644 index 00000000000..7d909839412 --- /dev/null +++ b/conf/provisioning/notifiers/sample.yaml @@ -0,0 +1,25 @@ +# # config file version +apiVersion: 1 + +# notifiers: +# - name: default-slack-temp +# type: slack +# org_name: Main Org. +# is_default: true +# uid: notifier1 +# settings: +# recipient: "XXX" +# token: "xoxb" +# uploadImage: true +# url: https://slack.com +# - name: default-email +# type: email +# org_id: 1 +# uid: notifier2 +# is_default: false +# settings: +# addresses: example11111@example.com +# delete_notifiers: +# - name: default-slack-temp +# org_name: Main Org. +# uid: notifier1 \ No newline at end of file diff --git a/docs/sources/administration/provisioning.md b/docs/sources/administration/provisioning.md index b2a1b1f42e7..cce25e4cf2b 100644 --- a/docs/sources/administration/provisioning.md +++ b/docs/sources/administration/provisioning.md @@ -231,3 +231,187 @@ By default Grafana will delete dashboards in the database if the file is removed > which leads to problems if you re-use settings that are supposed to be unique. > Be careful not to re-use the same `title` multiple times within a folder > or `uid` within the same installation as this will cause weird behaviors. + +## Alert Notification Channels + +Alert Notification Channels can be provisioned by adding one or more yaml config files in the [`provisioning/notifiers`](/installation/configuration/#provisioning) directory. + +Each config file can contain the following top-level fields: +- `notifiers`, a list of alert notifications that will be added or updated during start up. If the notification channel already exists, Grafana will update it to match the configuration file. +- `delete_notifiers`, a list of alert notifications to be deleted before before inserting/updating those in the `notifiers` list. + +Provisioning looks up alert notifications by uid, and will update any existing notification with the provided uid. + +By default, exporting a dashboard as JSON will use a sequential identifier to refer to alert notifications. The field `uid` can be optionally specified to specify a string identifier for the alert name. + +```json +{ + ... + "alert": { + ..., + "conditions": [...], + "frequency": "24h", + "noDataState": "ok", + "notifications": [ + {"uid": "notifier1"}, + {"uid": "notifier2"}, + ] + } + ... +} +``` + +### Example Alert Notification Channels Config File + +```yaml +notifiers: + - name: notification-channel-1 + type: slack + uid: notifier1 + # either + org_id: 2 + # or + org_name: Main Org. + is_default: true + # See `Supported Settings` section for settings supporter for each + # alert notification type. + settings: + recipient: "XXX" + token: "xoxb" + uploadImage: true + url: https://slack.com + +delete_notifiers: + - name: notification-channel-1 + uid: notifier1 + # either + org_id: 2 + # or + org_name: Main Org. + - name: notification-channel-2 + # default org_id: 1 +``` + +### Supported Settings + +The following sections detail the supported settings for each alert notification type. + +#### Alert notification `pushover` + +| Name | +| ---- | +| apiToken | +| userKey | +| device | +| retry | +| expire | + +#### Alert notification `slack` + +| Name | +| ---- | +| url | +| recipient | +| username | +| iconEmoji | +| iconUrl | +| uploadImage | +| mention | +| token | + +#### Alert notification `victorops` + +| Name | +| ---- | +| url | + +#### Alert notification `kafka` + +| Name | +| ---- | +| kafkaRestProxy | +| kafkaTopic | + +#### Alert notification `LINE` + +| Name | +| ---- | +| token | + +#### Alert notification `pagerduty` + +| Name | +| ---- | +| integrationKey | + +#### Alert notification `sensu` + +| Name | +| ---- | +| url | +| source | +| handler | +| username | +| password | + +#### Alert notification `prometheus-alertmanager` + +| Name | +| ---- | +| url | + +#### Alert notification `teams` + +| Name | +| ---- | +| url | + +#### Alert notification `dingding` + +| Name | +| ---- | +| url | + +#### Alert notification `email` + +| Name | +| ---- | +| addresses | + +#### Alert notification `hipchat` + +| Name | +| ---- | +| url | +| apikey | +| roomid | + +#### Alert notification `opsgenie` + +| Name | +| ---- | +| apiKey | +| apiUrl | + +#### Alert notification `telegram` + +| Name | +| ---- | +| bottoken | +| chatid | + +#### Alert notification `threema` + +| Name | +| ---- | +| gateway_id | +| recipient_id | +| api_secret | + +#### Alert notification `webhook` + +| Name | +| ---- | +| url | +| username | +| password | \ No newline at end of file diff --git a/pkg/api/dtos/alerting.go b/pkg/api/dtos/alerting.go index c037831f341..dcdc3976ec5 100644 --- a/pkg/api/dtos/alerting.go +++ b/pkg/api/dtos/alerting.go @@ -50,6 +50,7 @@ func formatShort(interval time.Duration) string { func NewAlertNotification(notification *models.AlertNotification) *AlertNotification { return &AlertNotification{ Id: notification.Id, + Uid: notification.Uid, Name: notification.Name, Type: notification.Type, IsDefault: notification.IsDefault, @@ -64,6 +65,7 @@ func NewAlertNotification(notification *models.AlertNotification) *AlertNotifica type AlertNotification struct { Id int64 `json:"id"` + Uid string `json:"uid"` Name string `json:"name"` Type string `json:"type"` IsDefault bool `json:"isDefault"` diff --git a/pkg/models/alert_notifications.go b/pkg/models/alert_notifications.go index e0fd12937ed..0a26276e787 100644 --- a/pkg/models/alert_notifications.go +++ b/pkg/models/alert_notifications.go @@ -8,10 +8,11 @@ import ( ) var ( - ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified") - ErrAlertNotificationStateNotFound = errors.New("alert notification state not found") - ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict") - ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists.") + ErrNotificationFrequencyNotFound = errors.New("Notification frequency not specified") + ErrAlertNotificationStateNotFound = errors.New("alert notification state not found") + ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict") + ErrAlertNotificationStateAlreadyExist = errors.New("alert notification state already exists.") + ErrAlertNotificationFailedGenerateUniqueUid = errors.New("Failed to generate unique alert notification uid") ) type AlertNotificationStateType string @@ -24,6 +25,7 @@ var ( type AlertNotification struct { Id int64 `json:"id"` + Uid string `json:"-"` OrgId int64 `json:"-"` Name string `json:"name"` Type string `json:"type"` @@ -37,6 +39,7 @@ type AlertNotification struct { } type CreateAlertNotificationCommand struct { + Uid string `json:"-"` Name string `json:"name" binding:"Required"` Type string `json:"type" binding:"Required"` SendReminder bool `json:"sendReminder"` @@ -63,10 +66,28 @@ type UpdateAlertNotificationCommand struct { Result *AlertNotification } +type UpdateAlertNotificationWithUidCommand struct { + Uid string + Name string + Type string + SendReminder bool + DisableResolveMessage bool + Frequency string + IsDefault bool + Settings *simplejson.Json + + OrgId int64 + Result *AlertNotification +} + type DeleteAlertNotificationCommand struct { Id int64 OrgId int64 } +type DeleteAlertNotificationWithUidCommand struct { + Uid string + OrgId int64 +} type GetAlertNotificationsQuery struct { Name string @@ -76,8 +97,15 @@ type GetAlertNotificationsQuery struct { Result *AlertNotification } -type GetAlertNotificationsToSendQuery struct { - Ids []int64 +type GetAlertNotificationsWithUidQuery struct { + Uid string + OrgId int64 + + Result *AlertNotification +} + +type GetAlertNotificationsWithUidToSendQuery struct { + Uids []string OrgId int64 Result []*AlertNotification diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index 9665a657bb7..9c689fec921 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" . "github.com/smartystreets/goconvey/convey" ) @@ -197,74 +198,84 @@ func TestAlertRuleExtraction(t *testing.T) { }) }) - Convey("Parse and validate dashboard containing influxdb alert", func() { - json, err := ioutil.ReadFile("./testdata/influxdb-alert.json") + Convey("Alert notifications are in DB", func() { + sqlstore.InitTestDB(t) + firstNotification := m.CreateAlertNotificationCommand{Uid: "notifier1", OrgId: 1, Name: "1"} + err = sqlstore.CreateAlertNotificationCommand(&firstNotification) + So(err, ShouldBeNil) + secondNotification := m.CreateAlertNotificationCommand{Uid: "notifier2", OrgId: 1, Name: "2"} + err = sqlstore.CreateAlertNotificationCommand(&secondNotification) So(err, ShouldBeNil) - dashJson, err := simplejson.NewJson(json) - So(err, ShouldBeNil) - dash := m.NewDashboardFromJson(dashJson) - extractor := NewDashAlertExtractor(dash, 1, nil) - - alerts, err := extractor.GetAlerts() - - Convey("Get rules without error", func() { + Convey("Parse and validate dashboard containing influxdb alert", func() { + json, err := ioutil.ReadFile("./testdata/influxdb-alert.json") So(err, ShouldBeNil) - }) - Convey("should be able to read interval", func() { - So(len(alerts), ShouldEqual, 1) - - for _, alert := range alerts { - So(alert.DashboardId, ShouldEqual, 4) - - conditions := alert.Settings.Get("conditions").MustArray() - cond := simplejson.NewFromAny(conditions[0]) - - So(cond.Get("query").Get("model").Get("interval").MustString(), ShouldEqual, ">10s") - } - }) - }) - - Convey("Should be able to extract collapsed panels", func() { - json, err := ioutil.ReadFile("./testdata/collapsed-panels.json") - So(err, ShouldBeNil) - - dashJson, err := simplejson.NewJson(json) - So(err, ShouldBeNil) - - dash := m.NewDashboardFromJson(dashJson) - extractor := NewDashAlertExtractor(dash, 1, nil) - - alerts, err := extractor.GetAlerts() - - Convey("Get rules without error", func() { + dashJson, err := simplejson.NewJson(json) So(err, ShouldBeNil) + dash := m.NewDashboardFromJson(dashJson) + extractor := NewDashAlertExtractor(dash, 1, nil) + + alerts, err := extractor.GetAlerts() + + Convey("Get rules without error", func() { + So(err, ShouldBeNil) + }) + + Convey("should be able to read interval", func() { + So(len(alerts), ShouldEqual, 1) + + for _, alert := range alerts { + So(alert.DashboardId, ShouldEqual, 4) + + conditions := alert.Settings.Get("conditions").MustArray() + cond := simplejson.NewFromAny(conditions[0]) + + So(cond.Get("query").Get("model").Get("interval").MustString(), ShouldEqual, ">10s") + } + }) }) - Convey("should be able to extract collapsed alerts", func() { - So(len(alerts), ShouldEqual, 4) - }) - }) - - Convey("Parse and validate dashboard without id and containing an alert", func() { - json, err := ioutil.ReadFile("./testdata/dash-without-id.json") - So(err, ShouldBeNil) - - dashJSON, err := simplejson.NewJson(json) - So(err, ShouldBeNil) - dash := m.NewDashboardFromJson(dashJSON) - extractor := NewDashAlertExtractor(dash, 1, nil) - - err = extractor.ValidateAlerts() - - Convey("Should validate without error", func() { + Convey("Should be able to extract collapsed panels", func() { + json, err := ioutil.ReadFile("./testdata/collapsed-panels.json") So(err, ShouldBeNil) + + dashJson, err := simplejson.NewJson(json) + So(err, ShouldBeNil) + + dash := m.NewDashboardFromJson(dashJson) + extractor := NewDashAlertExtractor(dash, 1, nil) + + alerts, err := extractor.GetAlerts() + + Convey("Get rules without error", func() { + So(err, ShouldBeNil) + }) + + Convey("should be able to extract collapsed alerts", func() { + So(len(alerts), ShouldEqual, 4) + }) }) - Convey("Should fail on save", func() { - _, err := extractor.GetAlerts() - So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1") + Convey("Parse and validate dashboard without id and containing an alert", func() { + json, err := ioutil.ReadFile("./testdata/dash-without-id.json") + So(err, ShouldBeNil) + + dashJSON, err := simplejson.NewJson(json) + So(err, ShouldBeNil) + dash := m.NewDashboardFromJson(dashJSON) + extractor := NewDashAlertExtractor(dash, 1, nil) + + err = extractor.ValidateAlerts() + + Convey("Should validate without error", func() { + So(err, ShouldBeNil) + }) + + Convey("Should fail on save", func() { + _, err := extractor.GetAlerts() + So(err.Error(), ShouldEqual, "Alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1") + }) }) }) }) diff --git a/pkg/services/alerting/interfaces.go b/pkg/services/alerting/interfaces.go index 040d0991861..bd7ca087769 100644 --- a/pkg/services/alerting/interfaces.go +++ b/pkg/services/alerting/interfaces.go @@ -24,7 +24,7 @@ type Notifier interface { // ShouldNotify checks this evaluation should send an alert notification ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool - GetNotifierId() int64 + GetNotifierUid() string GetIsDefault() bool GetSendReminder() bool GetDisableResolveMessage() bool diff --git a/pkg/services/alerting/notifier.go b/pkg/services/alerting/notifier.go index 75c68615750..e1a550d48f4 100644 --- a/pkg/services/alerting/notifier.go +++ b/pkg/services/alerting/notifier.go @@ -60,13 +60,13 @@ func (n *notificationService) SendIfNeeded(context *EvalContext) error { func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error { notifier := notifierState.notifier - n.log.Debug("Sending notification", "type", notifier.GetType(), "id", notifier.GetNotifierId(), "isDefault", notifier.GetIsDefault()) + n.log.Debug("Sending notification", "type", notifier.GetType(), "uid", notifier.GetNotifierUid(), "isDefault", notifier.GetIsDefault()) metrics.M_Alerting_Notification_Sent.WithLabelValues(notifier.GetType()).Inc() err := notifier.Notify(evalContext) if err != nil { - n.log.Error("failed to send notification", "id", notifier.GetNotifierId(), "error", err) + n.log.Error("failed to send notification", "uid", notifier.GetNotifierUid(), "error", err) } if evalContext.IsTestRun { @@ -110,7 +110,7 @@ func (n *notificationService) sendNotifications(evalContext *EvalContext, notifi for _, notifierState := range notifierStates { err := n.sendNotification(evalContext, notifierState) if err != nil { - n.log.Error("failed to send notification", "id", notifierState.notifier.GetNotifierId(), "error", err) + n.log.Error("failed to send notification", "uid", notifierState.notifier.GetNotifierUid(), "error", err) } } @@ -157,8 +157,8 @@ func (n *notificationService) uploadImage(context *EvalContext) (err error) { return nil } -func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []int64, evalContext *EvalContext) (notifierStateSlice, error) { - query := &m.GetAlertNotificationsToSendQuery{OrgId: orgId, Ids: notificationIds} +func (n *notificationService) getNeededNotifiers(orgId int64, notificationUids []string, evalContext *EvalContext) (notifierStateSlice, error) { + query := &m.GetAlertNotificationsWithUidToSendQuery{OrgId: orgId, Uids: notificationUids} if err := bus.Dispatch(query); err != nil { return nil, err @@ -168,7 +168,7 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds [] for _, notification := range query.Result { not, err := InitNotifier(notification) if err != nil { - n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err) + n.log.Error("Could not create notifier", "notifier", notification.Uid, "error", err) continue } diff --git a/pkg/services/alerting/notifiers/base.go b/pkg/services/alerting/notifiers/base.go index d4a9975bcba..9616c2ab7cf 100644 --- a/pkg/services/alerting/notifiers/base.go +++ b/pkg/services/alerting/notifiers/base.go @@ -16,7 +16,7 @@ const ( type NotifierBase struct { Name string Type string - Id int64 + Uid string IsDeault bool UploadImage bool SendReminder bool @@ -34,7 +34,7 @@ func NewNotifierBase(model *models.AlertNotification) NotifierBase { } return NotifierBase{ - Id: model.Id, + Uid: model.Uid, Name: model.Name, IsDeault: model.IsDefault, Type: model.Type, @@ -110,8 +110,8 @@ func (n *NotifierBase) NeedsImage() bool { return n.UploadImage } -func (n *NotifierBase) GetNotifierId() int64 { - return n.Id +func (n *NotifierBase) GetNotifierUid() string { + return n.Uid } func (n *NotifierBase) GetIsDefault() bool { diff --git a/pkg/services/alerting/notifiers/base_test.go b/pkg/services/alerting/notifiers/base_test.go index 3fd4447eefe..bf09ecab0cd 100644 --- a/pkg/services/alerting/notifiers/base_test.go +++ b/pkg/services/alerting/notifiers/base_test.go @@ -173,7 +173,7 @@ func TestBaseNotifier(t *testing.T) { bJson := simplejson.New() model := &m.AlertNotification{ - Id: 1, + Uid: "1", Name: "name", Type: "email", Settings: bJson, diff --git a/pkg/services/alerting/rule.go b/pkg/services/alerting/rule.go index 4423046d600..902c1660976 100644 --- a/pkg/services/alerting/rule.go +++ b/pkg/services/alerting/rule.go @@ -30,7 +30,7 @@ type Rule struct { ExecutionErrorState m.ExecutionErrorOption State m.AlertStateType Conditions []Condition - Notifications []int64 + Notifications []string StateChanges int64 } @@ -126,11 +126,15 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) { for _, v := range ruleDef.Settings.Get("notifications").MustArray() { jsonModel := simplejson.NewFromAny(v) - id, err := jsonModel.Get("id").Int64() - if err != nil { - return nil, ValidationError{Reason: "Invalid notification schema", DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId} + if id, err := jsonModel.Get("id").Int64(); err == nil { + model.Notifications = append(model.Notifications, fmt.Sprintf("%09d", id)) + } else { + if uid, err := jsonModel.Get("uid").String(); err != nil { + return nil, ValidationError{Reason: "Neither id nor uid is specified, " + err.Error(), DashboardId: model.DashboardId, Alertid: model.Id, PanelId: model.PanelId} + } else { + model.Notifications = append(model.Notifications, uid) + } } - model.Notifications = append(model.Notifications, id) } for index, condition := range ruleDef.Settings.Get("conditions").MustArray() { diff --git a/pkg/services/alerting/rule_test.go b/pkg/services/alerting/rule_test.go index cf25cc118f4..f4172a57e74 100644 --- a/pkg/services/alerting/rule_test.go +++ b/pkg/services/alerting/rule_test.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" . "github.com/smartystreets/goconvey/convey" ) @@ -45,6 +46,7 @@ func TestAlertRuleFrequencyParsing(t *testing.T) { } func TestAlertRuleModel(t *testing.T) { + sqlstore.InitTestDB(t) Convey("Testing alert rule", t, func() { RegisterCondition("test", func(model *simplejson.Json, index int) (Condition, error) { @@ -57,46 +59,57 @@ func TestAlertRuleModel(t *testing.T) { }) Convey("can construct alert rule model", func() { - json := ` - { - "name": "name2", - "description": "desc2", - "handler": 0, - "noDataMode": "critical", - "enabled": true, - "frequency": "60s", - "conditions": [ - { - "type": "test", - "prop": 123 - } - ], - "notifications": [ - {"id": 1134}, - {"id": 22} - ] - } - ` - - alertJSON, jsonErr := simplejson.NewJson([]byte(json)) - So(jsonErr, ShouldBeNil) - - alert := &m.Alert{ - Id: 1, - OrgId: 1, - DashboardId: 1, - PanelId: 1, - - Settings: alertJSON, - } - - alertRule, err := NewRuleFromDBAlert(alert) + firstNotification := m.CreateAlertNotificationCommand{OrgId: 1, Name: "1"} + err := sqlstore.CreateAlertNotificationCommand(&firstNotification) + So(err, ShouldBeNil) + secondNotification := m.CreateAlertNotificationCommand{Uid: "notifier2", OrgId: 1, Name: "2"} + err = sqlstore.CreateAlertNotificationCommand(&secondNotification) So(err, ShouldBeNil) - So(len(alertRule.Conditions), ShouldEqual, 1) + Convey("with notification id and uid", func() { + json := ` + { + "name": "name2", + "description": "desc2", + "handler": 0, + "noDataMode": "critical", + "enabled": true, + "frequency": "60s", + "conditions": [ + { + "type": "test", + "prop": 123 + } + ], + "notifications": [ + {"id": 1}, + {"uid": "notifier2"} + ] + } + ` - Convey("Can read notifications", func() { - So(len(alertRule.Notifications), ShouldEqual, 2) + alertJSON, jsonErr := simplejson.NewJson([]byte(json)) + So(jsonErr, ShouldBeNil) + + alert := &m.Alert{ + Id: 1, + OrgId: 1, + DashboardId: 1, + PanelId: 1, + + Settings: alertJSON, + } + + alertRule, err := NewRuleFromDBAlert(alert) + So(err, ShouldBeNil) + + So(len(alertRule.Conditions), ShouldEqual, 1) + + Convey("Can read notifications", func() { + So(len(alertRule.Notifications), ShouldEqual, 2) + So(alertRule.Notifications, ShouldContain, "000000001") + So(alertRule.Notifications, ShouldContain, "notifier2") + }) }) }) @@ -108,8 +121,8 @@ func TestAlertRuleModel(t *testing.T) { "noDataMode": "critical", "enabled": true, "frequency": "0s", - "conditions": [ { "type": "test", "prop": 123 } ], - "notifications": [] + "conditions": [ { "type": "test", "prop": 123 } ], + "notifications": [] }` alertJSON, jsonErr := simplejson.NewJson([]byte(json)) @@ -129,5 +142,43 @@ func TestAlertRuleModel(t *testing.T) { So(err, ShouldBeNil) So(alertRule.Frequency, ShouldEqual, 60) }) + + Convey("raise error in case of missing notification id and uid", func() { + json := ` + { + "name": "name2", + "description": "desc2", + "noDataMode": "critical", + "enabled": true, + "frequency": "60s", + "conditions": [ + { + "type": "test", + "prop": 123 + } + ], + "notifications": [ + {"not_id_uid": "1134"} + ] + } + ` + + alertJSON, jsonErr := simplejson.NewJson([]byte(json)) + So(jsonErr, ShouldBeNil) + + alert := &m.Alert{ + Id: 1, + OrgId: 1, + DashboardId: 1, + PanelId: 1, + Frequency: 0, + + Settings: alertJSON, + } + + _, err := NewRuleFromDBAlert(alert) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "Alert validation error: Neither id nor uid is specified, type assertion to string failed AlertId: 1 PanelId: 1 DashboardId: 1") + }) }) } diff --git a/pkg/services/alerting/testdata/dash-without-id.json b/pkg/services/alerting/testdata/dash-without-id.json index e0a212695d8..02cd2c002f0 100644 --- a/pkg/services/alerting/testdata/dash-without-id.json +++ b/pkg/services/alerting/testdata/dash-without-id.json @@ -44,7 +44,10 @@ "noDataState": "no_data", "notifications": [ { - "id": 6 + "uid": "notifier1" + }, + { + "id": 2 } ] }, diff --git a/pkg/services/alerting/testdata/influxdb-alert.json b/pkg/services/alerting/testdata/influxdb-alert.json index fd6feb31a47..29f1a0c8e5e 100644 --- a/pkg/services/alerting/testdata/influxdb-alert.json +++ b/pkg/services/alerting/testdata/influxdb-alert.json @@ -45,7 +45,10 @@ "noDataState": "no_data", "notifications": [ { - "id": 6 + "id": 1 + }, + { + "uid": "notifier2" } ] }, diff --git a/pkg/services/provisioning/notifiers/alert_notifications.go b/pkg/services/provisioning/notifiers/alert_notifications.go new file mode 100644 index 00000000000..514f11379c8 --- /dev/null +++ b/pkg/services/provisioning/notifiers/alert_notifications.go @@ -0,0 +1,180 @@ +package notifiers + +import ( + "errors" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/log" + "github.com/grafana/grafana/pkg/models" +) + +var ( + ErrInvalidConfigTooManyDefault = errors.New("Alert notification provisioning config is invalid. Only one alert notification can be marked as default") +) + +func Provision(configDirectory string) error { + dc := newNotificationProvisioner(log.New("provisioning.notifiers")) + return dc.applyChanges(configDirectory) +} + +type NotificationProvisioner struct { + log log.Logger + cfgProvider *configReader +} + +func newNotificationProvisioner(log log.Logger) NotificationProvisioner { + return NotificationProvisioner{ + log: log, + cfgProvider: &configReader{log: log}, + } +} + +func (dc *NotificationProvisioner) apply(cfg *notificationsAsConfig) error { + if err := dc.deleteNotifications(cfg.DeleteNotifications); err != nil { + return err + } + + if err := dc.mergeNotifications(cfg.Notifications); err != nil { + return err + } + + return nil +} + +func (dc *NotificationProvisioner) deleteNotifications(notificationToDelete []*deleteNotificationConfig) error { + for _, notification := range notificationToDelete { + dc.log.Info("Deleting alert notification", "name", notification.Name, "uid", notification.Uid) + + if notification.OrgId == 0 && notification.OrgName != "" { + getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName} + if err := bus.Dispatch(getOrg); err != nil { + return err + } + notification.OrgId = getOrg.Result.Id + } else if notification.OrgId < 0 { + notification.OrgId = 1 + } + + getNotification := &models.GetAlertNotificationsWithUidQuery{Uid: notification.Uid, OrgId: notification.OrgId} + + if err := bus.Dispatch(getNotification); err != nil { + return err + } + + if getNotification.Result != nil { + cmd := &models.DeleteAlertNotificationWithUidCommand{Uid: getNotification.Result.Uid, OrgId: getNotification.OrgId} + if err := bus.Dispatch(cmd); err != nil { + return err + } + } + } + + return nil +} + +func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*notificationFromConfig) error { + for _, notification := range notificationToMerge { + + if notification.OrgId == 0 && notification.OrgName != "" { + getOrg := &models.GetOrgByNameQuery{Name: notification.OrgName} + if err := bus.Dispatch(getOrg); err != nil { + return err + } + notification.OrgId = getOrg.Result.Id + } else if notification.OrgId < 0 { + notification.OrgId = 1 + } + + cmd := &models.GetAlertNotificationsWithUidQuery{OrgId: notification.OrgId, Uid: notification.Uid} + err := bus.Dispatch(cmd) + if err != nil { + return err + } + + if cmd.Result == nil { + dc.log.Info("Inserting alert notification from configuration ", "name", notification.Name, "uid", notification.Uid) + insertCmd := &models.CreateAlertNotificationCommand{ + Uid: notification.Uid, + Name: notification.Name, + Type: notification.Type, + IsDefault: notification.IsDefault, + Settings: notification.SettingsToJson(), + OrgId: notification.OrgId, + DisableResolveMessage: notification.DisableResolveMessage, + Frequency: notification.Frequency, + SendReminder: notification.SendReminder, + } + + if err := bus.Dispatch(insertCmd); err != nil { + return err + } + } else { + dc.log.Info("Updating alert notification from configuration", "name", notification.Name) + updateCmd := &models.UpdateAlertNotificationWithUidCommand{ + Uid: notification.Uid, + Name: notification.Name, + Type: notification.Type, + IsDefault: notification.IsDefault, + Settings: notification.SettingsToJson(), + OrgId: notification.OrgId, + DisableResolveMessage: notification.DisableResolveMessage, + Frequency: notification.Frequency, + SendReminder: notification.SendReminder, + } + + if err := bus.Dispatch(updateCmd); err != nil { + return err + } + } + } + + return nil +} + +func (cfg *notificationsAsConfig) mapToNotificationFromConfig() *notificationsAsConfig { + r := ¬ificationsAsConfig{} + if cfg == nil { + return r + } + + for _, notification := range cfg.Notifications { + r.Notifications = append(r.Notifications, ¬ificationFromConfig{ + Uid: notification.Uid, + OrgId: notification.OrgId, + OrgName: notification.OrgName, + Name: notification.Name, + Type: notification.Type, + IsDefault: notification.IsDefault, + Settings: notification.Settings, + DisableResolveMessage: notification.DisableResolveMessage, + Frequency: notification.Frequency, + SendReminder: notification.SendReminder, + }) + } + + for _, notification := range cfg.DeleteNotifications { + r.DeleteNotifications = append(r.DeleteNotifications, &deleteNotificationConfig{ + Uid: notification.Uid, + OrgId: notification.OrgId, + OrgName: notification.OrgName, + Name: notification.Name, + }) + } + + return r +} + +func (dc *NotificationProvisioner) applyChanges(configPath string) error { + configs, err := dc.cfgProvider.readConfig(configPath) + if err != nil { + return err + } + + for _, cfg := range configs { + if err := dc.apply(cfg); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/services/provisioning/notifiers/config_reader.go b/pkg/services/provisioning/notifiers/config_reader.go new file mode 100644 index 00000000000..e712e8e3eff --- /dev/null +++ b/pkg/services/provisioning/notifiers/config_reader.go @@ -0,0 +1,163 @@ +package notifiers + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + "gopkg.in/yaml.v2" +) + +type configReader struct { + log log.Logger +} + +func (cr *configReader) readConfig(path string) ([]*notificationsAsConfig, error) { + var notifications []*notificationsAsConfig + cr.log.Debug("Looking for alert notification provisioning files", "path", path) + + files, err := ioutil.ReadDir(path) + if err != nil { + cr.log.Error("Can't read alert notification provisioning files from directory", "path", path) + return notifications, nil + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { + cr.log.Debug("Parsing alert notifications provisioning file", "path", path, "file.Name", file.Name()) + notifs, err := cr.parseNotificationConfig(path, file) + if err != nil { + return nil, err + } + + if notifs != nil { + notifications = append(notifications, notifs) + } + } + } + + cr.log.Debug("Validating alert notifications") + if err = validateRequiredField(notifications); err != nil { + return nil, err + } + + checkOrgIdAndOrgName(notifications) + + err = validateNotifications(notifications) + if err != nil { + return nil, err + } + + return notifications, nil +} + +func (cr *configReader) parseNotificationConfig(path string, file os.FileInfo) (*notificationsAsConfig, error) { + filename, _ := filepath.Abs(filepath.Join(path, file.Name())) + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + var cfg *notificationsAsConfig + err = yaml.Unmarshal(yamlFile, &cfg) + if err != nil { + return nil, err + } + + return cfg.mapToNotificationFromConfig(), nil +} + +func checkOrgIdAndOrgName(notifications []*notificationsAsConfig) { + for i := range notifications { + for _, notification := range notifications[i].Notifications { + if notification.OrgId < 1 { + if notification.OrgName == "" { + notification.OrgId = 1 + } else { + notification.OrgId = 0 + } + } + } + + for _, notification := range notifications[i].DeleteNotifications { + if notification.OrgId < 1 { + if notification.OrgName == "" { + notification.OrgId = 1 + } else { + notification.OrgId = 0 + } + } + } + } +} + +func validateRequiredField(notifications []*notificationsAsConfig) error { + for i := range notifications { + var errStrings []string + for index, notification := range notifications[i].Notifications { + if notification.Name == "" { + errStrings = append( + errStrings, + fmt.Sprintf("Added alert notification item %d in configuration doesn't contain required field name", index+1), + ) + } + + if notification.Uid == "" { + errStrings = append( + errStrings, + fmt.Sprintf("Added alert notification item %d in configuration doesn't contain required field uid", index+1), + ) + } + } + + for index, notification := range notifications[i].DeleteNotifications { + if notification.Name == "" { + errStrings = append( + errStrings, + fmt.Sprintf("Deleted alert notification item %d in configuration doesn't contain required field name", index+1), + ) + } + + if notification.Uid == "" { + errStrings = append( + errStrings, + fmt.Sprintf("Deleted alert notification item %d in configuration doesn't contain required field uid", index+1), + ) + } + } + + if len(errStrings) != 0 { + return fmt.Errorf(strings.Join(errStrings, "\n")) + } + } + + return nil +} + +func validateNotifications(notifications []*notificationsAsConfig) error { + + for i := range notifications { + if notifications[i].Notifications == nil { + continue + } + + for _, notification := range notifications[i].Notifications { + _, err := alerting.InitNotifier(&m.AlertNotification{ + Name: notification.Name, + Settings: notification.SettingsToJson(), + Type: notification.Type, + }) + + if err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/services/provisioning/notifiers/config_reader_test.go b/pkg/services/provisioning/notifiers/config_reader_test.go new file mode 100644 index 00000000000..87645ee7d31 --- /dev/null +++ b/pkg/services/provisioning/notifiers/config_reader_test.go @@ -0,0 +1,313 @@ +package notifiers + +import ( + "testing" + + "github.com/grafana/grafana/pkg/log" + m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + "github.com/grafana/grafana/pkg/services/alerting/notifiers" + "github.com/grafana/grafana/pkg/services/sqlstore" + . "github.com/smartystreets/goconvey/convey" +) + +var ( + correct_properties = "./testdata/test-configs/correct-properties" + incorrect_settings = "./testdata/test-configs/incorrect-settings" + no_required_fields = "./testdata/test-configs/no-required-fields" + correct_properties_with_orgName = "./testdata/test-configs/correct-properties-with-orgName" + brokenYaml = "./testdata/test-configs/broken-yaml" + doubleNotificationsConfig = "./testdata/test-configs/double-default" + emptyFolder = "./testdata/test-configs/empty_folder" + emptyFile = "./testdata/test-configs/empty" + twoNotificationsConfig = "./testdata/test-configs/two-notifications" + unknownNotifier = "./testdata/test-configs/unknown-notifier" +) + +func TestNotificationAsConfig(t *testing.T) { + logger := log.New("fake.log") + + Convey("Testing notification as configuration", t, func() { + sqlstore.InitTestDB(t) + + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "slack", + Name: "slack", + Factory: notifiers.NewSlackNotifier, + }) + + alerting.RegisterNotifier(&alerting.NotifierPlugin{ + Type: "email", + Name: "email", + Factory: notifiers.NewEmailNotifier, + }) + + Convey("Can read correct properties", func() { + cfgProvifer := &configReader{log: log.New("test logger")} + cfg, err := cfgProvifer.readConfig(correct_properties) + if err != nil { + t.Fatalf("readConfig return an error %v", err) + } + So(len(cfg), ShouldEqual, 1) + + ntCfg := cfg[0] + nts := ntCfg.Notifications + So(len(nts), ShouldEqual, 4) + + nt := nts[0] + So(nt.Name, ShouldEqual, "default-slack-notification") + So(nt.Type, ShouldEqual, "slack") + So(nt.OrgId, ShouldEqual, 2) + So(nt.Uid, ShouldEqual, "notifier1") + So(nt.IsDefault, ShouldBeTrue) + So(nt.Settings, ShouldResemble, map[string]interface{}{ + "recipient": "XXX", "token": "xoxb", "uploadImage": true, "url": "https://slack.com", + }) + + nt = nts[1] + So(nt.Name, ShouldEqual, "another-not-default-notification") + So(nt.Type, ShouldEqual, "email") + So(nt.OrgId, ShouldEqual, 3) + So(nt.Uid, ShouldEqual, "notifier2") + So(nt.IsDefault, ShouldBeFalse) + + nt = nts[2] + So(nt.Name, ShouldEqual, "check-unset-is_default-is-false") + So(nt.Type, ShouldEqual, "slack") + So(nt.OrgId, ShouldEqual, 3) + So(nt.Uid, ShouldEqual, "notifier3") + So(nt.IsDefault, ShouldBeFalse) + + nt = nts[3] + So(nt.Name, ShouldEqual, "Added notification with whitespaces in name") + So(nt.Type, ShouldEqual, "email") + So(nt.Uid, ShouldEqual, "notifier4") + So(nt.OrgId, ShouldEqual, 3) + + deleteNts := ntCfg.DeleteNotifications + So(len(deleteNts), ShouldEqual, 4) + + deleteNt := deleteNts[0] + So(deleteNt.Name, ShouldEqual, "default-slack-notification") + So(deleteNt.Uid, ShouldEqual, "notifier1") + So(deleteNt.OrgId, ShouldEqual, 2) + + deleteNt = deleteNts[1] + So(deleteNt.Name, ShouldEqual, "deleted-notification-without-orgId") + So(deleteNt.OrgId, ShouldEqual, 1) + So(deleteNt.Uid, ShouldEqual, "notifier2") + + deleteNt = deleteNts[2] + So(deleteNt.Name, ShouldEqual, "deleted-notification-with-0-orgId") + So(deleteNt.OrgId, ShouldEqual, 1) + So(deleteNt.Uid, ShouldEqual, "notifier3") + + deleteNt = deleteNts[3] + So(deleteNt.Name, ShouldEqual, "Deleted notification with whitespaces in name") + So(deleteNt.OrgId, ShouldEqual, 1) + So(deleteNt.Uid, ShouldEqual, "notifier4") + }) + + Convey("One configured notification", func() { + Convey("no notification in database", func() { + dc := newNotificationProvisioner(logger) + err := dc.applyChanges(twoNotificationsConfig) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } + notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1} + err = sqlstore.GetAllAlertNotifications(¬ificationsQuery) + So(err, ShouldBeNil) + So(notificationsQuery.Result, ShouldNotBeNil) + So(len(notificationsQuery.Result), ShouldEqual, 2) + }) + + Convey("One notification in database with same name and uid", func() { + existingNotificationCmd := m.CreateAlertNotificationCommand{ + Name: "channel1", + OrgId: 1, + Uid: "notifier1", + Type: "slack", + } + err := sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd) + So(err, ShouldBeNil) + So(existingNotificationCmd.Result, ShouldNotBeNil) + notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1} + err = sqlstore.GetAllAlertNotifications(¬ificationsQuery) + So(err, ShouldBeNil) + So(notificationsQuery.Result, ShouldNotBeNil) + So(len(notificationsQuery.Result), ShouldEqual, 1) + + Convey("should update one notification", func() { + dc := newNotificationProvisioner(logger) + err = dc.applyChanges(twoNotificationsConfig) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } + err = sqlstore.GetAllAlertNotifications(¬ificationsQuery) + So(err, ShouldBeNil) + So(notificationsQuery.Result, ShouldNotBeNil) + So(len(notificationsQuery.Result), ShouldEqual, 2) + + nts := notificationsQuery.Result + nt1 := nts[0] + So(nt1.Type, ShouldEqual, "email") + So(nt1.Name, ShouldEqual, "channel1") + So(nt1.Uid, ShouldEqual, "notifier1") + + nt2 := nts[1] + So(nt2.Type, ShouldEqual, "slack") + So(nt2.Name, ShouldEqual, "channel2") + So(nt2.Uid, ShouldEqual, "notifier2") + }) + }) + Convey("Two notifications with is_default", func() { + dc := newNotificationProvisioner(logger) + err := dc.applyChanges(doubleNotificationsConfig) + Convey("should both be inserted", func() { + So(err, ShouldBeNil) + notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1} + err = sqlstore.GetAllAlertNotifications(¬ificationsQuery) + So(err, ShouldBeNil) + So(notificationsQuery.Result, ShouldNotBeNil) + So(len(notificationsQuery.Result), ShouldEqual, 2) + + So(notificationsQuery.Result[0].IsDefault, ShouldBeTrue) + So(notificationsQuery.Result[1].IsDefault, ShouldBeTrue) + }) + }) + }) + + Convey("Two configured notification", func() { + Convey("two other notifications in database", func() { + existingNotificationCmd := m.CreateAlertNotificationCommand{ + Name: "channel0", + OrgId: 1, + Uid: "notifier0", + Type: "slack", + } + err := sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd) + So(err, ShouldBeNil) + existingNotificationCmd = m.CreateAlertNotificationCommand{ + Name: "channel3", + OrgId: 1, + Uid: "notifier3", + Type: "slack", + } + err = sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd) + So(err, ShouldBeNil) + + notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1} + err = sqlstore.GetAllAlertNotifications(¬ificationsQuery) + So(err, ShouldBeNil) + So(notificationsQuery.Result, ShouldNotBeNil) + So(len(notificationsQuery.Result), ShouldEqual, 2) + + Convey("should have two new notifications", func() { + dc := newNotificationProvisioner(logger) + err := dc.applyChanges(twoNotificationsConfig) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } + notificationsQuery = m.GetAllAlertNotificationsQuery{OrgId: 1} + err = sqlstore.GetAllAlertNotifications(¬ificationsQuery) + So(err, ShouldBeNil) + So(notificationsQuery.Result, ShouldNotBeNil) + So(len(notificationsQuery.Result), ShouldEqual, 4) + }) + }) + }) + + Convey("Can read correct properties with orgName instead of orgId", func() { + existingOrg1 := m.CreateOrgCommand{Name: "Main Org. 1"} + err := sqlstore.CreateOrg(&existingOrg1) + So(err, ShouldBeNil) + So(existingOrg1.Result, ShouldNotBeNil) + existingOrg2 := m.CreateOrgCommand{Name: "Main Org. 2"} + err = sqlstore.CreateOrg(&existingOrg2) + So(err, ShouldBeNil) + So(existingOrg2.Result, ShouldNotBeNil) + + existingNotificationCmd := m.CreateAlertNotificationCommand{ + Name: "default-notification-delete", + OrgId: existingOrg2.Result.Id, + Uid: "notifier2", + Type: "slack", + } + err = sqlstore.CreateAlertNotificationCommand(&existingNotificationCmd) + So(err, ShouldBeNil) + + dc := newNotificationProvisioner(logger) + err = dc.applyChanges(correct_properties_with_orgName) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } + + notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: existingOrg2.Result.Id} + err = sqlstore.GetAllAlertNotifications(¬ificationsQuery) + So(err, ShouldBeNil) + So(notificationsQuery.Result, ShouldNotBeNil) + So(len(notificationsQuery.Result), ShouldEqual, 1) + + nt := notificationsQuery.Result[0] + So(nt.Name, ShouldEqual, "default-notification-create") + So(nt.OrgId, ShouldEqual, existingOrg2.Result.Id) + + }) + + Convey("Config doesn't contain required field", func() { + dc := newNotificationProvisioner(logger) + err := dc.applyChanges(no_required_fields) + So(err, ShouldNotBeNil) + + errString := err.Error() + So(errString, ShouldContainSubstring, "Deleted alert notification item 1 in configuration doesn't contain required field uid") + So(errString, ShouldContainSubstring, "Deleted alert notification item 2 in configuration doesn't contain required field name") + So(errString, ShouldContainSubstring, "Added alert notification item 1 in configuration doesn't contain required field name") + So(errString, ShouldContainSubstring, "Added alert notification item 2 in configuration doesn't contain required field uid") + }) + + Convey("Empty yaml file", func() { + Convey("should have not changed repo", func() { + dc := newNotificationProvisioner(logger) + err := dc.applyChanges(emptyFile) + if err != nil { + t.Fatalf("applyChanges return an error %v", err) + } + notificationsQuery := m.GetAllAlertNotificationsQuery{OrgId: 1} + err = sqlstore.GetAllAlertNotifications(¬ificationsQuery) + So(err, ShouldBeNil) + So(notificationsQuery.Result, ShouldBeEmpty) + }) + }) + + Convey("Broken yaml should return error", func() { + reader := &configReader{log: log.New("test logger")} + _, err := reader.readConfig(brokenYaml) + So(err, ShouldNotBeNil) + }) + + Convey("Skip invalid directory", func() { + cfgProvifer := &configReader{log: log.New("test logger")} + cfg, err := cfgProvifer.readConfig(emptyFolder) + if err != nil { + t.Fatalf("readConfig return an error %v", err) + } + So(len(cfg), ShouldEqual, 0) + }) + + Convey("Unknown notifier should return error", func() { + cfgProvifer := &configReader{log: log.New("test logger")} + _, err := cfgProvifer.readConfig(unknownNotifier) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "Unsupported notification type") + }) + + Convey("Read incorrect properties", func() { + cfgProvifer := &configReader{log: log.New("test logger")} + _, err := cfgProvifer.readConfig(incorrect_settings) + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "Alert validation error: Could not find url property in settings") + }) + }) +} diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml new file mode 100644 index 00000000000..72f2fbdbf63 --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/broken.yaml @@ -0,0 +1,9 @@ +notifiers: + - name: notification-channel-1 + type: slack + org_id: 2 + is_default: true + settings: + recipient: "XXX" + token: "xoxb" + uploadImage: true \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text new file mode 100644 index 00000000000..9050f543cef --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/broken-yaml/not.yaml.text @@ -0,0 +1,6 @@ +#sfxzgnsxzcvnbzcvn +cvbn +cvbn +c +vbn +cvbncvbn \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml new file mode 100644 index 00000000000..25c4536d1f3 --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties-with-orgName/correct-properties-with-orgName.yaml @@ -0,0 +1,12 @@ +notifiers: + - name: default-notification-create + type: email + uid: notifier2 + settings: + addresses: example@example.com + org_name: Main Org. 2 + is_default: false +delete_notifiers: + - name: default-notification-delete + org_name: Main Org. 2 + uid: notifier2 \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml new file mode 100644 index 00000000000..af0736f35a4 --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/correct-properties/correct-properties.yaml @@ -0,0 +1,42 @@ +notifiers: + - name: default-slack-notification + type: slack + uid: notifier1 + org_id: 2 + uid: "notifier1" + is_default: true + settings: + recipient: "XXX" + token: "xoxb" + uploadImage: true + url: https://slack.com + - name: another-not-default-notification + type: email + settings: + addresses: example@exmaple.com + org_id: 3 + uid: "notifier2" + is_default: false + - name: check-unset-is_default-is-false + type: slack + org_id: 3 + uid: "notifier3" + settings: + url: https://slack.com + - name: Added notification with whitespaces in name + type: email + org_id: 3 + uid: "notifier4" + settings: + addresses: example@exmaple.com +delete_notifiers: + - name: default-slack-notification + org_id: 2 + uid: notifier1 + - name: deleted-notification-without-orgId + uid: "notifier2" + - name: deleted-notification-with-0-orgId + org_id: 0 + uid: "notifier3" + - name: Deleted notification with whitespaces in name + uid: "notifier4" \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml new file mode 100644 index 00000000000..d9d2fe66081 --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-1.yml @@ -0,0 +1,7 @@ +notifiers: + - name: first-default + type: slack + uid: notifier1 + is_default: true + settings: + url: https://slack.com \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml new file mode 100644 index 00000000000..878f8b48aa5 --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/double-default/default-2.yaml @@ -0,0 +1,7 @@ +notifiers: + - name: second-default + type: email + uid: notifier2 + is_default: true + settings: + addresses: example@example.com \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/empty/empty.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore b/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore new file mode 100644 index 00000000000..86d0cb2726c --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/empty_folder/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml new file mode 100644 index 00000000000..b7ecfbdf012 --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/incorrect-settings/incorrect-settings.yaml @@ -0,0 +1,10 @@ +notifiers: + - name: slack-notification-without-url-in-settings + type: slack + org_id: 2 + uid: notifier1 + is_default: true + settings: + recipient: "XXX" + token: "xoxb" + uploadImage: true \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml new file mode 100644 index 00000000000..55ff545525e --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/no-required-fields/no-required-fields.yaml @@ -0,0 +1,35 @@ +notifiers: + - type: slack + org_id: 2 + uid: no-name_added-notification + is_default: true + settings: + recipient: "XXX" + token: "xoxb" + uploadImage: true + - name: no-uid + type: slack + org_id: 2 + is_default: true + settings: + recipient: "XXX" + token: "xoxb" + uploadImage: true +delete_notifiers: + - name: no-uid + type: slack + org_id: 2 + is_default: true + settings: + recipient: "XXX" + token: "xoxb" + uploadImage: true + - type: slack + org_id: 2 + uid: no-name_added-notification + is_default: true + settings: + recipient: "XXX" + token: "xoxb" + uploadImage: true + \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml new file mode 100644 index 00000000000..aeeb718e6de --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/two-notifications/two-notifications.yaml @@ -0,0 +1,12 @@ +notifiers: + - name: channel1 + type: email + uid: notifier1 + org_id: 1 + settings: + addresses: example@example.com + - name: channel2 + type: slack + uid: notifier2 + settings: + url: http://slack.com diff --git a/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml b/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml new file mode 100644 index 00000000000..ca0d3fa3c75 --- /dev/null +++ b/pkg/services/provisioning/notifiers/testdata/test-configs/unknown-notifier/notification.yaml @@ -0,0 +1,4 @@ +notifiers: + - name: unknown-notifier + type: nonexisting + uid: notifier1 \ No newline at end of file diff --git a/pkg/services/provisioning/notifiers/types.go b/pkg/services/provisioning/notifiers/types.go new file mode 100644 index 00000000000..f788da79c79 --- /dev/null +++ b/pkg/services/provisioning/notifiers/types.go @@ -0,0 +1,38 @@ +package notifiers + +import "github.com/grafana/grafana/pkg/components/simplejson" + +type notificationsAsConfig struct { + Notifications []*notificationFromConfig `json:"notifiers" yaml:"notifiers"` + DeleteNotifications []*deleteNotificationConfig `json:"delete_notifiers" yaml:"delete_notifiers"` +} + +type deleteNotificationConfig struct { + Uid string `json:"uid" yaml:"uid"` + Name string `json:"name" yaml:"name"` + OrgId int64 `json:"org_id" yaml:"org_id"` + OrgName string `json:"org_name" yaml:"org_name"` +} + +type notificationFromConfig struct { + Uid string `json:"uid" yaml:"uid"` + OrgId int64 `json:"org_id" yaml:"org_id"` + OrgName string `json:"org_name" yaml:"org_name"` + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + SendReminder bool `json:"send_reminder" yaml:"send_reminder"` + DisableResolveMessage bool `json:"disable_resolve_message" yaml:"disable_resolve_message"` + Frequency string `json:"frequency" yaml:"frequency"` + IsDefault bool `json:"is_default" yaml:"is_default"` + Settings map[string]interface{} `json:"settings" yaml:"settings"` +} + +func (notification notificationFromConfig) SettingsToJson() *simplejson.Json { + settings := simplejson.New() + if len(notification.Settings) > 0 { + for k, v := range notification.Settings { + settings.Set(k, v) + } + } + return settings +} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 9044ae97389..45f0972b885 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/provisioning/dashboards" "github.com/grafana/grafana/pkg/services/provisioning/datasources" + "github.com/grafana/grafana/pkg/services/provisioning/notifiers" "github.com/grafana/grafana/pkg/setting" ) @@ -25,6 +26,11 @@ func (ps *ProvisioningService) Init() error { return fmt.Errorf("Datasource provisioning error: %v", err) } + alertNotificationsPath := path.Join(ps.Cfg.ProvisioningPath, "notifiers") + if err := notifiers.Provision(alertNotificationsPath); err != nil { + return fmt.Errorf("Alert notification provisioning error: %v", err) + } + return nil } diff --git a/pkg/services/sqlstore/alert_notification.go b/pkg/services/sqlstore/alert_notification.go index afe6269510f..efb5f621392 100644 --- a/pkg/services/sqlstore/alert_notification.go +++ b/pkg/services/sqlstore/alert_notification.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" ) func init() { @@ -17,11 +18,15 @@ func init() { bus.AddHandler("sql", CreateAlertNotificationCommand) bus.AddHandler("sql", UpdateAlertNotification) bus.AddHandler("sql", DeleteAlertNotification) - bus.AddHandler("sql", GetAlertNotificationsToSend) bus.AddHandler("sql", GetAllAlertNotifications) bus.AddHandlerCtx("sql", GetOrCreateAlertNotificationState) bus.AddHandlerCtx("sql", SetAlertNotificationStateToCompleteCommand) bus.AddHandlerCtx("sql", SetAlertNotificationStateToPendingCommand) + + bus.AddHandler("sql", GetAlertNotificationsWithUid) + bus.AddHandler("sql", UpdateAlertNotificationWithUid) + bus.AddHandler("sql", DeleteAlertNotificationWithUid) + bus.AddHandler("sql", GetAlertNotificationsWithUidToSend) } func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error { @@ -39,10 +44,33 @@ func DeleteAlertNotification(cmd *m.DeleteAlertNotificationCommand) error { }) } +func DeleteAlertNotificationWithUid(cmd *m.DeleteAlertNotificationWithUidCommand) error { + existingNotification := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid} + if err := getAlertNotificationWithUidInternal(existingNotification, newSession()); err != nil { + return err + } + + if existingNotification.Result != nil { + deleteCommand := &m.DeleteAlertNotificationCommand{ + Id: existingNotification.Result.Id, + OrgId: existingNotification.Result.OrgId, + } + if err := bus.Dispatch(deleteCommand); err != nil { + return err + } + } + + return nil +} + func GetAlertNotifications(query *m.GetAlertNotificationsQuery) error { return getAlertNotificationInternal(query, newSession()) } +func GetAlertNotificationsWithUid(query *m.GetAlertNotificationsWithUidQuery) error { + return getAlertNotificationWithUidInternal(query, newSession()) +} + func GetAllAlertNotifications(query *m.GetAllAlertNotificationsQuery) error { results := make([]*m.AlertNotification, 0) if err := x.Where("org_id = ?", query.OrgId).Find(&results); err != nil { @@ -53,12 +81,13 @@ func GetAllAlertNotifications(query *m.GetAllAlertNotificationsQuery) error { return nil } -func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) error { +func GetAlertNotificationsWithUidToSend(query *m.GetAlertNotificationsWithUidToSendQuery) error { var sql bytes.Buffer params := make([]interface{}, 0) - sql.WriteString(`SELECT + sql.WriteString(`SELECT alert_notification.id, + alert_notification.uid, alert_notification.org_id, alert_notification.name, alert_notification.type, @@ -77,9 +106,10 @@ func GetAlertNotificationsToSend(query *m.GetAlertNotificationsToSendQuery) erro sql.WriteString(` AND ((alert_notification.is_default = ?)`) params = append(params, dialect.BooleanStr(true)) - if len(query.Ids) > 0 { - sql.WriteString(` OR alert_notification.id IN (?` + strings.Repeat(",?", len(query.Ids)-1) + ")") - for _, v := range query.Ids { + + if len(query.Uids) > 0 { + sql.WriteString(` OR alert_notification.uid IN (?` + strings.Repeat(",?", len(query.Uids)-1) + ")") + for _, v := range query.Uids { params = append(params, v) } } @@ -142,16 +172,70 @@ func getAlertNotificationInternal(query *m.GetAlertNotificationsQuery, sess *DBS return nil } +func getAlertNotificationWithUidInternal(query *m.GetAlertNotificationsWithUidQuery, sess *DBSession) error { + var sql bytes.Buffer + params := make([]interface{}, 0) + + sql.WriteString(`SELECT + alert_notification.id, + alert_notification.uid, + alert_notification.org_id, + alert_notification.name, + alert_notification.type, + alert_notification.created, + alert_notification.updated, + alert_notification.settings, + alert_notification.is_default, + alert_notification.disable_resolve_message, + alert_notification.send_reminder, + alert_notification.frequency + FROM alert_notification + `) + + sql.WriteString(` WHERE alert_notification.org_id = ? AND alert_notification.uid = ?`) + params = append(params, query.OrgId, query.Uid) + + results := make([]*m.AlertNotification, 0) + if err := sess.SQL(sql.String(), params...).Find(&results); err != nil { + return err + } + + if len(results) == 0 { + query.Result = nil + } else { + query.Result = results[0] + } + + return nil +} + func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error { return inTransaction(func(sess *DBSession) error { - existingQuery := &m.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name} - err := getAlertNotificationInternal(existingQuery, sess) + if cmd.Uid == "" { + if uid, uidGenerationErr := generateNewAlertNotificationUid(sess, cmd.OrgId); uidGenerationErr != nil { + return uidGenerationErr + } else { + cmd.Uid = uid + } + } + existingQuery := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid} + err := getAlertNotificationWithUidInternal(existingQuery, sess) if err != nil { return err } if existingQuery.Result != nil { + return fmt.Errorf("Alert notification uid %s already exists", cmd.Uid) + } + + // check if name exists + sameNameQuery := &m.GetAlertNotificationsQuery{OrgId: cmd.OrgId, Name: cmd.Name} + if err := getAlertNotificationInternal(sameNameQuery, sess); err != nil { + return err + } + + if sameNameQuery.Result != nil { return fmt.Errorf("Alert notification name %s already exists", cmd.Name) } @@ -168,6 +252,7 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error } alertNotification := &m.AlertNotification{ + Uid: cmd.Uid, OrgId: cmd.OrgId, Name: cmd.Name, Type: cmd.Type, @@ -189,6 +274,22 @@ func CreateAlertNotificationCommand(cmd *m.CreateAlertNotificationCommand) error }) } +func generateNewAlertNotificationUid(sess *DBSession, orgId int64) (string, error) { + for i := 0; i < 3; i++ { + uid := util.GenerateShortUid() + exists, err := sess.Where("org_id=? AND uid=?", orgId, uid).Get(&m.AlertNotification{}) + if err != nil { + return "", err + } + + if !exists { + return uid, nil + } + } + + return "", m.ErrAlertNotificationFailedGenerateUniqueUid +} + func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { return inTransaction(func(sess *DBSession) (err error) { current := m.AlertNotification{} @@ -241,6 +342,39 @@ func UpdateAlertNotification(cmd *m.UpdateAlertNotificationCommand) error { }) } +func UpdateAlertNotificationWithUid(cmd *m.UpdateAlertNotificationWithUidCommand) error { + getAlertNotificationWithUidQuery := &m.GetAlertNotificationsWithUidQuery{OrgId: cmd.OrgId, Uid: cmd.Uid} + + if err := getAlertNotificationWithUidInternal(getAlertNotificationWithUidQuery, newSession()); err != nil { + return err + } + + current := getAlertNotificationWithUidQuery.Result + + if current == nil { + return fmt.Errorf("Cannot update, alert notification uid %s doesn't exist", cmd.Uid) + } + + updateNotification := &m.UpdateAlertNotificationCommand{ + Id: current.Id, + Name: cmd.Name, + Type: cmd.Type, + SendReminder: cmd.SendReminder, + DisableResolveMessage: cmd.DisableResolveMessage, + Frequency: cmd.Frequency, + IsDefault: cmd.IsDefault, + Settings: cmd.Settings, + + OrgId: cmd.OrgId, + } + + if err := bus.Dispatch(updateNotification); err != nil { + return err + } + + return nil +} + func SetAlertNotificationStateToCompleteCommand(ctx context.Context, cmd *m.SetAlertNotificationStateToCompleteCommand) error { return inTransactionCtx(ctx, func(sess *DBSession) error { version := cmd.Version diff --git a/pkg/services/sqlstore/alert_notification_test.go b/pkg/services/sqlstore/alert_notification_test.go index 629a6292eb5..91b84cb91d0 100644 --- a/pkg/services/sqlstore/alert_notification_test.go +++ b/pkg/services/sqlstore/alert_notification_test.go @@ -220,11 +220,38 @@ func TestAlertNotificationSQLAccess(t *testing.T) { So(cmd.Result.Type, ShouldEqual, "email") So(cmd.Result.Frequency, ShouldEqual, 10*time.Second) So(cmd.Result.DisableResolveMessage, ShouldBeFalse) + So(cmd.Result.Uid, ShouldNotBeEmpty) Convey("Cannot save Alert Notification with the same name", func() { err = CreateAlertNotificationCommand(cmd) So(err, ShouldNotBeNil) }) + Convey("Cannot save Alert Notification with the same name and another uid", func() { + anotherUidCmd := &models.CreateAlertNotificationCommand{ + Name: cmd.Name, + Type: cmd.Type, + OrgId: 1, + SendReminder: cmd.SendReminder, + Frequency: cmd.Frequency, + Settings: cmd.Settings, + Uid: "notifier1", + } + err = CreateAlertNotificationCommand(anotherUidCmd) + So(err, ShouldNotBeNil) + }) + Convey("Can save Alert Notification with another name and another uid", func() { + anotherUidCmd := &models.CreateAlertNotificationCommand{ + Name: "another ops", + Type: cmd.Type, + OrgId: 1, + SendReminder: cmd.SendReminder, + Frequency: cmd.Frequency, + Settings: cmd.Settings, + Uid: "notifier2", + } + err = CreateAlertNotificationCommand(anotherUidCmd) + So(err, ShouldBeNil) + }) Convey("Can update alert notification", func() { newCmd := &models.UpdateAlertNotificationCommand{ @@ -274,12 +301,12 @@ func TestAlertNotificationSQLAccess(t *testing.T) { So(CreateAlertNotificationCommand(&otherOrg), ShouldBeNil) Convey("search", func() { - query := &models.GetAlertNotificationsToSendQuery{ - Ids: []int64{cmd1.Result.Id, cmd2.Result.Id, 112341231}, + query := &models.GetAlertNotificationsWithUidToSendQuery{ + Uids: []string{cmd1.Result.Uid, cmd2.Result.Uid, "112341231"}, OrgId: 1, } - err := GetAlertNotificationsToSend(query) + err := GetAlertNotificationsWithUidToSend(query) So(err, ShouldBeNil) So(len(query.Result), ShouldEqual, 3) }) diff --git a/pkg/services/sqlstore/migrations/alert_mig.go b/pkg/services/sqlstore/migrations/alert_mig.go index b5aeb26483c..84014bd386f 100644 --- a/pkg/services/sqlstore/migrations/alert_mig.go +++ b/pkg/services/sqlstore/migrations/alert_mig.go @@ -137,4 +137,21 @@ func addAlertMigrations(mg *Migrator) { mg.AddMigration("Add for to alert table", NewAddColumnMigration(alertV1, &Column{ Name: "for", Type: DB_BigInt, Nullable: true, })) + + mg.AddMigration("Add column uid in alert_notification", NewAddColumnMigration(alert_notification, &Column{ + Name: "uid", Type: DB_NVarchar, Length: 40, Nullable: true, + })) + + mg.AddMigration("Update uid column values in alert_notification", new(RawSqlMigration). + Sqlite("UPDATE alert_notification SET uid=printf('%09d',id) WHERE uid IS NULL;"). + Postgres("UPDATE alert_notification SET uid=lpad('' || id,9,'0') WHERE uid IS NULL;"). + Mysql("UPDATE alert_notification SET uid=lpad(id,9,'0') WHERE uid IS NULL;")) + + mg.AddMigration("Add unique index alert_notification_org_id_uid", NewAddIndexMigration(alert_notification, &Index{ + Cols: []string{"org_id", "uid"}, Type: UniqueIndex, + })) + + mg.AddMigration("Remove unique index org_id_name", NewDropIndexMigration(alert_notification, &Index{ + Cols: []string{"org_id", "name"}, Type: UniqueIndex, + })) } diff --git a/public/app/features/alerting/AlertTabCtrl.ts b/public/app/features/alerting/AlertTabCtrl.ts index af00e79b085..12943805c2c 100644 --- a/public/app/features/alerting/AlertTabCtrl.ts +++ b/public/app/features/alerting/AlertTabCtrl.ts @@ -140,8 +140,13 @@ export class AlertTabCtrl { name: model.name, iconClass: this.getNotificationIcon(model.type), isDefault: false, + uid: model.uid }); - this.alert.notifications.push({ id: model.id }); + + // avoid duplicates using both id and uid to be backwards compatible. + if (!_.find(this.alert.notifications, n => n.id === model.id || n.uid === model.uid)) { + this.alert.notifications.push({ uid: model.uid }); + } // reset plus button this.addNotificationSegment.value = this.uiSegmentSrv.newPlusButton().value; @@ -149,9 +154,11 @@ export class AlertTabCtrl { this.addNotificationSegment.fake = true; } - removeNotification(index) { - this.alert.notifications.splice(index, 1); - this.alertNotifications.splice(index, 1); + removeNotification(an) { + // remove notifiers refeered to by id and uid to support notifiers added + // before and after we added support for uid + _.remove(this.alert.notifications, n => n.uid === an.uid || n.id === an.id); + _.remove(this.alertNotifications, n => n.uid === an.uid || n.id === an.id); } initModel() { @@ -187,7 +194,14 @@ export class AlertTabCtrl { ThresholdMapper.alertToGraphThresholds(this.panel); for (const addedNotification of alert.notifications) { - const model = _.find(this.notifications, { id: addedNotification.id }); + // lookup notifier type by uid + let model = _.find(this.notifications, { uid: addedNotification.uid }); + + // fallback to using id if uid is missing + if (!model) { + model = _.find(this.notifications, { id: addedNotification.id }); + } + if (model && model.isDefault === false) { model.iconClass = this.getNotificationIcon(model.type); this.alertNotifications.push(model); diff --git a/public/app/features/alerting/partials/alert_tab.html b/public/app/features/alerting/partials/alert_tab.html index 9dfd3da47f9..b99859fd847 100644 --- a/public/app/features/alerting/partials/alert_tab.html +++ b/public/app/features/alerting/partials/alert_tab.html @@ -135,7 +135,7 @@
 {{nc.name}}  - +