2021-04-15 05:31:41 -05:00
|
|
|
package channels
|
|
|
|
|
|
|
|
import (
|
2021-04-22 09:00:21 -05:00
|
|
|
"bytes"
|
2021-04-15 05:31:41 -05:00
|
|
|
"context"
|
2021-04-22 09:00:21 -05:00
|
|
|
"crypto/tls"
|
2021-04-15 05:31:41 -05:00
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
2021-04-22 09:00:21 -05:00
|
|
|
"io"
|
|
|
|
"net"
|
2021-04-15 05:31:41 -05:00
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"regexp"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/grafana/grafana/pkg/infra/log"
|
|
|
|
"github.com/grafana/grafana/pkg/models"
|
|
|
|
"github.com/grafana/grafana/pkg/setting"
|
2021-10-07 09:33:50 -05:00
|
|
|
"github.com/prometheus/alertmanager/config"
|
|
|
|
"github.com/prometheus/alertmanager/template"
|
|
|
|
"github.com/prometheus/alertmanager/types"
|
2021-04-15 05:31:41 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
// SlackNotifier is responsible for sending
|
|
|
|
// alert notification to Slack.
|
|
|
|
type SlackNotifier struct {
|
2021-10-22 04:11:06 -05:00
|
|
|
*Base
|
2021-04-23 08:29:28 -05:00
|
|
|
log log.Logger
|
|
|
|
tmpl *template.Template
|
2021-04-15 05:31:41 -05:00
|
|
|
|
2021-04-22 09:00:21 -05:00
|
|
|
URL *url.URL
|
2021-04-15 05:31:41 -05:00
|
|
|
Username string
|
|
|
|
IconEmoji string
|
|
|
|
IconURL string
|
|
|
|
Recipient string
|
|
|
|
Text string
|
|
|
|
Title 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]+))$")
|
|
|
|
|
2021-05-13 12:28:19 -05:00
|
|
|
var SlackAPIEndpoint = "https://slack.com/api/chat.postMessage"
|
2021-04-22 09:00:21 -05:00
|
|
|
|
2021-04-15 05:31:41 -05:00
|
|
|
// NewSlackNotifier is the constructor for the Slack notifier
|
2021-10-07 09:33:50 -05:00
|
|
|
func NewSlackNotifier(model *NotificationChannelConfig, t *template.Template, fn GetDecryptedValueFn) (*SlackNotifier, error) {
|
2021-04-15 05:31:41 -05:00
|
|
|
if model.Settings == nil {
|
2021-07-19 03:58:35 -05:00
|
|
|
return nil, receiverInitError{Cfg: *model, Reason: "no settings supplied"}
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
|
2021-10-07 09:33:50 -05:00
|
|
|
slackURL := fn(context.Background(), model.SecureSettings, "url", model.Settings.Get("url").MustString(), setting.SecretKey)
|
2021-04-15 05:31:41 -05:00
|
|
|
if slackURL == "" {
|
2021-05-13 12:28:19 -05:00
|
|
|
slackURL = SlackAPIEndpoint
|
2021-04-22 09:00:21 -05:00
|
|
|
}
|
|
|
|
apiURL, err := url.Parse(slackURL)
|
|
|
|
if err != nil {
|
2021-07-19 03:58:35 -05:00
|
|
|
return nil, receiverInitError{Cfg: *model, Reason: fmt.Sprintf("invalid URL %q", slackURL), Err: err}
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
recipient := strings.TrimSpace(model.Settings.Get("recipient").MustString())
|
2021-04-22 09:00:21 -05:00
|
|
|
if recipient != "" {
|
|
|
|
if !reRecipient.MatchString(recipient) {
|
2021-07-19 03:58:35 -05:00
|
|
|
return nil, receiverInitError{Cfg: *model, Reason: fmt.Sprintf("recipient on invalid format: %q", recipient)}
|
2021-04-22 09:00:21 -05:00
|
|
|
}
|
2021-05-13 12:28:19 -05:00
|
|
|
} else if apiURL.String() == SlackAPIEndpoint {
|
2021-07-19 03:58:35 -05:00
|
|
|
return nil, receiverInitError{Cfg: *model,
|
2021-04-22 09:00:21 -05:00
|
|
|
Reason: "recipient must be specified when using the Slack chat API",
|
|
|
|
}
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
mentionChannel := model.Settings.Get("mentionChannel").MustString()
|
|
|
|
if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" {
|
2021-07-19 03:58:35 -05:00
|
|
|
return nil, receiverInitError{Cfg: *model,
|
|
|
|
Reason: fmt.Sprintf("invalid value for mentionChannel: %q", mentionChannel),
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-10-07 09:33:50 -05:00
|
|
|
token := fn(context.Background(), model.SecureSettings, "token", model.Settings.Get("token").MustString(), setting.SecretKey)
|
2021-05-13 12:28:19 -05:00
|
|
|
if token == "" && apiURL.String() == SlackAPIEndpoint {
|
2021-07-19 03:58:35 -05:00
|
|
|
return nil, receiverInitError{Cfg: *model,
|
2021-04-22 09:00:21 -05:00
|
|
|
Reason: "token must be specified when using the Slack chat API",
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-15 05:31:41 -05:00
|
|
|
return &SlackNotifier{
|
2021-10-22 04:11:06 -05:00
|
|
|
Base: NewBase(&models.AlertNotification{
|
2021-05-18 03:04:47 -05:00
|
|
|
Uid: model.UID,
|
|
|
|
Name: model.Name,
|
|
|
|
Type: model.Type,
|
|
|
|
DisableResolveMessage: model.DisableResolveMessage,
|
|
|
|
Settings: model.Settings,
|
|
|
|
}),
|
2021-04-22 09:00:21 -05:00
|
|
|
URL: apiURL,
|
2021-04-15 05:31:41 -05:00
|
|
|
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(),
|
2021-04-22 09:00:21 -05:00
|
|
|
Token: token,
|
2021-05-12 04:43:43 -05:00
|
|
|
Text: model.Settings.Get("text").MustString(`{{ template "default.message" . }}`),
|
|
|
|
Title: model.Settings.Get("title").MustString(`{{ template "default.title" . }}`),
|
2021-04-15 05:31:41 -05:00
|
|
|
log: log.New("alerting.notifier.slack"),
|
|
|
|
tmpl: t,
|
|
|
|
}, 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"`
|
|
|
|
}
|
|
|
|
|
2021-04-22 09:00:21 -05:00
|
|
|
// Notify sends an alert notification to Slack.
|
2021-04-15 05:31:41 -05:00
|
|
|
func (sn *SlackNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
|
|
|
msg, err := sn.buildSlackMessage(ctx, as)
|
|
|
|
if err != nil {
|
2021-04-22 04:18:25 -05:00
|
|
|
return false, fmt.Errorf("build slack message: %w", err)
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
b, err := json.Marshal(msg)
|
|
|
|
if err != nil {
|
2021-04-22 04:18:25 -05:00
|
|
|
return false, fmt.Errorf("marshal json: %w", err)
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
|
2021-04-22 09:00:21 -05:00
|
|
|
sn.log.Debug("Sending Slack API request", "url", sn.URL.String(), "data", string(b))
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, sn.URL.String(), bytes.NewReader(b))
|
|
|
|
if err != nil {
|
|
|
|
return false, fmt.Errorf("failed to create HTTP request: %w", err)
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
|
2021-04-22 09:00:21 -05:00
|
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
request.Header.Set("User-Agent", "Grafana")
|
|
|
|
if sn.Token == "" {
|
2021-05-13 12:28:19 -05:00
|
|
|
if sn.URL.String() == SlackAPIEndpoint {
|
2021-04-22 09:00:21 -05:00
|
|
|
panic("Token should be set when using the Slack chat API")
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
2021-04-22 09:00:21 -05:00
|
|
|
} else {
|
|
|
|
sn.log.Debug("Adding authorization header to HTTP request")
|
|
|
|
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sn.Token))
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
|
2021-04-22 09:00:21 -05:00
|
|
|
if err := sendSlackRequest(request, sn.log); err != nil {
|
2021-04-15 05:31:41 -05:00
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
|
2021-04-22 09:00:21 -05:00
|
|
|
// sendSlackRequest sends a request to the Slack API.
|
|
|
|
// Stubbable by tests.
|
|
|
|
var sendSlackRequest = func(request *http.Request, logger log.Logger) error {
|
|
|
|
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 {
|
|
|
|
logger.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)
|
|
|
|
}
|
|
|
|
|
2021-10-21 01:12:15 -05:00
|
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
2021-04-22 09:00:21 -05:00
|
|
|
logger.Warn("Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status, "body", string(body))
|
|
|
|
return fmt.Errorf("request to Slack API failed with status code %d", resp.StatusCode)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Slack responds to some requests with a JSON document, that might contain an error
|
2021-10-21 01:12:15 -05:00
|
|
|
rslt := struct {
|
|
|
|
Ok bool `json:"ok"`
|
|
|
|
Err string `json:"error"`
|
|
|
|
}{}
|
2021-04-22 09:00:21 -05:00
|
|
|
if err := json.Unmarshal(body, &rslt); err == nil {
|
2021-10-21 01:12:15 -05:00
|
|
|
if !rslt.Ok && rslt.Err != "" {
|
2021-04-22 09:00:21 -05:00
|
|
|
logger.Warn("Sending Slack API request failed", "url", request.URL.String(), "statusCode", resp.Status,
|
2021-10-21 01:12:15 -05:00
|
|
|
"err", rslt.Err)
|
|
|
|
return fmt.Errorf("failed to make Slack API request: %s", rslt.Err)
|
2021-04-22 09:00:21 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
logger.Debug("Sending Slack API request succeeded", "url", request.URL.String(), "statusCode", resp.Status)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2021-04-15 05:31:41 -05:00
|
|
|
func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Alert) (*slackMessage, error) {
|
|
|
|
alerts := types.Alerts(as...)
|
2021-04-22 09:00:21 -05:00
|
|
|
var tmplErr error
|
2021-06-03 09:09:32 -05:00
|
|
|
tmpl, _ := TmplText(ctx, sn.tmpl, as, sn.log, &tmplErr)
|
2021-04-15 05:31:41 -05:00
|
|
|
|
2021-06-03 09:09:32 -05:00
|
|
|
ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log)
|
2021-05-20 03:12:08 -05:00
|
|
|
|
2021-04-15 05:31:41 -05:00
|
|
|
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),
|
2021-05-04 06:58:39 -05:00
|
|
|
Fallback: tmpl(sn.Title),
|
2021-04-15 05:31:41 -05:00
|
|
|
Footer: "Grafana v" + setting.BuildVersion,
|
|
|
|
FooterIcon: FooterIconURL,
|
|
|
|
Ts: time.Now().Unix(),
|
2021-05-20 03:12:08 -05:00
|
|
|
TitleLink: ruleURL,
|
2021-04-15 05:31:41 -05:00
|
|
|
Text: tmpl(sn.Text),
|
|
|
|
Fields: nil, // TODO. Should be a config.
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
2021-04-22 09:00:21 -05:00
|
|
|
if tmplErr != nil {
|
2021-06-03 09:09:32 -05:00
|
|
|
sn.log.Debug("failed to template Slack message", "err", tmplErr.Error())
|
2021-04-22 09:00:21 -05:00
|
|
|
}
|
2021-04-15 05:31:41 -05:00
|
|
|
|
|
|
|
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 {
|
2021-06-22 04:42:54 -05:00
|
|
|
mentionsBuilder.WriteString(fmt.Sprintf("<!subteam^%s>", tmpl(g)))
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
if len(sn.MentionUsers) > 0 {
|
|
|
|
appendSpace()
|
|
|
|
for _, u := range sn.MentionUsers {
|
2021-06-22 04:42:54 -05:00
|
|
|
mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", tmpl(u)))
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if mentionsBuilder.Len() > 0 {
|
|
|
|
req.Blocks = []map[string]interface{}{
|
|
|
|
{
|
|
|
|
"type": "section",
|
|
|
|
"text": map[string]interface{}{
|
|
|
|
"type": "mrkdwn",
|
|
|
|
"text": mentionsBuilder.String(),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-22 09:00:21 -05:00
|
|
|
return req, nil
|
2021-04-15 05:31:41 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
func (sn *SlackNotifier) SendResolved() bool {
|
|
|
|
return !sn.GetDisableResolveMessage()
|
|
|
|
}
|