diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index 7ced46b199c..c6d449a4761 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -334,6 +334,8 @@ func (am *Alertmanager) buildReceiverIntegrations(receiver *apimodels.PostableAp n, err = channels.NewEmailNotifier(cfg, externalURL) case "pagerduty": n, err = channels.NewPagerdutyNotifier(cfg, tmpl, externalURL) + case "slack": + n, err = channels.NewSlackNotifier(cfg, tmpl, externalURL) } if err != nil { return nil, err diff --git a/pkg/services/ngalert/notifier/channels/email.go b/pkg/services/ngalert/notifier/channels/email.go index 85ade87f7b6..3ccad530fa1 100644 --- a/pkg/services/ngalert/notifier/channels/email.go +++ b/pkg/services/ngalert/notifier/channels/email.go @@ -2,22 +2,20 @@ package channels import ( "context" - "fmt" "net/url" - "strings" gokit_log "github.com/go-kit/kit/log" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/model" + + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" old_notifiers "github.com/grafana/grafana/pkg/services/alerting/notifiers" "github.com/grafana/grafana/pkg/util" - - "github.com/grafana/grafana/pkg/bus" - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" ) // EmailNotifier is responsible for sending @@ -34,6 +32,10 @@ type EmailNotifier struct { // NewEmailNotifier is the constructor function // for the EmailNotifier. func NewEmailNotifier(model *models.AlertNotification, externalUrl *url.URL) (*EmailNotifier, error) { + if model.Settings == nil { + return nil, alerting.ValidationError{Reason: "No Settings Supplied"} + } + addressesString := model.Settings.Get("addresses").MustString() singleEmail := model.Settings.Get("singleEmail").MustBool(false) autoResolve := model.Settings.Get("autoResolve").MustBool(true) @@ -95,18 +97,6 @@ func (en *EmailNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, return true, nil } -func getTitleFromTemplateData(data *template.Data) string { - title := "[" + data.Status - if data.Status == string(model.AlertFiring) { - title += fmt.Sprintf(":%d", len(data.Alerts.Firing())) - } - title += "] " + strings.Join(data.GroupLabels.SortedPairs().Values(), " ") + " " - if len(data.CommonLabels) > len(data.GroupLabels) { - title += "(" + strings.Join(data.CommonLabels.Remove(data.GroupLabels.Names()).Values(), " ") + ")" - } - return title -} - func (en *EmailNotifier) SendResolved() bool { return en.AutoResolve } diff --git a/pkg/services/ngalert/notifier/channels/pagerduty.go b/pkg/services/ngalert/notifier/channels/pagerduty.go index a4fbbb12b77..aad74fc478b 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty.go @@ -48,6 +48,10 @@ type PagerdutyNotifier struct { // NewPagerdutyNotifier is the constructor for the PagerDuty notifier func NewPagerdutyNotifier(model *models.AlertNotification, t *template.Template, externalUrl *url.URL) (*PagerdutyNotifier, error) { + if model.Settings == nil { + return nil, alerting.ValidationError{Reason: "No Settings Supplied"} + } + key := model.DecryptedValue("integrationKey", model.Settings.Get("integrationKey").MustString()) if key == "" { return nil, alerting.ValidationError{Reason: "Could not find integration key property in settings"} diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go new file mode 100644 index 00000000000..1900b0be82f --- /dev/null +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -0,0 +1,238 @@ +package channels + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + gokit_log "github.com/go-kit/kit/log" + "github.com/pkg/errors" + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" + old_notifiers "github.com/grafana/grafana/pkg/services/alerting/notifiers" + "github.com/grafana/grafana/pkg/setting" +) + +// SlackNotifier is responsible for sending +// alert notification to Slack. +type SlackNotifier struct { + old_notifiers.NotifierBase + log log.Logger + tmpl *template.Template + externalUrl *url.URL + + URL string + Username string + IconEmoji string + IconURL string + Recipient string + Text string + Title string + Fallback string + MentionUsers []string + MentionGroups []string + MentionChannel string + Token string +} + +var reRecipient *regexp.Regexp = regexp.MustCompile("^((@[a-z0-9][a-zA-Z0-9._-]*)|(#[^ .A-Z]{1,79})|([a-zA-Z0-9]+))$") + +// NewSlackNotifier is the constructor for the Slack notifier +func NewSlackNotifier(model *models.AlertNotification, t *template.Template, externalUrl *url.URL) (*SlackNotifier, error) { + if model.Settings == nil { + return nil, alerting.ValidationError{Reason: "No Settings Supplied"} + } + + slackURL := model.DecryptedValue("url", model.Settings.Get("url").MustString()) + if slackURL == "" { + return nil, alerting.ValidationError{Reason: "Could not find url property in settings"} + } + + recipient := strings.TrimSpace(model.Settings.Get("recipient").MustString()) + if recipient != "" && !reRecipient.MatchString(recipient) { + return nil, alerting.ValidationError{Reason: fmt.Sprintf("Recipient on invalid format: %q", recipient)} + } + + mentionChannel := model.Settings.Get("mentionChannel").MustString() + if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" { + return nil, alerting.ValidationError{ + Reason: fmt.Sprintf("Invalid value for mentionChannel: %q", mentionChannel), + } + } + + mentionUsersStr := model.Settings.Get("mentionUsers").MustString() + mentionUsers := []string{} + for _, u := range strings.Split(mentionUsersStr, ",") { + u = strings.TrimSpace(u) + if u != "" { + mentionUsers = append(mentionUsers, u) + } + } + + mentionGroupsStr := model.Settings.Get("mentionGroups").MustString() + mentionGroups := []string{} + for _, g := range strings.Split(mentionGroupsStr, ",") { + g = strings.TrimSpace(g) + if g != "" { + mentionGroups = append(mentionGroups, g) + } + } + + return &SlackNotifier{ + NotifierBase: old_notifiers.NewNotifierBase(model), + URL: slackURL, + Recipient: recipient, + MentionUsers: mentionUsers, + MentionGroups: mentionGroups, + MentionChannel: mentionChannel, + Username: model.Settings.Get("username").MustString("Grafana"), + IconEmoji: model.Settings.Get("icon_emoji").MustString(), + IconURL: model.Settings.Get("icon_url").MustString(), + Token: model.DecryptedValue("token", model.Settings.Get("token").MustString()), + Text: model.Settings.Get("text").MustString(`{{ template "slack.default.text" . }}`), + Title: model.Settings.Get("title").MustString(`{{ template "slack.default.title" . }}`), + Fallback: model.Settings.Get("fallback").MustString(`{{ template "slack.default.title" . }}`), + log: log.New("alerting.notifier.slack"), + tmpl: t, + externalUrl: externalUrl, + }, nil +} + +// slackMessage is the slackMessage for sending a slack notification. +type slackMessage struct { + Channel string `json:"channel,omitempty"` + Username string `json:"username,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` + Attachments []attachment `json:"attachments"` + Blocks []map[string]interface{} `json:"blocks"` +} + +// attachment is used to display a richly-formatted message block. +type attachment struct { + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Text string `json:"text"` + Fallback string `json:"fallback"` + Fields []config.SlackField `json:"fields,omitempty"` + Footer string `json:"footer"` + FooterIcon string `json:"footer_icon"` + Color string `json:"color,omitempty"` + Ts int64 `json:"ts,omitempty"` +} + +func (sn *SlackNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + msg, err := sn.buildSlackMessage(ctx, as) + if err != nil { + return false, errors.Wrap(err, "build slack message") + } + + b, err := json.Marshal(msg) + if err != nil { + return false, errors.Wrap(err, "marshal json") + } + + cmd := &models.SendWebhookSync{ + Url: sn.URL, + Body: string(b), + HttpMethod: http.MethodPost, + } + + if sn.Token != "" { + sn.log.Debug("Adding authorization header to HTTP request") + cmd.HttpHeader = map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", sn.Token), + } + } + + if err := bus.DispatchCtx(ctx, cmd); err != nil { + sn.log.Error("Failed to send slack notification", "error", err, "webhook", sn.Name) + return false, err + } + + return true, nil +} + +func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Alert) (*slackMessage, error) { + var tmplErr error + data := notify.GetTemplateData(ctx, &template.Template{ExternalURL: sn.externalUrl}, as, gokit_log.NewNopLogger()) + alerts := types.Alerts(as...) + tmpl := notify.TmplText(sn.tmpl, data, &tmplErr) + + req := &slackMessage{ + Channel: tmpl(sn.Recipient), + Username: tmpl(sn.Username), + IconEmoji: tmpl(sn.IconEmoji), + IconURL: tmpl(sn.IconURL), + Attachments: []attachment{ + { + Color: getAlertStatusColor(alerts.Status()), + Title: tmpl(sn.Title), + Fallback: tmpl(sn.Fallback), + Footer: "Grafana v" + setting.BuildVersion, + FooterIcon: FooterIconURL, + Ts: time.Now().Unix(), + TitleLink: "TODO: rule URL", + Text: tmpl(sn.Text), + Fields: nil, // TODO. Should be a config. + }, + }, + } + + mentionsBuilder := strings.Builder{} + appendSpace := func() { + if mentionsBuilder.Len() > 0 { + mentionsBuilder.WriteString(" ") + } + } + mentionChannel := strings.TrimSpace(sn.MentionChannel) + if mentionChannel != "" { + mentionsBuilder.WriteString(fmt.Sprintf("", mentionChannel, mentionChannel)) + } + if len(sn.MentionGroups) > 0 { + appendSpace() + for _, g := range sn.MentionGroups { + mentionsBuilder.WriteString(fmt.Sprintf("", g)) + } + } + if len(sn.MentionUsers) > 0 { + appendSpace() + for _, u := range sn.MentionUsers { + mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", u)) + } + } + + if mentionsBuilder.Len() > 0 { + req.Blocks = []map[string]interface{}{ + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": mentionsBuilder.String(), + }, + }, + } + } + + if tmplErr != nil { + tmplErr = errors.Wrap(tmplErr, "failed to template Slack message") + } + + return req, tmplErr +} + +func (sn *SlackNotifier) SendResolved() bool { + return !sn.GetDisableResolveMessage() +} diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go new file mode 100644 index 00000000000..fb2720d5d67 --- /dev/null +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -0,0 +1,176 @@ +package channels + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "testing" + + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/alerting" +) + +func TestSlackNotifier(t *testing.T) { + tmpl, err := template.FromGlobs("templates/default.tmpl") + require.NoError(t, err) + + cases := []struct { + name string + settings string + alerts []*types.Alert + expMsg *slackMessage + expInitError error + expMsgError error + }{ + { + name: "Correct config with one alert", + settings: `{ + "url": "https://test.slack.com", + "recipient": "#testchannel", + "icon_emoji": ":emoji:" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, + }, + expMsg: &slackMessage{ + Channel: "#testchannel", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "[FIRING:1] (val1)", + TitleLink: "TODO: rule URL", + Text: "", + Fallback: "[FIRING:1] (val1)", + Fields: nil, + Footer: "Grafana v", + FooterIcon: "https://grafana.com/assets/img/fav32.png", + Color: "#D63232", + Ts: 0, + }, + }, + }, + expInitError: nil, + expMsgError: nil, + }, + { + name: "Correct config with multiple alerts and template", + settings: `{ + "url": "https://test.slack.com", + "recipient": "#testchannel", + "icon_emoji": ":emoji:", + "title": "{{ .Alerts.Firing | len }} firing, {{ .Alerts.Resolved | len }} resolved" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: &slackMessage{ + Channel: "#testchannel", + Username: "Grafana", + IconEmoji: ":emoji:", + Attachments: []attachment{ + { + Title: "2 firing, 0 resolved", + TitleLink: "TODO: rule URL", + Text: "", + Fallback: "[FIRING:2] ", + Fields: nil, + Footer: "Grafana v", + FooterIcon: "https://grafana.com/assets/img/fav32.png", + Color: "#D63232", + Ts: 0, + }, + }, + }, + expInitError: nil, + expMsgError: nil, + }, { + name: "Error in initing", + settings: `{}`, + expInitError: alerting.ValidationError{Reason: "Could not find url property in settings"}, + }, { + name: "Error in building message", + settings: `{ + "url": "https://test.slack.com", + "title": "{{ .BrokenTemplate }" + }`, + expMsgError: errors.New("build slack message: failed to template Slack message: template: :1: unexpected \"}\" in operand"), + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + settingsJSON, err := simplejson.NewJson([]byte(c.settings)) + require.NoError(t, err) + + m := &models.AlertNotification{ + Name: "slack_testing", + Type: "slack", + Settings: settingsJSON, + } + + externalURL, err := url.Parse("http://localhost") + require.NoError(t, err) + pn, err := NewSlackNotifier(m, tmpl, externalURL) + if c.expInitError != nil { + require.Error(t, err) + require.Equal(t, c.expInitError.Error(), err.Error()) + return + } + require.NoError(t, err) + + body := "" + bus.AddHandlerCtx("test", func(ctx context.Context, webhook *models.SendWebhookSync) error { + body = webhook.Body + return nil + }) + + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + ok, err := pn.Notify(ctx, c.alerts...) + if c.expMsgError != nil { + require.False(t, ok) + require.Error(t, err) + require.Equal(t, c.expMsgError.Error(), err.Error()) + return + } + require.True(t, ok) + require.NoError(t, err) + + // Getting Ts from actual since that can't be predicted. + obj := &slackMessage{} + require.NoError(t, json.Unmarshal([]byte(body), obj)) + c.expMsg.Attachments[0].Ts = obj.Attachments[0].Ts + + expBody, err := json.Marshal(c.expMsg) + require.NoError(t, err) + + require.Equal(t, string(expBody), body) + }) + } +} diff --git a/pkg/services/ngalert/notifier/channels/utils.go b/pkg/services/ngalert/notifier/channels/utils.go new file mode 100644 index 00000000000..906acb50065 --- /dev/null +++ b/pkg/services/ngalert/notifier/channels/utils.go @@ -0,0 +1,34 @@ +package channels + +import ( + "fmt" + "strings" + + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/common/model" +) + +const ( + FooterIconURL = "https://grafana.com/assets/img/fav32.png" + ColorAlertFiring = "#D63232" + ColorAlertResolved = "#36a64f" +) + +func getAlertStatusColor(status model.AlertStatus) string { + if status == model.AlertFiring { + return ColorAlertFiring + } + return ColorAlertResolved +} + +func getTitleFromTemplateData(data *template.Data) string { + title := "[" + data.Status + if data.Status == string(model.AlertFiring) { + title += fmt.Sprintf(":%d", len(data.Alerts.Firing())) + } + title += "] " + strings.Join(data.GroupLabels.SortedPairs().Values(), " ") + " " + if len(data.CommonLabels) > len(data.GroupLabels) { + title += "(" + strings.Join(data.CommonLabels.Remove(data.GroupLabels.Names()).Values(), " ") + ")" + } + return title +}