diff --git a/go.mod b/go.mod index 46e27cb96ef..929a064ec6c 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 117b9a5662b..03ce7477d61 100644 --- a/go.sum +++ b/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= diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go index 72726192531..84fcfb7c4ec 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go @@ -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) } diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index e3bf892dd53..c0636e7321b 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -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, diff --git a/pkg/services/ngalert/notifier/channels/alertmanager.go b/pkg/services/ngalert/notifier/channels/alertmanager.go deleted file mode 100644 index 004aeb16c70..00000000000 --- a/pkg/services/ngalert/notifier/channels/alertmanager.go +++ /dev/null @@ -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() -} diff --git a/pkg/services/ngalert/notifier/channels/alertmanager_test.go b/pkg/services/ngalert/notifier/channels/alertmanager_test.go deleted file mode 100644 index 1770e39530f..00000000000 --- a/pkg/services/ngalert/notifier/channels/alertmanager_test.go +++ /dev/null @@ -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)) - } - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/default_template.go b/pkg/services/ngalert/notifier/channels/default_template.go deleted file mode 100644 index a7b4e14bf23..00000000000 --- a/pkg/services/ngalert/notifier/channels/default_template.go +++ /dev/null @@ -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 -} diff --git a/pkg/services/ngalert/notifier/channels/dingding.go b/pkg/services/ngalert/notifier/channels/dingding.go deleted file mode 100644 index b3bed47beec..00000000000 --- a/pkg/services/ngalert/notifier/channels/dingding.go +++ /dev/null @@ -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 -} diff --git a/pkg/services/ngalert/notifier/channels/dingding_test.go b/pkg/services/ngalert/notifier/channels/dingding_test.go deleted file mode 100644 index 20e579f3875..00000000000 --- a/pkg/services/ngalert/notifier/channels/dingding_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/discord.go b/pkg/services/ngalert/notifier/channels/discord.go deleted file mode 100644 index b7bb3534be9..00000000000 --- a/pkg/services/ngalert/notifier/channels/discord.go +++ /dev/null @@ -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 -} diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go deleted file mode 100644 index 3af76eae324..00000000000 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/email.go b/pkg/services/ngalert/notifier/channels/email.go deleted file mode 100644 index 2680f82c0e4..00000000000 --- a/pkg/services/ngalert/notifier/channels/email.go +++ /dev/null @@ -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 - }) -} diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go deleted file mode 100644 index 7136dd75b23..00000000000 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ /dev/null @@ -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) - }) -} diff --git a/pkg/services/ngalert/notifier/channels/googlechat.go b/pkg/services/ngalert/notifier/channels/googlechat.go deleted file mode 100644 index b4ca770b996..00000000000 --- a/pkg/services/ngalert/notifier/channels/googlechat.go +++ /dev/null @@ -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"` -} diff --git a/pkg/services/ngalert/notifier/channels/googlechat_test.go b/pkg/services/ngalert/notifier/channels/googlechat_test.go deleted file mode 100644 index 19d1b8c9b7b..00000000000 --- a/pkg/services/ngalert/notifier/channels/googlechat_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/kafka.go b/pkg/services/ngalert/notifier/channels/kafka.go deleted file mode 100644 index 32ab5dbc9cd..00000000000 --- a/pkg/services/ngalert/notifier/channels/kafka.go +++ /dev/null @@ -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 -} diff --git a/pkg/services/ngalert/notifier/channels/kafka_test.go b/pkg/services/ngalert/notifier/channels/kafka_test.go deleted file mode 100644 index 90f4d3c6267..00000000000 --- a/pkg/services/ngalert/notifier/channels/kafka_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/line.go b/pkg/services/ngalert/notifier/channels/line.go deleted file mode 100644 index 78fee70eb06..00000000000 --- a/pkg/services/ngalert/notifier/channels/line.go +++ /dev/null @@ -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 -} diff --git a/pkg/services/ngalert/notifier/channels/line_test.go b/pkg/services/ngalert/notifier/channels/line_test.go deleted file mode 100644 index 95389dbaf70..00000000000 --- a/pkg/services/ngalert/notifier/channels/line_test.go +++ /dev/null @@ -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) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go deleted file mode 100644 index 0a3c5ea93a8..00000000000 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ /dev/null @@ -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("", mentionChannel, mentionChannel)) - } - - if len(sn.settings.MentionGroups) > 0 { - appendSpace() - for _, g := range sn.settings.MentionGroups { - mentionsBuilder.WriteString(fmt.Sprintf("", 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() -} diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go deleted file mode 100644 index 0754485178b..00000000000 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/testing.go b/pkg/services/ngalert/notifier/channels/testing.go deleted file mode 100644 index 441a3328939..00000000000 --- a/pkg/services/ngalert/notifier/channels/testing.go +++ /dev/null @@ -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{} } diff --git a/pkg/services/ngalert/notifier/channels/util.go b/pkg/services/ngalert/notifier/channels/util.go deleted file mode 100644 index 35f08531760..00000000000 --- a/pkg/services/ngalert/notifier/channels/util.go +++ /dev/null @@ -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 "" -} diff --git a/pkg/services/ngalert/notifier/channels/util_test.go b/pkg/services/ngalert/notifier/channels/util_test.go deleted file mode 100644 index eeeeead901b..00000000000 --- a/pkg/services/ngalert/notifier/channels/util_test.go +++ /dev/null @@ -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) -} diff --git a/pkg/services/ngalert/notifier/channels/factory.go b/pkg/services/ngalert/notifier/channels_config/factory.go similarity index 69% rename from pkg/services/ngalert/notifier/channels/factory.go rename to pkg/services/ngalert/notifier/channels_config/factory.go index d1500af3b30..888ecdb8305 100644 --- a/pkg/services/ngalert/notifier/channels/factory.go +++ b/pkg/services/ngalert/notifier/channels_config/factory.go @@ -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, diff --git a/pkg/services/ngalert/notifier/email_test.go b/pkg/services/ngalert/notifier/email_test.go index 217b4a037f2..0f70048d8f3 100644 --- a/pkg/services/ngalert/notifier/email_test.go +++ b/pkg/services/ngalert/notifier/email_test.go @@ -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 } diff --git a/pkg/services/sqlstore/migrations/ualert/ualert.go b/pkg/services/sqlstore/migrations/ualert/ualert.go index b8e56fa0a06..f8c60169737 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert.go @@ -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) } diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index 2ac1a8ed37b..0a90e4ec626 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -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" }