Alerting: Use all notifiers from alerting repository (#60655)

pull/60697/head
Yuri Tseretyan 3 years ago committed by GitHub
parent 542cccaecc
commit f990be58cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      go.mod
  2. 2
      go.sum
  3. 3
      pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go
  4. 4
      pkg/services/ngalert/notifier/alertmanager.go
  5. 138
      pkg/services/ngalert/notifier/channels/alertmanager.go
  6. 222
      pkg/services/ngalert/notifier/channels/alertmanager_test.go
  7. 30
      pkg/services/ngalert/notifier/channels/default_template.go
  8. 160
      pkg/services/ngalert/notifier/channels/dingding.go
  9. 207
      pkg/services/ngalert/notifier/channels/dingding_test.go
  10. 341
      pkg/services/ngalert/notifier/channels/discord.go
  11. 363
      pkg/services/ngalert/notifier/channels/discord_test.go
  12. 175
      pkg/services/ngalert/notifier/channels/email.go
  13. 225
      pkg/services/ngalert/notifier/channels/email_test.go
  14. 285
      pkg/services/ngalert/notifier/channels/googlechat.go
  15. 511
      pkg/services/ngalert/notifier/channels/googlechat_test.go
  16. 208
      pkg/services/ngalert/notifier/channels/kafka.go
  17. 156
      pkg/services/ngalert/notifier/channels/kafka_test.go
  18. 129
      pkg/services/ngalert/notifier/channels/line.go
  19. 140
      pkg/services/ngalert/notifier/channels/line_test.go
  20. 597
      pkg/services/ngalert/notifier/channels/slack.go
  21. 578
      pkg/services/ngalert/notifier/channels/slack_test.go
  22. 75
      pkg/services/ngalert/notifier/channels/testing.go
  23. 213
      pkg/services/ngalert/notifier/channels/util.go
  24. 64
      pkg/services/ngalert/notifier/channels/util_test.go
  25. 18
      pkg/services/ngalert/notifier/channels_config/factory.go
  26. 3
      pkg/services/ngalert/notifier/email_test.go
  27. 4
      pkg/services/sqlstore/migrations/ualert/ualert.go
  28. 13
      pkg/tests/api/alerting/api_notification_channel_test.go

@ -59,7 +59,7 @@ require (
github.com/google/uuid v1.3.0
github.com/google/wire v0.5.0
github.com/gorilla/websocket v1.5.0
github.com/grafana/alerting v0.0.0-20221219210434-60ecaff51745
github.com/grafana/alerting v0.0.0-20221221211348-c5ab25d1cb8a
github.com/grafana/cuetsy v0.1.1
github.com/grafana/grafana-aws-sdk v0.11.0
github.com/grafana/grafana-azure-sdk-go v1.5.1

@ -1361,6 +1361,8 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20221219210434-60ecaff51745 h1:6HIwDYa01WcVBdz7WXnidVXfGLRAzYFNKPPFFwg9OXE=
github.com/grafana/alerting v0.0.0-20221219210434-60ecaff51745/go.mod h1:A+ko8Ui4Ojw9oTi1WMCPH937mFUozN8Y41cqrOfNuy8=
github.com/grafana/alerting v0.0.0-20221221211348-c5ab25d1cb8a h1:Ros/f8x4rPGiOyDEv0k0bDN26IDtaxn1qMs0RMN7daw=
github.com/grafana/alerting v0.0.0-20221221211348-c5ab25d1cb8a/go.mod h1:A+ko8Ui4Ojw9oTi1WMCPH937mFUozN8Y41cqrOfNuy8=
github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw=
github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cuetsy v0.1.1 h1:+1jaDDYCpvKlcOWJgBRbkc5+VZIClCEn5mbI+4PLZqM=

@ -6,7 +6,6 @@ import (
"github.com/grafana/alerting/alerting/notifier/channels"
"github.com/grafana/grafana/pkg/components/simplejson"
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/setting"
)
@ -107,7 +106,7 @@ func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) e
if e.Settings == nil {
return fmt.Errorf("settings should not be empty")
}
factory, exists := ngchannels.Factory(e.Type)
factory, exists := channels_config.Factory(e.Type)
if !exists {
return fmt.Errorf("unknown type '%s'", e.Type)
}

@ -39,7 +39,7 @@ import (
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/setting"
@ -522,7 +522,7 @@ func (am *Alertmanager) buildReceiverIntegration(r *apimodels.PostableGrafanaRec
Err: err,
}
}
receiverFactory, exists := ngchannels.Factory(r.Type)
receiverFactory, exists := channels_config.Factory(r.Type)
if !exists {
return nil, InvalidReceiverError{
Receiver: r,

@ -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 &notificationServiceMock{} }

@ -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)
}

@ -1,4 +1,4 @@
package channels
package channels_config
import (
"strings"
@ -7,18 +7,18 @@ import (
)
var receiverFactories = map[string]func(channels.FactoryConfig) (channels.NotificationChannel, error){
"prometheus-alertmanager": AlertmanagerFactory,
"dingding": DingDingFactory,
"discord": DiscordFactory,
"email": EmailFactory,
"googlechat": GoogleChatFactory,
"kafka": KafkaFactory,
"line": LineFactory,
"prometheus-alertmanager": channels.AlertmanagerFactory,
"dingding": channels.DingDingFactory,
"discord": channels.DiscordFactory,
"email": channels.EmailFactory,
"googlechat": channels.GoogleChatFactory,
"kafka": channels.KafkaFactory,
"line": channels.LineFactory,
"opsgenie": channels.OpsgenieFactory,
"pagerduty": channels.PagerdutyFactory,
"pushover": channels.PushoverFactory,
"sensugo": channels.SensuGoFactory,
"slack": SlackFactory,
"slack": channels.SlackFactory,
"teams": channels.TeamsFactory,
"telegram": channels.TelegramFactory,
"threema": channels.ThreemaFactory,

@ -16,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models"
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/setting"
)
@ -217,7 +216,7 @@ func createSut(t *testing.T, messageTmpl string, subjectTmpl string, emailTmpl *
Template: emailTmpl,
Logger: &channels.FakeLogger{},
}
emailNotifier, err := ngchannels.EmailFactory(fc)
emailNotifier, err := channels.EmailFactory(fc)
require.NoError(t, err)
return emailNotifier
}

@ -17,7 +17,7 @@ import (
"github.com/grafana/alerting/alerting/notifier/channels"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
@ -501,7 +501,7 @@ func (m *migration) validateAlertmanagerConfig(orgID int64, config *PostableUser
}
return fallback
}
receiverFactory, exists := ngchannels.Factory(gr.Type)
receiverFactory, exists := channels_config.Factory(gr.Type)
if !exists {
return fmt.Errorf("notifier %s is not supported", gr.Type)
}

@ -25,7 +25,6 @@ import (
"github.com/grafana/grafana/pkg/models"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
@ -719,22 +718,22 @@ func TestIntegrationNotificationChannels(t *testing.T) {
mockChannel.responses["slack_recvX"] = `{"ok": true}`
// Overriding some URLs to send to the mock channel.
os, opa, ot, opu, ogb, ol, oth := ngchannels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL,
os, opa, ot, opu, ogb, ol, oth := channels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL,
channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary,
ngchannels.LineNotifyURL, channels.ThreemaGwBaseURL
channels.LineNotifyURL, channels.ThreemaGwBaseURL
originalTemplate := channels.DefaultTemplateString
t.Cleanup(func() {
ngchannels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL,
channels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL,
channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary,
ngchannels.LineNotifyURL, channels.ThreemaGwBaseURL = os, opa, ot, opu, ogb, ol, oth
channels.LineNotifyURL, channels.ThreemaGwBaseURL = os, opa, ot, opu, ogb, ol, oth
channels.DefaultTemplateString = originalTemplate
})
channels.DefaultTemplateString = channels.TemplateForTestsString
ngchannels.SlackAPIEndpoint = fmt.Sprintf("http://%s/slack_recvX/slack_testX", mockChannel.server.Addr)
channels.SlackAPIEndpoint = fmt.Sprintf("http://%s/slack_recvX/slack_testX", mockChannel.server.Addr)
channels.PagerdutyEventAPIURL = fmt.Sprintf("http://%s/pagerduty_recvX/pagerduty_testX", mockChannel.server.Addr)
channels.TelegramAPIURL = fmt.Sprintf("http://%s/telegram_recv/bot%%s/%%s", mockChannel.server.Addr)
channels.PushoverEndpoint = fmt.Sprintf("http://%s/pushover_recv/pushover_test", mockChannel.server.Addr)
ngchannels.LineNotifyURL = fmt.Sprintf("http://%s/line_recv/line_test", mockChannel.server.Addr)
channels.LineNotifyURL = fmt.Sprintf("http://%s/line_recv/line_test", mockChannel.server.Addr)
channels.ThreemaGwBaseURL = fmt.Sprintf("http://%s/threema_recv/threema_test", mockChannel.server.Addr)
channels.GetBoundary = func() string { return "abcd" }

Loading…
Cancel
Save