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