mirror of https://github.com/grafana/grafana
Alerting: Use all notifiers from alerting repository (#60655)
parent
542cccaecc
commit
f990be58cb
@ -1,138 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net/url" |
||||
"strings" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
type AlertmanagerConfig struct { |
||||
*channels.NotificationChannelConfig |
||||
URLs []*url.URL |
||||
BasicAuthUser string |
||||
BasicAuthPassword string |
||||
} |
||||
|
||||
type alertmanagerSettings struct { |
||||
URLs []*url.URL |
||||
User string |
||||
Password string |
||||
} |
||||
|
||||
func AlertmanagerFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { |
||||
ch, err := buildAlertmanagerNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return ch, nil |
||||
} |
||||
|
||||
func buildAlertmanagerNotifier(fc channels.FactoryConfig) (*AlertmanagerNotifier, error) { |
||||
var settings struct { |
||||
URL channels.CommaSeparatedStrings `json:"url,omitempty" yaml:"url,omitempty"` |
||||
User string `json:"basicAuthUser,omitempty" yaml:"basicAuthUser,omitempty"` |
||||
Password string `json:"basicAuthPassword,omitempty" yaml:"basicAuthPassword,omitempty"` |
||||
} |
||||
err := json.Unmarshal(fc.Config.Settings, &settings) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err) |
||||
} |
||||
|
||||
urls := make([]*url.URL, 0, len(settings.URL)) |
||||
for _, uS := range settings.URL { |
||||
uS = strings.TrimSpace(uS) |
||||
if uS == "" { |
||||
continue |
||||
} |
||||
uS = strings.TrimSuffix(uS, "/") + "/api/v1/alerts" |
||||
u, err := url.Parse(uS) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("invalid url property in settings: %w", err) |
||||
} |
||||
urls = append(urls, u) |
||||
} |
||||
if len(settings.URL) == 0 || len(urls) == 0 { |
||||
return nil, errors.New("could not find url property in settings") |
||||
} |
||||
settings.Password = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "basicAuthPassword", settings.Password) |
||||
|
||||
return &AlertmanagerNotifier{ |
||||
Base: channels.NewBase(fc.Config), |
||||
images: fc.ImageStore, |
||||
settings: alertmanagerSettings{ |
||||
URLs: urls, |
||||
User: settings.User, |
||||
Password: settings.Password, |
||||
}, |
||||
logger: fc.Logger, |
||||
}, nil |
||||
} |
||||
|
||||
// AlertmanagerNotifier sends alert notifications to the alert manager
|
||||
type AlertmanagerNotifier struct { |
||||
*channels.Base |
||||
images channels.ImageStore |
||||
settings alertmanagerSettings |
||||
logger channels.Logger |
||||
} |
||||
|
||||
// Notify sends alert notifications to Alertmanager.
|
||||
func (n *AlertmanagerNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
n.logger.Debug("sending Alertmanager alert", "alertmanager", n.Name) |
||||
if len(as) == 0 { |
||||
return true, nil |
||||
} |
||||
|
||||
_ = withStoredImages(ctx, n.logger, n.images, |
||||
func(index int, image channels.Image) error { |
||||
// If there is an image for this alert and the image has been uploaded
|
||||
// to a public URL then include it as an annotation
|
||||
if image.URL != "" { |
||||
as[index].Annotations["image"] = model.LabelValue(image.URL) |
||||
} |
||||
return nil |
||||
}, as...) |
||||
|
||||
body, err := json.Marshal(as) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
var ( |
||||
lastErr error |
||||
numErrs int |
||||
) |
||||
for _, u := range n.settings.URLs { |
||||
if _, err := sendHTTPRequest(ctx, u, httpCfg{ |
||||
user: n.settings.User, |
||||
password: n.settings.Password, |
||||
body: body, |
||||
}, n.logger); err != nil { |
||||
n.logger.Warn("failed to send to Alertmanager", "error", err, "alertmanager", n.Name, "url", u.String()) |
||||
lastErr = err |
||||
numErrs++ |
||||
} |
||||
} |
||||
|
||||
if numErrs == len(n.settings.URLs) { |
||||
// All attempts to send alerts have failed
|
||||
n.logger.Warn("all attempts to send to Alertmanager failed", "alertmanager", n.Name) |
||||
return false, fmt.Errorf("failed to send alert to Alertmanager: %w", lastErr) |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (n *AlertmanagerNotifier) SendResolved() bool { |
||||
return !n.GetDisableResolveMessage() |
||||
} |
@ -1,222 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestNewAlertmanagerNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expectedInitError string |
||||
receiverName string |
||||
}{ |
||||
{ |
||||
name: "Error in initing: missing URL", |
||||
settings: `{}`, |
||||
expectedInitError: `could not find url property in settings`, |
||||
}, { |
||||
name: "Error in initing: invalid URL", |
||||
settings: `{ |
||||
"url": "://alertmanager.com" |
||||
}`, |
||||
expectedInitError: `invalid url property in settings: parse "://alertmanager.com/api/v1/alerts": missing protocol scheme`, |
||||
receiverName: "Alertmanager", |
||||
}, |
||||
{ |
||||
name: "Error in initing: empty URL", |
||||
settings: `{ |
||||
"url": "" |
||||
}`, |
||||
expectedInitError: `could not find url property in settings`, |
||||
receiverName: "Alertmanager", |
||||
}, |
||||
{ |
||||
name: "Error in initing: null URL", |
||||
settings: `{ |
||||
"url": null |
||||
}`, |
||||
expectedInitError: `could not find url property in settings`, |
||||
receiverName: "Alertmanager", |
||||
}, |
||||
{ |
||||
name: "Error in initing: one of multiple URLs is invalid", |
||||
settings: `{ |
||||
"url": "https://alertmanager-01.com,://url" |
||||
}`, |
||||
expectedInitError: "invalid url property in settings: parse \"://url/api/v1/alerts\": missing protocol scheme", |
||||
receiverName: "Alertmanager", |
||||
}, |
||||
} |
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
secureSettings := make(map[string][]byte) |
||||
|
||||
m := &channels.NotificationChannelConfig{ |
||||
Name: c.receiverName, |
||||
Type: "prometheus-alertmanager", |
||||
Settings: json.RawMessage(c.settings), |
||||
SecureSettings: secureSettings, |
||||
} |
||||
|
||||
decryptFn := func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
} |
||||
|
||||
fc := channels.FactoryConfig{ |
||||
Config: m, |
||||
DecryptFunc: decryptFn, |
||||
ImageStore: &channels.UnavailableImageStore{}, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
} |
||||
sn, err := buildAlertmanagerNotifier(fc) |
||||
if c.expectedInitError != "" { |
||||
require.ErrorContains(t, err, c.expectedInitError) |
||||
} else { |
||||
require.NotNil(t, sn) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestAlertmanagerNotifier_Notify(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
images := newFakeImageStore(1) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expectedError string |
||||
sendHTTPRequestError error |
||||
receiverName string |
||||
}{ |
||||
{ |
||||
name: "Default config with one alert", |
||||
settings: `{"url": "https://alertmanager.com"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
receiverName: "Alertmanager", |
||||
}, { |
||||
name: "Default config with one alert with image URL", |
||||
settings: `{"url": "https://alertmanager.com"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1"}, |
||||
Annotations: model.LabelSet{"__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
receiverName: "Alertmanager", |
||||
}, { |
||||
name: "Default config with one alert with empty receiver name", |
||||
settings: `{"url": "https://alertmanager.com"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, { |
||||
name: "Error sending to Alertmanager", |
||||
settings: `{ |
||||
"url": "https://alertmanager.com" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expectedError: "failed to send alert to Alertmanager: expected error", |
||||
sendHTTPRequestError: errors.New("expected error"), |
||||
receiverName: "Alertmanager", |
||||
}, |
||||
} |
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
require.NoError(t, err) |
||||
secureSettings := make(map[string][]byte) |
||||
|
||||
m := &channels.NotificationChannelConfig{ |
||||
Name: c.receiverName, |
||||
Type: "prometheus-alertmanager", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
} |
||||
|
||||
decryptFn := func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
} |
||||
fc := channels.FactoryConfig{ |
||||
Config: m, |
||||
DecryptFunc: decryptFn, |
||||
ImageStore: images, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
} |
||||
sn, err := buildAlertmanagerNotifier(fc) |
||||
require.NoError(t, err) |
||||
|
||||
var body []byte |
||||
origSendHTTPRequest := sendHTTPRequest |
||||
t.Cleanup(func() { |
||||
sendHTTPRequest = origSendHTTPRequest |
||||
}) |
||||
sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger channels.Logger) ([]byte, error) { |
||||
body = cfg.body |
||||
return nil, c.sendHTTPRequestError |
||||
} |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := sn.Notify(ctx, c.alerts...) |
||||
|
||||
if c.sendHTTPRequestError != nil { |
||||
require.EqualError(t, err, c.expectedError) |
||||
require.False(t, ok) |
||||
} else { |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
expBody, err := json.Marshal(c.alerts) |
||||
require.NoError(t, err) |
||||
require.JSONEq(t, string(expBody), string(body)) |
||||
} |
||||
}) |
||||
} |
||||
} |
@ -1,30 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"os" |
||||
"testing" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func templateForTests(t *testing.T) *template.Template { |
||||
f, err := os.CreateTemp("/tmp", "template") |
||||
require.NoError(t, err) |
||||
defer func(f *os.File) { |
||||
_ = f.Close() |
||||
}(f) |
||||
|
||||
t.Cleanup(func() { |
||||
require.NoError(t, os.RemoveAll(f.Name())) |
||||
}) |
||||
|
||||
_, err = f.WriteString(channels.TemplateForTestsString) |
||||
require.NoError(t, err) |
||||
|
||||
tmpl, err := template.FromGlobs(f.Name()) |
||||
require.NoError(t, err) |
||||
|
||||
return tmpl |
||||
} |
@ -1,160 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net/url" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
) |
||||
|
||||
const defaultDingdingMsgType = "link" |
||||
|
||||
type dingDingSettings struct { |
||||
URL string `json:"url,omitempty" yaml:"url,omitempty"` |
||||
MessageType string `json:"msgType,omitempty" yaml:"msgType,omitempty"` |
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` |
||||
Message string `json:"message,omitempty" yaml:"message,omitempty"` |
||||
} |
||||
|
||||
func buildDingDingSettings(fc channels.FactoryConfig) (*dingDingSettings, error) { |
||||
var settings dingDingSettings |
||||
err := json.Unmarshal(fc.Config.Settings, &settings) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err) |
||||
} |
||||
if settings.URL == "" { |
||||
return nil, errors.New("could not find url property in settings") |
||||
} |
||||
if settings.MessageType == "" { |
||||
settings.MessageType = defaultDingdingMsgType |
||||
} |
||||
if settings.Title == "" { |
||||
settings.Title = channels.DefaultMessageTitleEmbed |
||||
} |
||||
if settings.Message == "" { |
||||
settings.Message = channels.DefaultMessageEmbed |
||||
} |
||||
return &settings, nil |
||||
} |
||||
|
||||
func DingDingFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { |
||||
n, err := newDingDingNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return n, nil |
||||
} |
||||
|
||||
// newDingDingNotifier is the constructor for the Dingding notifier
|
||||
func newDingDingNotifier(fc channels.FactoryConfig) (*DingDingNotifier, error) { |
||||
settings, err := buildDingDingSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &DingDingNotifier{ |
||||
Base: channels.NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
ns: fc.NotificationService, |
||||
tmpl: fc.Template, |
||||
settings: *settings, |
||||
}, nil |
||||
} |
||||
|
||||
// DingDingNotifier is responsible for sending alert notifications to ding ding.
|
||||
type DingDingNotifier struct { |
||||
*channels.Base |
||||
log channels.Logger |
||||
ns channels.WebhookSender |
||||
tmpl *template.Template |
||||
settings dingDingSettings |
||||
} |
||||
|
||||
// Notify sends the alert notification to dingding.
|
||||
func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
dd.log.Info("sending dingding") |
||||
|
||||
msgUrl := buildDingDingURL(dd) |
||||
|
||||
var tmplErr error |
||||
tmpl, _ := channels.TmplText(ctx, dd.tmpl, as, dd.log, &tmplErr) |
||||
|
||||
message := tmpl(dd.settings.Message) |
||||
title := tmpl(dd.settings.Title) |
||||
|
||||
msgType := tmpl(dd.settings.MessageType) |
||||
b, err := buildBody(msgUrl, msgType, title, message) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
dd.log.Warn("failed to template DingDing message", "error", tmplErr.Error()) |
||||
tmplErr = nil |
||||
} |
||||
|
||||
u := tmpl(dd.settings.URL) |
||||
if tmplErr != nil { |
||||
dd.log.Warn("failed to template DingDing URL", "error", tmplErr.Error(), "fallback", dd.settings.URL) |
||||
u = dd.settings.URL |
||||
} |
||||
|
||||
cmd := &channels.SendWebhookSettings{URL: u, Body: b} |
||||
|
||||
if err := dd.ns.SendWebhook(ctx, cmd); err != nil { |
||||
return false, fmt.Errorf("send notification to dingding: %w", err) |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (dd *DingDingNotifier) SendResolved() bool { |
||||
return !dd.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func buildDingDingURL(dd *DingDingNotifier) string { |
||||
q := url.Values{ |
||||
"pc_slide": {"false"}, |
||||
"url": {joinUrlPath(dd.tmpl.ExternalURL.String(), "/alerting/list", dd.log)}, |
||||
} |
||||
|
||||
// Use special link to auto open the message url outside Dingding
|
||||
// Refer: https://open-doc.dingtalk.com/docs/doc.htm?treeId=385&articleId=104972&docType=1#s9
|
||||
return "dingtalk://dingtalkclient/page/link?" + q.Encode() |
||||
} |
||||
|
||||
func buildBody(msgUrl string, msgType string, title string, msg string) (string, error) { |
||||
var bodyMsg map[string]interface{} |
||||
if msgType == "actionCard" { |
||||
bodyMsg = map[string]interface{}{ |
||||
"msgtype": "actionCard", |
||||
"actionCard": map[string]string{ |
||||
"text": msg, |
||||
"title": title, |
||||
"singleTitle": "More", |
||||
"singleURL": msgUrl, |
||||
}, |
||||
} |
||||
} else { |
||||
bodyMsg = map[string]interface{}{ |
||||
"msgtype": "link", |
||||
"link": map[string]string{ |
||||
"text": msg, |
||||
"title": title, |
||||
"messageUrl": msgUrl, |
||||
}, |
||||
} |
||||
} |
||||
body, err := json.Marshal(bodyMsg) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
return string(body), nil |
||||
} |
@ -1,207 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestDingdingNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg map[string]interface{} |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Default config with one alert", |
||||
settings: `{"url": "http://localhost"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__values__": "{\"A\": 1234}", "__value_string__": "1234"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"msgtype": "link", |
||||
"link": map[string]interface{}{ |
||||
"messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", |
||||
"text": "**Firing**\n\nValue: A=1234\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"title": "[FIRING:1] (val1)", |
||||
}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Custom config with multiple alerts", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved", |
||||
"msgType": "actionCard" |
||||
}`, |
||||
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: map[string]interface{}{ |
||||
"actionCard": map[string]interface{}{ |
||||
"singleTitle": "More", |
||||
"singleURL": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", |
||||
"text": "2 alerts are firing, 0 are resolved", |
||||
"title": "[FIRING:2] ", |
||||
}, |
||||
"msgtype": "actionCard", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Default config with one alert and custom title and description", |
||||
settings: `{"url": "http://localhost", "title": "Alerts firing: {{ len .Alerts.Firing }}", "message": "customMessage"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__values__": "{\"A\": 1234}", "__value_string__": "1234"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"msgtype": "link", |
||||
"link": map[string]interface{}{ |
||||
"messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", |
||||
"text": "customMessage", |
||||
"title": "Alerts firing: 1", |
||||
}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Missing field in template", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"message": "I'm a custom template {{ .NotAField }} bad template", |
||||
"msgType": "actionCard" |
||||
}`, |
||||
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: map[string]interface{}{ |
||||
"link": map[string]interface{}{ |
||||
"messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", |
||||
"text": "I'm a custom template ", |
||||
"title": "", |
||||
}, |
||||
"msgtype": "link", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Invalid template", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"message": "I'm a custom template {{ {.NotAField }} bad template", |
||||
"msgType": "actionCard" |
||||
}`, |
||||
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: map[string]interface{}{ |
||||
"link": map[string]interface{}{ |
||||
"messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist", |
||||
"text": "", |
||||
"title": "", |
||||
}, |
||||
"msgtype": "link", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Error in initing", |
||||
settings: `{}`, |
||||
expInitError: `could not find url property in settings`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
webhookSender := mockNotificationService() |
||||
fc := channels.FactoryConfig{ |
||||
Config: &channels.NotificationChannelConfig{ |
||||
Name: "dingding_testing", |
||||
Type: "dingding", |
||||
Settings: json.RawMessage(c.settings), |
||||
}, |
||||
// TODO: allow changing the associated values for different tests.
|
||||
NotificationService: webhookSender, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
} |
||||
pn, err := newDingDingNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
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.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
require.NotEmpty(t, webhookSender.Webhook.URL) |
||||
|
||||
expBody, err := json.Marshal(c.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,341 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"mime/multipart" |
||||
"path/filepath" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
// Constants and models are set according to the official documentation https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params
|
||||
|
||||
type discordEmbedType string |
||||
|
||||
const ( |
||||
discordRichEmbed discordEmbedType = "rich" |
||||
|
||||
discordMaxEmbeds = 10 |
||||
discordMaxMessageLen = 2000 |
||||
) |
||||
|
||||
type discordMessage struct { |
||||
Username string `json:"username,omitempty"` |
||||
Content string `json:"content"` |
||||
AvatarURL string `json:"avatar_url,omitempty"` |
||||
Embeds []discordLinkEmbed `json:"embeds,omitempty"` |
||||
} |
||||
|
||||
// discordLinkEmbed implements https://discord.com/developers/docs/resources/channel#embed-object
|
||||
type discordLinkEmbed struct { |
||||
Title string `json:"title,omitempty"` |
||||
Type discordEmbedType `json:"type,omitempty"` |
||||
URL string `json:"url,omitempty"` |
||||
Color int64 `json:"color,omitempty"` |
||||
|
||||
Footer *discordFooter `json:"footer,omitempty"` |
||||
|
||||
Image *discordImage `json:"image,omitempty"` |
||||
} |
||||
|
||||
// discordFooter implements https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||
type discordFooter struct { |
||||
Text string `json:"text"` |
||||
IconURL string `json:"icon_url,omitempty"` |
||||
} |
||||
|
||||
// discordImage implements https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||
type discordImage struct { |
||||
URL string `json:"url"` |
||||
} |
||||
|
||||
type DiscordNotifier struct { |
||||
*channels.Base |
||||
log channels.Logger |
||||
ns channels.WebhookSender |
||||
images channels.ImageStore |
||||
tmpl *template.Template |
||||
settings *discordSettings |
||||
appVersion string |
||||
} |
||||
|
||||
type discordSettings struct { |
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` |
||||
Message string `json:"message,omitempty" yaml:"message,omitempty"` |
||||
AvatarURL string `json:"avatar_url,omitempty" yaml:"avatar_url,omitempty"` |
||||
WebhookURL string `json:"url,omitempty" yaml:"url,omitempty"` |
||||
UseDiscordUsername bool `json:"use_discord_username,omitempty" yaml:"use_discord_username,omitempty"` |
||||
} |
||||
|
||||
func buildDiscordSettings(fc channels.FactoryConfig) (*discordSettings, error) { |
||||
var settings discordSettings |
||||
err := json.Unmarshal(fc.Config.Settings, &settings) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err) |
||||
} |
||||
if settings.WebhookURL == "" { |
||||
return nil, errors.New("could not find webhook url property in settings") |
||||
} |
||||
if settings.Title == "" { |
||||
settings.Title = channels.DefaultMessageTitleEmbed |
||||
} |
||||
if settings.Message == "" { |
||||
settings.Message = channels.DefaultMessageEmbed |
||||
} |
||||
return &settings, nil |
||||
} |
||||
|
||||
type discordAttachment struct { |
||||
url string |
||||
reader io.ReadCloser |
||||
name string |
||||
alertName string |
||||
state model.AlertStatus |
||||
} |
||||
|
||||
func DiscordFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { |
||||
dn, err := newDiscordNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return dn, nil |
||||
} |
||||
|
||||
func newDiscordNotifier(fc channels.FactoryConfig) (*DiscordNotifier, error) { |
||||
settings, err := buildDiscordSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &DiscordNotifier{ |
||||
Base: channels.NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
ns: fc.NotificationService, |
||||
images: fc.ImageStore, |
||||
tmpl: fc.Template, |
||||
settings: settings, |
||||
appVersion: fc.GrafanaBuildVersion, |
||||
}, nil |
||||
} |
||||
|
||||
func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
alerts := types.Alerts(as...) |
||||
|
||||
var msg discordMessage |
||||
|
||||
if !d.settings.UseDiscordUsername { |
||||
msg.Username = "Grafana" |
||||
} |
||||
|
||||
var tmplErr error |
||||
tmpl, _ := channels.TmplText(ctx, d.tmpl, as, d.log, &tmplErr) |
||||
|
||||
msg.Content = tmpl(d.settings.Message) |
||||
if tmplErr != nil { |
||||
d.log.Warn("failed to template Discord notification content", "error", tmplErr.Error()) |
||||
// Reset tmplErr for templating other fields.
|
||||
tmplErr = nil |
||||
} |
||||
truncatedMsg, truncated := channels.TruncateInRunes(msg.Content, discordMaxMessageLen) |
||||
if truncated { |
||||
key, err := notify.ExtractGroupKey(ctx) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
d.log.Warn("Truncated content", "key", key, "max_runes", discordMaxMessageLen) |
||||
msg.Content = truncatedMsg |
||||
} |
||||
|
||||
if d.settings.AvatarURL != "" { |
||||
msg.AvatarURL = tmpl(d.settings.AvatarURL) |
||||
if tmplErr != nil { |
||||
d.log.Warn("failed to template Discord Avatar URL", "error", tmplErr.Error(), "fallback", d.settings.AvatarURL) |
||||
msg.AvatarURL = d.settings.AvatarURL |
||||
tmplErr = nil |
||||
} |
||||
} |
||||
|
||||
footer := &discordFooter{ |
||||
Text: "Grafana v" + d.appVersion, |
||||
IconURL: "https://grafana.com/static/assets/img/fav32.png", |
||||
} |
||||
|
||||
var linkEmbed discordLinkEmbed |
||||
|
||||
linkEmbed.Title = tmpl(d.settings.Title) |
||||
if tmplErr != nil { |
||||
d.log.Warn("failed to template Discord notification title", "error", tmplErr.Error()) |
||||
// Reset tmplErr for templating other fields.
|
||||
tmplErr = nil |
||||
} |
||||
linkEmbed.Footer = footer |
||||
linkEmbed.Type = discordRichEmbed |
||||
|
||||
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0) |
||||
linkEmbed.Color = color |
||||
|
||||
ruleURL := joinUrlPath(d.tmpl.ExternalURL.String(), "/alerting/list", d.log) |
||||
linkEmbed.URL = ruleURL |
||||
|
||||
embeds := []discordLinkEmbed{linkEmbed} |
||||
|
||||
attachments := d.constructAttachments(ctx, as, discordMaxEmbeds-1) |
||||
for _, a := range attachments { |
||||
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0) |
||||
embed := discordLinkEmbed{ |
||||
Image: &discordImage{ |
||||
URL: a.url, |
||||
}, |
||||
Color: color, |
||||
Title: a.alertName, |
||||
} |
||||
embeds = append(embeds, embed) |
||||
} |
||||
|
||||
msg.Embeds = embeds |
||||
|
||||
if tmplErr != nil { |
||||
d.log.Warn("failed to template Discord message", "error", tmplErr.Error()) |
||||
tmplErr = nil |
||||
} |
||||
|
||||
u := tmpl(d.settings.WebhookURL) |
||||
if tmplErr != nil { |
||||
d.log.Warn("failed to template Discord URL", "error", tmplErr.Error(), "fallback", d.settings.WebhookURL) |
||||
u = d.settings.WebhookURL |
||||
} |
||||
|
||||
body, err := json.Marshal(msg) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
cmd, err := d.buildRequest(u, body, attachments) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
if err := d.ns.SendWebhook(ctx, cmd); err != nil { |
||||
d.log.Error("failed to send notification to Discord", "error", err) |
||||
return false, err |
||||
} |
||||
return true, nil |
||||
} |
||||
|
||||
func (d DiscordNotifier) SendResolved() bool { |
||||
return !d.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.Alert, embedQuota int) []discordAttachment { |
||||
attachments := make([]discordAttachment, 0) |
||||
|
||||
_ = withStoredImages(ctx, d.log, d.images, |
||||
func(index int, image channels.Image) error { |
||||
if embedQuota < 1 { |
||||
return channels.ErrImagesDone |
||||
} |
||||
|
||||
if len(image.URL) > 0 { |
||||
attachments = append(attachments, discordAttachment{ |
||||
url: image.URL, |
||||
state: as[index].Status(), |
||||
alertName: as[index].Name(), |
||||
}) |
||||
embedQuota-- |
||||
return nil |
||||
} |
||||
|
||||
// If we have a local file, but no public URL, upload the image as an attachment.
|
||||
if len(image.Path) > 0 { |
||||
base := filepath.Base(image.Path) |
||||
url := fmt.Sprintf("attachment://%s", base) |
||||
reader, err := openImage(image.Path) |
||||
if err != nil && !errors.Is(err, channels.ErrImageNotFound) { |
||||
d.log.Warn("failed to retrieve image data from store", "error", err) |
||||
return nil |
||||
} |
||||
|
||||
attachments = append(attachments, discordAttachment{ |
||||
url: url, |
||||
name: base, |
||||
reader: reader, |
||||
state: as[index].Status(), |
||||
alertName: as[index].Name(), |
||||
}) |
||||
embedQuota-- |
||||
} |
||||
return nil |
||||
}, |
||||
as..., |
||||
) |
||||
|
||||
return attachments |
||||
} |
||||
|
||||
func (d DiscordNotifier) buildRequest(url string, body []byte, attachments []discordAttachment) (*channels.SendWebhookSettings, error) { |
||||
cmd := &channels.SendWebhookSettings{ |
||||
URL: url, |
||||
HTTPMethod: "POST", |
||||
} |
||||
if len(attachments) == 0 { |
||||
cmd.ContentType = "application/json" |
||||
cmd.Body = string(body) |
||||
return cmd, nil |
||||
} |
||||
|
||||
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
|
||||
d.log.Warn("failed to close multipart writer", "error", err) |
||||
} |
||||
}() |
||||
|
||||
payload, err := w.CreateFormField("payload_json") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if _, err := payload.Write(body); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
for _, a := range attachments { |
||||
if a.reader != nil { // We have an image to upload.
|
||||
err = func() error { |
||||
defer func() { _ = a.reader.Close() }() |
||||
part, err := w.CreateFormFile("", a.name) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
_, err = io.Copy(part, a.reader) |
||||
return err |
||||
}() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
} |
||||
|
||||
if err := w.Close(); err != nil { |
||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err) |
||||
} |
||||
|
||||
cmd.ContentType = w.FormDataContentType() |
||||
cmd.Body = b.String() |
||||
return cmd, nil |
||||
} |
@ -1,363 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"math/rand" |
||||
"net/url" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestDiscordNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
appVersion := fmt.Sprintf("%d.0.0", rand.Uint32()) |
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg map[string]interface{} |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Default config with one alert", |
||||
settings: `{"url": "http://localhost"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "[FIRING:1] (val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
"username": "Grafana", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Default config with one alert and custom title", |
||||
settings: `{"url": "http://localhost", "title": "Alerts firing: {{ len .Alerts.Firing }}"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "Alerts firing: 1", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
"username": "Grafana", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Missing field in template", |
||||
settings: `{ |
||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"url": "http://localhost", |
||||
"message": "I'm a custom template {{ .NotAField }} bad template" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"content": "I'm a custom template ", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "[FIRING:1] (val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
"username": "Grafana", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Invalid message template", |
||||
settings: `{ |
||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"url": "http://localhost", |
||||
"message": "{{ template \"invalid.template\" }}" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"content": "", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "[FIRING:1] (val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
"username": "Grafana", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Invalid avatar URL template", |
||||
settings: `{ |
||||
"avatar_url": "{{ invalid } }}", |
||||
"url": "http://localhost", |
||||
"message": "valid message" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"avatar_url": "{{ invalid } }}", |
||||
"content": "valid message", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "[FIRING:1] (val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
"username": "Grafana", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Invalid URL template", |
||||
settings: `{ |
||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"url": "http://localhost?q={{invalid }}}", |
||||
"message": "valid message" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"content": "valid message", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "[FIRING:1] (val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
"username": "Grafana", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Custom config with multiple alerts", |
||||
settings: `{ |
||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"url": "http://localhost", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are 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: map[string]interface{}{ |
||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"content": "2 alerts are firing, 0 are resolved", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "[FIRING:2] ", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
"username": "Grafana", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Error in initialization", |
||||
settings: `{}`, |
||||
expInitError: `could not find webhook url property in settings`, |
||||
}, |
||||
{ |
||||
name: "Default config with one alert, use default discord username", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"use_discord_username": true |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "[FIRING:1] (val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Should truncate too long messages", |
||||
settings: fmt.Sprintf(`{ |
||||
"url": "http://localhost", |
||||
"use_discord_username": true, |
||||
"message": "%s" |
||||
}`, strings.Repeat("Y", discordMaxMessageLen+rand.Intn(100)+1)), |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"content": strings.Repeat("Y", discordMaxMessageLen-1) + "…", |
||||
"embeds": []interface{}{map[string]interface{}{ |
||||
"color": 1.4037554e+07, |
||||
"footer": map[string]interface{}{ |
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png", |
||||
"text": "Grafana v" + appVersion, |
||||
}, |
||||
"title": "[FIRING:1] (val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"type": "rich", |
||||
}}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
webhookSender := mockNotificationService() |
||||
imageStore := &channels.UnavailableImageStore{} |
||||
|
||||
fc := channels.FactoryConfig{ |
||||
Config: &channels.NotificationChannelConfig{ |
||||
Name: "discord_testing", |
||||
Type: "discord", |
||||
Settings: json.RawMessage(c.settings), |
||||
}, |
||||
ImageStore: imageStore, |
||||
// TODO: allow changing the associated values for different tests.
|
||||
NotificationService: webhookSender, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
GrafanaBuildVersion: appVersion, |
||||
} |
||||
|
||||
dn, err := newDiscordNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := dn.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.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
expBody, err := json.Marshal(c.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,175 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net/url" |
||||
"os" |
||||
"path" |
||||
"path/filepath" |
||||
"strings" |
||||
|
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
) |
||||
|
||||
// EmailNotifier is responsible for sending
|
||||
// alert notifications over email.
|
||||
type EmailNotifier struct { |
||||
*channels.Base |
||||
log channels.Logger |
||||
ns channels.EmailSender |
||||
images channels.ImageStore |
||||
tmpl *template.Template |
||||
settings *emailSettings |
||||
} |
||||
|
||||
type emailSettings struct { |
||||
SingleEmail bool |
||||
Addresses []string |
||||
Message string |
||||
Subject string |
||||
} |
||||
|
||||
func EmailFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { |
||||
notifier, err := buildEmailNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return notifier, nil |
||||
} |
||||
|
||||
func buildEmailSettings(fc channels.FactoryConfig) (*emailSettings, error) { |
||||
type emailSettingsRaw struct { |
||||
SingleEmail bool `json:"singleEmail,omitempty"` |
||||
Addresses string `json:"addresses,omitempty"` |
||||
Message string `json:"message,omitempty"` |
||||
Subject string `json:"subject,omitempty"` |
||||
} |
||||
|
||||
var settings emailSettingsRaw |
||||
err := json.Unmarshal(fc.Config.Settings, &settings) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err) |
||||
} |
||||
if settings.Addresses == "" { |
||||
return nil, errors.New("could not find addresses in settings") |
||||
} |
||||
// split addresses with a few different ways
|
||||
addresses := splitEmails(settings.Addresses) |
||||
|
||||
if settings.Subject == "" { |
||||
settings.Subject = channels.DefaultMessageTitleEmbed |
||||
} |
||||
|
||||
return &emailSettings{ |
||||
SingleEmail: settings.SingleEmail, |
||||
Message: settings.Message, |
||||
Subject: settings.Subject, |
||||
Addresses: addresses, |
||||
}, nil |
||||
} |
||||
|
||||
func buildEmailNotifier(fc channels.FactoryConfig) (*EmailNotifier, error) { |
||||
settings, err := buildEmailSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &EmailNotifier{ |
||||
Base: channels.NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
ns: fc.NotificationService, |
||||
images: fc.ImageStore, |
||||
tmpl: fc.Template, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify sends the alert notification.
|
||||
func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { |
||||
var tmplErr error |
||||
tmpl, data := channels.TmplText(ctx, en.tmpl, alerts, en.log, &tmplErr) |
||||
|
||||
subject := tmpl(en.settings.Subject) |
||||
alertPageURL := en.tmpl.ExternalURL.String() |
||||
ruleURL := en.tmpl.ExternalURL.String() |
||||
u, err := url.Parse(en.tmpl.ExternalURL.String()) |
||||
if err == nil { |
||||
basePath := u.Path |
||||
u.Path = path.Join(basePath, "/alerting/list") |
||||
ruleURL = u.String() |
||||
u.RawQuery = "alertState=firing&view=state" |
||||
alertPageURL = u.String() |
||||
} else { |
||||
en.log.Debug("failed to parse external URL", "url", en.tmpl.ExternalURL.String(), "error", err.Error()) |
||||
} |
||||
|
||||
// Extend alerts data with images, if available.
|
||||
var embeddedFiles []string |
||||
_ = withStoredImages(ctx, en.log, en.images, |
||||
func(index int, image channels.Image) error { |
||||
if len(image.URL) != 0 { |
||||
data.Alerts[index].ImageURL = image.URL |
||||
} else if len(image.Path) != 0 { |
||||
_, err := os.Stat(image.Path) |
||||
if err == nil { |
||||
data.Alerts[index].EmbeddedImage = filepath.Base(image.Path) |
||||
embeddedFiles = append(embeddedFiles, image.Path) |
||||
} else { |
||||
en.log.Warn("failed to get image file for email attachment", "file", image.Path, "error", err) |
||||
} |
||||
} |
||||
return nil |
||||
}, alerts...) |
||||
|
||||
cmd := &channels.SendEmailSettings{ |
||||
Subject: subject, |
||||
Data: map[string]interface{}{ |
||||
"Title": subject, |
||||
"Message": tmpl(en.settings.Message), |
||||
"Status": data.Status, |
||||
"Alerts": data.Alerts, |
||||
"GroupLabels": data.GroupLabels, |
||||
"CommonLabels": data.CommonLabels, |
||||
"CommonAnnotations": data.CommonAnnotations, |
||||
"ExternalURL": data.ExternalURL, |
||||
"RuleUrl": ruleURL, |
||||
"AlertPageUrl": alertPageURL, |
||||
}, |
||||
EmbeddedFiles: embeddedFiles, |
||||
To: en.settings.Addresses, |
||||
SingleEmail: en.settings.SingleEmail, |
||||
Template: "ng_alert_notification", |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
en.log.Warn("failed to template email message", "error", tmplErr.Error()) |
||||
} |
||||
|
||||
if err := en.ns.SendEmail(ctx, cmd); err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (en *EmailNotifier) SendResolved() bool { |
||||
return !en.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func splitEmails(emails string) []string { |
||||
return strings.FieldsFunc(emails, func(r rune) bool { |
||||
switch r { |
||||
case ',', ';', '\n': |
||||
return true |
||||
} |
||||
return false |
||||
}) |
||||
} |
@ -1,225 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestEmailNotifier_Init(t *testing.T) { |
||||
testCase := []struct { |
||||
Name string |
||||
Config json.RawMessage |
||||
Expected *emailSettings |
||||
ExpectedError string |
||||
}{ |
||||
{ |
||||
Name: "error if JSON is empty", |
||||
Config: json.RawMessage(`{}`), |
||||
ExpectedError: "could not find addresses in settings", |
||||
}, |
||||
{ |
||||
Name: "should split addresses separated by semicolon", |
||||
Config: json.RawMessage(`{ |
||||
"addresses": "someops@example.com;somedev@example.com" |
||||
}`), |
||||
Expected: &emailSettings{ |
||||
SingleEmail: false, |
||||
Addresses: []string{ |
||||
"someops@example.com", |
||||
"somedev@example.com", |
||||
}, |
||||
Message: "", |
||||
Subject: channels.DefaultMessageTitleEmbed, |
||||
}, |
||||
}, |
||||
{ |
||||
Name: "should split addresses separated by comma", |
||||
Config: json.RawMessage(`{ |
||||
"addresses": "someops@example.com,somedev@example.com" |
||||
}`), |
||||
Expected: &emailSettings{ |
||||
SingleEmail: false, |
||||
Addresses: []string{ |
||||
"someops@example.com", |
||||
"somedev@example.com", |
||||
}, |
||||
Message: "", |
||||
Subject: channels.DefaultMessageTitleEmbed, |
||||
}, |
||||
}, |
||||
{ |
||||
Name: "should split addresses separated by new-line", |
||||
Config: json.RawMessage(`{ |
||||
"addresses": "someops@example.com\nsomedev@example.com" |
||||
}`), |
||||
Expected: &emailSettings{ |
||||
SingleEmail: false, |
||||
Addresses: []string{ |
||||
"someops@example.com", |
||||
"somedev@example.com", |
||||
}, |
||||
Message: "", |
||||
Subject: channels.DefaultMessageTitleEmbed, |
||||
}, |
||||
}, |
||||
{ |
||||
Name: "should split addresses separated by mixed separators", |
||||
Config: json.RawMessage(`{ |
||||
"addresses": "someops@example.com\nsomedev@example.com;somedev2@example.com,somedev3@example.com" |
||||
}`), |
||||
Expected: &emailSettings{ |
||||
SingleEmail: false, |
||||
Addresses: []string{ |
||||
"someops@example.com", |
||||
"somedev@example.com", |
||||
"somedev2@example.com", |
||||
"somedev3@example.com", |
||||
}, |
||||
Message: "", |
||||
Subject: channels.DefaultMessageTitleEmbed, |
||||
}, |
||||
}, |
||||
{ |
||||
Name: "should split addresses separated by mixed separators", |
||||
Config: json.RawMessage(`{ |
||||
"addresses": "someops@example.com\nsomedev@example.com;somedev2@example.com,somedev3@example.com" |
||||
}`), |
||||
Expected: &emailSettings{ |
||||
SingleEmail: false, |
||||
Addresses: []string{ |
||||
"someops@example.com", |
||||
"somedev@example.com", |
||||
"somedev2@example.com", |
||||
"somedev3@example.com", |
||||
}, |
||||
Message: "", |
||||
Subject: channels.DefaultMessageTitleEmbed, |
||||
}, |
||||
}, |
||||
{ |
||||
Name: "should parse all settings", |
||||
Config: json.RawMessage(`{ |
||||
"singleEmail": true, |
||||
"addresses": "someops@example.com", |
||||
"message": "test-message", |
||||
"subject": "test-subject" |
||||
}`), |
||||
Expected: &emailSettings{ |
||||
SingleEmail: true, |
||||
Addresses: []string{ |
||||
"someops@example.com", |
||||
}, |
||||
Message: "test-message", |
||||
Subject: "test-subject", |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
for _, test := range testCase { |
||||
t.Run(test.Name, func(t *testing.T) { |
||||
cfg := &channels.NotificationChannelConfig{ |
||||
Name: "ops", |
||||
Type: "email", |
||||
Settings: test.Config, |
||||
} |
||||
settings, err := buildEmailSettings(channels.FactoryConfig{Config: cfg}) |
||||
if test.ExpectedError != "" { |
||||
require.ErrorContains(t, err, test.ExpectedError) |
||||
} else { |
||||
require.Equal(t, *test.Expected, *settings) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestEmailNotifier_Notify(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost/base") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
t.Run("with the correct settings it should not fail and produce the expected command", func(t *testing.T) { |
||||
jsonData := `{ |
||||
"addresses": "someops@example.com;somedev@example.com", |
||||
"message": "{{ template \"default.title\" . }}" |
||||
}` |
||||
|
||||
emailSender := mockNotificationService() |
||||
|
||||
fc := channels.FactoryConfig{ |
||||
Config: &channels.NotificationChannelConfig{ |
||||
Name: "ops", |
||||
Type: "email", |
||||
Settings: json.RawMessage(jsonData), |
||||
}, |
||||
NotificationService: emailSender, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
ImageStore: &channels.UnavailableImageStore{}, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
} |
||||
|
||||
emailNotifier, err := EmailFactory(fc) |
||||
require.NoError(t, err) |
||||
|
||||
alerts := []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"}, |
||||
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
ok, err := emailNotifier.Notify(context.Background(), alerts...) |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
expected := map[string]interface{}{ |
||||
"subject": emailSender.EmailSync.Subject, |
||||
"to": emailSender.EmailSync.To, |
||||
"single_email": emailSender.EmailSync.SingleEmail, |
||||
"template": emailSender.EmailSync.Template, |
||||
"data": emailSender.EmailSync.Data, |
||||
} |
||||
require.Equal(t, map[string]interface{}{ |
||||
"subject": "[FIRING:1] (AlwaysFiring warning)", |
||||
"to": []string{"someops@example.com", "somedev@example.com"}, |
||||
"single_email": false, |
||||
"template": "ng_alert_notification", |
||||
"data": map[string]interface{}{ |
||||
"Title": "[FIRING:1] (AlwaysFiring warning)", |
||||
"Message": "[FIRING:1] (AlwaysFiring warning)", |
||||
"Status": "firing", |
||||
"Alerts": channels.ExtendedAlerts{ |
||||
channels.ExtendedAlert{ |
||||
Status: "firing", |
||||
Labels: template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, |
||||
Annotations: template.KV{"runbook_url": "http://fix.me"}, |
||||
Fingerprint: "15a37193dce72bab", |
||||
SilenceURL: "http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DAlwaysFiring&matcher=severity%3Dwarning", |
||||
DashboardURL: "http://localhost/base/d/abc", |
||||
PanelURL: "http://localhost/base/d/abc?viewPanel=5", |
||||
}, |
||||
}, |
||||
"GroupLabels": template.KV{}, |
||||
"CommonLabels": template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, |
||||
"CommonAnnotations": template.KV{"runbook_url": "http://fix.me"}, |
||||
"ExternalURL": "http://localhost/base", |
||||
"RuleUrl": "http://localhost/base/alerting/list", |
||||
"AlertPageUrl": "http://localhost/base/alerting/list?alertState=firing&view=state", |
||||
}, |
||||
}, expected) |
||||
}) |
||||
} |
@ -1,285 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net/url" |
||||
"time" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
) |
||||
|
||||
// GoogleChatNotifier is responsible for sending
|
||||
// alert notifications to Google chat.
|
||||
type GoogleChatNotifier struct { |
||||
*channels.Base |
||||
log channels.Logger |
||||
ns channels.WebhookSender |
||||
images channels.ImageStore |
||||
tmpl *template.Template |
||||
settings *googleChatSettings |
||||
appVersion string |
||||
} |
||||
|
||||
type googleChatSettings struct { |
||||
URL string `json:"url,omitempty" yaml:"url,omitempty"` |
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` |
||||
Message string `json:"message,omitempty" yaml:"message,omitempty"` |
||||
} |
||||
|
||||
func buildGoogleChatSettings(fc channels.FactoryConfig) (*googleChatSettings, error) { |
||||
var settings googleChatSettings |
||||
err := json.Unmarshal(fc.Config.Settings, &settings) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err) |
||||
} |
||||
|
||||
if settings.URL == "" { |
||||
return nil, errors.New("could not find url property in settings") |
||||
} |
||||
if settings.Title == "" { |
||||
settings.Title = channels.DefaultMessageTitleEmbed |
||||
} |
||||
if settings.Message == "" { |
||||
settings.Message = channels.DefaultMessageEmbed |
||||
} |
||||
return &settings, nil |
||||
} |
||||
|
||||
func GoogleChatFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { |
||||
gcn, err := newGoogleChatNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return gcn, nil |
||||
} |
||||
|
||||
func newGoogleChatNotifier(fc channels.FactoryConfig) (*GoogleChatNotifier, error) { |
||||
settings, err := buildGoogleChatSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &GoogleChatNotifier{ |
||||
Base: channels.NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
ns: fc.NotificationService, |
||||
images: fc.ImageStore, |
||||
tmpl: fc.Template, |
||||
settings: settings, |
||||
appVersion: fc.GrafanaBuildVersion, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify send an alert notification to Google Chat.
|
||||
func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
gcn.log.Debug("executing Google Chat notification") |
||||
|
||||
var tmplErr error |
||||
tmpl, _ := channels.TmplText(ctx, gcn.tmpl, as, gcn.log, &tmplErr) |
||||
|
||||
var widgets []widget |
||||
|
||||
if msg := tmpl(gcn.settings.Message); msg != "" { |
||||
// Add a text paragraph widget for the message if there is a message.
|
||||
// Google Chat API doesn't accept an empty text property.
|
||||
widgets = append(widgets, textParagraphWidget{Text: text{Text: msg}}) |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
gcn.log.Warn("failed to template Google Chat message", "error", tmplErr.Error()) |
||||
tmplErr = nil |
||||
} |
||||
|
||||
ruleURL := joinUrlPath(gcn.tmpl.ExternalURL.String(), "/alerting/list", gcn.log) |
||||
if gcn.isUrlAbsolute(ruleURL) { |
||||
// Add a button widget (link to Grafana).
|
||||
widgets = append(widgets, buttonWidget{ |
||||
Buttons: []button{ |
||||
{ |
||||
TextButton: textButton{ |
||||
Text: "OPEN IN GRAFANA", |
||||
OnClick: onClick{ |
||||
OpenLink: openLink{ |
||||
URL: ruleURL, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}) |
||||
} else { |
||||
gcn.log.Warn("Grafana external URL setting is missing or invalid. Skipping 'open in grafana' button to prevent Google from displaying empty alerts.", "ruleURL", ruleURL) |
||||
} |
||||
|
||||
// Add text paragraph widget for the build version and timestamp.
|
||||
widgets = append(widgets, textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "Grafana v" + gcn.appVersion + " | " + (timeNow()).Format(time.RFC822), |
||||
}, |
||||
}) |
||||
|
||||
title := tmpl(gcn.settings.Title) |
||||
// Nest the required structs.
|
||||
res := &outerStruct{ |
||||
PreviewText: title, |
||||
FallbackText: title, |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{Title: title}, |
||||
Sections: []section{ |
||||
{Widgets: widgets}, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
if screenshots := gcn.buildScreenshotCard(ctx, as); screenshots != nil { |
||||
res.Cards = append(res.Cards, *screenshots) |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
gcn.log.Warn("failed to template GoogleChat message", "error", tmplErr.Error()) |
||||
tmplErr = nil |
||||
} |
||||
|
||||
u := tmpl(gcn.settings.URL) |
||||
if tmplErr != nil { |
||||
gcn.log.Warn("failed to template GoogleChat URL", "error", tmplErr.Error(), "fallback", gcn.settings.URL) |
||||
u = gcn.settings.URL |
||||
} |
||||
|
||||
body, err := json.Marshal(res) |
||||
if err != nil { |
||||
return false, fmt.Errorf("marshal json: %w", err) |
||||
} |
||||
|
||||
cmd := &channels.SendWebhookSettings{ |
||||
URL: u, |
||||
HTTPMethod: "POST", |
||||
HTTPHeader: map[string]string{ |
||||
"Content-Type": "application/json; charset=UTF-8", |
||||
}, |
||||
Body: string(body), |
||||
} |
||||
|
||||
if err := gcn.ns.SendWebhook(ctx, cmd); err != nil { |
||||
gcn.log.Error("Failed to send Google Hangouts Chat alert", "error", err, "webhook", gcn.Name) |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (gcn *GoogleChatNotifier) SendResolved() bool { |
||||
return !gcn.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func (gcn *GoogleChatNotifier) isUrlAbsolute(urlToCheck string) bool { |
||||
parsed, err := url.Parse(urlToCheck) |
||||
if err != nil { |
||||
gcn.log.Warn("could not parse URL", "urlToCheck", urlToCheck) |
||||
return false |
||||
} |
||||
|
||||
return parsed.IsAbs() |
||||
} |
||||
|
||||
func (gcn *GoogleChatNotifier) buildScreenshotCard(ctx context.Context, alerts []*types.Alert) *card { |
||||
card := card{ |
||||
Header: header{Title: "Screenshots"}, |
||||
Sections: []section{}, |
||||
} |
||||
|
||||
_ = withStoredImages(ctx, gcn.log, gcn.images, |
||||
func(index int, image channels.Image) error { |
||||
if len(image.URL) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
section := section{ |
||||
Widgets: []widget{ |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: fmt.Sprintf("%s: %s", alerts[index].Status(), alerts[index].Name()), |
||||
}, |
||||
}, |
||||
imageWidget{Image: imageData{ImageURL: image.URL}}, |
||||
}, |
||||
} |
||||
card.Sections = append(card.Sections, section) |
||||
|
||||
return nil |
||||
}, alerts...) |
||||
|
||||
if len(card.Sections) == 0 { |
||||
return nil |
||||
} |
||||
return &card |
||||
} |
||||
|
||||
// Structs used to build a custom Google Hangouts Chat message card.
|
||||
// See: https://developers.google.com/hangouts/chat/reference/message-formats/cards
|
||||
type outerStruct struct { |
||||
PreviewText string `json:"previewText"` |
||||
FallbackText string `json:"fallbackText"` |
||||
Cards []card `json:"cards"` |
||||
} |
||||
|
||||
type card struct { |
||||
Header header `json:"header"` |
||||
Sections []section `json:"sections"` |
||||
} |
||||
|
||||
type header struct { |
||||
Title string `json:"title"` |
||||
} |
||||
|
||||
type section struct { |
||||
Widgets []widget `json:"widgets"` |
||||
} |
||||
|
||||
// "generic" widget used to add different types of widgets (buttonWidget, textParagraphWidget, imageWidget)
|
||||
type widget interface{} |
||||
|
||||
type buttonWidget struct { |
||||
Buttons []button `json:"buttons"` |
||||
} |
||||
|
||||
type textParagraphWidget struct { |
||||
Text text `json:"textParagraph"` |
||||
} |
||||
|
||||
type imageWidget struct { |
||||
Image imageData `json:"image"` |
||||
} |
||||
|
||||
type imageData struct { |
||||
ImageURL string `json:"imageUrl"` |
||||
} |
||||
|
||||
type text struct { |
||||
Text string `json:"text"` |
||||
} |
||||
|
||||
type button struct { |
||||
TextButton textButton `json:"textButton"` |
||||
} |
||||
|
||||
type textButton struct { |
||||
Text string `json:"text"` |
||||
OnClick onClick `json:"onClick"` |
||||
} |
||||
|
||||
type onClick struct { |
||||
OpenLink openLink `json:"openLink"` |
||||
} |
||||
|
||||
type openLink struct { |
||||
URL string `json:"url"` |
||||
} |
@ -1,511 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"math/rand" |
||||
"net/url" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
) |
||||
|
||||
func TestGoogleChatNotifier(t *testing.T) { |
||||
constNow := time.Now() |
||||
defer mockTimeNow(constNow)() |
||||
appVersion := fmt.Sprintf("%d.0.0", rand.Uint32()) |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg *outerStruct |
||||
expInitError string |
||||
expMsgError error |
||||
externalURL string |
||||
}{ |
||||
{ |
||||
name: "One alert", |
||||
settings: `{"url": "http://localhost"}`, |
||||
externalURL: "http://localhost", |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &outerStruct{ |
||||
PreviewText: "[FIRING:1] (val1)", |
||||
FallbackText: "[FIRING:1] (val1)", |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{ |
||||
Title: "[FIRING:1] (val1)", |
||||
}, |
||||
Sections: []section{ |
||||
{ |
||||
Widgets: []widget{ |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
}, |
||||
}, |
||||
buttonWidget{ |
||||
Buttons: []button{ |
||||
{ |
||||
TextButton: textButton{ |
||||
Text: "OPEN IN GRAFANA", |
||||
OnClick: onClick{ |
||||
OpenLink: openLink{ |
||||
URL: "http://localhost/alerting/list", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
// RFC822 only has the minute, hence it works in most cases.
|
||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Multiple alerts", |
||||
settings: `{"url": "http://localhost"}`, |
||||
externalURL: "http://localhost", |
||||
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: &outerStruct{ |
||||
PreviewText: "[FIRING:2] ", |
||||
FallbackText: "[FIRING:2] ", |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{ |
||||
Title: "[FIRING:2] ", |
||||
}, |
||||
Sections: []section{ |
||||
{ |
||||
Widgets: []widget{ |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", |
||||
}, |
||||
}, |
||||
buttonWidget{ |
||||
Buttons: []button{ |
||||
{ |
||||
TextButton: textButton{ |
||||
Text: "OPEN IN GRAFANA", |
||||
OnClick: onClick{ |
||||
OpenLink: openLink{ |
||||
URL: "http://localhost/alerting/list", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Error in initing", |
||||
settings: `{}`, |
||||
externalURL: "http://localhost", |
||||
expInitError: `could not find url property in settings`, |
||||
}, { |
||||
name: "Customized message", |
||||
settings: `{"url": "http://localhost", "message": "I'm a custom template and you have {{ len .Alerts.Firing }} firing alert."}`, |
||||
externalURL: "http://localhost", |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &outerStruct{ |
||||
PreviewText: "[FIRING:1] (val1)", |
||||
FallbackText: "[FIRING:1] (val1)", |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{ |
||||
Title: "[FIRING:1] (val1)", |
||||
}, |
||||
Sections: []section{ |
||||
{ |
||||
Widgets: []widget{ |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "I'm a custom template and you have 1 firing alert.", |
||||
}, |
||||
}, |
||||
buttonWidget{ |
||||
Buttons: []button{ |
||||
{ |
||||
TextButton: textButton{ |
||||
Text: "OPEN IN GRAFANA", |
||||
OnClick: onClick{ |
||||
OpenLink: openLink{ |
||||
URL: "http://localhost/alerting/list", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
// RFC822 only has the minute, hence it works in most cases.
|
||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Customized title", |
||||
settings: `{"url": "http://localhost", "title": "Alerts firing: {{ len .Alerts.Firing }}"}`, |
||||
externalURL: "http://localhost", |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &outerStruct{ |
||||
PreviewText: "Alerts firing: 1", |
||||
FallbackText: "Alerts firing: 1", |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{ |
||||
Title: "Alerts firing: 1", |
||||
}, |
||||
Sections: []section{ |
||||
{ |
||||
Widgets: []widget{ |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
}, |
||||
}, |
||||
buttonWidget{ |
||||
Buttons: []button{ |
||||
{ |
||||
TextButton: textButton{ |
||||
Text: "OPEN IN GRAFANA", |
||||
OnClick: onClick{ |
||||
OpenLink: openLink{ |
||||
URL: "http://localhost/alerting/list", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
// RFC822 only has the minute, hence it works in most cases.
|
||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Missing field in template", |
||||
settings: `{"url": "http://localhost", "message": "I'm a custom template {{ .NotAField }} bad template"}`, |
||||
externalURL: "http://localhost", |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &outerStruct{ |
||||
PreviewText: "[FIRING:1] (val1)", |
||||
FallbackText: "[FIRING:1] (val1)", |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{ |
||||
Title: "[FIRING:1] (val1)", |
||||
}, |
||||
Sections: []section{ |
||||
{ |
||||
Widgets: []widget{ |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "I'm a custom template ", |
||||
}, |
||||
}, |
||||
buttonWidget{ |
||||
Buttons: []button{ |
||||
{ |
||||
TextButton: textButton{ |
||||
Text: "OPEN IN GRAFANA", |
||||
OnClick: onClick{ |
||||
OpenLink: openLink{ |
||||
URL: "http://localhost/alerting/list", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
// RFC822 only has the minute, hence it works in most cases.
|
||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Invalid template", |
||||
settings: `{"url": "http://localhost", "message": "I'm a custom template {{ {.NotAField }} bad template"}`, |
||||
externalURL: "http://localhost", |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &outerStruct{ |
||||
PreviewText: "[FIRING:1] (val1)", |
||||
FallbackText: "[FIRING:1] (val1)", |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{ |
||||
Title: "[FIRING:1] (val1)", |
||||
}, |
||||
Sections: []section{ |
||||
{ |
||||
Widgets: []widget{ |
||||
buttonWidget{ |
||||
Buttons: []button{ |
||||
{ |
||||
TextButton: textButton{ |
||||
Text: "OPEN IN GRAFANA", |
||||
OnClick: onClick{ |
||||
OpenLink: openLink{ |
||||
URL: "http://localhost/alerting/list", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
// RFC822 only has the minute, hence it works in most cases.
|
||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Empty external URL", |
||||
settings: `{ "url": "http://localhost" }`, // URL in settings = googlechat url
|
||||
externalURL: "", // external URL = URL of grafana from configuration
|
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &outerStruct{ |
||||
PreviewText: "[FIRING:1] (val1)", |
||||
FallbackText: "[FIRING:1] (val1)", |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{ |
||||
Title: "[FIRING:1] (val1)", |
||||
}, |
||||
Sections: []section{ |
||||
{ |
||||
Widgets: []widget{ |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\n", |
||||
}, |
||||
}, |
||||
|
||||
// No button widget here since the external URL is not absolute
|
||||
|
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
// RFC822 only has the minute, hence it works in most cases.
|
||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Relative external URL", |
||||
settings: `{ "url": "http://localhost" }`, // URL in settings = googlechat url
|
||||
externalURL: "/grafana", // external URL = URL of grafana from configuration
|
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &outerStruct{ |
||||
PreviewText: "[FIRING:1] (val1)", |
||||
FallbackText: "[FIRING:1] (val1)", |
||||
Cards: []card{ |
||||
{ |
||||
Header: header{ |
||||
Title: "[FIRING:1] (val1)", |
||||
}, |
||||
Sections: []section{ |
||||
{ |
||||
Widgets: []widget{ |
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: /grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: /grafana/d/abcd\nPanel: /grafana/d/abcd?viewPanel=efgh\n", |
||||
}, |
||||
}, |
||||
|
||||
// No button widget here since the external URL is not absolute
|
||||
|
||||
textParagraphWidget{ |
||||
Text: text{ |
||||
// RFC822 only has the minute, hence it works in most cases.
|
||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse(c.externalURL) |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
webhookSender := mockNotificationService() |
||||
imageStore := &channels.UnavailableImageStore{} |
||||
|
||||
fc := channels.FactoryConfig{ |
||||
Config: &channels.NotificationChannelConfig{ |
||||
Name: "googlechat_testing", |
||||
Type: "googlechat", |
||||
Settings: json.RawMessage(c.settings), |
||||
}, |
||||
ImageStore: imageStore, |
||||
NotificationService: webhookSender, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
GrafanaBuildVersion: appVersion, |
||||
} |
||||
|
||||
pn, err := newGoogleChatNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
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.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
require.NotEmpty(t, webhookSender.Webhook.URL) |
||||
|
||||
expBody, err := json.Marshal(c.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,208 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
|
||||
"github.com/grafana/grafana/pkg/models" |
||||
) |
||||
|
||||
type kafkaBody struct { |
||||
Records []kafkaRecordEnvelope `json:"records"` |
||||
} |
||||
|
||||
type kafkaRecordEnvelope struct { |
||||
Value kafkaRecord `json:"value"` |
||||
} |
||||
|
||||
type kafkaRecord struct { |
||||
Description string `json:"description"` |
||||
Client string `json:"client,omitempty"` |
||||
Details string `json:"details,omitempty"` |
||||
AlertState models.AlertStateType `json:"alert_state,omitempty"` |
||||
ClientURL string `json:"client_url,omitempty"` |
||||
Contexts []kafkaContext `json:"contexts,omitempty"` |
||||
IncidentKey string `json:"incident_key,omitempty"` |
||||
} |
||||
|
||||
type kafkaContext struct { |
||||
Type string `json:"type"` |
||||
Source string `json:"src"` |
||||
} |
||||
|
||||
// KafkaNotifier is responsible for sending
|
||||
// alert notifications to Kafka.
|
||||
type KafkaNotifier struct { |
||||
*channels.Base |
||||
log channels.Logger |
||||
images channels.ImageStore |
||||
ns channels.WebhookSender |
||||
tmpl *template.Template |
||||
settings *kafkaSettings |
||||
} |
||||
|
||||
type kafkaSettings struct { |
||||
Endpoint string `json:"kafkaRestProxy,omitempty" yaml:"kafkaRestProxy,omitempty"` |
||||
Topic string `json:"kafkaTopic,omitempty" yaml:"kafkaTopic,omitempty"` |
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"` |
||||
Details string `json:"details,omitempty" yaml:"details,omitempty"` |
||||
} |
||||
|
||||
func buildKafkaSettings(fc channels.FactoryConfig) (*kafkaSettings, error) { |
||||
var settings kafkaSettings |
||||
err := json.Unmarshal(fc.Config.Settings, &settings) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err) |
||||
} |
||||
|
||||
if settings.Endpoint == "" { |
||||
return nil, errors.New("could not find kafka rest proxy endpoint property in settings") |
||||
} |
||||
if settings.Topic == "" { |
||||
return nil, errors.New("could not find kafka topic property in settings") |
||||
} |
||||
if settings.Description == "" { |
||||
settings.Description = channels.DefaultMessageTitleEmbed |
||||
} |
||||
if settings.Details == "" { |
||||
settings.Details = channels.DefaultMessageEmbed |
||||
} |
||||
return &settings, nil |
||||
} |
||||
|
||||
func KafkaFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { |
||||
ch, err := newKafkaNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return ch, nil |
||||
} |
||||
|
||||
// newKafkaNotifier is the constructor function for the Kafka notifier.
|
||||
func newKafkaNotifier(fc channels.FactoryConfig) (*KafkaNotifier, error) { |
||||
settings, err := buildKafkaSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &KafkaNotifier{ |
||||
Base: channels.NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
images: fc.ImageStore, |
||||
ns: fc.NotificationService, |
||||
tmpl: fc.Template, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify sends the alert notification.
|
||||
func (kn *KafkaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
var tmplErr error |
||||
tmpl, _ := channels.TmplText(ctx, kn.tmpl, as, kn.log, &tmplErr) |
||||
|
||||
topicURL := strings.TrimRight(kn.settings.Endpoint, "/") + "/topics/" + tmpl(kn.settings.Topic) |
||||
|
||||
body, err := kn.buildBody(ctx, tmpl, as...) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
kn.log.Warn("failed to template Kafka message", "error", tmplErr.Error()) |
||||
} |
||||
|
||||
cmd := &channels.SendWebhookSettings{ |
||||
URL: topicURL, |
||||
Body: body, |
||||
HTTPMethod: "POST", |
||||
HTTPHeader: map[string]string{ |
||||
"Content-Type": "application/vnd.kafka.json.v2+json", |
||||
"Accept": "application/vnd.kafka.v2+json", |
||||
}, |
||||
} |
||||
|
||||
if err = kn.ns.SendWebhook(ctx, cmd); err != nil { |
||||
kn.log.Error("Failed to send notification to Kafka", "error", err, "body", body) |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (kn *KafkaNotifier) SendResolved() bool { |
||||
return !kn.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func (kn *KafkaNotifier) buildBody(ctx context.Context, tmpl func(string) string, as ...*types.Alert) (string, error) { |
||||
var record kafkaRecord |
||||
record.Client = "Grafana" |
||||
record.Description = tmpl(kn.settings.Description) |
||||
record.Details = tmpl(kn.settings.Details) |
||||
|
||||
state := buildState(as...) |
||||
kn.log.Debug("notifying Kafka", "alert_state", state) |
||||
record.AlertState = state |
||||
|
||||
ruleURL := joinUrlPath(kn.tmpl.ExternalURL.String(), "/alerting/list", kn.log) |
||||
record.ClientURL = ruleURL |
||||
|
||||
contexts := buildContextImages(ctx, kn.log, kn.images, as...) |
||||
if len(contexts) > 0 { |
||||
record.Contexts = contexts |
||||
} |
||||
|
||||
groupKey, err := notify.ExtractGroupKey(ctx) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
record.IncidentKey = groupKey.Hash() |
||||
|
||||
records := kafkaBody{ |
||||
Records: []kafkaRecordEnvelope{ |
||||
{Value: record}, |
||||
}, |
||||
} |
||||
|
||||
body, err := json.Marshal(records) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
return string(body), nil |
||||
} |
||||
|
||||
func buildState(as ...*types.Alert) models.AlertStateType { |
||||
// We are using the state from 7.x to not break kafka.
|
||||
// TODO: should we switch to the new ones?
|
||||
if types.Alerts(as...).Status() == model.AlertResolved { |
||||
return models.AlertStateOK |
||||
} |
||||
return models.AlertStateAlerting |
||||
} |
||||
|
||||
func buildContextImages(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, as ...*types.Alert) []kafkaContext { |
||||
var contexts []kafkaContext |
||||
_ = withStoredImages(ctx, l, imageStore, |
||||
func(_ int, image channels.Image) error { |
||||
if image.URL != "" { |
||||
contexts = append(contexts, kafkaContext{ |
||||
Type: "image", |
||||
Source: image.URL, |
||||
}) |
||||
} |
||||
return nil |
||||
}, as...) |
||||
return contexts |
||||
} |
@ -1,156 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestKafkaNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
images := newFakeImageStore(2) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expUrl, expMsg string |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "A single alert with image and custom description and details", |
||||
settings: `{ |
||||
"kafkaRestProxy": "http://localhost", |
||||
"kafkaTopic": "sometopic", |
||||
"description": "customDescription", |
||||
"details": "customDetails" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expUrl: "http://localhost/topics/sometopic", |
||||
expMsg: `{ |
||||
"records": [ |
||||
{ |
||||
"value": { |
||||
"alert_state": "alerting", |
||||
"client": "Grafana", |
||||
"client_url": "http://localhost/alerting/list", |
||||
"contexts": [{"type": "image", "src": "https://www.example.com/test-image-1.jpg"}], |
||||
"description": "customDescription", |
||||
"details": "customDetails", |
||||
"incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733" |
||||
} |
||||
} |
||||
] |
||||
}`, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Multiple alerts with images with default description and details", |
||||
settings: `{ |
||||
"kafkaRestProxy": "http://localhost", |
||||
"kafkaTopic": "sometopic" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expUrl: "http://localhost/topics/sometopic", |
||||
expMsg: `{ |
||||
"records": [ |
||||
{ |
||||
"value": { |
||||
"alert_state": "alerting", |
||||
"client": "Grafana", |
||||
"client_url": "http://localhost/alerting/list", |
||||
"contexts": [{"type": "image", "src": "https://www.example.com/test-image-1.jpg"}, {"type": "image", "src": "https://www.example.com/test-image-2.jpg"}], |
||||
"description": "[FIRING:2] ", |
||||
"details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", |
||||
"incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733" |
||||
} |
||||
} |
||||
] |
||||
}`, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Endpoint missing", |
||||
settings: `{"kafkaTopic": "sometopic"}`, |
||||
expInitError: `could not find kafka rest proxy endpoint property in settings`, |
||||
}, { |
||||
name: "Topic missing", |
||||
settings: `{"kafkaRestProxy": "http://localhost"}`, |
||||
expInitError: `could not find kafka topic property in settings`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := channels.FactoryConfig{ |
||||
Config: &channels.NotificationChannelConfig{ |
||||
Name: "kafka_testing", |
||||
Type: "kafka", |
||||
Settings: json.RawMessage(c.settings), |
||||
}, |
||||
ImageStore: images, |
||||
// TODO: allow changing the associated values for different tests.
|
||||
NotificationService: webhookSender, |
||||
DecryptFunc: nil, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
} |
||||
|
||||
pn, err := newKafkaNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
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.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
require.Equal(t, c.expUrl, webhookSender.Webhook.URL) |
||||
require.JSONEq(t, c.expMsg, webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,129 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net/url" |
||||
"path" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
) |
||||
|
||||
var ( |
||||
LineNotifyURL string = "https://notify-api.line.me/api/notify" |
||||
) |
||||
|
||||
// LineNotifier is responsible for sending
|
||||
// alert notifications to LINE.
|
||||
type LineNotifier struct { |
||||
*channels.Base |
||||
log channels.Logger |
||||
ns channels.WebhookSender |
||||
tmpl *template.Template |
||||
settings *lineSettings |
||||
} |
||||
|
||||
type lineSettings struct { |
||||
Token string `json:"token,omitempty" yaml:"token,omitempty"` |
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` |
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"` |
||||
} |
||||
|
||||
func buildLineSettings(fc channels.FactoryConfig) (*lineSettings, error) { |
||||
var settings lineSettings |
||||
err := json.Unmarshal(fc.Config.Settings, &settings) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err) |
||||
} |
||||
settings.Token = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "token", settings.Token) |
||||
if settings.Token == "" { |
||||
return nil, errors.New("could not find token in settings") |
||||
} |
||||
if settings.Title == "" { |
||||
settings.Title = channels.DefaultMessageTitleEmbed |
||||
} |
||||
if settings.Description == "" { |
||||
settings.Description = channels.DefaultMessageEmbed |
||||
} |
||||
return &settings, nil |
||||
} |
||||
|
||||
func LineFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { |
||||
n, err := newLineNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return n, nil |
||||
} |
||||
|
||||
// newLineNotifier is the constructor for the LINE notifier
|
||||
func newLineNotifier(fc channels.FactoryConfig) (*LineNotifier, error) { |
||||
settings, err := buildLineSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &LineNotifier{ |
||||
Base: channels.NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
ns: fc.NotificationService, |
||||
tmpl: fc.Template, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify send an alert notification to LINE
|
||||
func (ln *LineNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
ln.log.Debug("executing line notification", "notification", ln.Name) |
||||
|
||||
body := ln.buildMessage(ctx, as...) |
||||
|
||||
form := url.Values{} |
||||
form.Add("message", body) |
||||
|
||||
cmd := &channels.SendWebhookSettings{ |
||||
URL: LineNotifyURL, |
||||
HTTPMethod: "POST", |
||||
HTTPHeader: map[string]string{ |
||||
"Authorization": fmt.Sprintf("Bearer %s", ln.settings.Token), |
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", |
||||
}, |
||||
Body: form.Encode(), |
||||
} |
||||
|
||||
if err := ln.ns.SendWebhook(ctx, cmd); err != nil { |
||||
ln.log.Error("failed to send notification to LINE", "error", err, "body", body) |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (ln *LineNotifier) SendResolved() bool { |
||||
return !ln.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func (ln *LineNotifier) buildMessage(ctx context.Context, as ...*types.Alert) string { |
||||
ruleURL := path.Join(ln.tmpl.ExternalURL.String(), "/alerting/list") |
||||
|
||||
var tmplErr error |
||||
tmpl, _ := channels.TmplText(ctx, ln.tmpl, as, ln.log, &tmplErr) |
||||
|
||||
body := fmt.Sprintf( |
||||
"%s\n%s\n\n%s", |
||||
tmpl(ln.settings.Title), |
||||
ruleURL, |
||||
tmpl(ln.settings.Description), |
||||
) |
||||
if tmplErr != nil { |
||||
ln.log.Warn("failed to template Line message", "error", tmplErr.Error()) |
||||
} |
||||
return body |
||||
} |
@ -1,140 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestLineNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expHeaders map[string]string |
||||
expMsg string |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "One alert", |
||||
settings: `{"token": "sometoken"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expHeaders: map[string]string{ |
||||
"Authorization": "Bearer sometoken", |
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", |
||||
}, |
||||
expMsg: "message=%5BFIRING%3A1%5D++%28val1%29%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A", |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Multiple alerts", |
||||
settings: `{"token": "sometoken"}`, |
||||
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"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expHeaders: map[string]string{ |
||||
"Authorization": "Bearer sometoken", |
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", |
||||
}, |
||||
expMsg: "message=%5BFIRING%3A2%5D++%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval2%0A", |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "One alert custom title and description", |
||||
settings: `{"token": "sometoken", "title": "customTitle {{ .Alerts.Firing | len }}", "description": "customDescription"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expHeaders: map[string]string{ |
||||
"Authorization": "Bearer sometoken", |
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", |
||||
}, |
||||
expMsg: "message=customTitle+1%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0AcustomDescription", |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Token missing", |
||||
settings: `{}`, |
||||
expInitError: `could not find token in settings`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
secureSettings := make(map[string][]byte) |
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := channels.FactoryConfig{ |
||||
Config: &channels.NotificationChannelConfig{ |
||||
Name: "line_testing", |
||||
Type: "line", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
}, |
||||
// TODO: allow changing the associated values for different tests.
|
||||
NotificationService: webhookSender, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
} |
||||
pn, err := newLineNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
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.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
require.Equal(t, c.expHeaders, webhookSender.Webhook.HTTPHeader) |
||||
require.Equal(t, c.expMsg, webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,597 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"crypto/tls" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"mime/multipart" |
||||
"net" |
||||
"net/http" |
||||
"net/url" |
||||
"os" |
||||
"path" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/prometheus/alertmanager/config" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
) |
||||
|
||||
const ( |
||||
// maxImagesPerThreadTs is the maximum number of images that can be posted as
|
||||
// replies to the same thread_ts. It should prevent tokens from exceeding the
|
||||
// rate limits for files.upload https://api.slack.com/docs/rate-limits#tier_t2
|
||||
maxImagesPerThreadTs = 5 |
||||
maxImagesPerThreadTsMessage = "There are more images than can be shown here. To see the panels for all firing and resolved alerts please check Grafana" |
||||
) |
||||
|
||||
var ( |
||||
slackClient = &http.Client{ |
||||
Timeout: time.Second * 30, |
||||
Transport: &http.Transport{ |
||||
TLSClientConfig: &tls.Config{ |
||||
Renegotiation: tls.RenegotiateFreelyAsClient, |
||||
}, |
||||
Proxy: http.ProxyFromEnvironment, |
||||
DialContext: (&net.Dialer{ |
||||
Timeout: 30 * time.Second, |
||||
}).DialContext, |
||||
TLSHandshakeTimeout: 5 * time.Second, |
||||
}, |
||||
} |
||||
) |
||||
|
||||
var SlackAPIEndpoint = "https://slack.com/api/chat.postMessage" |
||||
|
||||
type sendFunc func(ctx context.Context, req *http.Request, logger channels.Logger) (string, error) |
||||
|
||||
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
|
||||
const slackMaxTitleLenRunes = 1024 |
||||
|
||||
// SlackNotifier is responsible for sending
|
||||
// alert notification to Slack.
|
||||
type SlackNotifier struct { |
||||
*channels.Base |
||||
log channels.Logger |
||||
tmpl *template.Template |
||||
images channels.ImageStore |
||||
webhookSender channels.WebhookSender |
||||
sendFn sendFunc |
||||
settings slackSettings |
||||
appVersion string |
||||
} |
||||
|
||||
type slackSettings struct { |
||||
EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"` |
||||
URL string `json:"url,omitempty" yaml:"url,omitempty"` |
||||
Token string `json:"token,omitempty" yaml:"token,omitempty"` |
||||
Recipient string `json:"recipient,omitempty" yaml:"recipient,omitempty"` |
||||
Text string `json:"text,omitempty" yaml:"text,omitempty"` |
||||
Title string `json:"title,omitempty" yaml:"title,omitempty"` |
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"` |
||||
IconEmoji string `json:"icon_emoji,omitempty" yaml:"icon_emoji,omitempty"` |
||||
IconURL string `json:"icon_url,omitempty" yaml:"icon_url,omitempty"` |
||||
MentionChannel string `json:"mentionChannel,omitempty" yaml:"mentionChannel,omitempty"` |
||||
MentionUsers channels.CommaSeparatedStrings `json:"mentionUsers,omitempty" yaml:"mentionUsers,omitempty"` |
||||
MentionGroups channels.CommaSeparatedStrings `json:"mentionGroups,omitempty" yaml:"mentionGroups,omitempty"` |
||||
} |
||||
|
||||
// isIncomingWebhook returns true if the settings are for an incoming webhook.
|
||||
func isIncomingWebhook(s slackSettings) bool { |
||||
return s.Token == "" |
||||
} |
||||
|
||||
// uploadURL returns the upload URL for Slack.
|
||||
func uploadURL(s slackSettings) (string, error) { |
||||
u, err := url.Parse(s.URL) |
||||
if err != nil { |
||||
return "", fmt.Errorf("failed to parse URL: %w", err) |
||||
} |
||||
dir, _ := path.Split(u.Path) |
||||
u.Path = path.Join(dir, "files.upload") |
||||
return u.String(), nil |
||||
} |
||||
|
||||
// SlackFactory creates a new NotificationChannel that sends notifications to Slack.
|
||||
func SlackFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { |
||||
ch, err := buildSlackNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return ch, nil |
||||
} |
||||
|
||||
func buildSlackNotifier(factoryConfig channels.FactoryConfig) (*SlackNotifier, error) { |
||||
decryptFunc := factoryConfig.DecryptFunc |
||||
var settings slackSettings |
||||
err := json.Unmarshal(factoryConfig.Config.Settings, &settings) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err) |
||||
} |
||||
|
||||
if settings.EndpointURL == "" { |
||||
settings.EndpointURL = SlackAPIEndpoint |
||||
} |
||||
slackURL := decryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "url", settings.URL) |
||||
if slackURL == "" { |
||||
slackURL = settings.EndpointURL |
||||
} |
||||
|
||||
apiURL, err := url.Parse(slackURL) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("invalid URL %q", slackURL) |
||||
} |
||||
settings.URL = apiURL.String() |
||||
|
||||
settings.Recipient = strings.TrimSpace(settings.Recipient) |
||||
if settings.Recipient == "" && settings.URL == SlackAPIEndpoint { |
||||
return nil, errors.New("recipient must be specified when using the Slack chat API") |
||||
} |
||||
if settings.MentionChannel != "" && settings.MentionChannel != "here" && settings.MentionChannel != "channel" { |
||||
return nil, fmt.Errorf("invalid value for mentionChannel: %q", settings.MentionChannel) |
||||
} |
||||
settings.Token = decryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "token", settings.Token) |
||||
if settings.Token == "" && settings.URL == SlackAPIEndpoint { |
||||
return nil, errors.New("token must be specified when using the Slack chat API") |
||||
} |
||||
if settings.Username == "" { |
||||
settings.Username = "Grafana" |
||||
} |
||||
if settings.Text == "" { |
||||
settings.Text = channels.DefaultMessageEmbed |
||||
} |
||||
if settings.Title == "" { |
||||
settings.Title = channels.DefaultMessageTitleEmbed |
||||
} |
||||
return &SlackNotifier{ |
||||
Base: channels.NewBase(factoryConfig.Config), |
||||
settings: settings, |
||||
|
||||
images: factoryConfig.ImageStore, |
||||
webhookSender: factoryConfig.NotificationService, |
||||
sendFn: sendSlackRequest, |
||||
log: factoryConfig.Logger, |
||||
tmpl: factoryConfig.Template, |
||||
appVersion: factoryConfig.GrafanaBuildVersion, |
||||
}, nil |
||||
} |
||||
|
||||
// slackMessage is the slackMessage for sending a slack notification.
|
||||
type slackMessage struct { |
||||
Channel string `json:"channel,omitempty"` |
||||
Text string `json:"text,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,omitempty"` |
||||
ThreadTs string `json:"thread_ts,omitempty"` |
||||
} |
||||
|
||||
// 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"` |
||||
ImageURL string `json:"image_url,omitempty"` |
||||
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"` |
||||
Pretext string `json:"pretext,omitempty"` |
||||
MrkdwnIn []string `json:"mrkdwn_in,omitempty"` |
||||
} |
||||
|
||||
// Notify sends an alert notification to Slack.
|
||||
func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { |
||||
sn.log.Debug("Creating slack message", "alerts", len(alerts)) |
||||
|
||||
m, err := sn.createSlackMessage(ctx, alerts) |
||||
if err != nil { |
||||
sn.log.Error("Failed to create Slack message", "err", err) |
||||
return false, fmt.Errorf("failed to create Slack message: %w", err) |
||||
} |
||||
|
||||
thread_ts, err := sn.sendSlackMessage(ctx, m) |
||||
if err != nil { |
||||
sn.log.Error("Failed to send Slack message", "err", err) |
||||
return false, fmt.Errorf("failed to send Slack message: %w", err) |
||||
} |
||||
|
||||
// Do not upload images if using an incoming webhook as incoming webhooks cannot upload files
|
||||
if !isIncomingWebhook(sn.settings) { |
||||
if err := withStoredImages(ctx, sn.log, sn.images, func(index int, image channels.Image) error { |
||||
// If we have exceeded the maximum number of images for this thread_ts
|
||||
// then tell the recipient and stop iterating subsequent images
|
||||
if index >= maxImagesPerThreadTs { |
||||
if _, err := sn.sendSlackMessage(ctx, &slackMessage{ |
||||
Channel: sn.settings.Recipient, |
||||
Text: maxImagesPerThreadTsMessage, |
||||
ThreadTs: thread_ts, |
||||
}); err != nil { |
||||
sn.log.Error("Failed to send Slack message", "err", err) |
||||
} |
||||
return channels.ErrImagesDone |
||||
} |
||||
comment := initialCommentForImage(alerts[index]) |
||||
return sn.uploadImage(ctx, image, sn.settings.Recipient, comment, thread_ts) |
||||
}, alerts...); err != nil { |
||||
// Do not return an error here as we might have exceeded the rate limit for uploading files
|
||||
sn.log.Error("Failed to upload image", "err", err) |
||||
} |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
// sendSlackRequest sends a request to the Slack API.
|
||||
// Stubbable by tests.
|
||||
var sendSlackRequest = func(ctx context.Context, req *http.Request, logger channels.Logger) (string, error) { |
||||
resp, err := slackClient.Do(req) |
||||
if err != nil { |
||||
return "", fmt.Errorf("failed to send request: %w", err) |
||||
} |
||||
|
||||
defer func() { |
||||
if err := resp.Body.Close(); err != nil { |
||||
logger.Warn("Failed to close response body", "err", err) |
||||
} |
||||
}() |
||||
|
||||
if resp.StatusCode < http.StatusOK { |
||||
logger.Error("Unexpected 1xx response", "status", resp.StatusCode) |
||||
return "", fmt.Errorf("unexpected 1xx status code: %d", resp.StatusCode) |
||||
} else if resp.StatusCode >= 300 && resp.StatusCode < 400 { |
||||
logger.Error("Unexpected 3xx response", "status", resp.StatusCode) |
||||
return "", fmt.Errorf("unexpected 3xx status code: %d", resp.StatusCode) |
||||
} else if resp.StatusCode >= http.StatusInternalServerError { |
||||
logger.Error("Unexpected 5xx response", "status", resp.StatusCode) |
||||
return "", fmt.Errorf("unexpected 5xx status code: %d", resp.StatusCode) |
||||
} |
||||
|
||||
content := resp.Header.Get("Content-Type") |
||||
// If the response is text/html it could be the response to an incoming webhook
|
||||
if strings.HasPrefix(content, "text/html") { |
||||
return handleSlackIncomingWebhookResponse(resp, logger) |
||||
} else { |
||||
return handleSlackJSONResponse(resp, logger) |
||||
} |
||||
} |
||||
|
||||
func handleSlackIncomingWebhookResponse(resp *http.Response, logger channels.Logger) (string, error) { |
||||
b, err := io.ReadAll(resp.Body) |
||||
if err != nil { |
||||
return "", fmt.Errorf("failed to read response: %w", err) |
||||
} |
||||
|
||||
// Incoming webhooks return the string "ok" on success
|
||||
if bytes.Equal(b, []byte("ok")) { |
||||
logger.Debug("The incoming webhook was successful") |
||||
return "", nil |
||||
} |
||||
|
||||
logger.Debug("Incoming webhook was unsuccessful", "status", resp.StatusCode, "body", string(b)) |
||||
|
||||
// There are a number of known errors that we can check. The documentation incoming webhooks
|
||||
// errors can be found at https://api.slack.com/messaging/webhooks#handling_errors and
|
||||
// https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks
|
||||
if bytes.Equal(b, []byte("user_not_found")) { |
||||
return "", errors.New("the user does not exist or is invalid") |
||||
} |
||||
|
||||
if bytes.Equal(b, []byte("channel_not_found")) { |
||||
return "", errors.New("the channel does not exist or is invalid") |
||||
} |
||||
|
||||
if bytes.Equal(b, []byte("channel_is_archived")) { |
||||
return "", errors.New("cannot send an incoming webhook for an archived channel") |
||||
} |
||||
|
||||
if bytes.Equal(b, []byte("posting_to_general_channel_denied")) { |
||||
return "", errors.New("cannot send an incoming webhook to the #general channel") |
||||
} |
||||
|
||||
if bytes.Equal(b, []byte("no_service")) { |
||||
return "", errors.New("the incoming webhook is either disabled, removed, or invalid") |
||||
} |
||||
|
||||
if bytes.Equal(b, []byte("no_text")) { |
||||
return "", errors.New("cannot send an incoming webhook without a message") |
||||
} |
||||
|
||||
return "", fmt.Errorf("failed incoming webhook: %s", string(b)) |
||||
} |
||||
|
||||
func handleSlackJSONResponse(resp *http.Response, logger channels.Logger) (string, error) { |
||||
b, err := io.ReadAll(resp.Body) |
||||
if err != nil { |
||||
return "", fmt.Errorf("failed to read response: %w", err) |
||||
} |
||||
|
||||
if len(b) == 0 { |
||||
logger.Error("Expected JSON but got empty response") |
||||
return "", errors.New("unexpected empty response") |
||||
} |
||||
|
||||
// Slack responds to some requests with a JSON document, that might contain an error.
|
||||
result := struct { |
||||
OK bool `json:"ok"` |
||||
Ts string `json:"ts"` |
||||
Err string `json:"error"` |
||||
}{} |
||||
|
||||
if err := json.Unmarshal(b, &result); err != nil { |
||||
logger.Error("Failed to unmarshal response", "body", string(b), "err", err) |
||||
return "", fmt.Errorf("failed to unmarshal response: %w", err) |
||||
} |
||||
|
||||
if !result.OK { |
||||
logger.Error("The request was unsuccessful", "body", string(b), "err", result.Err) |
||||
return "", fmt.Errorf("failed to send request: %s", result.Err) |
||||
} |
||||
|
||||
logger.Debug("The request was successful") |
||||
return result.Ts, nil |
||||
} |
||||
|
||||
func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types.Alert) (*slackMessage, error) { |
||||
var tmplErr error |
||||
tmpl, _ := channels.TmplText(ctx, sn.tmpl, alerts, sn.log, &tmplErr) |
||||
|
||||
ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) |
||||
|
||||
title, truncated := channels.TruncateInRunes(tmpl(sn.settings.Title), slackMaxTitleLenRunes) |
||||
if truncated { |
||||
key, err := notify.ExtractGroupKey(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
sn.log.Warn("Truncated title", "key", key, "max_runes", slackMaxTitleLenRunes) |
||||
} |
||||
|
||||
req := &slackMessage{ |
||||
Channel: tmpl(sn.settings.Recipient), |
||||
Username: tmpl(sn.settings.Username), |
||||
IconEmoji: tmpl(sn.settings.IconEmoji), |
||||
IconURL: tmpl(sn.settings.IconURL), |
||||
// TODO: We should use the Block Kit API instead:
|
||||
// https://api.slack.com/messaging/composing/layouts#when-to-use-attachments
|
||||
Attachments: []attachment{ |
||||
{ |
||||
Color: getAlertStatusColor(types.Alerts(alerts...).Status()), |
||||
Title: title, |
||||
Fallback: title, |
||||
Footer: "Grafana v" + sn.appVersion, |
||||
FooterIcon: channels.FooterIconURL, |
||||
Ts: time.Now().Unix(), |
||||
TitleLink: ruleURL, |
||||
Text: tmpl(sn.settings.Text), |
||||
Fields: nil, // TODO. Should be a config.
|
||||
}, |
||||
}, |
||||
} |
||||
|
||||
if isIncomingWebhook(sn.settings) { |
||||
// Incoming webhooks cannot upload files, instead share images via their URL
|
||||
_ = withStoredImages(ctx, sn.log, sn.images, func(index int, image channels.Image) error { |
||||
if image.URL != "" { |
||||
req.Attachments[0].ImageURL = image.URL |
||||
return channels.ErrImagesDone |
||||
} |
||||
return nil |
||||
}, alerts...) |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
sn.log.Warn("failed to template Slack message", "error", tmplErr.Error()) |
||||
} |
||||
|
||||
mentionsBuilder := strings.Builder{} |
||||
appendSpace := func() { |
||||
if mentionsBuilder.Len() > 0 { |
||||
mentionsBuilder.WriteString(" ") |
||||
} |
||||
} |
||||
|
||||
mentionChannel := strings.TrimSpace(sn.settings.MentionChannel) |
||||
if mentionChannel != "" { |
||||
mentionsBuilder.WriteString(fmt.Sprintf("<!%s|%s>", mentionChannel, mentionChannel)) |
||||
} |
||||
|
||||
if len(sn.settings.MentionGroups) > 0 { |
||||
appendSpace() |
||||
for _, g := range sn.settings.MentionGroups { |
||||
mentionsBuilder.WriteString(fmt.Sprintf("<!subteam^%s>", tmpl(g))) |
||||
} |
||||
} |
||||
|
||||
if len(sn.settings.MentionUsers) > 0 { |
||||
appendSpace() |
||||
for _, u := range sn.settings.MentionUsers { |
||||
mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", tmpl(u))) |
||||
} |
||||
} |
||||
|
||||
if mentionsBuilder.Len() > 0 { |
||||
// Use markdown-formatted pretext for any mentions.
|
||||
req.Attachments[0].MrkdwnIn = []string{"pretext"} |
||||
req.Attachments[0].Pretext = mentionsBuilder.String() |
||||
} |
||||
|
||||
return req, nil |
||||
} |
||||
|
||||
func (sn *SlackNotifier) sendSlackMessage(ctx context.Context, m *slackMessage) (string, error) { |
||||
b, err := json.Marshal(m) |
||||
if err != nil { |
||||
return "", fmt.Errorf("failed to marshal Slack message: %w", err) |
||||
} |
||||
|
||||
sn.log.Debug("sending Slack API request", "url", sn.settings.URL, "data", string(b)) |
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, sn.settings.URL, bytes.NewReader(b)) |
||||
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.settings.Token == "" { |
||||
if sn.settings.URL == SlackAPIEndpoint { |
||||
panic("Token should be set when using the Slack chat API") |
||||
} |
||||
sn.log.Debug("Looks like we are using an incoming webhook, no Authorization header required") |
||||
} else { |
||||
sn.log.Debug("Looks like we are using the Slack API, have set the Bearer token for this request") |
||||
request.Header.Set("Authorization", "Bearer "+sn.settings.Token) |
||||
} |
||||
|
||||
thread_ts, err := sn.sendFn(ctx, request, sn.log) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return thread_ts, nil |
||||
} |
||||
|
||||
// createImageMultipart returns the mutlipart/form-data request and headers for files.upload.
|
||||
// It returns an error if the image does not exist or there was an error preparing the
|
||||
// multipart form.
|
||||
func (sn *SlackNotifier) createImageMultipart(image channels.Image, channel, comment, thread_ts string) (http.Header, []byte, error) { |
||||
buf := bytes.Buffer{} |
||||
w := multipart.NewWriter(&buf) |
||||
defer func() { |
||||
if err := w.Close(); err != nil { |
||||
sn.log.Error("Failed to close multipart writer", "err", err) |
||||
} |
||||
}() |
||||
|
||||
f, err := os.Open(image.Path) |
||||
if err != nil { |
||||
return nil, nil, err |
||||
} |
||||
defer func() { |
||||
if err := f.Close(); err != nil { |
||||
sn.log.Error("Failed to close image file reader", "error", err) |
||||
} |
||||
}() |
||||
|
||||
fw, err := w.CreateFormFile("file", image.Path) |
||||
if err != nil { |
||||
return nil, nil, fmt.Errorf("failed to create form file: %w", err) |
||||
} |
||||
|
||||
if _, err := io.Copy(fw, f); err != nil { |
||||
return nil, nil, fmt.Errorf("failed to copy file to form: %w", err) |
||||
} |
||||
|
||||
if err := w.WriteField("channels", channel); err != nil { |
||||
return nil, nil, fmt.Errorf("failed to write channels to form: %w", err) |
||||
} |
||||
|
||||
if err := w.WriteField("initial_comment", comment); err != nil { |
||||
return nil, nil, fmt.Errorf("failed to write initial_comment to form: %w", err) |
||||
} |
||||
|
||||
if err := w.WriteField("thread_ts", thread_ts); err != nil { |
||||
return nil, nil, fmt.Errorf("failed to write thread_ts to form: %w", err) |
||||
} |
||||
|
||||
if err := w.Close(); err != nil { |
||||
return nil, nil, fmt.Errorf("failed to close multipart writer: %w", err) |
||||
} |
||||
|
||||
b := buf.Bytes() |
||||
headers := http.Header{} |
||||
headers.Set("Content-Type", w.FormDataContentType()) |
||||
return headers, b, nil |
||||
} |
||||
|
||||
func (sn *SlackNotifier) sendMultipart(ctx context.Context, headers http.Header, data io.Reader) error { |
||||
sn.log.Debug("Sending multipart request to files.upload") |
||||
|
||||
u, err := uploadURL(sn.settings) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to get URL for files.upload: %w", err) |
||||
} |
||||
|
||||
req, err := http.NewRequest(http.MethodPost, u, data) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to create request: %w", err) |
||||
} |
||||
for k, v := range headers { |
||||
req.Header[k] = v |
||||
} |
||||
req.Header.Set("Authorization", "Bearer "+sn.settings.Token) |
||||
|
||||
if _, err := sn.sendFn(ctx, req, sn.log); err != nil { |
||||
return fmt.Errorf("failed to send request: %w", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// uploadImage shares the image to the channel names or IDs. It returns an error if the file
|
||||
// does not exist, or if there was an error either preparing or sending the multipart/form-data
|
||||
// request.
|
||||
func (sn *SlackNotifier) uploadImage(ctx context.Context, image channels.Image, channel, comment, thread_ts string) error { |
||||
sn.log.Debug("Uploadimg image", "image", image.Token) |
||||
headers, data, err := sn.createImageMultipart(image, channel, comment, thread_ts) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to create multipart form: %w", err) |
||||
} |
||||
|
||||
return sn.sendMultipart(ctx, headers, bytes.NewReader(data)) |
||||
} |
||||
|
||||
func (sn *SlackNotifier) SendResolved() bool { |
||||
return !sn.GetDisableResolveMessage() |
||||
} |
||||
|
||||
// initialCommentForImage returns the initial comment for the image.
|
||||
// Here is an example of the initial comment for an alert called
|
||||
// AlertName with two labels:
|
||||
//
|
||||
// Resolved|Firing: AlertName, Labels: A=B, C=D
|
||||
//
|
||||
// where Resolved|Firing and Labels is in bold text.
|
||||
func initialCommentForImage(alert *types.Alert) string { |
||||
sb := strings.Builder{} |
||||
|
||||
if alert.Resolved() { |
||||
sb.WriteString("*Resolved*:") |
||||
} else { |
||||
sb.WriteString("*Firing*:") |
||||
} |
||||
|
||||
sb.WriteString(" ") |
||||
sb.WriteString(alert.Name()) |
||||
sb.WriteString(", ") |
||||
|
||||
sb.WriteString("*Labels*: ") |
||||
|
||||
var n int |
||||
for k, v := range alert.Labels { |
||||
sb.WriteString(string(k)) |
||||
sb.WriteString(" = ") |
||||
sb.WriteString(string(v)) |
||||
if n < len(alert.Labels)-1 { |
||||
sb.WriteString(", ") |
||||
n += 1 |
||||
} |
||||
} |
||||
|
||||
return sb.String() |
||||
} |
@ -1,578 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"math/rand" |
||||
"mime" |
||||
"mime/multipart" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"net/url" |
||||
"os" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
) |
||||
|
||||
var appVersion = fmt.Sprintf("%d.0.0", rand.Uint32()) |
||||
|
||||
func TestSlackIncomingWebhook(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
alerts []*types.Alert |
||||
expectedMessage *slackMessage |
||||
expectedError string |
||||
settings string |
||||
}{{ |
||||
name: "Message is sent", |
||||
settings: `{ |
||||
"icon_emoji": ":emoji:", |
||||
"recipient": "#test", |
||||
"url": "https://example.com/hooks/xxxx" |
||||
}`, |
||||
alerts: []*types.Alert{{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}}, |
||||
expectedMessage: &slackMessage{ |
||||
Channel: "#test", |
||||
Username: "Grafana", |
||||
IconEmoji: ":emoji:", |
||||
Attachments: []attachment{ |
||||
{ |
||||
Title: "[FIRING:1] (val1)", |
||||
TitleLink: "http://localhost/alerting/list", |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", |
||||
Fallback: "[FIRING:1] (val1)", |
||||
Fields: nil, |
||||
Footer: "Grafana v" + appVersion, |
||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png", |
||||
Color: "#D63232", |
||||
}, |
||||
}, |
||||
}, |
||||
}, { |
||||
name: "Message is sent with image URL", |
||||
settings: `{ |
||||
"icon_emoji": ":emoji:", |
||||
"recipient": "#test", |
||||
"url": "https://example.com/hooks/xxxx" |
||||
}`, |
||||
alerts: []*types.Alert{{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-with-url"}, |
||||
}, |
||||
}}, |
||||
expectedMessage: &slackMessage{ |
||||
Channel: "#test", |
||||
Username: "Grafana", |
||||
IconEmoji: ":emoji:", |
||||
Attachments: []attachment{ |
||||
{ |
||||
Title: "[FIRING:1] (val1)", |
||||
TitleLink: "http://localhost/alerting/list", |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
Fallback: "[FIRING:1] (val1)", |
||||
Fields: nil, |
||||
Footer: "Grafana v" + appVersion, |
||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png", |
||||
Color: "#D63232", |
||||
ImageURL: "https://www.example.com/test.png", |
||||
}, |
||||
}, |
||||
}, |
||||
}, { |
||||
name: "Message is sent and image on local disk is ignored", |
||||
settings: `{ |
||||
"icon_emoji": ":emoji:", |
||||
"recipient": "#test", |
||||
"url": "https://example.com/hooks/xxxx" |
||||
}`, |
||||
alerts: []*types.Alert{{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-on-disk"}, |
||||
}, |
||||
}}, |
||||
expectedMessage: &slackMessage{ |
||||
Channel: "#test", |
||||
Username: "Grafana", |
||||
IconEmoji: ":emoji:", |
||||
Attachments: []attachment{ |
||||
{ |
||||
Title: "[FIRING:1] (val1)", |
||||
TitleLink: "http://localhost/alerting/list", |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
Fallback: "[FIRING:1] (val1)", |
||||
Fields: nil, |
||||
Footer: "Grafana v" + appVersion, |
||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png", |
||||
Color: "#D63232", |
||||
}, |
||||
}, |
||||
}, |
||||
}} |
||||
|
||||
for _, test := range tests { |
||||
t.Run(test.name, func(t *testing.T) { |
||||
notifier, recorder, err := setupSlackForTests(t, test.settings) |
||||
require.NoError(t, err) |
||||
|
||||
ctx := context.Background() |
||||
ctx = notify.WithGroupKey(ctx, "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
|
||||
ok, err := notifier.Notify(ctx, test.alerts...) |
||||
if test.expectedError != "" { |
||||
assert.EqualError(t, err, test.expectedError) |
||||
assert.False(t, ok) |
||||
} else { |
||||
assert.NoError(t, err) |
||||
assert.True(t, ok) |
||||
|
||||
// When sending a notification to an Incoming Webhook there should a single request.
|
||||
// This is different from PostMessage where some content, such as images, are sent
|
||||
// as replies to the original message
|
||||
require.Len(t, recorder.requests, 1) |
||||
|
||||
// Get the request and check that it's sending to the URL of the Incoming Webhook
|
||||
r := recorder.requests[0] |
||||
assert.Equal(t, notifier.settings.URL, r.URL.String()) |
||||
|
||||
// Check that the request contains the expected message
|
||||
b, err := io.ReadAll(r.Body) |
||||
require.NoError(t, err) |
||||
|
||||
message := slackMessage{} |
||||
require.NoError(t, json.Unmarshal(b, &message)) |
||||
for i, v := range message.Attachments { |
||||
// Need to update the ts as these cannot be set in the test definition
|
||||
test.expectedMessage.Attachments[i].Ts = v.Ts |
||||
} |
||||
assert.Equal(t, *test.expectedMessage, message) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestSlackPostMessage(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
alerts []*types.Alert |
||||
expectedMessage *slackMessage |
||||
expectedReplies []interface{} // can contain either slackMessage or map[string]struct{} for multipart/form-data
|
||||
expectedError string |
||||
settings string |
||||
}{{ |
||||
name: "Message is sent", |
||||
settings: `{ |
||||
"icon_emoji": ":emoji:", |
||||
"recipient": "#test", |
||||
"token": "1234" |
||||
}`, |
||||
alerts: []*types.Alert{{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}}, |
||||
expectedMessage: &slackMessage{ |
||||
Channel: "#test", |
||||
Username: "Grafana", |
||||
IconEmoji: ":emoji:", |
||||
Attachments: []attachment{ |
||||
{ |
||||
Title: "[FIRING:1] (val1)", |
||||
TitleLink: "http://localhost/alerting/list", |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
Fallback: "[FIRING:1] (val1)", |
||||
Fields: nil, |
||||
Footer: "Grafana v" + appVersion, |
||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png", |
||||
Color: "#D63232", |
||||
}, |
||||
}, |
||||
}, |
||||
}, { |
||||
name: "Message is sent with two firing alerts", |
||||
settings: `{ |
||||
"title": "{{ .Alerts.Firing | len }} firing, {{ .Alerts.Resolved | len }} resolved", |
||||
"icon_emoji": ":emoji:", |
||||
"recipient": "#test", |
||||
"token": "1234" |
||||
}`, |
||||
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"}, |
||||
}, |
||||
}}, |
||||
expectedMessage: &slackMessage{ |
||||
Channel: "#test", |
||||
Username: "Grafana", |
||||
IconEmoji: ":emoji:", |
||||
Attachments: []attachment{ |
||||
{ |
||||
Title: "2 firing, 0 resolved", |
||||
TitleLink: "http://localhost/alerting/list", |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", |
||||
Fallback: "2 firing, 0 resolved", |
||||
Fields: nil, |
||||
Footer: "Grafana v" + appVersion, |
||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png", |
||||
Color: "#D63232", |
||||
}, |
||||
}, |
||||
}, |
||||
}, { |
||||
name: "Message is sent and image is uploaded", |
||||
settings: `{ |
||||
"icon_emoji": ":emoji:", |
||||
"recipient": "#test", |
||||
"token": "1234" |
||||
}`, |
||||
alerts: []*types.Alert{{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-on-disk"}, |
||||
}, |
||||
}}, |
||||
expectedMessage: &slackMessage{ |
||||
Channel: "#test", |
||||
Username: "Grafana", |
||||
IconEmoji: ":emoji:", |
||||
Attachments: []attachment{ |
||||
{ |
||||
Title: "[FIRING:1] (val1)", |
||||
TitleLink: "http://localhost/alerting/list", |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
Fallback: "[FIRING:1] (val1)", |
||||
Fields: nil, |
||||
Footer: "Grafana v" + appVersion, |
||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png", |
||||
Color: "#D63232", |
||||
}, |
||||
}, |
||||
}, |
||||
expectedReplies: []interface{}{ |
||||
// check that the following parts are present in the multipart/form-data
|
||||
map[string]struct{}{ |
||||
"file": {}, |
||||
"channels": {}, |
||||
"initial_comment": {}, |
||||
"thread_ts": {}, |
||||
}, |
||||
}, |
||||
}, { |
||||
name: "Message is sent to custom URL", |
||||
settings: `{ |
||||
"icon_emoji": ":emoji:", |
||||
"recipient": "#test", |
||||
"endpointUrl": "https://example.com/api", |
||||
"token": "1234" |
||||
}`, |
||||
alerts: []*types.Alert{{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}}, |
||||
expectedMessage: &slackMessage{ |
||||
Channel: "#test", |
||||
Username: "Grafana", |
||||
IconEmoji: ":emoji:", |
||||
Attachments: []attachment{ |
||||
{ |
||||
Title: "[FIRING:1] (val1)", |
||||
TitleLink: "http://localhost/alerting/list", |
||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", |
||||
Fallback: "[FIRING:1] (val1)", |
||||
Fields: nil, |
||||
Footer: "Grafana v" + appVersion, |
||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png", |
||||
Color: "#D63232", |
||||
}, |
||||
}, |
||||
}, |
||||
}} |
||||
|
||||
for _, test := range tests { |
||||
t.Run(test.name, func(t *testing.T) { |
||||
notifier, recorder, err := setupSlackForTests(t, test.settings) |
||||
require.NoError(t, err) |
||||
|
||||
ctx := context.Background() |
||||
ctx = notify.WithGroupKey(ctx, "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
|
||||
ok, err := notifier.Notify(ctx, test.alerts...) |
||||
if test.expectedError != "" { |
||||
assert.EqualError(t, err, test.expectedError) |
||||
assert.False(t, ok) |
||||
} else { |
||||
assert.NoError(t, err) |
||||
assert.True(t, ok) |
||||
|
||||
// When sending a notification via PostMessage some content, such as images,
|
||||
// are sent as replies to the original message
|
||||
require.Len(t, recorder.requests, len(test.expectedReplies)+1) |
||||
|
||||
// Get the request and check that it's sending to the URL
|
||||
r := recorder.requests[0] |
||||
assert.Equal(t, notifier.settings.URL, r.URL.String()) |
||||
|
||||
// Check that the request contains the expected message
|
||||
b, err := io.ReadAll(r.Body) |
||||
require.NoError(t, err) |
||||
|
||||
message := slackMessage{} |
||||
require.NoError(t, json.Unmarshal(b, &message)) |
||||
for i, v := range message.Attachments { |
||||
// Need to update the ts as these cannot be set in the test definition
|
||||
test.expectedMessage.Attachments[i].Ts = v.Ts |
||||
} |
||||
assert.Equal(t, *test.expectedMessage, message) |
||||
|
||||
// Check that the replies match expectations
|
||||
for i := 1; i < len(recorder.requests); i++ { |
||||
r = recorder.requests[i] |
||||
assert.Equal(t, "https://slack.com/api/files.upload", r.URL.String()) |
||||
|
||||
media, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) |
||||
require.NoError(t, err) |
||||
if media == "multipart/form-data" { |
||||
// Some replies are file uploads, so check the multipart form
|
||||
checkMultipart(t, test.expectedReplies[i-1].(map[string]struct{}), r.Body, params["boundary"]) |
||||
} else { |
||||
b, err = io.ReadAll(r.Body) |
||||
require.NoError(t, err) |
||||
message = slackMessage{} |
||||
require.NoError(t, json.Unmarshal(b, &message)) |
||||
assert.Equal(t, test.expectedReplies[i-1], message) |
||||
} |
||||
} |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// slackRequestRecorder is used in tests to record all requests.
|
||||
type slackRequestRecorder struct { |
||||
requests []*http.Request |
||||
} |
||||
|
||||
func (s *slackRequestRecorder) fn(_ context.Context, r *http.Request, _ channels.Logger) (string, error) { |
||||
s.requests = append(s.requests, r) |
||||
return "", nil |
||||
} |
||||
|
||||
// checkMulipart checks that each part is present, but not its contents
|
||||
func checkMultipart(t *testing.T, expected map[string]struct{}, r io.Reader, boundary string) { |
||||
m := multipart.NewReader(r, boundary) |
||||
visited := make(map[string]struct{}) |
||||
for { |
||||
part, err := m.NextPart() |
||||
if errors.Is(err, io.EOF) { |
||||
break |
||||
} |
||||
require.NoError(t, err) |
||||
visited[part.FormName()] = struct{}{} |
||||
} |
||||
assert.Equal(t, expected, visited) |
||||
} |
||||
|
||||
func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRequestRecorder, error) { |
||||
tmpl := templateForTests(t) |
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
f, err := os.Create(t.TempDir() + "test.png") |
||||
require.NoError(t, err) |
||||
t.Cleanup(func() { |
||||
_ = f.Close() |
||||
if err := os.Remove(f.Name()); err != nil { |
||||
t.Logf("failed to delete test file: %s", err) |
||||
} |
||||
}) |
||||
|
||||
images := &fakeImageStore{ |
||||
Images: []*channels.Image{{ |
||||
Token: "image-on-disk", |
||||
Path: f.Name(), |
||||
}, { |
||||
Token: "image-with-url", |
||||
URL: "https://www.example.com/test.png", |
||||
}}, |
||||
} |
||||
notificationService := mockNotificationService() |
||||
|
||||
c := channels.FactoryConfig{ |
||||
Config: &channels.NotificationChannelConfig{ |
||||
Name: "slack_testing", |
||||
Type: "slack", |
||||
Settings: json.RawMessage(settings), |
||||
SecureSettings: make(map[string][]byte), |
||||
}, |
||||
ImageStore: images, |
||||
NotificationService: notificationService, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
Template: tmpl, |
||||
Logger: &channels.FakeLogger{}, |
||||
GrafanaBuildVersion: appVersion, |
||||
} |
||||
|
||||
sn, err := buildSlackNotifier(c) |
||||
if err != nil { |
||||
return nil, nil, err |
||||
} |
||||
|
||||
sr := &slackRequestRecorder{} |
||||
sn.sendFn = sr.fn |
||||
return sn, sr, nil |
||||
} |
||||
|
||||
func TestCreateSlackNotifierFromConfig(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
settings string |
||||
expectedError string |
||||
}{{ |
||||
name: "Missing token", |
||||
settings: `{ |
||||
"recipient": "#testchannel" |
||||
}`, |
||||
expectedError: "token must be specified when using the Slack chat API", |
||||
}, { |
||||
name: "Missing recipient", |
||||
settings: `{ |
||||
"token": "1234" |
||||
}`, |
||||
expectedError: "recipient must be specified when using the Slack chat API", |
||||
}} |
||||
|
||||
for _, test := range tests { |
||||
t.Run(test.name, func(t *testing.T) { |
||||
n, _, err := setupSlackForTests(t, test.settings) |
||||
if test.expectedError != "" { |
||||
assert.Nil(t, n) |
||||
assert.EqualError(t, err, test.expectedError) |
||||
} else { |
||||
assert.NotNil(t, n) |
||||
assert.Nil(t, err) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestSendSlackRequest(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
response string |
||||
statusCode int |
||||
expectError bool |
||||
}{ |
||||
{ |
||||
name: "Example error", |
||||
response: `{ |
||||
"ok": false, |
||||
"error": "too_many_attachments" |
||||
}`, |
||||
statusCode: http.StatusBadRequest, |
||||
expectError: true, |
||||
}, |
||||
{ |
||||
name: "Non 200 status code, no response body", |
||||
statusCode: http.StatusMovedPermanently, |
||||
expectError: true, |
||||
}, |
||||
{ |
||||
name: "Success case, normal response body", |
||||
response: `{ |
||||
"ok": true, |
||||
"channel": "C1H9RESGL", |
||||
"ts": "1503435956.000247", |
||||
"message": { |
||||
"text": "Here's a message for you", |
||||
"username": "ecto1", |
||||
"bot_id": "B19LU7CSY", |
||||
"attachments": [ |
||||
{ |
||||
"text": "This is an attachment", |
||||
"id": 1, |
||||
"fallback": "This is an attachment's fallback" |
||||
} |
||||
], |
||||
"type": "message", |
||||
"subtype": "bot_message", |
||||
"ts": "1503435956.000247" |
||||
} |
||||
}`, |
||||
statusCode: http.StatusOK, |
||||
expectError: false, |
||||
}, |
||||
{ |
||||
name: "No response body", |
||||
statusCode: http.StatusOK, |
||||
expectError: true, |
||||
}, |
||||
{ |
||||
name: "Success case, unexpected response body", |
||||
statusCode: http.StatusOK, |
||||
response: `{"test": true}`, |
||||
expectError: true, |
||||
}, |
||||
{ |
||||
name: "Success case, ok: true", |
||||
statusCode: http.StatusOK, |
||||
response: `{"ok": true}`, |
||||
expectError: false, |
||||
}, |
||||
{ |
||||
name: "200 status code, error in body", |
||||
statusCode: http.StatusOK, |
||||
response: `{"ok": false, "error": "test error"}`, |
||||
expectError: true, |
||||
}, |
||||
} |
||||
|
||||
for _, test := range tests { |
||||
t.Run(test.name, func(tt *testing.T) { |
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
w.WriteHeader(test.statusCode) |
||||
_, err := w.Write([]byte(test.response)) |
||||
require.NoError(tt, err) |
||||
})) |
||||
defer server.Close() |
||||
req, err := http.NewRequest(http.MethodGet, server.URL, nil) |
||||
require.NoError(tt, err) |
||||
|
||||
_, err = sendSlackRequest(context.Background(), req, &channels.FakeLogger{}) |
||||
if !test.expectError { |
||||
require.NoError(tt, err) |
||||
} else { |
||||
require.Error(tt, err) |
||||
} |
||||
}) |
||||
} |
||||
} |
@ -1,75 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
) |
||||
|
||||
type fakeImageStore struct { |
||||
Images []*channels.Image |
||||
} |
||||
|
||||
// getImage returns an image with the same token.
|
||||
func (f *fakeImageStore) GetImage(_ context.Context, token string) (*channels.Image, error) { |
||||
for _, img := range f.Images { |
||||
if img.Token == token { |
||||
return img, nil |
||||
} |
||||
} |
||||
return nil, channels.ErrImageNotFound |
||||
} |
||||
|
||||
// newFakeImageStore returns an image store with N test images.
|
||||
// Each image has a token and a URL, but does not have a file on disk.
|
||||
func newFakeImageStore(n int) channels.ImageStore { |
||||
s := fakeImageStore{} |
||||
for i := 1; i <= n; i++ { |
||||
s.Images = append(s.Images, &channels.Image{ |
||||
Token: fmt.Sprintf("test-image-%d", i), |
||||
URL: fmt.Sprintf("https://www.example.com/test-image-%d.jpg", i), |
||||
CreatedAt: time.Now().UTC(), |
||||
}) |
||||
} |
||||
return &s |
||||
} |
||||
|
||||
// mockTimeNow replaces function timeNow to return constant time.
|
||||
// It returns a function that resets the variable back to its original value.
|
||||
// This allows usage of this function with defer:
|
||||
//
|
||||
// func Test (t *testing.T) {
|
||||
// now := time.Now()
|
||||
// defer mockTimeNow(now)()
|
||||
// ...
|
||||
// }
|
||||
func mockTimeNow(constTime time.Time) func() { |
||||
timeNow = func() time.Time { |
||||
return constTime |
||||
} |
||||
return resetTimeNow |
||||
} |
||||
|
||||
// resetTimeNow resets the global variable timeNow to the default value, which is time.Now
|
||||
func resetTimeNow() { |
||||
timeNow = time.Now |
||||
} |
||||
|
||||
type notificationServiceMock struct { |
||||
Webhook channels.SendWebhookSettings |
||||
EmailSync channels.SendEmailSettings |
||||
ShouldError error |
||||
} |
||||
|
||||
func (ns *notificationServiceMock) SendWebhook(ctx context.Context, cmd *channels.SendWebhookSettings) error { |
||||
ns.Webhook = *cmd |
||||
return ns.ShouldError |
||||
} |
||||
func (ns *notificationServiceMock) SendEmail(ctx context.Context, cmd *channels.SendEmailSettings) error { |
||||
ns.EmailSync = *cmd |
||||
return ns.ShouldError |
||||
} |
||||
|
||||
func mockNotificationService() *notificationServiceMock { return ¬ificationServiceMock{} } |
@ -1,213 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"crypto/tls" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"net" |
||||
"net/http" |
||||
"net/url" |
||||
"os" |
||||
"path" |
||||
"path/filepath" |
||||
"time" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models" |
||||
) |
||||
|
||||
var ( |
||||
// Provides current time. Can be overwritten in tests.
|
||||
timeNow = time.Now |
||||
) |
||||
|
||||
type forEachImageFunc func(index int, image channels.Image) error |
||||
|
||||
// getImage returns the image for the alert or an error. It returns a nil
|
||||
// image if the alert does not have an image token or the image does not exist.
|
||||
func getImage(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, alert types.Alert) (*channels.Image, error) { |
||||
token := getTokenFromAnnotations(alert.Annotations) |
||||
if token == "" { |
||||
return nil, nil |
||||
} |
||||
|
||||
ctx, cancelFunc := context.WithTimeout(ctx, channels.ImageStoreTimeout) |
||||
defer cancelFunc() |
||||
|
||||
img, err := imageStore.GetImage(ctx, token) |
||||
if errors.Is(err, channels.ErrImageNotFound) || errors.Is(err, channels.ErrImagesUnavailable) { |
||||
return nil, nil |
||||
} else if err != nil { |
||||
l.Warn("failed to get image with token", "token", token, "error", err) |
||||
return nil, err |
||||
} else { |
||||
return img, nil |
||||
} |
||||
} |
||||
|
||||
// withStoredImages retrieves the image for each alert and then calls forEachFunc
|
||||
// with the index of the alert and the retrieved image struct. If the alert does
|
||||
// not have an image token, or the image does not exist then forEachFunc will not be
|
||||
// called for that alert. If forEachFunc returns an error, withStoredImages will return
|
||||
// the error and not iterate the remaining alerts. A forEachFunc can return ErrImagesDone
|
||||
// to stop the iteration of remaining alerts if the intended image or maximum number of
|
||||
// images have been found.
|
||||
func withStoredImages(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, forEachFunc forEachImageFunc, alerts ...*types.Alert) error { |
||||
for index, alert := range alerts { |
||||
logger := l.New("alert", alert.String()) |
||||
img, err := getImage(ctx, logger, imageStore, *alert) |
||||
if err != nil { |
||||
return err |
||||
} else if img != nil { |
||||
if err := forEachFunc(index, *img); err != nil { |
||||
if errors.Is(err, channels.ErrImagesDone) { |
||||
return nil |
||||
} |
||||
logger.Error("Failed to attach image to notification", "error", err) |
||||
return err |
||||
} |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// The path argument here comes from reading internal image storage, not user
|
||||
// input, so we ignore the security check here.
|
||||
//
|
||||
//nolint:gosec
|
||||
func openImage(path string) (io.ReadCloser, error) { |
||||
fp := filepath.Clean(path) |
||||
_, err := os.Stat(fp) |
||||
if os.IsNotExist(err) || os.IsPermission(err) { |
||||
return nil, channels.ErrImageNotFound |
||||
} |
||||
|
||||
f, err := os.Open(fp) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return f, nil |
||||
} |
||||
|
||||
func getTokenFromAnnotations(annotations model.LabelSet) string { |
||||
if value, ok := annotations[models.ImageTokenAnnotation]; ok { |
||||
return string(value) |
||||
} |
||||
return "" |
||||
} |
||||
|
||||
type receiverInitError struct { |
||||
Reason string |
||||
Err error |
||||
Cfg channels.NotificationChannelConfig |
||||
} |
||||
|
||||
func (e receiverInitError) Error() string { |
||||
name := "" |
||||
if e.Cfg.Name != "" { |
||||
name = fmt.Sprintf("%q ", e.Cfg.Name) |
||||
} |
||||
|
||||
s := fmt.Sprintf("failed to validate receiver %sof type %q: %s", name, e.Cfg.Type, e.Reason) |
||||
if e.Err != nil { |
||||
return fmt.Sprintf("%s: %s", s, e.Err.Error()) |
||||
} |
||||
|
||||
return s |
||||
} |
||||
|
||||
func (e receiverInitError) Unwrap() error { return e.Err } |
||||
|
||||
func getAlertStatusColor(status model.AlertStatus) string { |
||||
if status == model.AlertFiring { |
||||
return channels.ColorAlertFiring |
||||
} |
||||
return channels.ColorAlertResolved |
||||
} |
||||
|
||||
type httpCfg struct { |
||||
body []byte |
||||
user string |
||||
password string |
||||
} |
||||
|
||||
// sendHTTPRequest sends an HTTP request.
|
||||
// Stubbable by tests.
|
||||
var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger channels.Logger) ([]byte, error) { |
||||
var reader io.Reader |
||||
if len(cfg.body) > 0 { |
||||
reader = bytes.NewReader(cfg.body) |
||||
} |
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), reader) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err) |
||||
} |
||||
if cfg.user != "" && cfg.password != "" { |
||||
request.SetBasicAuth(cfg.user, cfg.password) |
||||
} |
||||
|
||||
request.Header.Set("Content-Type", "application/json") |
||||
request.Header.Set("User-Agent", "Grafana") |
||||
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 nil, err |
||||
} |
||||
defer func() { |
||||
if err := resp.Body.Close(); err != nil { |
||||
logger.Warn("failed to close response body", "error", err) |
||||
} |
||||
}() |
||||
|
||||
respBody, err := io.ReadAll(resp.Body) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to read response body: %w", err) |
||||
} |
||||
|
||||
if resp.StatusCode/100 != 2 { |
||||
logger.Warn("HTTP request failed", "url", request.URL.String(), "statusCode", resp.Status, "body", |
||||
string(respBody)) |
||||
return nil, fmt.Errorf("failed to send HTTP request - status code %d", resp.StatusCode) |
||||
} |
||||
|
||||
logger.Debug("sending HTTP request succeeded", "url", request.URL.String(), "statusCode", resp.Status) |
||||
return respBody, nil |
||||
} |
||||
|
||||
func joinUrlPath(base, additionalPath string, logger channels.Logger) string { |
||||
u, err := url.Parse(base) |
||||
if err != nil { |
||||
logger.Debug("failed to parse URL while joining URL", "url", base, "error", err.Error()) |
||||
return base |
||||
} |
||||
|
||||
u.Path = path.Join(u.Path, additionalPath) |
||||
|
||||
return u.String() |
||||
} |
||||
|
||||
// GetBoundary is used for overriding the behaviour for tests
|
||||
// and set a boundary for multipart body. DO NOT set this outside tests.
|
||||
var GetBoundary = func() string { |
||||
return "" |
||||
} |
@ -1,64 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models" |
||||
) |
||||
|
||||
func TestWithStoredImages(t *testing.T) { |
||||
ctx := context.Background() |
||||
alerts := []*types.Alert{{ |
||||
Alert: model.Alert{ |
||||
Annotations: model.LabelSet{ |
||||
models.ImageTokenAnnotation: "test-image-1", |
||||
}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Annotations: model.LabelSet{ |
||||
models.ImageTokenAnnotation: "test-image-2", |
||||
}, |
||||
}, |
||||
}} |
||||
imageStore := &fakeImageStore{Images: []*channels.Image{{ |
||||
Token: "test-image-1", |
||||
URL: "https://www.example.com/test-image-1.jpg", |
||||
CreatedAt: time.Now().UTC(), |
||||
}, { |
||||
Token: "test-image-2", |
||||
URL: "https://www.example.com/test-image-2.jpg", |
||||
CreatedAt: time.Now().UTC(), |
||||
}}} |
||||
|
||||
var ( |
||||
err error |
||||
i int |
||||
) |
||||
|
||||
// should iterate all images
|
||||
err = withStoredImages(ctx, &channels.FakeLogger{}, imageStore, func(index int, image channels.Image) error { |
||||
i += 1 |
||||
return nil |
||||
}, alerts...) |
||||
require.NoError(t, err) |
||||
assert.Equal(t, 2, i) |
||||
|
||||
// should iterate just the first image
|
||||
i = 0 |
||||
err = withStoredImages(ctx, &channels.FakeLogger{}, imageStore, func(index int, image channels.Image) error { |
||||
i += 1 |
||||
return channels.ErrImagesDone |
||||
}, alerts...) |
||||
require.NoError(t, err) |
||||
assert.Equal(t, 1, i) |
||||
} |
Loading…
Reference in new issue