mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Use all notifiers from alerting repository (#60655)
This commit is contained in:
parent
542cccaecc
commit
f990be58cb
2
go.mod
2
go.mod
@ -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
|
||||
|
2
go.sum
2
go.sum
@ -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 ¬ificationServiceMock{} }
|
@ -1,213 +0,0 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
var (
|
||||
// Provides current time. Can be overwritten in tests.
|
||||
timeNow = time.Now
|
||||
)
|
||||
|
||||
type forEachImageFunc func(index int, image channels.Image) error
|
||||
|
||||
// getImage returns the image for the alert or an error. It returns a nil
|
||||
// image if the alert does not have an image token or the image does not exist.
|
||||
func getImage(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, alert types.Alert) (*channels.Image, error) {
|
||||
token := getTokenFromAnnotations(alert.Annotations)
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(ctx, channels.ImageStoreTimeout)
|
||||
defer cancelFunc()
|
||||
|
||||
img, err := imageStore.GetImage(ctx, token)
|
||||
if errors.Is(err, channels.ErrImageNotFound) || errors.Is(err, channels.ErrImagesUnavailable) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
l.Warn("failed to get image with token", "token", token, "error", err)
|
||||
return nil, err
|
||||
} else {
|
||||
return img, nil
|
||||
}
|
||||
}
|
||||
|
||||
// withStoredImages retrieves the image for each alert and then calls forEachFunc
|
||||
// with the index of the alert and the retrieved image struct. If the alert does
|
||||
// not have an image token, or the image does not exist then forEachFunc will not be
|
||||
// called for that alert. If forEachFunc returns an error, withStoredImages will return
|
||||
// the error and not iterate the remaining alerts. A forEachFunc can return ErrImagesDone
|
||||
// to stop the iteration of remaining alerts if the intended image or maximum number of
|
||||
// images have been found.
|
||||
func withStoredImages(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, forEachFunc forEachImageFunc, alerts ...*types.Alert) error {
|
||||
for index, alert := range alerts {
|
||||
logger := l.New("alert", alert.String())
|
||||
img, err := getImage(ctx, logger, imageStore, *alert)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if img != nil {
|
||||
if err := forEachFunc(index, *img); err != nil {
|
||||
if errors.Is(err, channels.ErrImagesDone) {
|
||||
return nil
|
||||
}
|
||||
logger.Error("Failed to attach image to notification", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// The path argument here comes from reading internal image storage, not user
|
||||
// input, so we ignore the security check here.
|
||||
//
|
||||
//nolint:gosec
|
||||
func openImage(path string) (io.ReadCloser, error) {
|
||||
fp := filepath.Clean(path)
|
||||
_, err := os.Stat(fp)
|
||||
if os.IsNotExist(err) || os.IsPermission(err) {
|
||||
return nil, channels.ErrImageNotFound
|
||||
}
|
||||
|
||||
f, err := os.Open(fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func getTokenFromAnnotations(annotations model.LabelSet) string {
|
||||
if value, ok := annotations[models.ImageTokenAnnotation]; ok {
|
||||
return string(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type receiverInitError struct {
|
||||
Reason string
|
||||
Err error
|
||||
Cfg channels.NotificationChannelConfig
|
||||
}
|
||||
|
||||
func (e receiverInitError) Error() string {
|
||||
name := ""
|
||||
if e.Cfg.Name != "" {
|
||||
name = fmt.Sprintf("%q ", e.Cfg.Name)
|
||||
}
|
||||
|
||||
s := fmt.Sprintf("failed to validate receiver %sof type %q: %s", name, e.Cfg.Type, e.Reason)
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %s", s, e.Err.Error())
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (e receiverInitError) Unwrap() error { return e.Err }
|
||||
|
||||
func getAlertStatusColor(status model.AlertStatus) string {
|
||||
if status == model.AlertFiring {
|
||||
return channels.ColorAlertFiring
|
||||
}
|
||||
return channels.ColorAlertResolved
|
||||
}
|
||||
|
||||
type httpCfg struct {
|
||||
body []byte
|
||||
user string
|
||||
password string
|
||||
}
|
||||
|
||||
// sendHTTPRequest sends an HTTP request.
|
||||
// Stubbable by tests.
|
||||
var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger channels.Logger) ([]byte, error) {
|
||||
var reader io.Reader
|
||||
if len(cfg.body) > 0 {
|
||||
reader = bytes.NewReader(cfg.body)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
if cfg.user != "" && cfg.password != "" {
|
||||
request.SetBasicAuth(cfg.user, cfg.password)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Grafana")
|
||||
netTransport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
Renegotiation: tls.RenegotiateFreelyAsClient,
|
||||
},
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
netClient := &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
Transport: netTransport,
|
||||
}
|
||||
resp, err := netClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
logger.Warn("failed to close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode/100 != 2 {
|
||||
logger.Warn("HTTP request failed", "url", request.URL.String(), "statusCode", resp.Status, "body",
|
||||
string(respBody))
|
||||
return nil, fmt.Errorf("failed to send HTTP request - status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
logger.Debug("sending HTTP request succeeded", "url", request.URL.String(), "statusCode", resp.Status)
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func joinUrlPath(base, additionalPath string, logger channels.Logger) string {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
logger.Debug("failed to parse URL while joining URL", "url", base, "error", err.Error())
|
||||
return base
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, additionalPath)
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// GetBoundary is used for overriding the behaviour for tests
|
||||
// and set a boundary for multipart body. DO NOT set this outside tests.
|
||||
var GetBoundary = func() string {
|
||||
return ""
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func TestWithStoredImages(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
alerts := []*types.Alert{{
|
||||
Alert: model.Alert{
|
||||
Annotations: model.LabelSet{
|
||||
models.ImageTokenAnnotation: "test-image-1",
|
||||
},
|
||||
},
|
||||
}, {
|
||||
Alert: model.Alert{
|
||||
Annotations: model.LabelSet{
|
||||
models.ImageTokenAnnotation: "test-image-2",
|
||||
},
|
||||
},
|
||||
}}
|
||||
imageStore := &fakeImageStore{Images: []*channels.Image{{
|
||||
Token: "test-image-1",
|
||||
URL: "https://www.example.com/test-image-1.jpg",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}, {
|
||||
Token: "test-image-2",
|
||||
URL: "https://www.example.com/test-image-2.jpg",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}}}
|
||||
|
||||
var (
|
||||
err error
|
||||
i int
|
||||
)
|
||||
|
||||
// should iterate all images
|
||||
err = withStoredImages(ctx, &channels.FakeLogger{}, imageStore, func(index int, image channels.Image) error {
|
||||
i += 1
|
||||
return nil
|
||||
}, alerts...)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, i)
|
||||
|
||||
// should iterate just the first image
|
||||
i = 0
|
||||
err = withStoredImages(ctx, &channels.FakeLogger{}, imageStore, func(index int, image channels.Image) error {
|
||||
i += 1
|
||||
return channels.ErrImagesDone
|
||||
}, alerts...)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, i)
|
||||
}
|
@ -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…
Reference in New Issue
Block a user