grafana/pkg/services/alerting/notifiers/slack.go
Serge Zaitsev 43b15b92ad
Chore: Remove bus from the alerting service (#44496)
* propagate notificationservice down to the notifiers

* replace dispatch in result handler

* remove dispatch from the rule reader

* remove dispatch from eval context

* remove dispatch from alerting usage

* remove dispatch from alerting usage

* remove dispatch from notifier

* attempt to fix tests in alerting

* hello linter, my old friend; also disable some tests for now

* use mocks to fix the tests

* resolving wire providers

* make linter happy

* remove yet another bus.dispatch

* fix tests using store mock
2022-02-03 13:26:05 +01:00

479 lines
15 KiB
Go

package notifiers
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/setting"
)
func init() {
alerting.RegisterNotifier(&alerting.NotifierPlugin{
Type: "slack",
Name: "Slack",
Description: "Sends notifications to Slack",
Heading: "Slack settings",
Factory: NewSlackNotifier,
Options: []alerting.NotifierOption{
{
Label: "Recipient",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Specify channel, private group, or IM channel (can be an encoded ID or a name) - required unless you provide a webhook",
PropertyName: "recipient",
},
// Logically, this field should be required when not using a webhook, since the Slack API needs a token.
// However, since the UI doesn't allow to say that a field is required or not depending on another field,
// we've gone with the compromise of making this field optional and instead return a validation error
// if it's necessary and missing.
{
Label: "Token",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Provide a Slack API token (starts with \"xoxb\") - required unless you provide a webhook",
PropertyName: "token",
Secure: true,
},
{
Label: "Username",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Set the username for the bot's message",
PropertyName: "username",
},
{
Label: "Icon emoji",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Provide an emoji to use as the icon for the bot's message. Overrides the icon URL.",
PropertyName: "icon_emoji",
},
{
Label: "Icon URL",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Provide a URL to an image to use as the icon for the bot's message",
PropertyName: "icon_url",
},
{
Label: "Mention Users",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)",
PropertyName: "mentionUsers",
},
{
Label: "Mention Groups",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)",
PropertyName: "mentionGroups",
},
{
Label: "Mention Channel",
Element: alerting.ElementTypeSelect,
SelectOptions: []alerting.SelectOption{
{
Value: "",
Label: "Disabled",
},
{
Value: "here",
Label: "Every active channel member",
},
{
Value: "channel",
Label: "Every channel member",
},
},
Description: "Mention whole channel or just active members when notifying",
PropertyName: "mentionChannel",
},
{
Label: "Webhook URL",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Description: "Optionally provide a Slack incoming webhook URL for sending messages, in this case the token isn't necessary",
Placeholder: "Slack incoming webhook URL",
PropertyName: "url",
Secure: true,
},
},
})
}
const slackAPIEndpoint = "https://slack.com/api/chat.postMessage"
// NewSlackNotifier is the constructor for the Slack notifier.
func NewSlackNotifier(model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
urlStr := fn(context.Background(), model.SecureSettings, "url", model.Settings.Get("url").MustString(), setting.SecretKey)
if urlStr == "" {
urlStr = slackAPIEndpoint
}
apiURL, err := url.Parse(urlStr)
if err != nil {
return nil, fmt.Errorf("invalid URL %q: %w", urlStr, err)
}
recipient := strings.TrimSpace(model.Settings.Get("recipient").MustString())
if recipient == "" && apiURL.String() == slackAPIEndpoint {
return nil, alerting.ValidationError{
Reason: "recipient must be specified when using the Slack chat API",
}
}
username := model.Settings.Get("username").MustString()
iconEmoji := model.Settings.Get("icon_emoji").MustString()
iconURL := model.Settings.Get("icon_url").MustString()
mentionUsersStr := model.Settings.Get("mentionUsers").MustString()
mentionGroupsStr := model.Settings.Get("mentionGroups").MustString()
mentionChannel := model.Settings.Get("mentionChannel").MustString()
token := fn(context.Background(), model.SecureSettings, "token", model.Settings.Get("token").MustString(), setting.SecretKey)
if token == "" && apiURL.String() == slackAPIEndpoint {
return nil, alerting.ValidationError{
Reason: "token must be specified when using the Slack chat API",
}
}
uploadImage := model.Settings.Get("uploadImage").MustBool(true)
if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" {
return nil, alerting.ValidationError{
Reason: fmt.Sprintf("Invalid value for mentionChannel: %q", mentionChannel),
}
}
mentionUsers := []string{}
for _, u := range strings.Split(mentionUsersStr, ",") {
u = strings.TrimSpace(u)
if u != "" {
mentionUsers = append(mentionUsers, u)
}
}
mentionGroups := []string{}
for _, g := range strings.Split(mentionGroupsStr, ",") {
g = strings.TrimSpace(g)
if g != "" {
mentionGroups = append(mentionGroups, g)
}
}
return &SlackNotifier{
url: apiURL,
NotifierBase: NewNotifierBase(model, ns),
recipient: recipient,
username: username,
iconEmoji: iconEmoji,
iconURL: iconURL,
mentionUsers: mentionUsers,
mentionGroups: mentionGroups,
mentionChannel: mentionChannel,
token: token,
upload: uploadImage,
log: log.New("alerting.notifier.slack"),
}, nil
}
// SlackNotifier is responsible for sending
// alert notification to Slack.
type SlackNotifier struct {
NotifierBase
url *url.URL
recipient string
username string
iconEmoji string
iconURL string
mentionUsers []string
mentionGroups []string
mentionChannel string
token string
upload bool
log log.Logger
}
// Notify sends an alert notification to Slack.
func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
sn.log.Info("Executing slack notification", "ruleId", evalContext.Rule.ID, "notification", sn.Name)
ruleURL, err := evalContext.GetRuleURL()
if err != nil {
sn.log.Error("Failed to get rule link", "error", err)
return err
}
fields := make([]map[string]interface{}, 0)
for _, evt := range evalContext.EvalMatches {
fields = append(fields, map[string]interface{}{
"title": evt.Metric,
"value": evt.Value,
"short": true,
})
}
if evalContext.Error != nil {
fields = append(fields, map[string]interface{}{
"title": "Error message",
"value": evalContext.Error.Error(),
"short": false,
})
}
mentionsBuilder := strings.Builder{}
appendSpace := func() {
if mentionsBuilder.Len() > 0 {
mentionsBuilder.WriteString(" ")
}
}
mentionChannel := strings.TrimSpace(sn.mentionChannel)
if mentionChannel != "" {
mentionsBuilder.WriteString(fmt.Sprintf("<!%s|%s>", mentionChannel, mentionChannel))
}
if len(sn.mentionGroups) > 0 {
appendSpace()
for _, g := range sn.mentionGroups {
mentionsBuilder.WriteString(fmt.Sprintf("<!subteam^%s>", g))
}
}
if len(sn.mentionUsers) > 0 {
appendSpace()
for _, u := range sn.mentionUsers {
mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", u))
}
}
msg := ""
if evalContext.Rule.State != models.AlertStateOK { // don't add message when going back to alert state ok.
msg = evalContext.Rule.Message
}
imageURL := ""
// default to file.upload API method if a token is provided
if sn.token == "" {
imageURL = evalContext.ImagePublicURL
}
var blocks []map[string]interface{}
if mentionsBuilder.Len() > 0 {
blocks = []map[string]interface{}{
{
"type": "section",
"text": map[string]interface{}{
"type": "mrkdwn",
"text": mentionsBuilder.String(),
},
},
}
}
attachment := map[string]interface{}{
"color": evalContext.GetStateModel().Color,
"title": evalContext.GetNotificationTitle(),
"title_link": ruleURL,
"text": msg,
"fallback": evalContext.GetNotificationTitle(),
"fields": fields,
"footer": "Grafana v" + setting.BuildVersion,
"footer_icon": "https://grafana.com/assets/img/fav32.png",
"ts": time.Now().Unix(),
}
if sn.NeedsImage() && imageURL != "" {
attachment["image_url"] = imageURL
}
body := map[string]interface{}{
"channel": sn.recipient,
"attachments": []map[string]interface{}{
attachment,
},
}
if len(blocks) > 0 {
body["blocks"] = blocks
}
if sn.username != "" {
body["username"] = sn.username
}
if sn.iconEmoji != "" {
body["icon_emoji"] = sn.iconEmoji
}
if sn.iconURL != "" {
body["icon_url"] = sn.iconURL
}
data, err := json.Marshal(&body)
if err != nil {
return err
}
if err := sn.sendRequest(evalContext.Ctx, data); err != nil {
return err
}
if sn.token != "" && sn.UploadImage {
err := sn.slackFileUpload(evalContext, sn.log, sn.recipient, sn.token)
if err != nil {
return err
}
}
return nil
}
func (sn *SlackNotifier) sendRequest(ctx context.Context, data []byte) error {
sn.log.Debug("Sending Slack API request", "url", sn.url.String(), "data", string(data))
request, err := http.NewRequestWithContext(ctx, http.MethodPost, sn.url.String(), bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to create HTTP request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Grafana")
if sn.token == "" {
if sn.url.String() == slackAPIEndpoint {
panic("Token should be set when using the Slack chat API")
}
} else {
sn.log.Debug("Adding authorization header to HTTP request")
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sn.token))
}
netTransport := &http.Transport{
TLSClientConfig: &tls.Config{
Renegotiation: tls.RenegotiateFreelyAsClient,
},
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
}
netClient := &http.Client{
Timeout: time.Second * 30,
Transport: netTransport,
}
resp, err := netClient.Do(request)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
sn.log.Warn("Failed to close response body", "err", err)
}
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
// Slack responds to some requests with a JSON document, that might contain an error.
rslt := struct {
Ok bool `json:"ok"`
Err string `json:"error"`
}{}
// Marshaling can fail if Slack's response body is plain text (e.g. "ok").
if err := json.Unmarshal(body, &rslt); err != nil && json.Valid(body) {
sn.log.Error("Failed to unmarshal Slack API response", "url", sn.url.String(), "statusCode", resp.Status,
"err", err)
return fmt.Errorf("failed to unmarshal Slack API response with status code %d: %s", resp.StatusCode, err)
}
if !rslt.Ok && rslt.Err != "" {
sn.log.Error("Sending Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status,
"err", rslt.Err)
return fmt.Errorf("failed to make Slack API request: %s", rslt.Err)
}
sn.log.Debug("Sending Slack API request succeeded", "url", sn.url.String(), "statusCode", resp.Status)
return nil
}
sn.log.Error("Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status, "body", string(body))
return fmt.Errorf("request to Slack API failed with status code %d", resp.StatusCode)
}
func (sn *SlackNotifier) slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, recipient, token string) error {
if evalContext.ImageOnDiskPath == "" {
// nolint:gosec
// We can ignore the gosec G304 warning on this one because `setting.HomePath` comes from Grafana's configuration file.
evalContext.ImageOnDiskPath = filepath.Join(setting.HomePath, "public/img/mixed_styles.png")
}
log.Info("Uploading to slack via file.upload API")
headers, uploadBody, err := sn.generateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
if err != nil {
return err
}
cmd := &models.SendWebhookSync{
Url: "https://slack.com/api/files.upload", Body: uploadBody.String(), HttpHeader: headers, HttpMethod: "POST",
}
if err := sn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
log.Error("Failed to upload slack image", "error", err, "webhook", "file.upload")
return err
}
return nil
}
func (sn *SlackNotifier) generateSlackBody(path string, token string, recipient string) (map[string]string, bytes.Buffer, error) {
// Slack requires all POSTs to files.upload to present
// an "application/x-www-form-urlencoded" encoded querystring
// See https://api.slack.com/methods/files.upload
var b bytes.Buffer
w := multipart.NewWriter(&b)
defer func() {
if err := w.Close(); err != nil {
// Shouldn't matter since we already close w explicitly on the non-error path
sn.log.Warn("Failed to close multipart writer", "err", err)
}
}()
// Add the generated image file
// We can ignore the gosec G304 warning on this one because `imagePath` comes
// from the alert `evalContext` that generates the images. `evalContext` in turn derives the root of the file
// path from configuration variables.
// nolint:gosec
f, err := os.Open(path)
if err != nil {
return nil, b, err
}
defer func() {
if err := f.Close(); err != nil {
sn.log.Warn("Failed to close file", "path", path, "err", err)
}
}()
fw, err := w.CreateFormFile("file", path)
if err != nil {
return nil, b, err
}
if _, err := io.Copy(fw, f); err != nil {
return nil, b, err
}
// Add the authorization token
if err := w.WriteField("token", token); err != nil {
return nil, b, err
}
// Add the channel(s) to POST to
if err := w.WriteField("channels", recipient); err != nil {
return nil, b, err
}
if err := w.Close(); err != nil {
return nil, b, fmt.Errorf("failed to close multipart writer: %w", err)
}
headers := map[string]string{
"Content-Type": w.FormDataContentType(),
"Authorization": "auth_token=\"" + token + "\"",
}
return headers, b, nil
}