From f0cabe14d5cfd7968c1a82ff716dec808d27b64c Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 19 Dec 2022 10:53:58 -0500 Subject: [PATCH] Alerting: import Grafana alerting package and update usages (#60490) * update remaining notifiers to use alerting package --- go.mod | 1 + go.sum | 4 + .../definitions/provisioning_contactpoints.go | 6 +- pkg/services/ngalert/notifier/alertmanager.go | 5 +- .../ngalert/notifier/channels/alertmanager.go | 23 +- .../notifier/channels/alertmanager_test.go | 11 +- .../ngalert/notifier/channels/base.go | 22 - .../notifier/channels/default_template.go | 96 +-- .../channels/default_template_test.go | 219 ------- .../ngalert/notifier/channels/dingding.go | 23 +- .../notifier/channels/dingding_test.go | 9 +- .../ngalert/notifier/channels/discord.go | 35 +- .../ngalert/notifier/channels/discord_test.go | 9 +- .../ngalert/notifier/channels/email.go | 28 +- .../ngalert/notifier/channels/email_test.go | 15 +- .../ngalert/notifier/channels/factory.go | 61 +- .../ngalert/notifier/channels/googlechat.go | 33 +- .../notifier/channels/googlechat_test.go | 12 +- .../ngalert/notifier/channels/images.go | 26 - .../ngalert/notifier/channels/kafka.go | 34 +- .../ngalert/notifier/channels/kafka_test.go | 9 +- .../ngalert/notifier/channels/line.go | 27 +- .../ngalert/notifier/channels/line_test.go | 9 +- pkg/services/ngalert/notifier/channels/log.go | 45 -- .../ngalert/notifier/channels/opsgenie.go | 316 ---------- .../notifier/channels/opsgenie_test.go | 279 --------- .../ngalert/notifier/channels/pagerduty.go | 279 --------- .../notifier/channels/pagerduty_test.go | 318 ---------- .../ngalert/notifier/channels/pushover.go | 339 ----------- .../notifier/channels/pushover_test.go | 273 --------- .../ngalert/notifier/channels/sender.go | 46 -- .../ngalert/notifier/channels/sensugo.go | 182 ------ .../ngalert/notifier/channels/sensugo_test.go | 185 ------ .../ngalert/notifier/channels/slack.go | 72 +-- .../ngalert/notifier/channels/slack_test.go | 14 +- .../ngalert/notifier/channels/teams.go | 378 ------------ .../ngalert/notifier/channels/teams_test.go | 309 ---------- .../ngalert/notifier/channels/telegram.go | 239 -------- .../notifier/channels/telegram_test.go | 162 ----- .../notifier/channels/template_data.go | 206 ------- .../ngalert/notifier/channels/testing.go | 84 +-- .../ngalert/notifier/channels/threema.go | 166 ------ .../ngalert/notifier/channels/threema_test.go | 161 ----- .../ngalert/notifier/channels/util.go | 172 +----- .../ngalert/notifier/channels/util_test.go | 10 +- .../ngalert/notifier/channels/victorops.go | 38 +- .../notifier/channels/victorops_test.go | 10 +- .../ngalert/notifier/channels/webex.go | 163 ------ .../ngalert/notifier/channels/webex_test.go | 145 ----- .../ngalert/notifier/channels/webhook.go | 224 ------- .../ngalert/notifier/channels/webhook_test.go | 392 ------------- .../ngalert/notifier/channels/wecom.go | 252 -------- .../ngalert/notifier/channels/wecom_test.go | 552 ------------------ .../channels_config/available_channels.go | 2 +- pkg/services/ngalert/notifier/images.go | 3 +- pkg/services/ngalert/notifier/log.go | 3 +- .../ngalert/notifier/multiorg_alertmanager.go | 2 +- pkg/services/ngalert/notifier/sender.go | 9 +- .../sqlstore/migrations/ualert/ualert.go | 6 +- .../alerting/api_notification_channel_test.go | 15 +- 60 files changed, 296 insertions(+), 6472 deletions(-) delete mode 100644 pkg/services/ngalert/notifier/channels/base.go delete mode 100644 pkg/services/ngalert/notifier/channels/default_template_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/images.go delete mode 100644 pkg/services/ngalert/notifier/channels/log.go delete mode 100644 pkg/services/ngalert/notifier/channels/opsgenie.go delete mode 100644 pkg/services/ngalert/notifier/channels/opsgenie_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/pagerduty.go delete mode 100644 pkg/services/ngalert/notifier/channels/pagerduty_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/pushover.go delete mode 100644 pkg/services/ngalert/notifier/channels/pushover_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/sender.go delete mode 100644 pkg/services/ngalert/notifier/channels/sensugo.go delete mode 100644 pkg/services/ngalert/notifier/channels/sensugo_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/teams.go delete mode 100644 pkg/services/ngalert/notifier/channels/teams_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/telegram.go delete mode 100644 pkg/services/ngalert/notifier/channels/telegram_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/template_data.go delete mode 100644 pkg/services/ngalert/notifier/channels/threema.go delete mode 100644 pkg/services/ngalert/notifier/channels/threema_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/webex.go delete mode 100644 pkg/services/ngalert/notifier/channels/webex_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/webhook.go delete mode 100644 pkg/services/ngalert/notifier/channels/webhook_test.go delete mode 100644 pkg/services/ngalert/notifier/channels/wecom.go delete mode 100644 pkg/services/ngalert/notifier/channels/wecom_test.go diff --git a/go.mod b/go.mod index 82a7c7e4942..50319306f6c 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,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-20221216210437-c818b1197cdd github.com/grafana/cuetsy v0.1.1 github.com/grafana/grafana-aws-sdk v0.11.0 github.com/grafana/grafana-azure-sdk-go v1.3.1 diff --git a/go.sum b/go.sum index e5b33e0db0b..6ce330a95c5 100644 --- a/go.sum +++ b/go.sum @@ -1369,6 +1369,10 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/alerting v0.0.0-20221215195045-4dd9b084e84d h1:2uPWbeBhkBfS5wpwU7wtZGLVn/XML2EqtiCdKGOBzDA= +github.com/grafana/alerting v0.0.0-20221215195045-4dd9b084e84d/go.mod h1:BO51roH8bMRpAqeWxvnGePyCQoqgk1TiNISYKfoyHzQ= +github.com/grafana/alerting v0.0.0-20221216210437-c818b1197cdd h1:EiSgiWT16KVktYkZxblUqXPfueLcyLQf1oF5mTDh4NY= +github.com/grafana/alerting v0.0.0-20221216210437-c818b1197cdd/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 f8decca6afd..0342a328e6b 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go @@ -3,8 +3,10 @@ package definitions import ( "fmt" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" + ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" ) @@ -104,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 := channels.Factory(e.Type) + factory, exists := ngchannels.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 d0e3fc01ba0..2c0735915c3 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -15,6 +15,7 @@ import ( "time" "unicode/utf8" + "github.com/grafana/alerting/alerting/notifier/channels" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/cluster" "github.com/prometheus/alertmanager/config" @@ -38,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" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" + ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/setting" @@ -521,7 +522,7 @@ func (am *Alertmanager) buildReceiverIntegration(r *apimodels.PostableGrafanaRec Err: err, } } - receiverFactory, exists := channels.Factory(r.Type) + receiverFactory, exists := ngchannels.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 index 80be1663e26..c830adb5b61 100644 --- a/pkg/services/ngalert/notifier/channels/alertmanager.go +++ b/pkg/services/ngalert/notifier/channels/alertmanager.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" @@ -15,18 +16,14 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" ) -// GetDecryptedValueFn is a function that returns the decrypted value of -// the given key. If the key is not present, then it returns the fallback value. -type GetDecryptedValueFn func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string - type AlertmanagerConfig struct { - *NotificationChannelConfig + *channels.NotificationChannelConfig URLs []*url.URL BasicAuthUser string BasicAuthPassword string } -func NewAlertmanagerConfig(config *NotificationChannelConfig, fn GetDecryptedValueFn) (*AlertmanagerConfig, error) { +func NewAlertmanagerConfig(config *channels.NotificationChannelConfig, fn channels.GetDecryptedValueFn) (*AlertmanagerConfig, error) { simpleConfig, err := simplejson.NewJson(config.Settings) if err != nil { return nil, err @@ -59,7 +56,7 @@ func NewAlertmanagerConfig(config *NotificationChannelConfig, fn GetDecryptedVal }, nil } -func AlertmanagerFactory(fc FactoryConfig) (NotificationChannel, error) { +func AlertmanagerFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { config, err := NewAlertmanagerConfig(fc.Config, fc.DecryptFunc) if err != nil { return nil, receiverInitError{ @@ -71,9 +68,9 @@ func AlertmanagerFactory(fc FactoryConfig) (NotificationChannel, error) { } // NewAlertmanagerNotifier returns a new Alertmanager notifier. -func NewAlertmanagerNotifier(config *AlertmanagerConfig, l Logger, images ImageStore, _ *template.Template, fn GetDecryptedValueFn) *AlertmanagerNotifier { +func NewAlertmanagerNotifier(config *AlertmanagerConfig, l channels.Logger, images channels.ImageStore, _ *template.Template, fn channels.GetDecryptedValueFn) *AlertmanagerNotifier { return &AlertmanagerNotifier{ - Base: NewBase(config.NotificationChannelConfig), + Base: channels.NewBase(config.NotificationChannelConfig), images: images, urls: config.URLs, basicAuthUser: config.BasicAuthUser, @@ -84,13 +81,13 @@ func NewAlertmanagerNotifier(config *AlertmanagerConfig, l Logger, images ImageS // AlertmanagerNotifier sends alert notifications to the alert manager type AlertmanagerNotifier struct { - *Base - images ImageStore + *channels.Base + images channels.ImageStore urls []*url.URL basicAuthUser string basicAuthPassword string - logger Logger + logger channels.Logger } // Notify sends alert notifications to Alertmanager. @@ -101,7 +98,7 @@ func (n *AlertmanagerNotifier) Notify(ctx context.Context, as ...*types.Alert) ( } _ = withStoredImages(ctx, n.logger, n.images, - func(index int, image Image) error { + 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 != "" { diff --git a/pkg/services/ngalert/notifier/channels/alertmanager_test.go b/pkg/services/ngalert/notifier/channels/alertmanager_test.go index fdc352aeee8..d0eb0f8684e 100644 --- a/pkg/services/ngalert/notifier/channels/alertmanager_test.go +++ b/pkg/services/ngalert/notifier/channels/alertmanager_test.go @@ -7,6 +7,7 @@ import ( "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" @@ -44,7 +45,7 @@ func TestNewAlertmanagerNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { secureSettings := make(map[string][]byte) - m := &NotificationChannelConfig{ + m := &channels.NotificationChannelConfig{ Name: c.receiverName, Type: "prometheus-alertmanager", Settings: json.RawMessage(c.settings), @@ -60,7 +61,7 @@ func TestNewAlertmanagerNotifier(t *testing.T) { return } require.NoError(t, err) - sn := NewAlertmanagerNotifier(cfg, &FakeLogger{}, &UnavailableImageStore{}, tmpl, decryptFn) + sn := NewAlertmanagerNotifier(cfg, &channels.FakeLogger{}, &channels.UnavailableImageStore{}, tmpl, decryptFn) require.NotNil(t, sn) }) } @@ -142,7 +143,7 @@ func TestAlertmanagerNotifier_Notify(t *testing.T) { require.NoError(t, err) secureSettings := make(map[string][]byte) - m := &NotificationChannelConfig{ + m := &channels.NotificationChannelConfig{ Name: c.receiverName, Type: "prometheus-alertmanager", Settings: settingsJSON, @@ -154,13 +155,13 @@ func TestAlertmanagerNotifier_Notify(t *testing.T) { } cfg, err := NewAlertmanagerConfig(m, decryptFn) require.NoError(t, err) - sn := NewAlertmanagerNotifier(cfg, &FakeLogger{}, images, tmpl, decryptFn) + sn := NewAlertmanagerNotifier(cfg, &channels.FakeLogger{}, images, tmpl, decryptFn) var body []byte origSendHTTPRequest := sendHTTPRequest t.Cleanup(func() { sendHTTPRequest = origSendHTTPRequest }) - sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger Logger) ([]byte, error) { + sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger channels.Logger) ([]byte, error) { body = cfg.body return nil, c.sendHTTPRequestError } diff --git a/pkg/services/ngalert/notifier/channels/base.go b/pkg/services/ngalert/notifier/channels/base.go deleted file mode 100644 index 99a1aaeef11..00000000000 --- a/pkg/services/ngalert/notifier/channels/base.go +++ /dev/null @@ -1,22 +0,0 @@ -package channels - -// Base is the base implementation of a notifier. It contains the common fields across all notifier types. -type Base struct { - Name string - Type string - UID string - DisableResolveMessage bool -} - -func (n *Base) GetDisableResolveMessage() bool { - return n.DisableResolveMessage -} - -func NewBase(cfg *NotificationChannelConfig) *Base { - return &Base{ - UID: cfg.UID, - Name: cfg.Name, - Type: cfg.Type, - DisableResolveMessage: cfg.DisableResolveMessage, - } -} diff --git a/pkg/services/ngalert/notifier/channels/default_template.go b/pkg/services/ngalert/notifier/channels/default_template.go index d2cf9e5a1a9..a7b4e14bf23 100644 --- a/pkg/services/ngalert/notifier/channels/default_template.go +++ b/pkg/services/ngalert/notifier/channels/default_template.go @@ -4,103 +4,11 @@ import ( "os" "testing" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/template" "github.com/stretchr/testify/require" ) -const ( - DefaultMessageTitleEmbed = `{{ template "default.title" . }}` - DefaultMessageEmbed = `{{ template "default.message" . }}` -) - -var DefaultTemplateString = ` -{{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ if gt (.Alerts.Resolved | len) 0 }}, RESOLVED:{{ .Alerts.Resolved | len }}{{ end }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }} - -{{ define "__text_values_list" }}{{ $len := len .Values }}{{ if $len }}{{ $first := gt $len 1 }}{{ range $refID, $value := .Values -}} -{{ $refID }}={{ $value }}{{ if $first }}, {{ end }}{{ $first = false }}{{ end -}} -{{ else }}[no value]{{ end }}{{ end }} - -{{ define "__text_alert_list" }}{{ range . }} -Value: {{ template "__text_values_list" . }} -Labels: -{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} -{{ end }}Annotations: -{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} -{{ end }}{{ if gt (len .GeneratorURL) 0 }}Source: {{ .GeneratorURL }} -{{ end }}{{ if gt (len .SilenceURL) 0 }}Silence: {{ .SilenceURL }} -{{ end }}{{ if gt (len .DashboardURL) 0 }}Dashboard: {{ .DashboardURL }} -{{ end }}{{ if gt (len .PanelURL) 0 }}Panel: {{ .PanelURL }} -{{ end }}{{ end }}{{ end }} - -{{ define "default.title" }}{{ template "__subject" . }}{{ end }} - -{{ define "default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing** -{{ template "__text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }} - -{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved** -{{ template "__text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }} - - -{{ define "__teams_text_alert_list" }}{{ range . }} -Value: {{ template "__text_values_list" . }} -Labels: -{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} -{{ end }} -Annotations: -{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} -{{ end }} -{{ if gt (len .GeneratorURL) 0 }}Source: [{{ .GeneratorURL }}]({{ .GeneratorURL }}) - -{{ end }}{{ if gt (len .SilenceURL) 0 }}Silence: [{{ .SilenceURL }}]({{ .SilenceURL }}) - -{{ end }}{{ if gt (len .DashboardURL) 0 }}Dashboard: [{{ .DashboardURL }}]({{ .DashboardURL }}) - -{{ end }}{{ if gt (len .PanelURL) 0 }}Panel: [{{ .PanelURL }}]({{ .PanelURL }}) - -{{ end }} -{{ end }}{{ end }} - - -{{ define "teams.default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing** -{{ template "__teams_text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }} - -{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved** -{{ template "__teams_text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }} -` - -// TemplateForTestsString is the template used for unit tests and integration tests. -// We have it separate from above default template because any tiny change in the template -// will require updating almost all channel tests (15+ files) and it's very time consuming. -const TemplateForTestsString = ` -{{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }} - -{{ define "__text_values_list" }}{{ $len := len .Values }}{{ if $len }}{{ $first := gt $len 1 }}{{ range $refID, $value := .Values -}} -{{ $refID }}={{ $value }}{{ if $first }}, {{ end }}{{ $first = false }}{{ end -}} -{{ else }}[no value]{{ end }}{{ end }} - -{{ define "__text_alert_list" }}{{ range . }} -Value: {{ template "__text_values_list" . }} -Labels: -{{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} -{{ end }}Annotations: -{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} -{{ end }}{{ if gt (len .GeneratorURL) 0 }}Source: {{ .GeneratorURL }} -{{ end }}{{ if gt (len .SilenceURL) 0 }}Silence: {{ .SilenceURL }} -{{ end }}{{ if gt (len .DashboardURL) 0 }}Dashboard: {{ .DashboardURL }} -{{ end }}{{ if gt (len .PanelURL) 0 }}Panel: {{ .PanelURL }} -{{ end }}{{ end }}{{ end }} - -{{ define "default.title" }}{{ template "__subject" . }}{{ end }} - -{{ define "default.message" }}{{ if gt (len .Alerts.Firing) 0 }}**Firing** -{{ template "__text_alert_list" .Alerts.Firing }}{{ if gt (len .Alerts.Resolved) 0 }} - -{{ end }}{{ end }}{{ if gt (len .Alerts.Resolved) 0 }}**Resolved** -{{ template "__text_alert_list" .Alerts.Resolved }}{{ end }}{{ end }} - -{{ define "teams.default.message" }}{{ template "default.message" . }}{{ end }} -` - func templateForTests(t *testing.T) *template.Template { f, err := os.CreateTemp("/tmp", "template") require.NoError(t, err) @@ -112,7 +20,7 @@ func templateForTests(t *testing.T) *template.Template { require.NoError(t, os.RemoveAll(f.Name())) }) - _, err = f.WriteString(TemplateForTestsString) + _, err = f.WriteString(channels.TemplateForTestsString) require.NoError(t, err) tmpl, err := template.FromGlobs(f.Name()) diff --git a/pkg/services/ngalert/notifier/channels/default_template_test.go b/pkg/services/ngalert/notifier/channels/default_template_test.go deleted file mode 100644 index b05f3516f17..00000000000 --- a/pkg/services/ngalert/notifier/channels/default_template_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package channels - -import ( - "context" - "net/url" - "os" - "testing" - "time" - - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestDefaultTemplateString(t *testing.T) { - alerts := []*types.Alert{ - { // Firing with dashboard and panel ID. - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{ - "ann1": "annv1", "__orgId__": "1", "__dashboardUid__": "dbuid123", "__panelId__": "puid123", "__values__": "{\"A\": 1234}", "__value_string__": "1234", - }, - StartsAt: time.Now(), - EndsAt: time.Now().Add(1 * time.Hour), - GeneratorURL: "http://localhost/alert1?orgId=1", - }, - }, { // Firing without dashboard and panel ID. - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2", "__values__": "{\"A\": 1234}", "__value_string__": "1234"}, - StartsAt: time.Now(), - EndsAt: time.Now().Add(2 * time.Hour), - GeneratorURL: "http://localhost/alert2", - }, - }, { // Resolved with dashboard and panel ID. - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val3"}, - Annotations: model.LabelSet{ - "ann1": "annv3", "__orgId__": "1", "__dashboardUid__": "dbuid456", "__panelId__": "puid456", "__values__": "{\"A\": 1234}", "__value_string__": "1234", - }, - StartsAt: time.Now().Add(-1 * time.Hour), - EndsAt: time.Now().Add(-30 * time.Minute), - GeneratorURL: "http://localhost/alert3", - }, - }, { // Resolved without dashboard and panel ID. - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val4"}, - Annotations: model.LabelSet{"ann1": "annv4", "__values__": "{\"A\": 1234}", "__value_string__": "1234"}, - StartsAt: time.Now().Add(-2 * time.Hour), - EndsAt: time.Now().Add(-3 * time.Hour), - GeneratorURL: "http://localhost/alert4", - }, - }, - } - - 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(DefaultTemplateString) - require.NoError(t, err) - - tmpl, err := template.FromGlobs(f.Name()) - require.NoError(t, err) - - externalURL, err := url.Parse("http://localhost/grafana") - require.NoError(t, err) - tmpl.ExternalURL = externalURL - - var tmplErr error - l := &FakeLogger{} - expand, _ := TmplText(context.Background(), tmpl, alerts, l, &tmplErr) - - cases := []struct { - templateString string - expected string - }{ - { - templateString: DefaultMessageTitleEmbed, - expected: `[FIRING:2, RESOLVED:2] (alert1)`, - }, - { - templateString: DefaultMessageEmbed, - expected: `**Firing** - -Value: A=1234 -Labels: - - alertname = alert1 - - lbl1 = val1 -Annotations: - - ann1 = annv1 -Source: http://localhost/alert1?orgId=1 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1 -Dashboard: http://localhost/grafana/d/dbuid123?orgId=1 -Panel: http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123 - -Value: A=1234 -Labels: - - alertname = alert1 - - lbl1 = val2 -Annotations: - - ann1 = annv2 -Source: http://localhost/alert2 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2 - - -**Resolved** - -Value: A=1234 -Labels: - - alertname = alert1 - - lbl1 = val3 -Annotations: - - ann1 = annv3 -Source: http://localhost/alert3?orgId=1 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3 -Dashboard: http://localhost/grafana/d/dbuid456?orgId=1 -Panel: http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456 - -Value: A=1234 -Labels: - - alertname = alert1 - - lbl1 = val4 -Annotations: - - ann1 = annv4 -Source: http://localhost/alert4 -Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4 -`, - }, - { - templateString: `{{ template "teams.default.message" .}}`, - expected: `**Firing** - -Value: A=1234 -Labels: - - alertname = alert1 - - lbl1 = val1 - -Annotations: - - ann1 = annv1 - -Source: [http://localhost/alert1?orgId=1](http://localhost/alert1?orgId=1) - -Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1) - -Dashboard: [http://localhost/grafana/d/dbuid123?orgId=1](http://localhost/grafana/d/dbuid123?orgId=1) - -Panel: [http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123](http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123) - - - -Value: A=1234 -Labels: - - alertname = alert1 - - lbl1 = val2 - -Annotations: - - ann1 = annv2 - -Source: [http://localhost/alert2](http://localhost/alert2) - -Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2) - - - - -**Resolved** - -Value: A=1234 -Labels: - - alertname = alert1 - - lbl1 = val3 - -Annotations: - - ann1 = annv3 - -Source: [http://localhost/alert3?orgId=1](http://localhost/alert3?orgId=1) - -Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3) - -Dashboard: [http://localhost/grafana/d/dbuid456?orgId=1](http://localhost/grafana/d/dbuid456?orgId=1) - -Panel: [http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456](http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456) - - - -Value: A=1234 -Labels: - - alertname = alert1 - - lbl1 = val4 - -Annotations: - - ann1 = annv4 - -Source: [http://localhost/alert4](http://localhost/alert4) - -Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4) - - -`, - }, - } - - for _, c := range cases { - t.Run(c.templateString, func(t *testing.T) { - act := expand(c.templateString) - require.NoError(t, tmplErr) - require.Equal(t, c.expected, act) - }) - } - require.NoError(t, tmplErr) -} diff --git a/pkg/services/ngalert/notifier/channels/dingding.go b/pkg/services/ngalert/notifier/channels/dingding.go index af4648d99c6..fb92c63b950 100644 --- a/pkg/services/ngalert/notifier/channels/dingding.go +++ b/pkg/services/ngalert/notifier/channels/dingding.go @@ -7,6 +7,7 @@ import ( "fmt" "net/url" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" @@ -22,7 +23,7 @@ type dingDingSettings struct { Message string } -func buildDingDingSettings(fc FactoryConfig) (*dingDingSettings, error) { +func buildDingDingSettings(fc channels.FactoryConfig) (*dingDingSettings, error) { settings, err := simplejson.NewJson(fc.Config.Settings) if err != nil { return nil, err @@ -34,12 +35,12 @@ func buildDingDingSettings(fc FactoryConfig) (*dingDingSettings, error) { return &dingDingSettings{ URL: URL, MessageType: settings.Get("msgType").MustString(defaultDingdingMsgType), - Title: settings.Get("title").MustString(DefaultMessageTitleEmbed), - Message: settings.Get("message").MustString(DefaultMessageEmbed), + Title: settings.Get("title").MustString(channels.DefaultMessageTitleEmbed), + Message: settings.Get("message").MustString(channels.DefaultMessageEmbed), }, nil } -func DingDingFactory(fc FactoryConfig) (NotificationChannel, error) { +func DingDingFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { n, err := newDingDingNotifier(fc) if err != nil { return nil, receiverInitError{ @@ -51,13 +52,13 @@ func DingDingFactory(fc FactoryConfig) (NotificationChannel, error) { } // newDingDingNotifier is the constructor for the Dingding notifier -func newDingDingNotifier(fc FactoryConfig) (*DingDingNotifier, error) { +func newDingDingNotifier(fc channels.FactoryConfig) (*DingDingNotifier, error) { settings, err := buildDingDingSettings(fc) if err != nil { return nil, err } return &DingDingNotifier{ - Base: NewBase(fc.Config), + Base: channels.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, tmpl: fc.Template, @@ -67,9 +68,9 @@ func newDingDingNotifier(fc FactoryConfig) (*DingDingNotifier, error) { // DingDingNotifier is responsible for sending alert notifications to ding ding. type DingDingNotifier struct { - *Base - log Logger - ns WebhookSender + *channels.Base + log channels.Logger + ns channels.WebhookSender tmpl *template.Template settings dingDingSettings } @@ -81,7 +82,7 @@ func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo msgUrl := buildDingDingURL(dd) var tmplErr error - tmpl, _ := TmplText(ctx, dd.tmpl, as, dd.log, &tmplErr) + tmpl, _ := channels.TmplText(ctx, dd.tmpl, as, dd.log, &tmplErr) message := tmpl(dd.settings.Message) title := tmpl(dd.settings.Title) @@ -103,7 +104,7 @@ func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo u = dd.settings.URL } - cmd := &SendWebhookSettings{Url: u, Body: b} + 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) diff --git a/pkg/services/ngalert/notifier/channels/dingding_test.go b/pkg/services/ngalert/notifier/channels/dingding_test.go index f0618cf61a1..91f172efef7 100644 --- a/pkg/services/ngalert/notifier/channels/dingding_test.go +++ b/pkg/services/ngalert/notifier/channels/dingding_test.go @@ -6,6 +6,7 @@ import ( "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" @@ -165,8 +166,8 @@ func TestDingdingNotifier(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { webhookSender := mockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := channels.FactoryConfig{ + Config: &channels.NotificationChannelConfig{ Name: "dingding_testing", Type: "dingding", Settings: json.RawMessage(c.settings), @@ -174,7 +175,7 @@ func TestDingdingNotifier(t *testing.T) { // TODO: allow changing the associated values for different tests. NotificationService: webhookSender, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &channels.FakeLogger{}, } pn, err := newDingDingNotifier(fc) if c.expInitError != "" { @@ -195,7 +196,7 @@ func TestDingdingNotifier(t *testing.T) { require.NoError(t, err) require.True(t, ok) - require.NotEmpty(t, webhookSender.Webhook.Url) + require.NotEmpty(t, webhookSender.Webhook.URL) expBody, err := json.Marshal(c.expMsg) require.NoError(t, err) diff --git a/pkg/services/ngalert/notifier/channels/discord.go b/pkg/services/ngalert/notifier/channels/discord.go index 8840e340a01..34ee56a4229 100644 --- a/pkg/services/ngalert/notifier/channels/discord.go +++ b/pkg/services/ngalert/notifier/channels/discord.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" @@ -21,10 +22,10 @@ import ( ) type DiscordNotifier struct { - *Base - log Logger - ns WebhookSender - images ImageStore + *channels.Base + log channels.Logger + ns channels.WebhookSender + images channels.ImageStore tmpl *template.Template settings discordSettings } @@ -47,7 +48,7 @@ type discordAttachment struct { const DiscordMaxEmbeds = 10 -func DiscordFactory(fc FactoryConfig) (NotificationChannel, error) { +func DiscordFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { dn, err := newDiscordNotifier(fc) if err != nil { return nil, receiverInitError{ @@ -58,7 +59,7 @@ func DiscordFactory(fc FactoryConfig) (NotificationChannel, error) { return dn, nil } -func newDiscordNotifier(fc FactoryConfig) (*DiscordNotifier, error) { +func newDiscordNotifier(fc channels.FactoryConfig) (*DiscordNotifier, error) { settings, err := simplejson.NewJson(fc.Config.Settings) if err != nil { return nil, err @@ -69,14 +70,14 @@ func newDiscordNotifier(fc FactoryConfig) (*DiscordNotifier, error) { } return &DiscordNotifier{ - Base: NewBase(fc.Config), + Base: channels.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, images: fc.ImageStore, tmpl: fc.Template, settings: discordSettings{ - Title: settings.Get("title").MustString(DefaultMessageTitleEmbed), - Content: settings.Get("message").MustString(DefaultMessageEmbed), + Title: settings.Get("title").MustString(channels.DefaultMessageTitleEmbed), + Content: settings.Get("message").MustString(channels.DefaultMessageEmbed), AvatarURL: settings.Get("avatar_url").MustString(), WebhookURL: dUrl, UseDiscordUsername: settings.Get("use_discord_username").MustBool(false), @@ -94,7 +95,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, } var tmplErr error - tmpl, _ := TmplText(ctx, d.tmpl, as, d.log, &tmplErr) + tmpl, _ := channels.TmplText(ctx, d.tmpl, as, d.log, &tmplErr) bodyJSON.Set("content", tmpl(d.settings.Content)) if tmplErr != nil { @@ -187,9 +188,9 @@ func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.A attachments := make([]discordAttachment, 0) _ = withStoredImages(ctx, d.log, d.images, - func(index int, image Image) error { + func(index int, image channels.Image) error { if embedQuota < 1 { - return ErrImagesDone + return channels.ErrImagesDone } if len(image.URL) > 0 { @@ -207,7 +208,7 @@ func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.A base := filepath.Base(image.Path) url := fmt.Sprintf("attachment://%s", base) reader, err := openImage(image.Path) - if err != nil && !errors.Is(err, ErrImageNotFound) { + if err != nil && !errors.Is(err, channels.ErrImageNotFound) { d.log.Warn("failed to retrieve image data from store", "error", err) return nil } @@ -229,10 +230,10 @@ func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.A return attachments } -func (d DiscordNotifier) buildRequest(url string, body []byte, attachments []discordAttachment) (*SendWebhookSettings, error) { - cmd := &SendWebhookSettings{ - Url: url, - HttpMethod: "POST", +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" diff --git a/pkg/services/ngalert/notifier/channels/discord_test.go b/pkg/services/ngalert/notifier/channels/discord_test.go index e2972182a4c..8abe4baf71a 100644 --- a/pkg/services/ngalert/notifier/channels/discord_test.go +++ b/pkg/services/ngalert/notifier/channels/discord_test.go @@ -6,6 +6,7 @@ import ( "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" @@ -287,10 +288,10 @@ func TestDiscordNotifier(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { webhookSender := mockNotificationService() - imageStore := &UnavailableImageStore{} + imageStore := &channels.UnavailableImageStore{} - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := channels.FactoryConfig{ + Config: &channels.NotificationChannelConfig{ Name: "discord_testing", Type: "discord", Settings: json.RawMessage(c.settings), @@ -299,7 +300,7 @@ func TestDiscordNotifier(t *testing.T) { // TODO: allow changing the associated values for different tests. NotificationService: webhookSender, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &channels.FakeLogger{}, } dn, err := newDiscordNotifier(fc) diff --git a/pkg/services/ngalert/notifier/channels/email.go b/pkg/services/ngalert/notifier/channels/email.go index 6249eaa047f..d2542c81238 100644 --- a/pkg/services/ngalert/notifier/channels/email.go +++ b/pkg/services/ngalert/notifier/channels/email.go @@ -11,6 +11,8 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/util" ) @@ -18,26 +20,26 @@ import ( // EmailNotifier is responsible for sending // alert notifications over email. type EmailNotifier struct { - *Base + *channels.Base Addresses []string SingleEmail bool Message string Subject string - log Logger - ns EmailSender - images ImageStore + log channels.Logger + ns channels.EmailSender + images channels.ImageStore tmpl *template.Template } type EmailConfig struct { - *NotificationChannelConfig + *channels.NotificationChannelConfig SingleEmail bool Addresses []string Message string Subject string } -func EmailFactory(fc FactoryConfig) (NotificationChannel, error) { +func EmailFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { cfg, err := NewEmailConfig(fc.Config) if err != nil { return nil, receiverInitError{ @@ -48,7 +50,7 @@ func EmailFactory(fc FactoryConfig) (NotificationChannel, error) { return NewEmailNotifier(cfg, fc.Logger, fc.NotificationService, fc.ImageStore, fc.Template), nil } -func NewEmailConfig(config *NotificationChannelConfig) (*EmailConfig, error) { +func NewEmailConfig(config *channels.NotificationChannelConfig) (*EmailConfig, error) { settings, err := simplejson.NewJson(config.Settings) if err != nil { return nil, err @@ -63,16 +65,16 @@ func NewEmailConfig(config *NotificationChannelConfig) (*EmailConfig, error) { NotificationChannelConfig: config, SingleEmail: settings.Get("singleEmail").MustBool(false), Message: settings.Get("message").MustString(), - Subject: settings.Get("subject").MustString(DefaultMessageTitleEmbed), + Subject: settings.Get("subject").MustString(channels.DefaultMessageTitleEmbed), Addresses: addresses, }, nil } // NewEmailNotifier is the constructor function // for the EmailNotifier. -func NewEmailNotifier(config *EmailConfig, l Logger, ns EmailSender, images ImageStore, t *template.Template) *EmailNotifier { +func NewEmailNotifier(config *EmailConfig, l channels.Logger, ns channels.EmailSender, images channels.ImageStore, t *template.Template) *EmailNotifier { return &EmailNotifier{ - Base: NewBase(config.NotificationChannelConfig), + Base: channels.NewBase(config.NotificationChannelConfig), Addresses: config.Addresses, SingleEmail: config.SingleEmail, Message: config.Message, @@ -87,7 +89,7 @@ func NewEmailNotifier(config *EmailConfig, l Logger, ns EmailSender, images Imag // Notify sends the alert notification. func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { var tmplErr error - tmpl, data := TmplText(ctx, en.tmpl, alerts, en.log, &tmplErr) + tmpl, data := channels.TmplText(ctx, en.tmpl, alerts, en.log, &tmplErr) subject := tmpl(en.Subject) alertPageURL := en.tmpl.ExternalURL.String() @@ -106,7 +108,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo // Extend alerts data with images, if available. var embeddedFiles []string _ = withStoredImages(ctx, en.log, en.images, - func(index int, image Image) error { + func(index int, image channels.Image) error { if len(image.URL) != 0 { data.Alerts[index].ImageURL = image.URL } else if len(image.Path) != 0 { @@ -121,7 +123,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo return nil }, alerts...) - cmd := &SendEmailSettings{ + cmd := &channels.SendEmailSettings{ Subject: subject, Data: map[string]interface{}{ "Title": subject, diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go index f4a02aad302..8e641d4e657 100644 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ b/pkg/services/ngalert/notifier/channels/email_test.go @@ -6,6 +6,7 @@ import ( "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" @@ -24,7 +25,7 @@ func TestEmailNotifier(t *testing.T) { t.Run("empty settings should return error", func(t *testing.T) { jsonData := `{ }` settingsJSON := json.RawMessage(jsonData) - model := &NotificationChannelConfig{ + model := &channels.NotificationChannelConfig{ Name: "ops", Type: "email", Settings: settingsJSON, @@ -41,13 +42,13 @@ func TestEmailNotifier(t *testing.T) { }` emailSender := mockNotificationService() - cfg, err := NewEmailConfig(&NotificationChannelConfig{ + cfg, err := NewEmailConfig(&channels.NotificationChannelConfig{ Name: "ops", Type: "email", Settings: json.RawMessage(jsonData), }) require.NoError(t, err) - emailNotifier := NewEmailNotifier(cfg, &FakeLogger{}, emailSender, &UnavailableImageStore{}, tmpl) + emailNotifier := NewEmailNotifier(cfg, &channels.FakeLogger{}, emailSender, &channels.UnavailableImageStore{}, tmpl) alerts := []*types.Alert{ { @@ -78,8 +79,8 @@ func TestEmailNotifier(t *testing.T) { "Title": "[FIRING:1] (AlwaysFiring warning)", "Message": "[FIRING:1] (AlwaysFiring warning)", "Status": "firing", - "Alerts": ExtendedAlerts{ - ExtendedAlert{ + "Alerts": channels.ExtendedAlerts{ + channels.ExtendedAlert{ Status: "firing", Labels: template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, Annotations: template.KV{"runbook_url": "http://fix.me"}, @@ -280,13 +281,13 @@ func createSut(t *testing.T, messageTmpl string, subjectTmpl string, emailTmpl * } bytes, err := json.Marshal(jsonData) require.NoError(t, err) - cfg, err := NewEmailConfig(&NotificationChannelConfig{ + cfg, err := NewEmailConfig(&channels.NotificationChannelConfig{ Name: "ops", Type: "email", Settings: bytes, }) require.NoError(t, err) - emailNotifier := NewEmailNotifier(cfg, &FakeLogger{}, ns, &UnavailableImageStore{}, emailTmpl) + emailNotifier := NewEmailNotifier(cfg, &channels.FakeLogger{}, ns, &channels.UnavailableImageStore{}, emailTmpl) return emailNotifier } diff --git a/pkg/services/ngalert/notifier/channels/factory.go b/pkg/services/ngalert/notifier/channels/factory.go index 27cbe6a5f52..e5a5d505bdb 100644 --- a/pkg/services/ngalert/notifier/channels/factory.go +++ b/pkg/services/ngalert/notifier/channels/factory.go @@ -1,47 +1,12 @@ package channels import ( - "errors" "strings" - "github.com/prometheus/alertmanager/template" + "github.com/grafana/alerting/alerting/notifier/channels" ) -type FactoryConfig struct { - Config *NotificationChannelConfig - NotificationService NotificationSender - DecryptFunc GetDecryptedValueFn - ImageStore ImageStore - // Used to retrieve image URLs for messages, or data for uploads. - Template *template.Template - Logger Logger -} - -func NewFactoryConfig(config *NotificationChannelConfig, notificationService NotificationSender, - decryptFunc GetDecryptedValueFn, template *template.Template, imageStore ImageStore, loggerFactory LoggerFactory) (FactoryConfig, error) { - if config.Settings == nil { - return FactoryConfig{}, errors.New("no settings supplied") - } - // not all receivers do need secure settings, we still might interact with - // them, so we make sure they are never nil - if config.SecureSettings == nil { - config.SecureSettings = map[string][]byte{} - } - - if imageStore == nil { - imageStore = &UnavailableImageStore{} - } - return FactoryConfig{ - Config: config, - NotificationService: notificationService, - DecryptFunc: decryptFunc, - Template: template, - ImageStore: imageStore, - Logger: loggerFactory("ngalert.notifier." + config.Type), - }, nil -} - -var receiverFactories = map[string]func(FactoryConfig) (NotificationChannel, error){ +var receiverFactories = map[string]func(channels.FactoryConfig) (channels.NotificationChannel, error){ "prometheus-alertmanager": AlertmanagerFactory, "dingding": DingDingFactory, "discord": DiscordFactory, @@ -49,21 +14,21 @@ var receiverFactories = map[string]func(FactoryConfig) (NotificationChannel, err "googlechat": GoogleChatFactory, "kafka": KafkaFactory, "line": LineFactory, - "opsgenie": OpsgenieFactory, - "pagerduty": PagerdutyFactory, - "pushover": PushoverFactory, - "sensugo": SensuGoFactory, + "opsgenie": channels.OpsgenieFactory, + "pagerduty": channels.PagerdutyFactory, + "pushover": channels.PushoverFactory, + "sensugo": channels.SensuGoFactory, "slack": SlackFactory, - "teams": TeamsFactory, - "telegram": TelegramFactory, - "threema": ThreemaFactory, + "teams": channels.TeamsFactory, + "telegram": channels.TelegramFactory, + "threema": channels.ThreemaFactory, "victorops": VictorOpsFactory, - "webhook": WebHookFactory, - "wecom": WeComFactory, - "webex": WebexFactory, + "webhook": channels.WebHookFactory, + "wecom": channels.WeComFactory, + "webex": channels.WebexFactory, } -func Factory(receiverType string) (func(FactoryConfig) (NotificationChannel, error), bool) { +func Factory(receiverType string) (func(channels.FactoryConfig) (channels.NotificationChannel, error), bool) { receiverType = strings.ToLower(receiverType) factory, exists := receiverFactories[receiverType] return factory, exists diff --git a/pkg/services/ngalert/notifier/channels/googlechat.go b/pkg/services/ngalert/notifier/channels/googlechat.go index da8d5fb0880..8679971796a 100644 --- a/pkg/services/ngalert/notifier/channels/googlechat.go +++ b/pkg/services/ngalert/notifier/channels/googlechat.go @@ -8,6 +8,7 @@ import ( "net/url" "time" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" @@ -18,10 +19,10 @@ import ( // GoogleChatNotifier is responsible for sending // alert notifications to Google chat. type GoogleChatNotifier struct { - *Base - log Logger - ns WebhookSender - images ImageStore + *channels.Base + log channels.Logger + ns channels.WebhookSender + images channels.ImageStore tmpl *template.Template settings googleChatSettings } @@ -32,7 +33,7 @@ type googleChatSettings struct { Content string } -func GoogleChatFactory(fc FactoryConfig) (NotificationChannel, error) { +func GoogleChatFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { gcn, err := newGoogleChatNotifier(fc) if err != nil { return nil, receiverInitError{ @@ -43,9 +44,9 @@ func GoogleChatFactory(fc FactoryConfig) (NotificationChannel, error) { return gcn, nil } -func newGoogleChatNotifier(fc FactoryConfig) (*GoogleChatNotifier, error) { +func newGoogleChatNotifier(fc channels.FactoryConfig) (*GoogleChatNotifier, error) { var settings googleChatSettings - err := fc.Config.unmarshalSettings(&settings) + err := json.Unmarshal(fc.Config.Settings, &settings) if err != nil { return nil, fmt.Errorf("failed to unmarshal settings: %w", err) } @@ -60,15 +61,15 @@ func newGoogleChatNotifier(fc FactoryConfig) (*GoogleChatNotifier, error) { } return &GoogleChatNotifier{ - Base: NewBase(fc.Config), + Base: channels.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, images: fc.ImageStore, tmpl: fc.Template, settings: googleChatSettings{ URL: URL, - Title: rawsettings.Get("title").MustString(DefaultMessageTitleEmbed), - Content: rawsettings.Get("message").MustString(DefaultMessageEmbed), + Title: rawsettings.Get("title").MustString(channels.DefaultMessageTitleEmbed), + Content: rawsettings.Get("message").MustString(channels.DefaultMessageEmbed), }, }, nil } @@ -78,7 +79,7 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( gcn.log.Debug("executing Google Chat notification") var tmplErr error - tmpl, _ := TmplText(ctx, gcn.tmpl, as, gcn.log, &tmplErr) + tmpl, _ := channels.TmplText(ctx, gcn.tmpl, as, gcn.log, &tmplErr) var widgets []widget @@ -155,10 +156,10 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( return false, fmt.Errorf("marshal json: %w", err) } - cmd := &SendWebhookSettings{ - Url: u, - HttpMethod: "POST", - HttpHeader: map[string]string{ + cmd := &channels.SendWebhookSettings{ + URL: u, + HTTPMethod: "POST", + HTTPHeader: map[string]string{ "Content-Type": "application/json; charset=UTF-8", }, Body: string(body), @@ -193,7 +194,7 @@ func (gcn *GoogleChatNotifier) buildScreenshotCard(ctx context.Context, alerts [ } _ = withStoredImages(ctx, gcn.log, gcn.images, - func(index int, image Image) error { + func(index int, image channels.Image) error { if len(image.URL) == 0 { return nil } diff --git a/pkg/services/ngalert/notifier/channels/googlechat_test.go b/pkg/services/ngalert/notifier/channels/googlechat_test.go index adae8138140..3aae5156bc7 100644 --- a/pkg/services/ngalert/notifier/channels/googlechat_test.go +++ b/pkg/services/ngalert/notifier/channels/googlechat_test.go @@ -12,6 +12,8 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/setting" ) @@ -462,10 +464,10 @@ func TestGoogleChatNotifier(t *testing.T) { tmpl.ExternalURL = externalURL webhookSender := mockNotificationService() - imageStore := &UnavailableImageStore{} + imageStore := &channels.UnavailableImageStore{} - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := channels.FactoryConfig{ + Config: &channels.NotificationChannelConfig{ Name: "googlechat_testing", Type: "googlechat", Settings: json.RawMessage(c.settings), @@ -473,7 +475,7 @@ func TestGoogleChatNotifier(t *testing.T) { ImageStore: imageStore, NotificationService: webhookSender, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &channels.FakeLogger{}, } pn, err := newGoogleChatNotifier(fc) @@ -496,7 +498,7 @@ func TestGoogleChatNotifier(t *testing.T) { require.NoError(t, err) require.True(t, ok) - require.NotEmpty(t, webhookSender.Webhook.Url) + require.NotEmpty(t, webhookSender.Webhook.URL) expBody, err := json.Marshal(c.expMsg) require.NoError(t, err) diff --git a/pkg/services/ngalert/notifier/channels/images.go b/pkg/services/ngalert/notifier/channels/images.go deleted file mode 100644 index ceb723a2396..00000000000 --- a/pkg/services/ngalert/notifier/channels/images.go +++ /dev/null @@ -1,26 +0,0 @@ -package channels - -import ( - "context" - "errors" - "time" -) - -var ( - ErrImageNotFound = errors.New("image not found") -) - -type Image struct { - Token string - Path string - URL string - CreatedAt time.Time -} - -func (i Image) HasURL() bool { - return i.URL != "" -} - -type ImageStore interface { - GetImage(ctx context.Context, token string) (*Image, error) -} diff --git a/pkg/services/ngalert/notifier/channels/kafka.go b/pkg/services/ngalert/notifier/channels/kafka.go index 6dad1bec1d0..0dcd2670fd7 100644 --- a/pkg/services/ngalert/notifier/channels/kafka.go +++ b/pkg/services/ngalert/notifier/channels/kafka.go @@ -10,6 +10,8 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" ) @@ -17,10 +19,10 @@ import ( // KafkaNotifier is responsible for sending // alert notifications to Kafka. type KafkaNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender + *channels.Base + log channels.Logger + images channels.ImageStore + ns channels.WebhookSender tmpl *template.Template settings kafkaSettings } @@ -32,7 +34,7 @@ type kafkaSettings struct { Details string } -func KafkaFactory(fc FactoryConfig) (NotificationChannel, error) { +func KafkaFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { ch, err := newKafkaNotifier(fc) if err != nil { return nil, receiverInitError{ @@ -44,7 +46,7 @@ func KafkaFactory(fc FactoryConfig) (NotificationChannel, error) { } // newKafkaNotifier is the constructor function for the Kafka notifier. -func newKafkaNotifier(fc FactoryConfig) (*KafkaNotifier, error) { +func newKafkaNotifier(fc channels.FactoryConfig) (*KafkaNotifier, error) { settings, err := simplejson.NewJson(fc.Config.Settings) if err != nil { return nil, err @@ -57,11 +59,11 @@ func newKafkaNotifier(fc FactoryConfig) (*KafkaNotifier, error) { if topic == "" { return nil, errors.New("could not find kafka topic property in settings") } - description := settings.Get("description").MustString(DefaultMessageTitleEmbed) - details := settings.Get("details").MustString(DefaultMessageEmbed) + description := settings.Get("description").MustString(channels.DefaultMessageTitleEmbed) + details := settings.Get("details").MustString(channels.DefaultMessageEmbed) return &KafkaNotifier{ - Base: NewBase(fc.Config), + Base: channels.NewBase(fc.Config), log: fc.Logger, images: fc.ImageStore, ns: fc.NotificationService, @@ -73,7 +75,7 @@ func newKafkaNotifier(fc FactoryConfig) (*KafkaNotifier, error) { // Notify sends the alert notification. func (kn *KafkaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { var tmplErr error - tmpl, _ := TmplText(ctx, kn.tmpl, as, kn.log, &tmplErr) + tmpl, _ := channels.TmplText(ctx, kn.tmpl, as, kn.log, &tmplErr) topicURL := strings.TrimRight(kn.settings.Endpoint, "/") + "/topics/" + tmpl(kn.settings.Topic) @@ -86,11 +88,11 @@ func (kn *KafkaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, kn.log.Warn("failed to template Kafka message", "error", tmplErr.Error()) } - cmd := &SendWebhookSettings{ - Url: topicURL, + cmd := &channels.SendWebhookSettings{ + URL: topicURL, Body: body, - HttpMethod: "POST", - HttpHeader: map[string]string{ + HTTPMethod: "POST", + HTTPHeader: map[string]string{ "Content-Type": "application/vnd.kafka.json.v2+json", "Accept": "application/vnd.kafka.v2+json", }, @@ -154,10 +156,10 @@ func buildState(as ...*types.Alert) models.AlertStateType { return models.AlertStateAlerting } -func buildContextImages(ctx context.Context, l Logger, imageStore ImageStore, as ...*types.Alert) []interface{} { +func buildContextImages(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, as ...*types.Alert) []interface{} { var contexts []interface{} _ = withStoredImages(ctx, l, imageStore, - func(_ int, image Image) error { + func(_ int, image channels.Image) error { if image.URL != "" { imageJSON := simplejson.New() imageJSON.Set("type", "image") diff --git a/pkg/services/ngalert/notifier/channels/kafka_test.go b/pkg/services/ngalert/notifier/channels/kafka_test.go index a7bbd15adf4..90f4d3c6267 100644 --- a/pkg/services/ngalert/notifier/channels/kafka_test.go +++ b/pkg/services/ngalert/notifier/channels/kafka_test.go @@ -6,6 +6,7 @@ import ( "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" @@ -113,8 +114,8 @@ func TestKafkaNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { webhookSender := mockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := channels.FactoryConfig{ + Config: &channels.NotificationChannelConfig{ Name: "kafka_testing", Type: "kafka", Settings: json.RawMessage(c.settings), @@ -124,7 +125,7 @@ func TestKafkaNotifier(t *testing.T) { NotificationService: webhookSender, DecryptFunc: nil, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &channels.FakeLogger{}, } pn, err := newKafkaNotifier(fc) @@ -148,7 +149,7 @@ func TestKafkaNotifier(t *testing.T) { require.NoError(t, err) require.True(t, ok) - require.Equal(t, c.expUrl, webhookSender.Webhook.Url) + 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 index 7c9a9a7c9eb..11611cef9df 100644 --- a/pkg/services/ngalert/notifier/channels/line.go +++ b/pkg/services/ngalert/notifier/channels/line.go @@ -7,6 +7,7 @@ import ( "net/url" "path" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" @@ -20,9 +21,9 @@ var ( // LineNotifier is responsible for sending // alert notifications to LINE. type LineNotifier struct { - *Base - log Logger - ns WebhookSender + *channels.Base + log channels.Logger + ns channels.WebhookSender tmpl *template.Template settings lineSettings } @@ -33,7 +34,7 @@ type lineSettings struct { description string } -func LineFactory(fc FactoryConfig) (NotificationChannel, error) { +func LineFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { n, err := newLineNotifier(fc) if err != nil { return nil, receiverInitError{ @@ -45,7 +46,7 @@ func LineFactory(fc FactoryConfig) (NotificationChannel, error) { } // newLineNotifier is the constructor for the LINE notifier -func newLineNotifier(fc FactoryConfig) (*LineNotifier, error) { +func newLineNotifier(fc channels.FactoryConfig) (*LineNotifier, error) { settings, err := simplejson.NewJson(fc.Config.Settings) if err != nil { return nil, err @@ -54,11 +55,11 @@ func newLineNotifier(fc FactoryConfig) (*LineNotifier, error) { if token == "" { return nil, errors.New("could not find token in settings") } - title := settings.Get("title").MustString(DefaultMessageTitleEmbed) - description := settings.Get("description").MustString(DefaultMessageEmbed) + title := settings.Get("title").MustString(channels.DefaultMessageTitleEmbed) + description := settings.Get("description").MustString(channels.DefaultMessageEmbed) return &LineNotifier{ - Base: NewBase(fc.Config), + Base: channels.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, tmpl: fc.Template, @@ -75,10 +76,10 @@ func (ln *LineNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e form := url.Values{} form.Add("message", body) - cmd := &SendWebhookSettings{ - Url: LineNotifyURL, - HttpMethod: "POST", - HttpHeader: map[string]string{ + 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", }, @@ -101,7 +102,7 @@ func (ln *LineNotifier) buildMessage(ctx context.Context, as ...*types.Alert) st ruleURL := path.Join(ln.tmpl.ExternalURL.String(), "/alerting/list") var tmplErr error - tmpl, _ := TmplText(ctx, ln.tmpl, as, ln.log, &tmplErr) + tmpl, _ := channels.TmplText(ctx, ln.tmpl, as, ln.log, &tmplErr) body := fmt.Sprintf( "%s\n%s\n\n%s", diff --git a/pkg/services/ngalert/notifier/channels/line_test.go b/pkg/services/ngalert/notifier/channels/line_test.go index 0fedf5a192a..95389dbaf70 100644 --- a/pkg/services/ngalert/notifier/channels/line_test.go +++ b/pkg/services/ngalert/notifier/channels/line_test.go @@ -6,6 +6,7 @@ import ( "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" @@ -97,8 +98,8 @@ func TestLineNotifier(t *testing.T) { secureSettings := make(map[string][]byte) webhookSender := mockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := channels.FactoryConfig{ + Config: &channels.NotificationChannelConfig{ Name: "line_testing", Type: "line", Settings: settingsJSON, @@ -110,7 +111,7 @@ func TestLineNotifier(t *testing.T) { return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &channels.FakeLogger{}, } pn, err := newLineNotifier(fc) if c.expInitError != "" { @@ -132,7 +133,7 @@ func TestLineNotifier(t *testing.T) { require.NoError(t, err) require.True(t, ok) - require.Equal(t, c.expHeaders, webhookSender.Webhook.HttpHeader) + require.Equal(t, c.expHeaders, webhookSender.Webhook.HTTPHeader) require.Equal(t, c.expMsg, webhookSender.Webhook.Body) }) } diff --git a/pkg/services/ngalert/notifier/channels/log.go b/pkg/services/ngalert/notifier/channels/log.go deleted file mode 100644 index 6fe9148e9bb..00000000000 --- a/pkg/services/ngalert/notifier/channels/log.go +++ /dev/null @@ -1,45 +0,0 @@ -package channels - -type LoggerFactory func(ctx ...interface{}) Logger - -type Logger interface { - // New returns a new contextual Logger that has this logger's context plus the given context. - New(ctx ...interface{}) Logger - - Log(keyvals ...interface{}) error - - // Debug logs a message with debug level and key/value pairs, if any. - Debug(msg string, ctx ...interface{}) - - // Info logs a message with info level and key/value pairs, if any. - Info(msg string, ctx ...interface{}) - - // Warn logs a message with warning level and key/value pairs, if any. - Warn(msg string, ctx ...interface{}) - - // Error logs a message with error level and key/value pairs, if any. - Error(msg string, ctx ...interface{}) -} - -type FakeLogger struct { -} - -func (f FakeLogger) New(ctx ...interface{}) Logger { - return f -} - -func (f FakeLogger) Log(keyvals ...interface{}) error { - return nil -} - -func (f FakeLogger) Debug(msg string, ctx ...interface{}) { -} - -func (f FakeLogger) Info(msg string, ctx ...interface{}) { -} - -func (f FakeLogger) Warn(msg string, ctx ...interface{}) { -} - -func (f FakeLogger) Error(msg string, ctx ...interface{}) { -} diff --git a/pkg/services/ngalert/notifier/channels/opsgenie.go b/pkg/services/ngalert/notifier/channels/opsgenie.go deleted file mode 100644 index 52160b5d62b..00000000000 --- a/pkg/services/ngalert/notifier/channels/opsgenie.go +++ /dev/null @@ -1,316 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "sort" - "strings" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - ptr "github.com/xorcare/pointer" -) - -const ( - OpsgenieSendTags = "tags" - OpsgenieSendDetails = "details" - OpsgenieSendBoth = "both" - // https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes. - opsGenieMaxMessageLenRunes = 130 -) - -var ( - OpsgenieAlertURL = "https://api.opsgenie.com/v2/alerts" - ValidPriorities = map[string]bool{"P1": true, "P2": true, "P3": true, "P4": true, "P5": true} -) - -// OpsgenieNotifier is responsible for sending alert notifications to Opsgenie. -type OpsgenieNotifier struct { - *Base - tmpl *template.Template - log Logger - ns WebhookSender - images ImageStore - settings *opsgenieSettings -} - -type opsgenieSettings struct { - APIKey string - APIUrl string - Message string - Description string - AutoClose bool - OverridePriority bool - SendTagsAs string -} - -func buildOpsgenieSettings(fc FactoryConfig) (*opsgenieSettings, error) { - type rawSettings struct { - APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` - APIUrl string `json:"apiUrl,omitempty" yaml:"apiUrl,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - AutoClose *bool `json:"autoClose,omitempty" yaml:"autoClose,omitempty"` - OverridePriority *bool `json:"overridePriority,omitempty" yaml:"overridePriority,omitempty"` - SendTagsAs string `json:"sendTagsAs,omitempty" yaml:"sendTagsAs,omitempty"` - } - - raw := rawSettings{} - err := fc.Config.unmarshalSettings(&raw) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - raw.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apiKey", raw.APIKey) - if raw.APIKey == "" { - return nil, errors.New("could not find api key property in settings") - } - if raw.APIUrl == "" { - raw.APIUrl = OpsgenieAlertURL - } - - if strings.TrimSpace(raw.Message) == "" { - raw.Message = DefaultMessageTitleEmbed - } - - switch raw.SendTagsAs { - case OpsgenieSendTags, OpsgenieSendDetails, OpsgenieSendBoth: - case "": - raw.SendTagsAs = OpsgenieSendTags - default: - return nil, fmt.Errorf("invalid value for sendTagsAs: %q", raw.SendTagsAs) - } - - if raw.AutoClose == nil { - raw.AutoClose = ptr.Bool(true) - } - if raw.OverridePriority == nil { - raw.OverridePriority = ptr.Bool(true) - } - - return &opsgenieSettings{ - APIKey: raw.APIKey, - APIUrl: raw.APIUrl, - Message: raw.Message, - Description: raw.Description, - AutoClose: *raw.AutoClose, - OverridePriority: *raw.OverridePriority, - SendTagsAs: raw.SendTagsAs, - }, nil -} - -func OpsgenieFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := NewOpsgenieNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -// NewOpsgenieNotifier is the constructor for the Opsgenie notifier -func NewOpsgenieNotifier(fc FactoryConfig) (*OpsgenieNotifier, error) { - settings, err := buildOpsgenieSettings(fc) - if err != nil { - return nil, err - } - return &OpsgenieNotifier{ - Base: NewBase(fc.Config), - tmpl: fc.Template, - log: fc.Logger, - ns: fc.NotificationService, - images: fc.ImageStore, - settings: settings, - }, nil -} - -// Notify sends an alert notification to Opsgenie -func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - on.log.Debug("executing Opsgenie notification", "notification", on.Name) - - alerts := types.Alerts(as...) - if alerts.Status() == model.AlertResolved && !on.SendResolved() { - on.log.Debug("not sending a trigger to Opsgenie", "status", alerts.Status(), "auto resolve", on.SendResolved()) - return true, nil - } - - body, url, err := on.buildOpsgenieMessage(ctx, alerts, as) - if err != nil { - return false, fmt.Errorf("build Opsgenie message: %w", err) - } - - if url == "" { - // Resolved alert with no auto close. - // Hence skip sending anything. - return true, nil - } - - cmd := &SendWebhookSettings{ - Url: url, - Body: string(body), - HttpMethod: http.MethodPost, - HttpHeader: map[string]string{ - "Content-Type": "application/json", - "Authorization": fmt.Sprintf("GenieKey %s", on.settings.APIKey), - }, - } - - if err := on.ns.SendWebhook(ctx, cmd); err != nil { - return false, fmt.Errorf("send notification to Opsgenie: %w", err) - } - - return true, nil -} - -func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts model.Alerts, as []*types.Alert) (payload []byte, apiURL string, err error) { - key, err := notify.ExtractGroupKey(ctx) - if err != nil { - return nil, "", err - } - - if alerts.Status() == model.AlertResolved { - // For resolved notification, we only need the source. - // Don't need to run other templates. - if !on.settings.AutoClose { // TODO This should be handled by DisableResolveMessage? - return nil, "", nil - } - msg := opsGenieCloseMessage{ - Source: "Grafana", - } - data, err := json.Marshal(msg) - apiURL = fmt.Sprintf("%s/%s/close?identifierType=alias", on.settings.APIUrl, key.Hash()) - return data, apiURL, err - } - - ruleURL := joinUrlPath(on.tmpl.ExternalURL.String(), "/alerting/list", on.log) - - var tmplErr error - tmpl, data := TmplText(ctx, on.tmpl, as, on.log, &tmplErr) - - message, truncated := TruncateInRunes(tmpl(on.settings.Message), opsGenieMaxMessageLenRunes) - if truncated { - on.log.Warn("Truncated message", "alert", key, "max_runes", opsGenieMaxMessageLenRunes) - } - - description := tmpl(on.settings.Description) - if strings.TrimSpace(description) == "" { - description = fmt.Sprintf( - "%s\n%s\n\n%s", - tmpl(DefaultMessageTitleEmbed), - ruleURL, - tmpl(DefaultMessageEmbed), - ) - } - - var priority string - - // In the new alerting system we've moved away from the grafana-tags. Instead, annotations on the rule itself should be used. - lbls := make(map[string]string, len(data.CommonLabels)) - for k, v := range data.CommonLabels { - lbls[k] = tmpl(v) - if k == "og_priority" && on.settings.OverridePriority { - if ValidPriorities[v] { - priority = v - } - } - } - - // Check for templating errors - if tmplErr != nil { - on.log.Warn("failed to template Opsgenie message", "error", tmplErr.Error()) - tmplErr = nil - } - - details := make(map[string]interface{}) - details["url"] = ruleURL - if on.sendDetails() { - for k, v := range lbls { - details[k] = v - } - var images []string - _ = withStoredImages(ctx, on.log, on.images, - func(_ int, image Image) error { - if len(image.URL) == 0 { - return nil - } - images = append(images, image.URL) - return nil - }, - as...) - - if len(images) != 0 { - details["image_urls"] = images - } - } - - tags := make([]string, 0, len(lbls)) - if on.sendTags() { - for k, v := range lbls { - tags = append(tags, fmt.Sprintf("%s:%s", k, v)) - } - } - sort.Strings(tags) - - result := opsGenieCreateMessage{ - Alias: key.Hash(), - Description: description, - Tags: tags, - Source: "Grafana", - Message: message, - Details: details, - Priority: priority, - } - - apiURL = tmpl(on.settings.APIUrl) - if tmplErr != nil { - on.log.Warn("failed to template Opsgenie URL", "error", tmplErr.Error(), "fallback", on.settings.APIUrl) - apiURL = on.settings.APIUrl - } - - b, err := json.Marshal(result) - return b, apiURL, err -} - -func (on *OpsgenieNotifier) SendResolved() bool { - return !on.GetDisableResolveMessage() -} - -func (on *OpsgenieNotifier) sendDetails() bool { - return on.settings.SendTagsAs == OpsgenieSendDetails || on.settings.SendTagsAs == OpsgenieSendBoth -} - -func (on *OpsgenieNotifier) sendTags() bool { - return on.settings.SendTagsAs == OpsgenieSendTags || on.settings.SendTagsAs == OpsgenieSendBoth -} - -type opsGenieCreateMessage struct { - Alias string `json:"alias"` - Message string `json:"message"` - Description string `json:"description,omitempty"` - Details map[string]interface{} `json:"details"` - Source string `json:"source"` - Responders []opsGenieCreateMessageResponder `json:"responders,omitempty"` - Tags []string `json:"tags"` - Note string `json:"note,omitempty"` - Priority string `json:"priority,omitempty"` - Entity string `json:"entity,omitempty"` - Actions []string `json:"actions,omitempty"` -} - -type opsGenieCreateMessageResponder struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Username string `json:"username,omitempty"` - Type string `json:"type"` // team, user, escalation, schedule etc. -} - -type opsGenieCloseMessage struct { - Source string `json:"source"` -} diff --git a/pkg/services/ngalert/notifier/channels/opsgenie_test.go b/pkg/services/ngalert/notifier/channels/opsgenie_test.go deleted file mode 100644 index 7ee3e50cf94..00000000000 --- a/pkg/services/ngalert/notifier/channels/opsgenie_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "net/url" - "testing" - "time" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestOpsgenieNotifier(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 string - expInitError string - expMsgError error - }{ - { - name: "Default config with one alert", - settings: `{"apiKey": "abcdefgh0123456789"}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: `{ - "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**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", - "details": { - "url": "http://localhost/alerting/list" - }, - "message": "[FIRING:1] (val1)", - "source": "Grafana", - "tags": ["alertname:alert1", "lbl1:val1"] - }`, - }, - { - name: "Default config with one alert, custom message and description", - settings: `{"apiKey": "abcdefgh0123456789", "message": "test message", "description": "test description"}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: `{ - "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "test description", - "details": { - "url": "http://localhost/alerting/list" - }, - "message": "test message", - "source": "Grafana", - "tags": ["alertname:alert1", "lbl1:val1"] - }`, - }, - { - name: "Default config with one alert, message length > 130", - settings: `{ - "apiKey": "abcdefgh0123456789", - "message": "IyJnsW78xQoiBJ7L7NqASv31JCFf0At3r9KUykqBVxSiC6qkDhvDLDW9VImiFcq0Iw2XwFy5fX4FcbTmlkaZzUzjVwx9VUuokhzqQlJVhWDYFqhj3a5wX0LjyvNQjsqT9WaWJAWOJanwOAWon" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: `{ - "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**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", - "details": { - "url": "http://localhost/alerting/list" - }, - "message": "IyJnsW78xQoiBJ7L7NqASv31JCFf0At3r9KUykqBVxSiC6qkDhvDLDW9VImiFcq0Iw2XwFy5fX4FcbTmlkaZzUzjVwx9VUuokhzqQlJVhWDYFqhj3a5wX0LjyvNQjsqT9…", - "source": "Grafana", - "tags": ["alertname:alert1", "lbl1:val1"] - }`, - }, - { - name: "Default config with one alert, templated message and description", - settings: `{"apiKey": "abcdefgh0123456789", "message": "Firing: {{ len .Alerts.Firing }}", "description": "{{ len .Alerts.Firing }} firing, {{ len .Alerts.Resolved }} resolved."}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: `{ - "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "1 firing, 0 resolved.", - "details": { - "url": "http://localhost/alerting/list" - }, - "message": "Firing: 1", - "source": "Grafana", - "tags": ["alertname:alert1", "lbl1:val1"] - }`, - }, - { - name: "Default config with one alert and send tags as tags, empty description and message", - settings: `{ - "apiKey": "abcdefgh0123456789", - "sendTagsAs": "tags", - "message": " ", - "description": " " - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1"}, - }, - }, - }, - expMsg: `{ - "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**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", - "details": { - "url": "http://localhost/alerting/list" - }, - "message": "[FIRING:1] (val1)", - "source": "Grafana", - "tags": ["alertname:alert1", "lbl1:val1"] - }`, - }, - { - name: "Default config with one alert and send tags as details", - settings: `{ - "apiKey": "abcdefgh0123456789", - "sendTagsAs": "details" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1"}, - }, - }, - }, - expMsg: `{ - "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**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", - "details": { - "alertname": "alert1", - "lbl1": "val1", - "url": "http://localhost/alerting/list" - }, - "message": "[FIRING:1] (val1)", - "source": "Grafana", - "tags": [] - }`, - }, - { - name: "Custom config with multiple alerts and send tags as both details and tag", - settings: `{ - "apiKey": "abcdefgh0123456789", - "sendTagsAs": "both" - }`, - 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": "annv1"}, - }, - }, - }, - expMsg: `{ - "alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - "description": "[FIRING:2] \nhttp://localhost/alerting/list\n\n**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 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", - "details": { - "alertname": "alert1", - "url": "http://localhost/alerting/list" - }, - "message": "[FIRING:2] ", - "source": "Grafana", - "tags": ["alertname:alert1"] - }`, - expMsgError: nil, - }, - { - name: "Resolved is not sent when auto close is false", - settings: `{"apiKey": "abcdefgh0123456789", "autoClose": false}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1"}, - EndsAt: time.Now().Add(-1 * time.Minute), - }, - }, - }, - }, - { - name: "Error when incorrect settings", - settings: `{}`, - expInitError: `could not find api key property 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() - webhookSender.Webhook.Body = "" - - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - SecureSettings: secureSettings, - }, - NotificationService: webhookSender, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - ImageStore: &UnavailableImageStore{}, - Template: tmpl, - Logger: &FakeLogger{}, - } - - ctx := notify.WithGroupKey(context.Background(), "alertname") - ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - pn, err := NewOpsgenieNotifier(fc) - if c.expInitError != "" { - require.Error(t, err) - require.Equal(t, c.expInitError, err.Error()) - return - } - require.NoError(t, err) - 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.True(t, ok) - require.NoError(t, err) - - if c.expMsg == "" { - // No notification was expected. - require.Equal(t, "", webhookSender.Webhook.Body) - } else { - require.JSONEq(t, c.expMsg, webhookSender.Webhook.Body) - } - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/pagerduty.go b/pkg/services/ngalert/notifier/channels/pagerduty.go deleted file mode 100644 index df0cfdb20f8..00000000000 --- a/pkg/services/ngalert/notifier/channels/pagerduty.go +++ /dev/null @@ -1,279 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "strings" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" -) - -const ( - // https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTgx-send-an-alert-event - 1024 characters or runes. - pagerDutyMaxV2SummaryLenRunes = 1024 -) - -const ( - pagerDutyEventTrigger = "trigger" - pagerDutyEventResolve = "resolve" - - defaultSeverity = "critical" - defaultClass = "default" - defaultGroup = "default" - defaultClient = "Grafana" -) - -var ( - knownSeverity = map[string]struct{}{defaultSeverity: {}, "error": {}, "warning": {}, "info": {}} - PagerdutyEventAPIURL = "https://events.pagerduty.com/v2/enqueue" -) - -// PagerdutyNotifier is responsible for sending -// alert notifications to pagerduty -type PagerdutyNotifier struct { - *Base - tmpl *template.Template - log Logger - ns WebhookSender - images ImageStore - settings *pagerdutySettings -} - -type pagerdutySettings struct { - Key string `json:"integrationKey,omitempty" yaml:"integrationKey,omitempty"` - Severity string `json:"severity,omitempty" yaml:"severity,omitempty"` - customDetails map[string]string - Class string `json:"class,omitempty" yaml:"class,omitempty"` - Component string `json:"component,omitempty" yaml:"component,omitempty"` - Group string `json:"group,omitempty" yaml:"group,omitempty"` - Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` - Source string `json:"source,omitempty" yaml:"source,omitempty"` - Client string `json:"client,omitempty" yaml:"client,omitempty"` - ClientURL string `json:"client_url,omitempty" yaml:"client_url,omitempty"` -} - -func buildPagerdutySettings(fc FactoryConfig) (*pagerdutySettings, error) { - settings := pagerdutySettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - settings.Key = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "integrationKey", settings.Key) - if settings.Key == "" { - return nil, errors.New("could not find integration key property in settings") - } - - settings.customDetails = map[string]string{ - "firing": `{{ template "__text_alert_list" .Alerts.Firing }}`, - "resolved": `{{ template "__text_alert_list" .Alerts.Resolved }}`, - "num_firing": `{{ .Alerts.Firing | len }}`, - "num_resolved": `{{ .Alerts.Resolved | len }}`, - } - - if settings.Severity == "" { - settings.Severity = defaultSeverity - } - if settings.Class == "" { - settings.Class = defaultClass - } - if settings.Component == "" { - settings.Component = "Grafana" - } - if settings.Group == "" { - settings.Group = defaultGroup - } - if settings.Summary == "" { - settings.Summary = DefaultMessageTitleEmbed - } - if settings.Client == "" { - settings.Client = defaultClient - } - if settings.ClientURL == "" { - settings.ClientURL = "{{ .ExternalURL }}" - } - if settings.Source == "" { - source, err := os.Hostname() - if err != nil { - source = settings.Client - } - settings.Source = source - } - return &settings, nil -} - -func PagerdutyFactory(fc FactoryConfig) (NotificationChannel, error) { - pdn, err := newPagerdutyNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return pdn, nil -} - -// NewPagerdutyNotifier is the constructor for the PagerDuty notifier -func newPagerdutyNotifier(fc FactoryConfig) (*PagerdutyNotifier, error) { - settings, err := buildPagerdutySettings(fc) - if err != nil { - return nil, err - } - - return &PagerdutyNotifier{ - Base: NewBase(fc.Config), - tmpl: fc.Template, - log: fc.Logger, - ns: fc.NotificationService, - images: fc.ImageStore, - settings: settings, - }, nil -} - -// Notify sends an alert notification to PagerDuty -func (pn *PagerdutyNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - alerts := types.Alerts(as...) - if alerts.Status() == model.AlertResolved && !pn.SendResolved() { - pn.log.Debug("not sending a trigger to Pagerduty", "status", alerts.Status(), "auto resolve", pn.SendResolved()) - return true, nil - } - - msg, eventType, err := pn.buildPagerdutyMessage(ctx, alerts, as) - if err != nil { - return false, fmt.Errorf("build pagerduty message: %w", err) - } - - body, err := json.Marshal(msg) - if err != nil { - return false, fmt.Errorf("marshal json: %w", err) - } - - pn.log.Info("notifying Pagerduty", "event_type", eventType) - cmd := &SendWebhookSettings{ - Url: PagerdutyEventAPIURL, - Body: string(body), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": "application/json", - }, - } - if err := pn.ns.SendWebhook(ctx, cmd); err != nil { - return false, fmt.Errorf("send notification to Pagerduty: %w", err) - } - - return true, nil -} - -func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts model.Alerts, as []*types.Alert) (*pagerDutyMessage, string, error) { - key, err := notify.ExtractGroupKey(ctx) - if err != nil { - return nil, "", err - } - - eventType := pagerDutyEventTrigger - if alerts.Status() == model.AlertResolved { - eventType = pagerDutyEventResolve - } - - var tmplErr error - tmpl, data := TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) - - details := make(map[string]string, len(pn.settings.customDetails)) - for k, v := range pn.settings.customDetails { - detail, err := pn.tmpl.ExecuteTextString(v, data) - if err != nil { - return nil, "", fmt.Errorf("%q: failed to template %q: %w", k, v, err) - } - details[k] = detail - } - - severity := strings.ToLower(tmpl(pn.settings.Severity)) - if _, ok := knownSeverity[severity]; !ok { - pn.log.Warn("Severity is not in the list of known values - using default severity", "actualSeverity", severity, "defaultSeverity", defaultSeverity) - severity = defaultSeverity - } - - msg := &pagerDutyMessage{ - Client: tmpl(pn.settings.Client), - ClientURL: tmpl(pn.settings.ClientURL), - RoutingKey: pn.settings.Key, - EventAction: eventType, - DedupKey: key.Hash(), - Links: []pagerDutyLink{{ - HRef: pn.tmpl.ExternalURL.String(), - Text: "External URL", - }}, - Payload: pagerDutyPayload{ - Source: tmpl(pn.settings.Source), - Component: tmpl(pn.settings.Component), - Summary: tmpl(pn.settings.Summary), - Severity: severity, - CustomDetails: details, - Class: tmpl(pn.settings.Class), - Group: tmpl(pn.settings.Group), - }, - } - - _ = withStoredImages(ctx, pn.log, pn.images, - func(_ int, image Image) error { - if len(image.URL) != 0 { - msg.Images = append(msg.Images, pagerDutyImage{Src: image.URL}) - } - - return nil - }, - as...) - - summary, truncated := TruncateInRunes(msg.Payload.Summary, pagerDutyMaxV2SummaryLenRunes) - if truncated { - pn.log.Warn("Truncated summary", "key", key, "runes", pagerDutyMaxV2SummaryLenRunes) - } - msg.Payload.Summary = summary - - if tmplErr != nil { - pn.log.Warn("failed to template PagerDuty message", "error", tmplErr.Error()) - } - - return msg, eventType, nil -} - -func (pn *PagerdutyNotifier) SendResolved() bool { - return !pn.GetDisableResolveMessage() -} - -type pagerDutyMessage struct { - RoutingKey string `json:"routing_key,omitempty"` - ServiceKey string `json:"service_key,omitempty"` - DedupKey string `json:"dedup_key,omitempty"` - EventAction string `json:"event_action"` - Payload pagerDutyPayload `json:"payload"` - Client string `json:"client,omitempty"` - ClientURL string `json:"client_url,omitempty"` - Links []pagerDutyLink `json:"links,omitempty"` - Images []pagerDutyImage `json:"images,omitempty"` -} - -type pagerDutyLink struct { - HRef string `json:"href"` - Text string `json:"text"` -} - -type pagerDutyImage struct { - Src string `json:"src"` -} - -type pagerDutyPayload struct { - Summary string `json:"summary"` - Source string `json:"source"` - Severity string `json:"severity"` - Class string `json:"class,omitempty"` - Component string `json:"component,omitempty"` - Group string `json:"group,omitempty"` - CustomDetails map[string]string `json:"custom_details,omitempty"` -} diff --git a/pkg/services/ngalert/notifier/channels/pagerduty_test.go b/pkg/services/ngalert/notifier/channels/pagerduty_test.go deleted file mode 100644 index 388bee43631..00000000000 --- a/pkg/services/ngalert/notifier/channels/pagerduty_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "fmt" - "math/rand" - "net/url" - "os" - "strings" - "testing" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestPagerdutyNotifier(t *testing.T) { - tmpl := templateForTests(t) - - externalURL, err := url.Parse("http://localhost") - require.NoError(t, err) - tmpl.ExternalURL = externalURL - - hostname, err := os.Hostname() - require.NoError(t, err) - - cases := []struct { - name string - settings string - alerts []*types.Alert - expMsg *pagerDutyMessage - expInitError string - expMsgError error - }{ - { - name: "Default config with one alert", - settings: `{"integrationKey": "abcdefgh0123456789"}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: &pagerDutyMessage{ - RoutingKey: "abcdefgh0123456789", - DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - EventAction: "trigger", - Payload: pagerDutyPayload{ - Summary: "[FIRING:1] (val1)", - Source: hostname, - Severity: defaultSeverity, - Class: "default", - Component: "Grafana", - Group: "default", - CustomDetails: map[string]string{ - "firing": "\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", - "num_firing": "1", - "num_resolved": "0", - "resolved": "", - }, - }, - Client: "Grafana", - ClientURL: "http://localhost", - Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, - }, - expMsgError: nil, - }, - { - name: "should map unknown severity", - settings: `{"integrationKey": "abcdefgh0123456789", "severity": "{{ .CommonLabels.severity }}"}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1", "severity": "invalid-severity"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: &pagerDutyMessage{ - RoutingKey: "abcdefgh0123456789", - DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - EventAction: "trigger", - Payload: pagerDutyPayload{ - Summary: "[FIRING:1] (val1 invalid-severity)", - Source: hostname, - Severity: defaultSeverity, - Class: "default", - Component: "Grafana", - Group: "default", - CustomDetails: map[string]string{ - "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\n - severity = invalid-severity\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1&matcher=severity%3Dinvalid-severity\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", - "num_firing": "1", - "num_resolved": "0", - "resolved": "", - }, - }, - Client: "Grafana", - ClientURL: "http://localhost", - Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, - }, - expMsgError: nil, - }, - { - name: "Should expand templates in fields", - settings: `{ - "integrationKey": "abcdefgh0123456789", - "severity" : "{{ .CommonLabels.severity }}", - "class": "{{ .CommonLabels.class }}", - "component": "{{ .CommonLabels.component }}", - "group" : "{{ .CommonLabels.group }}", - "source": "{{ .CommonLabels.source }}", - "client": "client-{{ .CommonLabels.source }}", - "client_url": "http://localhost:20200/{{ .CommonLabels.group }}" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1", "severity": "critical", "class": "test-class", "group": "test-group", "component": "test-component", "source": "test-source"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: &pagerDutyMessage{ - RoutingKey: "abcdefgh0123456789", - DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - EventAction: "trigger", - Payload: pagerDutyPayload{ - Summary: "[FIRING:1] (test-class test-component test-group val1 critical test-source)", - Source: "test-source", - Severity: "critical", - Class: "test-class", - Component: "test-component", - Group: "test-group", - CustomDetails: map[string]string{ - "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - class = test-class\n - component = test-component\n - group = test-group\n - lbl1 = val1\n - severity = critical\n - source = test-source\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=class%3Dtest-class&matcher=component%3Dtest-component&matcher=group%3Dtest-group&matcher=lbl1%3Dval1&matcher=severity%3Dcritical&matcher=source%3Dtest-source\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", - "num_firing": "1", - "num_resolved": "0", - "resolved": "", - }, - }, - Client: "client-test-source", - ClientURL: "http://localhost:20200/test-group", - Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, - }, - expMsgError: nil, - }, - { - name: "Default config with one alert and custom summary", - settings: `{"integrationKey": "abcdefgh0123456789", "summary": "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: &pagerDutyMessage{ - RoutingKey: "abcdefgh0123456789", - DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - EventAction: "trigger", - Payload: pagerDutyPayload{ - Summary: "Alerts firing: 1", - Source: hostname, - Severity: defaultSeverity, - Class: "default", - Component: "Grafana", - Group: "default", - CustomDetails: map[string]string{ - "firing": "\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", - "num_firing": "1", - "num_resolved": "0", - "resolved": "", - }, - }, - Client: "Grafana", - ClientURL: "http://localhost", - Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, - }, - expMsgError: nil, - }, { - name: "Custom config with multiple alerts", - settings: `{ - "integrationKey": "abcdefgh0123456789", - "severity": "warning", - "class": "{{ .Status }}", - "component": "My Grafana", - "group": "my_group" - }`, - 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: &pagerDutyMessage{ - RoutingKey: "abcdefgh0123456789", - DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - EventAction: "trigger", - Payload: pagerDutyPayload{ - Summary: "[FIRING:2] ", - Source: hostname, - Severity: "warning", - Class: "firing", - Component: "My Grafana", - Group: "my_group", - CustomDetails: map[string]string{ - "firing": "\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", - "num_firing": "2", - "num_resolved": "0", - "resolved": "", - }, - }, - Client: "Grafana", - ClientURL: "http://localhost", - Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, - }, - expMsgError: nil, - }, - { - name: "should truncate long summary", - settings: fmt.Sprintf(`{"integrationKey": "abcdefgh0123456789", "summary": "%s"}`, strings.Repeat("1", rand.Intn(100)+1025)), - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: &pagerDutyMessage{ - RoutingKey: "abcdefgh0123456789", - DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", - EventAction: "trigger", - Payload: pagerDutyPayload{ - Summary: fmt.Sprintf("%s…", strings.Repeat("1", 1023)), - Source: hostname, - Severity: defaultSeverity, - Class: "default", - Component: "Grafana", - Group: "default", - CustomDetails: map[string]string{ - "firing": "\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", - "num_firing": "1", - "num_resolved": "0", - "resolved": "", - }, - }, - Client: "Grafana", - ClientURL: "http://localhost", - Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, - }, - expMsgError: nil, - }, - { - name: "Error in initing", - settings: `{}`, - expInitError: `could not find integration key property 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 := FactoryConfig{ - Config: &NotificationChannelConfig{ - Name: "pageduty_testing", - Type: "pagerduty", - Settings: settingsJSON, - SecureSettings: secureSettings, - }, - NotificationService: webhookSender, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - Template: tmpl, - Logger: &FakeLogger{}, - } - pn, err := newPagerdutyNotifier(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.True(t, ok) - require.NoError(t, err) - - 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/pushover.go b/pkg/services/ngalert/notifier/channels/pushover.go deleted file mode 100644 index 99f2ee853ae..00000000000 --- a/pkg/services/ngalert/notifier/channels/pushover.go +++ /dev/null @@ -1,339 +0,0 @@ -package channels - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "os" - "strconv" - "strings" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" -) - -const ( - pushoverMaxFileSize = 1 << 21 // 2MB - // https://pushover.net/api#limits - 250 characters or runes. - pushoverMaxTitleLenRunes = 250 - // https://pushover.net/api#limits - 1024 characters or runes. - pushoverMaxMessageLenRunes = 1024 - // https://pushover.net/api#limits - 512 characters or runes. - pushoverMaxURLLenRunes = 512 -) - -var ( - PushoverEndpoint = "https://api.pushover.net/1/messages.json" -) - -// PushoverNotifier is responsible for sending -// alert notifications to Pushover -type PushoverNotifier struct { - *Base - tmpl *template.Template - log Logger - images ImageStore - ns WebhookSender - settings pushoverSettings -} - -type pushoverSettings struct { - userKey string - apiToken string - alertingPriority int64 - okPriority int64 - retry int64 - expire int64 - device string - alertingSound string - okSound string - upload bool - title string - message string -} - -func buildPushoverSettings(fc FactoryConfig) (pushoverSettings, error) { - settings := pushoverSettings{} - rawSettings := struct { - UserKey string `json:"userKey,omitempty" yaml:"userKey,omitempty"` - APIToken string `json:"apiToken,omitempty" yaml:"apiToken,omitempty"` - AlertingPriority json.Number `json:"priority,omitempty" yaml:"priority,omitempty"` - OKPriority json.Number `json:"okPriority,omitempty" yaml:"okPriority,omitempty"` - Retry json.Number `json:"retry,omitempty" yaml:"retry,omitempty"` - Expire json.Number `json:"expire,omitempty" yaml:"expire,omitempty"` - Device string `json:"device,omitempty" yaml:"device,omitempty"` - AlertingSound string `json:"sound,omitempty" yaml:"sound,omitempty"` - OKSound string `json:"okSound,omitempty" yaml:"okSound,omitempty"` - Upload *bool `json:"uploadImage,omitempty" yaml:"uploadImage,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - }{} - - err := fc.Config.unmarshalSettings(&rawSettings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - settings.userKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "userKey", rawSettings.UserKey) - if settings.userKey == "" { - return settings, errors.New("user key not found") - } - settings.apiToken = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apiToken", rawSettings.APIToken) - if settings.apiToken == "" { - return settings, errors.New("API token not found") - } - if rawSettings.AlertingPriority != "" { - settings.alertingPriority, err = rawSettings.AlertingPriority.Int64() - if err != nil { - return settings, fmt.Errorf("failed to convert alerting priority to integer: %w", err) - } - } - - if rawSettings.OKPriority != "" { - settings.okPriority, err = rawSettings.OKPriority.Int64() - if err != nil { - return settings, fmt.Errorf("failed to convert OK priority to integer: %w", err) - } - } - - settings.retry, _ = rawSettings.Retry.Int64() - settings.expire, _ = rawSettings.Expire.Int64() - - settings.device = rawSettings.Device - settings.alertingSound = rawSettings.AlertingSound - settings.okSound = rawSettings.OKSound - - if rawSettings.Upload == nil || *rawSettings.Upload { - settings.upload = true - } - - settings.message = rawSettings.Message - if settings.message == "" { - settings.message = DefaultMessageEmbed - } - - settings.title = rawSettings.Title - if settings.title == "" { - settings.title = DefaultMessageTitleEmbed - } - - return settings, nil -} - -func PushoverFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := NewPushoverNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -// NewSlackNotifier is the constructor for the Slack notifier -func NewPushoverNotifier(fc FactoryConfig) (*PushoverNotifier, error) { - settings, err := buildPushoverSettings(fc) - if err != nil { - return nil, err - } - return &PushoverNotifier{ - Base: NewBase(fc.Config), - tmpl: fc.Template, - log: fc.Logger, - images: fc.ImageStore, - ns: fc.NotificationService, - settings: settings, - }, nil -} - -// Notify sends an alert notification to Slack. -func (pn *PushoverNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - headers, uploadBody, err := pn.genPushoverBody(ctx, as...) - if err != nil { - pn.log.Error("Failed to generate body for pushover", "error", err) - return false, err - } - - cmd := &SendWebhookSettings{ - Url: PushoverEndpoint, - HttpMethod: "POST", - HttpHeader: headers, - Body: uploadBody.String(), - } - - if err := pn.ns.SendWebhook(ctx, cmd); err != nil { - pn.log.Error("failed to send pushover notification", "error", err, "webhook", pn.Name) - return false, err - } - - return true, nil -} -func (pn *PushoverNotifier) SendResolved() bool { - return !pn.GetDisableResolveMessage() -} - -func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Alert) (map[string]string, bytes.Buffer, error) { - key, err := notify.ExtractGroupKey(ctx) - if err != nil { - return nil, bytes.Buffer{}, err - } - - b := bytes.Buffer{} - w := multipart.NewWriter(&b) - - // tests use a non-random boundary separator - if boundary := GetBoundary(); boundary != "" { - err := w.SetBoundary(boundary) - if err != nil { - return nil, b, err - } - } - - var tmplErr error - tmpl, _ := TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) - - if err := w.WriteField("user", tmpl(pn.settings.userKey)); err != nil { - return nil, b, fmt.Errorf("failed to write the user: %w", err) - } - - if err := w.WriteField("token", pn.settings.apiToken); err != nil { - return nil, b, fmt.Errorf("failed to write the token: %w", err) - } - - title, truncated := TruncateInRunes(tmpl(pn.settings.title), pushoverMaxTitleLenRunes) - if truncated { - pn.log.Warn("Truncated title", "incident", key, "max_runes", pushoverMaxTitleLenRunes) - } - message := tmpl(pn.settings.message) - message, truncated = TruncateInRunes(message, pushoverMaxMessageLenRunes) - if truncated { - pn.log.Warn("Truncated message", "incident", key, "max_runes", pushoverMaxMessageLenRunes) - } - message = strings.TrimSpace(message) - if message == "" { - // Pushover rejects empty messages. - message = "(no details)" - } - - supplementaryURL := joinUrlPath(pn.tmpl.ExternalURL.String(), "/alerting/list", pn.log) - supplementaryURL, truncated = TruncateInRunes(supplementaryURL, pushoverMaxURLLenRunes) - if truncated { - pn.log.Warn("Truncated URL", "incident", key, "max_runes", pushoverMaxURLLenRunes) - } - - status := types.Alerts(as...).Status() - priority := pn.settings.alertingPriority - if status == model.AlertResolved { - priority = pn.settings.okPriority - } - if err := w.WriteField("priority", strconv.FormatInt(priority, 10)); err != nil { - return nil, b, fmt.Errorf("failed to write the priority: %w", err) - } - - if priority == 2 { - if err := w.WriteField("retry", strconv.FormatInt(pn.settings.retry, 10)); err != nil { - return nil, b, fmt.Errorf("failed to write retry: %w", err) - } - - if err := w.WriteField("expire", strconv.FormatInt(pn.settings.expire, 10)); err != nil { - return nil, b, fmt.Errorf("failed to write expire: %w", err) - } - } - - if pn.settings.device != "" { - if err := w.WriteField("device", tmpl(pn.settings.device)); err != nil { - return nil, b, fmt.Errorf("failed to write the device: %w", err) - } - } - - if err := w.WriteField("title", title); err != nil { - return nil, b, fmt.Errorf("failed to write the title: %w", err) - } - - if err := w.WriteField("url", supplementaryURL); err != nil { - return nil, b, fmt.Errorf("failed to write the URL: %w", err) - } - - if err := w.WriteField("url_title", "Show alert rule"); err != nil { - return nil, b, fmt.Errorf("failed to write the URL title: %w", err) - } - - if err := w.WriteField("message", message); err != nil { - return nil, b, fmt.Errorf("failed write the message: %w", err) - } - - pn.writeImageParts(ctx, w, as...) - - var sound string - if status == model.AlertResolved { - sound = tmpl(pn.settings.okSound) - } else { - sound = tmpl(pn.settings.alertingSound) - } - if sound != "default" { - if err := w.WriteField("sound", sound); err != nil { - return nil, b, fmt.Errorf("failed to write the sound: %w", err) - } - } - - // Mark the message as HTML - if err := w.WriteField("html", "1"); err != nil { - return nil, b, fmt.Errorf("failed to mark the message as HTML: %w", err) - } - if err := w.Close(); err != nil { - return nil, b, fmt.Errorf("failed to close the multipart request: %w", err) - } - - if tmplErr != nil { - pn.log.Warn("failed to template pushover message", "error", tmplErr.Error()) - } - - headers := map[string]string{ - "Content-Type": w.FormDataContentType(), - } - - return headers, b, nil -} - -func (pn *PushoverNotifier) writeImageParts(ctx context.Context, w *multipart.Writer, as ...*types.Alert) { - // Pushover supports at most one image attachment with a maximum size of pushoverMaxFileSize. - // If the image is larger than pushoverMaxFileSize then return an error. - _ = withStoredImages(ctx, pn.log, pn.images, func(index int, image Image) error { - f, err := os.Open(image.Path) - if err != nil { - return fmt.Errorf("failed to open the image: %w", err) - } - defer func() { - if err := f.Close(); err != nil { - pn.log.Error("failed to close the image", "file", image.Path) - } - }() - - fileInfo, err := f.Stat() - if err != nil { - return fmt.Errorf("failed to stat the image: %w", err) - } - - if fileInfo.Size() > pushoverMaxFileSize { - return fmt.Errorf("image would exceeded maximum file size: %d", fileInfo.Size()) - } - - fw, err := w.CreateFormFile("attachment", image.Path) - if err != nil { - return fmt.Errorf("failed to create form file for the image: %w", err) - } - - if _, err = io.Copy(fw, f); err != nil { - return fmt.Errorf("failed to copy the image to the form file: %w", err) - } - - return ErrImagesDone - }, as...) -} diff --git a/pkg/services/ngalert/notifier/channels/pushover_test.go b/pkg/services/ngalert/notifier/channels/pushover_test.go deleted file mode 100644 index e54a24da82b..00000000000 --- a/pkg/services/ngalert/notifier/channels/pushover_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package channels - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "mime/multipart" - "net/url" - "strings" - "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" -) - -func TestPushoverNotifier(t *testing.T) { - tmpl := templateForTests(t) - - images := newFakeImageStoreWithFile(t, 2) - - 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]string - expInitError string - expMsgError error - }{ - { - name: "Correct config with single alert", - settings: `{ - "userKey": "", - "apiToken": "" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, - }, - }, - }, - expMsg: map[string]string{ - "user": "", - "token": "", - "priority": "0", - "sound": "", - "title": "[FIRING:1] (val1)", - "url": "http://localhost/alerting/list", - "url_title": "Show alert rule", - "message": "**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", - "attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", - "html": "1", - }, - expMsgError: nil, - }, - { - name: "Custom title", - settings: `{ - "userKey": "", - "apiToken": "", - "title": "Alerts firing: {{ len .Alerts.Firing }}" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, - }, - }, - }, - expMsg: map[string]string{ - "user": "", - "token": "", - "priority": "0", - "sound": "", - "title": "Alerts firing: 1", - "url": "http://localhost/alerting/list", - "url_title": "Show alert rule", - "message": "**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", - "attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", - "html": "1", - }, - expMsgError: nil, - }, - { - name: "Custom config with multiple alerts", - settings: `{ - "userKey": "", - "apiToken": "", - "device": "device", - "priority": "2", - "okpriority": "0", - "retry": "30", - "expire": "86400", - "sound": "echo", - "oksound": "magic", - "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "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"}, - }, - }, - }, - expMsg: map[string]string{ - "user": "", - "token": "", - "priority": "2", - "sound": "echo", - "title": "[FIRING:2] ", - "url": "http://localhost/alerting/list", - "url_title": "Show alert rule", - "message": "2 alerts are firing, 0 are resolved", - "attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", - "html": "1", - "retry": "30", - "expire": "86400", - "device": "device", - }, - expMsgError: nil, - }, - { - name: "Integer fields as integers", - settings: `{ - "userKey": "", - "apiToken": "", - "device": "device", - "priority": 2, - "okpriority": 0, - "retry": 30, - "expire": 86400, - "sound": "echo", - "oksound": "magic", - "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "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"}, - }, - }, - }, - expMsg: map[string]string{ - "user": "", - "token": "", - "priority": "2", - "sound": "echo", - "title": "[FIRING:2] ", - "url": "http://localhost/alerting/list", - "url_title": "Show alert rule", - "message": "2 alerts are firing, 0 are resolved", - "attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", - "html": "1", - "retry": "30", - "expire": "86400", - "device": "device", - }, - expMsgError: nil, - }, - { - name: "Missing user key", - settings: `{ - "apiToken": "" - }`, - expInitError: `user key not found`, - }, { - name: "Missing api key", - settings: `{ - "userKey": "" - }`, - expInitError: `API token not found`, - }, - } - - for _, c := range cases { - origGetBoundary := GetBoundary - boundary := "abcd" - GetBoundary = func() string { - return boundary - } - t.Cleanup(func() { - GetBoundary = origGetBoundary - }) - - t.Run(c.name, func(t *testing.T) { - settingsJSON := json.RawMessage(c.settings) - secureSettings := make(map[string][]byte) - - webhookSender := mockNotificationService() - - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ - Name: "pushover_testing", - Type: "pushover", - Settings: settingsJSON, - SecureSettings: secureSettings, - }, - ImageStore: images, - NotificationService: webhookSender, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - Template: tmpl, - Logger: &FakeLogger{}, - } - - pn, err := NewPushoverNotifier(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.Error(t, err) - require.False(t, ok) - require.Equal(t, c.expMsgError.Error(), err.Error()) - return - } - require.NoError(t, err) - require.True(t, ok) - - bodyReader := multipart.NewReader(strings.NewReader(webhookSender.Webhook.Body), boundary) - for { - part, err := bodyReader.NextPart() - if part == nil || errors.Is(err, io.EOF) { - assert.Empty(t, c.expMsg, fmt.Sprintf("expected fields %v", c.expMsg)) - break - } - formField := part.FormName() - expected, ok := c.expMsg[formField] - assert.True(t, ok, fmt.Sprintf("unexpected field %s", formField)) - actual := []byte("") - if expected != "" { - buf := new(bytes.Buffer) - _, err := buf.ReadFrom(part) - require.NoError(t, err) - actual = buf.Bytes() - } - assert.Equal(t, expected, string(actual)) - delete(c.expMsg, formField) - } - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/sender.go b/pkg/services/ngalert/notifier/channels/sender.go deleted file mode 100644 index c3a682cf1d0..00000000000 --- a/pkg/services/ngalert/notifier/channels/sender.go +++ /dev/null @@ -1,46 +0,0 @@ -package channels - -import "context" - -type SendWebhookSettings struct { - Url string - User string - Password string - Body string - HttpMethod string - HttpHeader map[string]string - ContentType string - Validation func(body []byte, statusCode int) error -} - -// SendEmailSettings is the command for sending emails -type SendEmailSettings struct { - To []string - SingleEmail bool - Template string - Subject string - Data map[string]interface{} - Info string - ReplyTo []string - EmbeddedFiles []string - AttachedFiles []*SendEmailAttachFile -} - -// SendEmailAttachFile is a definition of the attached files without path -type SendEmailAttachFile struct { - Name string - Content []byte -} - -type WebhookSender interface { - SendWebhook(ctx context.Context, cmd *SendWebhookSettings) error -} - -type EmailSender interface { - SendEmail(ctx context.Context, cmd *SendEmailSettings) error -} - -type NotificationSender interface { - WebhookSender - EmailSender -} diff --git a/pkg/services/ngalert/notifier/channels/sensugo.go b/pkg/services/ngalert/notifier/channels/sensugo.go deleted file mode 100644 index 06ff9886706..00000000000 --- a/pkg/services/ngalert/notifier/channels/sensugo.go +++ /dev/null @@ -1,182 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" -) - -type SensuGoNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender - tmpl *template.Template - settings sensuGoSettings -} - -type sensuGoSettings struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` - Check string `json:"check,omitempty" yaml:"check,omitempty"` - Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` - Handler string `json:"handler,omitempty" yaml:"handler,omitempty"` - APIKey string `json:"apikey,omitempty" yaml:"apikey,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` -} - -func buildSensuGoConfig(fc FactoryConfig) (sensuGoSettings, error) { - settings := sensuGoSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if settings.URL == "" { - return settings, errors.New("could not find URL property in settings") - } - settings.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apikey", settings.APIKey) - if settings.APIKey == "" { - return settings, errors.New("could not find the API key property in settings") - } - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - return settings, nil -} - -func SensuGoFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := NewSensuGoNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -// NewSensuGoNotifier is the constructor for the SensuGo notifier -func NewSensuGoNotifier(fc FactoryConfig) (*SensuGoNotifier, error) { - settings, err := buildSensuGoConfig(fc) - if err != nil { - return nil, err - } - return &SensuGoNotifier{ - Base: NewBase(fc.Config), - log: fc.Logger, - images: fc.ImageStore, - ns: fc.NotificationService, - tmpl: fc.Template, - settings: settings, - }, nil -} - -// Notify sends an alert notification to Sensu Go -func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - sn.log.Debug("sending Sensu Go result") - - var tmplErr error - tmpl, _ := TmplText(ctx, sn.tmpl, as, sn.log, &tmplErr) - - // Sensu Go alerts require an entity and a check. We set it to the user-specified - // value (optional), else we fallback and use the grafana rule anme and ruleID. - entity := tmpl(sn.settings.Entity) - if entity == "" { - entity = "default" - } - - check := tmpl(sn.settings.Check) - if check == "" { - check = "default" - } - - alerts := types.Alerts(as...) - status := 0 - if alerts.Status() == model.AlertFiring { - // TODO figure out about NoData old state (we used to send status 1 in that case) - status = 2 - } - - namespace := tmpl(sn.settings.Namespace) - if namespace == "" { - namespace = "default" - } - - var handlers []string - if sn.settings.Handler != "" { - handlers = []string{tmpl(sn.settings.Handler)} - } - - labels := make(map[string]string) - - _ = withStoredImages(ctx, sn.log, sn.images, - func(_ int, image Image) error { - // If there is an image for this alert and the image has been uploaded - // to a public URL then add it to the request. We cannot add more than - // one image per request. - if image.URL != "" { - labels["imageURL"] = image.URL - return ErrImagesDone - } - return nil - }, as...) - - ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) - labels["ruleURL"] = ruleURL - - bodyMsgType := map[string]interface{}{ - "entity": map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": entity, - "namespace": namespace, - }, - }, - "check": map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": check, - "labels": labels, - }, - "output": tmpl(sn.settings.Message), - "issued": timeNow().Unix(), - "interval": 86400, - "status": status, - "handlers": handlers, - }, - "ruleUrl": ruleURL, - } - - if tmplErr != nil { - sn.log.Warn("failed to template sensugo message", "error", tmplErr.Error()) - } - - body, err := json.Marshal(bodyMsgType) - if err != nil { - return false, err - } - - cmd := &SendWebhookSettings{ - Url: fmt.Sprintf("%s/api/core/v2/namespaces/%s/events", strings.TrimSuffix(sn.settings.URL, "/"), namespace), - Body: string(body), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": "application/json", - "Authorization": fmt.Sprintf("Key %s", sn.settings.APIKey), - }, - } - if err := sn.ns.SendWebhook(ctx, cmd); err != nil { - sn.log.Error("failed to send Sensu Go event", "error", err, "sensugo", sn.Name) - return false, err - } - - return true, nil -} - -func (sn *SensuGoNotifier) SendResolved() bool { - return !sn.GetDisableResolveMessage() -} diff --git a/pkg/services/ngalert/notifier/channels/sensugo_test.go b/pkg/services/ngalert/notifier/channels/sensugo_test.go deleted file mode 100644 index f4cd4c53f6b..00000000000 --- a/pkg/services/ngalert/notifier/channels/sensugo_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "net/url" - "testing" - "time" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestSensuGoNotifier(t *testing.T) { - constNow := time.Now() - defer mockTimeNow(constNow)() - - tmpl := templateForTests(t) - - externalURL, err := url.Parse("http://localhost") - require.NoError(t, err) - tmpl.ExternalURL = externalURL - - images := newFakeImageStore(2) - - 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://sensu-api.local:8080", "apikey": ""}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, - }, - }, - }, - expMsg: map[string]interface{}{ - "entity": map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "default", - "namespace": "default", - }, - }, - "check": map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "default", - "labels": map[string]string{ - "imageURL": "https://www.example.com/test-image-1.jpg", - "ruleURL": "http://localhost/alerting/list", - }, - }, - "output": "**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", - "issued": timeNow().Unix(), - "interval": 86400, - "status": 2, - "handlers": nil, - }, - "ruleUrl": "http://localhost/alerting/list", - }, - expMsgError: nil, - }, { - name: "Custom config with multiple alerts", - settings: `{ - "url": "http://sensu-api.local:8080", - "entity": "grafana_instance_01", - "check": "grafana_rule_0", - "namespace": "namespace", - "handler": "myhandler", - "apikey": "", - "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "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"}, - }, - }, - }, - expMsg: map[string]interface{}{ - "entity": map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "grafana_instance_01", - "namespace": "namespace", - }, - }, - "check": map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": "grafana_rule_0", - "labels": map[string]string{ - "imageURL": "https://www.example.com/test-image-1.jpg", - "ruleURL": "http://localhost/alerting/list", - }, - }, - "output": "2 alerts are firing, 0 are resolved", - "issued": timeNow().Unix(), - "interval": 86400, - "status": 2, - "handlers": []string{"myhandler"}, - }, - "ruleUrl": "http://localhost/alerting/list", - }, - expMsgError: nil, - }, { - name: "Error in initing: missing URL", - settings: `{ - "apikey": "" - }`, - expInitError: `could not find URL property in settings`, - }, { - name: "Error in initing: missing API key", - settings: `{ - "url": "http://sensu-api.local:8080" - }`, - expInitError: `could not find the API key property in settings`, - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - settingsJSON := json.RawMessage(c.settings) - secureSettings := make(map[string][]byte) - - m := &NotificationChannelConfig{ - Name: "Sensu Go", - Type: "sensugo", - Settings: settingsJSON, - SecureSettings: secureSettings, - } - - webhookSender := mockNotificationService() - - fc := FactoryConfig{ - Config: m, - ImageStore: images, - NotificationService: webhookSender, - Template: tmpl, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - Logger: &FakeLogger{}, - } - - sn, err := NewSensuGoNotifier(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 := sn.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/slack.go b/pkg/services/ngalert/notifier/channels/slack.go index 437b0772106..57b57c675b0 100644 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -22,6 +22,8 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/setting" ) @@ -51,7 +53,7 @@ var ( var SlackAPIEndpoint = "https://slack.com/api/chat.postMessage" -type sendFunc func(ctx context.Context, req *http.Request, logger Logger) (string, error) +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 @@ -59,28 +61,28 @@ const slackMaxTitleLenRunes = 1024 // SlackNotifier is responsible for sending // alert notification to Slack. type SlackNotifier struct { - *Base - log Logger + *channels.Base + log channels.Logger tmpl *template.Template - images ImageStore - webhookSender WebhookSender + images channels.ImageStore + webhookSender channels.WebhookSender sendFn sendFunc settings slackSettings } 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 CommaSeparatedStrings `json:"mentionUsers,omitempty" yaml:"mentionUsers,omitempty"` - MentionGroups CommaSeparatedStrings `json:"mentionGroups,omitempty" yaml:"mentionGroups,omitempty"` + 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. @@ -100,7 +102,7 @@ func uploadURL(s slackSettings) (string, error) { } // SlackFactory creates a new NotificationChannel that sends notifications to Slack. -func SlackFactory(fc FactoryConfig) (NotificationChannel, error) { +func SlackFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { ch, err := buildSlackNotifier(fc) if err != nil { return nil, receiverInitError{ @@ -111,10 +113,10 @@ func SlackFactory(fc FactoryConfig) (NotificationChannel, error) { return ch, nil } -func buildSlackNotifier(factoryConfig FactoryConfig) (*SlackNotifier, error) { +func buildSlackNotifier(factoryConfig channels.FactoryConfig) (*SlackNotifier, error) { decryptFunc := factoryConfig.DecryptFunc var settings slackSettings - err := factoryConfig.Config.unmarshalSettings(&settings) + err := json.Unmarshal(factoryConfig.Config.Settings, &settings) if err != nil { return nil, fmt.Errorf("failed to unmarshal settings: %w", err) } @@ -148,13 +150,13 @@ func buildSlackNotifier(factoryConfig FactoryConfig) (*SlackNotifier, error) { settings.Username = "Grafana" } if settings.Text == "" { - settings.Text = DefaultMessageEmbed + settings.Text = channels.DefaultMessageEmbed } if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed + settings.Title = channels.DefaultMessageTitleEmbed } return &SlackNotifier{ - Base: NewBase(factoryConfig.Config), + Base: channels.NewBase(factoryConfig.Config), settings: settings, images: factoryConfig.ImageStore, @@ -211,7 +213,7 @@ func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo // 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 Image) error { + 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 { @@ -222,7 +224,7 @@ func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo }); err != nil { sn.log.Error("Failed to send Slack message", "err", err) } - return ErrImagesDone + return channels.ErrImagesDone } comment := initialCommentForImage(alerts[index]) return sn.uploadImage(ctx, image, sn.settings.Recipient, comment, thread_ts) @@ -237,7 +239,7 @@ func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo // sendSlackRequest sends a request to the Slack API. // Stubbable by tests. -var sendSlackRequest = func(ctx context.Context, req *http.Request, logger Logger) (string, error) { +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) @@ -269,7 +271,7 @@ var sendSlackRequest = func(ctx context.Context, req *http.Request, logger Logge } } -func handleSlackIncomingWebhookResponse(resp *http.Response, logger Logger) (string, error) { +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) @@ -313,7 +315,7 @@ func handleSlackIncomingWebhookResponse(resp *http.Response, logger Logger) (str return "", fmt.Errorf("failed incoming webhook: %s", string(b)) } -func handleSlackJSONResponse(resp *http.Response, logger Logger) (string, error) { +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) @@ -347,11 +349,11 @@ func handleSlackJSONResponse(resp *http.Response, logger Logger) (string, error) func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types.Alert) (*slackMessage, error) { var tmplErr error - tmpl, _ := TmplText(ctx, sn.tmpl, alerts, sn.log, &tmplErr) + tmpl, _ := channels.TmplText(ctx, sn.tmpl, alerts, sn.log, &tmplErr) ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) - title, truncated := TruncateInRunes(tmpl(sn.settings.Title), slackMaxTitleLenRunes) + title, truncated := channels.TruncateInRunes(tmpl(sn.settings.Title), slackMaxTitleLenRunes) if truncated { key, err := notify.ExtractGroupKey(ctx) if err != nil { @@ -373,7 +375,7 @@ func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types Title: title, Fallback: title, Footer: "Grafana v" + setting.BuildVersion, - FooterIcon: FooterIconURL, + FooterIcon: channels.FooterIconURL, Ts: time.Now().Unix(), TitleLink: ruleURL, Text: tmpl(sn.settings.Text), @@ -384,10 +386,10 @@ func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types 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 Image) error { + _ = withStoredImages(ctx, sn.log, sn.images, func(index int, image channels.Image) error { if image.URL != "" { req.Attachments[0].ImageURL = image.URL - return ErrImagesDone + return channels.ErrImagesDone } return nil }, alerts...) @@ -467,7 +469,7 @@ func (sn *SlackNotifier) sendSlackMessage(ctx context.Context, m *slackMessage) // 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 Image, channel, comment, thread_ts string) (http.Header, []byte, error) { +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() { @@ -544,7 +546,7 @@ func (sn *SlackNotifier) sendMultipart(ctx context.Context, headers http.Header, // 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 Image, channel, comment, thread_ts string) error { +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 { diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index 09d082b11b9..8260b71ea6a 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -19,6 +19,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/setting" ) @@ -375,7 +377,7 @@ type slackRequestRecorder struct { requests []*http.Request } -func (s *slackRequestRecorder) fn(_ context.Context, r *http.Request, _ Logger) (string, error) { +func (s *slackRequestRecorder) fn(_ context.Context, r *http.Request, _ channels.Logger) (string, error) { s.requests = append(s.requests, r) return "", nil } @@ -411,7 +413,7 @@ func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRe }) images := &fakeImageStore{ - Images: []*Image{{ + Images: []*channels.Image{{ Token: "image-on-disk", Path: f.Name(), }, { @@ -421,8 +423,8 @@ func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRe } notificationService := mockNotificationService() - c := FactoryConfig{ - Config: &NotificationChannelConfig{ + c := channels.FactoryConfig{ + Config: &channels.NotificationChannelConfig{ Name: "slack_testing", Type: "slack", Settings: json.RawMessage(settings), @@ -434,7 +436,7 @@ func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRe return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &channels.FakeLogger{}, } sn, err := buildSlackNotifier(c) @@ -562,7 +564,7 @@ func TestSendSlackRequest(t *testing.T) { req, err := http.NewRequest(http.MethodGet, server.URL, nil) require.NoError(tt, err) - _, err = sendSlackRequest(context.Background(), req, &FakeLogger{}) + _, err = sendSlackRequest(context.Background(), req, &channels.FakeLogger{}) if !test.expectError { require.NoError(tt, err) } else { diff --git a/pkg/services/ngalert/notifier/channels/teams.go b/pkg/services/ngalert/notifier/channels/teams.go deleted file mode 100644 index 899f08b8cbf..00000000000 --- a/pkg/services/ngalert/notifier/channels/teams.go +++ /dev/null @@ -1,378 +0,0 @@ -package channels - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - - "github.com/pkg/errors" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" -) - -const ( - ImageSizeSmall = "small" - ImageSizeMedium = "medium" - ImageSizeLarge = "large" - - TextColorDark = "dark" - TextColorLight = "light" - TextColorAccent = "accent" - TextColorGood = "good" - TextColorWarning = "warning" - TextColorAttention = "attention" - - TextSizeSmall = "small" - TextSizeMedium = "medium" - TextSizeLarge = "large" - TextSizeExtraLarge = "extraLarge" - TextSizeDefault = "default" - - TextWeightLighter = "lighter" - TextWeightBolder = "bolder" - TextWeightDefault = "default" -) - -// AdaptiveCardsMessage represents a message for adaptive cards. -type AdaptiveCardsMessage struct { - Attachments []AdaptiveCardsAttachment `json:"attachments"` - Summary string `json:"summary,omitempty"` // Summary is the text shown in notifications - Type string `json:"type"` -} - -// NewAdaptiveCardsMessage returns a message prepared for adaptive cards. -// https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#send-adaptive-cards-using-an-incoming-webhook -func NewAdaptiveCardsMessage(card AdaptiveCard) AdaptiveCardsMessage { - return AdaptiveCardsMessage{ - Attachments: []AdaptiveCardsAttachment{{ - ContentType: "application/vnd.microsoft.card.adaptive", - Content: card, - }}, - Type: "message", - } -} - -// AdaptiveCardsAttachment contains an adaptive card. -type AdaptiveCardsAttachment struct { - Content AdaptiveCard `json:"content"` - ContentType string `json:"contentType"` - ContentURL string `json:"contentUrl,omitempty"` -} - -// AdapativeCard repesents an Adaptive Card. -// https://adaptivecards.io/explorer/AdaptiveCard.html -type AdaptiveCard struct { - Body []AdaptiveCardItem - Schema string - Type string - Version string -} - -// NewAdaptiveCard returns a prepared Adaptive Card. -func NewAdaptiveCard() AdaptiveCard { - return AdaptiveCard{ - Body: make([]AdaptiveCardItem, 0), - Schema: "http://adaptivecards.io/schemas/adaptive-card.json", - Type: "AdaptiveCard", - Version: "1.4", - } -} - -func (c *AdaptiveCard) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - Body []AdaptiveCardItem `json:"body"` - Schema string `json:"$schema"` - Type string `json:"type"` - Version string `json:"version"` - MsTeams map[string]interface{} `json:"msTeams,omitempty"` - }{ - Body: c.Body, - Schema: c.Schema, - Type: c.Type, - Version: c.Version, - MsTeams: map[string]interface{}{"width": "Full"}, - }) -} - -// AppendItem appends an item, such as text or an image, to the Adaptive Card. -func (c *AdaptiveCard) AppendItem(i AdaptiveCardItem) { - c.Body = append(c.Body, i) -} - -// AdaptiveCardItem is an interface for adaptive card items such as containers, elements and inputs. -type AdaptiveCardItem interface { - MarshalJSON() ([]byte, error) -} - -// AdaptiveCardTextBlockItem is a TextBlock. -type AdaptiveCardTextBlockItem struct { - Color string - Size string - Text string - Weight string - Wrap bool -} - -func (i AdaptiveCardTextBlockItem) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - Type string `json:"type"` - Text string `json:"text"` - Color string `json:"color,omitempty"` - Size string `json:"size,omitempty"` - Weight string `json:"weight,omitempty"` - Wrap bool `json:"wrap,omitempty"` - }{ - Type: "TextBlock", - Text: i.Text, - Color: i.Color, - Size: i.Size, - Weight: i.Weight, - Wrap: i.Wrap, - }) -} - -// AdaptiveCardImageSetItem is an ImageSet. -type AdaptiveCardImageSetItem struct { - Images []AdaptiveCardImageItem - Size string -} - -// AppendImage appends an image to image set. -func (i *AdaptiveCardImageSetItem) AppendImage(image AdaptiveCardImageItem) { - i.Images = append(i.Images, image) -} - -func (i AdaptiveCardImageSetItem) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - Type string `json:"type"` - Images []AdaptiveCardImageItem `json:"images"` - Size string `json:"imageSize"` - }{ - Type: "ImageSet", - Images: i.Images, - Size: i.Size, - }) -} - -// AdaptiveCardImageItem is an Image. -type AdaptiveCardImageItem struct { - AltText string - Size string - URL string -} - -func (i AdaptiveCardImageItem) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - Type string `json:"type"` - URL string `json:"url"` - AltText string `json:"altText,omitempty"` - Size string `json:"size,omitempty"` - MsTeams map[string]interface{} `json:"msTeams,omitempty"` - }{ - Type: "Image", - URL: i.URL, - AltText: i.AltText, - Size: i.Size, - MsTeams: map[string]interface{}{"allowExpand": true}, - }) -} - -// AdaptiveCardActionSetItem is an ActionSet. -type AdaptiveCardActionSetItem struct { - Actions []AdaptiveCardActionItem -} - -func (i AdaptiveCardActionSetItem) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - Type string `json:"type"` - Actions []AdaptiveCardActionItem `json:"actions"` - }{ - Type: "ActionSet", - Actions: i.Actions, - }) -} - -type AdaptiveCardActionItem interface { - MarshalJSON() ([]byte, error) -} - -// AdapativeCardOpenURLActionItem is an Action.OpenUrl action. -type AdaptiveCardOpenURLActionItem struct { - IconURL string - Title string - URL string -} - -func (i AdaptiveCardOpenURLActionItem) MarshalJSON() ([]byte, error) { - return json.Marshal(struct { - Type string `json:"type"` - Title string `json:"title"` - URL string `json:"url"` - IconURL string `json:"iconUrl,omitempty"` - }{ - Type: "Action.OpenUrl", - Title: i.Title, - URL: i.URL, - IconURL: i.IconURL, - }) -} - -type teamsSettings struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - SectionTitle string `json:"sectiontitle,omitempty" yaml:"sectiontitle,omitempty"` -} - -func buildTeamsSettings(fc FactoryConfig) (teamsSettings, error) { - settings := teamsSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if settings.URL == "" { - return settings, errors.New("could not find url property in settings") - } - if settings.Message == "" { - settings.Message = `{{ template "teams.default.message" .}}` - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - return settings, nil -} - -type TeamsNotifier struct { - *Base - tmpl *template.Template - log Logger - ns WebhookSender - images ImageStore - settings teamsSettings -} - -// NewTeamsNotifier is the constructor for Teams notifier. -func NewTeamsNotifier(fc FactoryConfig) (*TeamsNotifier, error) { - settings, err := buildTeamsSettings(fc) - if err != nil { - return nil, err - } - return &TeamsNotifier{ - Base: NewBase(fc.Config), - log: fc.Logger, - ns: fc.NotificationService, - images: fc.ImageStore, - tmpl: fc.Template, - settings: settings, - }, nil -} - -func TeamsFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := NewTeamsNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - var tmplErr error - tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) - - card := NewAdaptiveCard() - card.AppendItem(AdaptiveCardTextBlockItem{ - Color: getTeamsTextColor(types.Alerts(as...)), - Text: tmpl(tn.settings.Title), - Size: TextSizeLarge, - Weight: TextWeightBolder, - Wrap: true, - }) - card.AppendItem(AdaptiveCardTextBlockItem{ - Text: tmpl(tn.settings.Message), - Wrap: true, - }) - - var s AdaptiveCardImageSetItem - _ = withStoredImages(ctx, tn.log, tn.images, - func(_ int, image Image) error { - if image.URL != "" { - s.AppendImage(AdaptiveCardImageItem{URL: image.URL}) - } - return nil - }, - as...) - - if len(s.Images) > 2 { - s.Size = ImageSizeMedium - card.AppendItem(s) - } else if len(s.Images) > 0 { - s.Size = ImageSizeLarge - card.AppendItem(s) - } - - card.AppendItem(AdaptiveCardActionSetItem{ - Actions: []AdaptiveCardActionItem{ - AdaptiveCardOpenURLActionItem{ - Title: "View URL", - URL: joinUrlPath(tn.tmpl.ExternalURL.String(), "/alerting/list", tn.log), - }, - }, - }) - - msg := NewAdaptiveCardsMessage(card) - msg.Summary = tmpl(tn.settings.Title) - - // This check for tmplErr must happen before templating the URL - if tmplErr != nil { - tn.log.Warn("failed to template Teams message", "error", tmplErr.Error()) - tmplErr = nil - } - - u := tmpl(tn.settings.URL) - if tmplErr != nil { - tn.log.Warn("failed to template Teams URL", "error", tmplErr.Error(), "fallback", tn.settings.URL) - u = tn.settings.URL - } - - b, err := json.Marshal(msg) - if err != nil { - return false, fmt.Errorf("failed to marshal JSON: %w", err) - } - - cmd := &SendWebhookSettings{Url: u, Body: string(b)} - // Teams sometimes does not use status codes to show when a request has failed. Instead, the - // response can contain an error message, irrespective of status code (i.e. https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#rate-limiting-for-connectors) - cmd.Validation = validateResponse - - if err := tn.ns.SendWebhook(ctx, cmd); err != nil { - return false, errors.Wrap(err, "send notification to Teams") - } - - return true, nil -} - -func validateResponse(b []byte, statusCode int) error { - // The request succeeded if the response is "1" - // https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#send-messages-using-curl-and-powershell - if !bytes.Equal(b, []byte("1")) { - return errors.New(string(b)) - } - return nil -} - -func (tn *TeamsNotifier) SendResolved() bool { - return !tn.GetDisableResolveMessage() -} - -// getTeamsTextColor returns the text color for the message title. -func getTeamsTextColor(alerts model.Alerts) string { - if getAlertStatusColor(alerts.Status()) == ColorAlertFiring { - return TextColorAttention - } - return TextColorGood -} diff --git a/pkg/services/ngalert/notifier/channels/teams_test.go b/pkg/services/ngalert/notifier/channels/teams_test.go deleted file mode 100644 index a9117911b21..00000000000 --- a/pkg/services/ngalert/notifier/channels/teams_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "math/rand" - "net/url" - "testing" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestTeamsNotifier(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"}, - }, - }, - }, - expMsg: map[string]interface{}{ - "attachments": []map[string]interface{}{{ - "content": map[string]interface{}{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "body": []map[string]interface{}{{ - "color": "attention", - "size": "large", - "text": "[FIRING:1] (val1)", - "type": "TextBlock", - "weight": "bolder", - "wrap": true, - }, { - "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", - "type": "TextBlock", - "wrap": true, - }, { - "actions": []map[string]interface{}{{ - "title": "View URL", - "type": "Action.OpenUrl", - "url": "http://localhost/alerting/list", - }}, - "type": "ActionSet", - }}, - "type": "AdaptiveCard", - "version": "1.4", - "msTeams": map[string]interface{}{ - "width": "Full", - }, - }, - "contentType": "application/vnd.microsoft.card.adaptive", - }}, - "summary": "[FIRING:1] (val1)", - "type": "message", - }, - expMsgError: nil, - }, { - name: "Custom config with multiple alerts", - settings: `{ - "url": "http://localhost", - "title": "{{ .CommonLabels.alertname }}", - "sectiontitle": "Details", - "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{}{ - "attachments": []map[string]interface{}{{ - "content": map[string]interface{}{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "body": []map[string]interface{}{{ - "color": "attention", - "size": "large", - "text": "alert1", - "type": "TextBlock", - "weight": "bolder", - "wrap": true, - }, { - "text": "2 alerts are firing, 0 are resolved", - "type": "TextBlock", - "wrap": true, - }, { - "actions": []map[string]interface{}{{ - "title": "View URL", - "type": "Action.OpenUrl", - "url": "http://localhost/alerting/list", - }}, - "type": "ActionSet", - }}, - "type": "AdaptiveCard", - "version": "1.4", - "msTeams": map[string]interface{}{ - "width": "Full", - }, - }, - "contentType": "application/vnd.microsoft.card.adaptive", - }}, - "summary": "alert1", - "type": "message", - }, - expMsgError: nil, - }, { - name: "Missing field in template", - settings: `{ - "url": "http://localhost", - "title": "{{ .CommonLabels.alertname }}", - "sectiontitle": "Details", - "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"}, - }, - }, { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2"}, - }, - }, - }, - expMsg: map[string]interface{}{ - "attachments": []map[string]interface{}{{ - "content": map[string]interface{}{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "body": []map[string]interface{}{{ - "color": "attention", - "size": "large", - "text": "alert1", - "type": "TextBlock", - "weight": "bolder", - "wrap": true, - }, { - "text": "I'm a custom template ", - "type": "TextBlock", - "wrap": true, - }, { - "actions": []map[string]interface{}{{ - "title": "View URL", - "type": "Action.OpenUrl", - "url": "http://localhost/alerting/list", - }}, - "type": "ActionSet", - }}, - "type": "AdaptiveCard", - "version": "1.4", - "msTeams": map[string]interface{}{ - "width": "Full", - }, - }, - "contentType": "application/vnd.microsoft.card.adaptive", - }}, - "type": "message", - }, - expMsgError: nil, - }, { - name: "Invalid template", - settings: `{ - "url": "http://localhost", - "title": "{{ .CommonLabels.alertname }}", - "sectiontitle": "Details", - "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"}, - }, - }, { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2"}, - }, - }, - }, - expMsg: map[string]interface{}{ - "attachments": []map[string]interface{}{{ - "content": map[string]interface{}{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "body": []map[string]interface{}{{ - "color": "attention", - "size": "large", - "text": "alert1", - "type": "TextBlock", - "weight": "bolder", - "wrap": true, - }, { - "text": "", - "type": "TextBlock", - "wrap": true, - }, { - "actions": []map[string]interface{}{{ - "title": "View URL", - "type": "Action.OpenUrl", - "url": "http://localhost/alerting/list", - }}, - "type": "ActionSet", - }}, - "type": "AdaptiveCard", - "version": "1.4", - "msTeams": map[string]interface{}{ - "width": "Full", - }, - }, - "contentType": "application/vnd.microsoft.card.adaptive", - }}, - "type": "message", - }, - 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) { - settingsJSON := json.RawMessage(c.settings) - - m := &NotificationChannelConfig{ - Name: "teams_testing", - Type: "teams", - Settings: settingsJSON, - } - - webhookSender := mockNotificationService() - - fc := FactoryConfig{ - Config: m, - ImageStore: &UnavailableImageStore{}, - NotificationService: webhookSender, - Template: tmpl, - Logger: &FakeLogger{}, - } - - pn, err := NewTeamsNotifier(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.True(t, ok) - require.NoError(t, err) - - require.NotNil(t, webhookSender.Webhook) - lastRequest := webhookSender.Webhook - - require.NotEmpty(t, lastRequest.Url) - - expBody, err := json.Marshal(c.expMsg) - require.NoError(t, err) - - require.JSONEq(t, string(expBody), lastRequest.Body) - - require.NotNil(t, lastRequest.Validation) - }) - } -} - -func Test_ValidateResponse(t *testing.T) { - require.NoError(t, validateResponse([]byte("1"), rand.Int())) - err := validateResponse([]byte("some error message"), rand.Int()) - require.Error(t, err) - require.Equal(t, "some error message", err.Error()) -} diff --git a/pkg/services/ngalert/notifier/channels/telegram.go b/pkg/services/ngalert/notifier/channels/telegram.go deleted file mode 100644 index 433ab6139d4..00000000000 --- a/pkg/services/ngalert/notifier/channels/telegram.go +++ /dev/null @@ -1,239 +0,0 @@ -package channels - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime/multipart" - "os" - "strings" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" -) - -var ( - TelegramAPIURL = "https://api.telegram.org/bot%s/%s" - - DefaultParseMode = "HTML" - // SupportedParseMode is a map of all supported values for field `parse_mode`. https://core.telegram.org/bots/api#formatting-options. - // Keys are options accepted by Grafana API, values are options accepted by Telegram API - SupportedParseMode = map[string]string{"Markdown": "Markdown", "MarkdownV2": "MarkdownV2", DefaultParseMode: "HTML", "None": ""} -) - -// Telegram supports 4096 chars max - from https://limits.tginfo.me/en. -const telegramMaxMessageLenRunes = 4096 - -// TelegramNotifier is responsible for sending -// alert notifications to Telegram. -type TelegramNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender - tmpl *template.Template - settings telegramSettings -} - -type telegramSettings struct { - BotToken string `json:"bottoken,omitempty" yaml:"bottoken,omitempty"` - ChatID string `json:"chatid,omitempty" yaml:"chatid,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - ParseMode string `json:"parse_mode,omitempty" yaml:"parse_mode,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty" yaml:"disable_notifications,omitempty"` -} - -func buildTelegramSettings(fc FactoryConfig) (telegramSettings, error) { - settings := telegramSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - settings.BotToken = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "bottoken", settings.BotToken) - if settings.BotToken == "" { - return settings, errors.New("could not find Bot Token in settings") - } - if settings.ChatID == "" { - return settings, errors.New("could not find Chat Id in settings") - } - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - // if field is missing, then we fall back to the previous default: HTML - if settings.ParseMode == "" { - settings.ParseMode = DefaultParseMode - } - found := false - for parseMode, value := range SupportedParseMode { - if strings.EqualFold(settings.ParseMode, parseMode) { - settings.ParseMode = value - found = true - break - } - } - if !found { - return settings, fmt.Errorf("unknown parse_mode, must be Markdown, MarkdownV2, HTML or None") - } - return settings, nil -} - -func TelegramFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := NewTelegramNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -// NewTelegramNotifier is the constructor for the Telegram notifier -func NewTelegramNotifier(fc FactoryConfig) (*TelegramNotifier, error) { - settings, err := buildTelegramSettings(fc) - if err != nil { - return nil, err - } - return &TelegramNotifier{ - Base: NewBase(fc.Config), - tmpl: fc.Template, - log: fc.Logger, - images: fc.ImageStore, - ns: fc.NotificationService, - settings: settings, - }, nil -} - -// Notify send an alert notification to Telegram. -func (tn *TelegramNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - // Create the cmd for sendMessage - cmd, err := tn.newWebhookSyncCmd("sendMessage", func(w *multipart.Writer) error { - msg, err := tn.buildTelegramMessage(ctx, as) - if err != nil { - return fmt.Errorf("failed to build message: %w", err) - } - for k, v := range msg { - fw, err := w.CreateFormField(k) - if err != nil { - return fmt.Errorf("failed to create form field: %w", err) - } - if _, err := fw.Write([]byte(v)); err != nil { - return fmt.Errorf("failed to write value: %w", err) - } - } - return nil - }) - if err != nil { - return false, fmt.Errorf("failed to create telegram message: %w", err) - } - if err := tn.ns.SendWebhook(ctx, cmd); err != nil { - return false, fmt.Errorf("failed to send telegram message: %w", err) - } - - // Create the cmd to upload each image - _ = withStoredImages(ctx, tn.log, tn.images, func(index int, image Image) error { - cmd, err = tn.newWebhookSyncCmd("sendPhoto", func(w *multipart.Writer) error { - f, err := os.Open(image.Path) - if err != nil { - return fmt.Errorf("failed to open image: %w", err) - } - defer func() { - if err := f.Close(); err != nil { - tn.log.Warn("failed to close image", "error", err) - } - }() - fw, err := w.CreateFormFile("photo", image.Path) - if err != nil { - return fmt.Errorf("failed to create form file: %w", err) - } - if _, err := io.Copy(fw, f); err != nil { - return fmt.Errorf("failed to write to form file: %w", err) - } - return nil - }) - if err != nil { - return fmt.Errorf("failed to create image: %w", err) - } - if err := tn.ns.SendWebhook(ctx, cmd); err != nil { - return fmt.Errorf("failed to upload image to telegram: %w", err) - } - return nil - }, as...) - - return true, nil -} - -func (tn *TelegramNotifier) buildTelegramMessage(ctx context.Context, as []*types.Alert) (map[string]string, error) { - var tmplErr error - defer func() { - if tmplErr != nil { - tn.log.Warn("failed to template Telegram message", "error", tmplErr) - } - }() - - tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) - // Telegram supports 4096 chars max - messageText, truncated := TruncateInRunes(tmpl(tn.settings.Message), telegramMaxMessageLenRunes) - if truncated { - key, err := notify.ExtractGroupKey(ctx) - if err != nil { - return nil, err - } - tn.log.Warn("Truncated message", "alert", key, "max_runes", telegramMaxMessageLenRunes) - } - - m := make(map[string]string) - m["text"] = messageText - if tn.settings.ParseMode != "" { - m["parse_mode"] = tn.settings.ParseMode - } - if tn.settings.DisableNotifications { - m["disable_notification"] = "true" - } - return m, nil -} - -func (tn *TelegramNotifier) newWebhookSyncCmd(action string, fn func(writer *multipart.Writer) error) (*SendWebhookSettings, error) { - b := bytes.Buffer{} - w := multipart.NewWriter(&b) - - boundary := GetBoundary() - if boundary != "" { - if err := w.SetBoundary(boundary); err != nil { - return nil, err - } - } - - fw, err := w.CreateFormField("chat_id") - if err != nil { - return nil, err - } - if _, err := fw.Write([]byte(tn.settings.ChatID)); err != nil { - return nil, err - } - - if err := fn(w); err != nil { - return nil, err - } - - if err := w.Close(); err != nil { - return nil, fmt.Errorf("failed to close multipart: %w", err) - } - - cmd := &SendWebhookSettings{ - Url: fmt.Sprintf(TelegramAPIURL, tn.settings.BotToken, action), - Body: b.String(), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": w.FormDataContentType(), - }, - } - return cmd, nil -} - -func (tn *TelegramNotifier) SendResolved() bool { - return !tn.GetDisableResolveMessage() -} diff --git a/pkg/services/ngalert/notifier/channels/telegram_test.go b/pkg/services/ngalert/notifier/channels/telegram_test.go deleted file mode 100644 index 0f084dbe078..00000000000 --- a/pkg/services/ngalert/notifier/channels/telegram_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "net/url" - "strings" - "testing" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestTelegramNotifier(t *testing.T) { - tmpl := templateForTests(t) - images := newFakeImageStoreWithFile(t, 2) - 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]string - expInitError string - expMsgError error - }{ - { - name: "A single alert with default template", - settings: `{ - "bottoken": "abcdefgh0123456789", - "chatid": "someid", - "parse_mode": "markdown", - "disable_notifications": true - }`, - 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"}, - GeneratorURL: "a URL", - }, - }, - }, - expMsg: map[string]string{ - "parse_mode": "Markdown", - "text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\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", - "disable_notification": "true", - }, - expMsgError: nil, - }, { - name: "Multiple alerts with custom template", - settings: `{ - "bottoken": "abcdefgh0123456789", - "chatid": "someid", - "message": "__Custom Firing__\n{{len .Alerts.Firing}} Firing\n{{ template \"__text_alert_list\" .Alerts.Firing }}" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, - GeneratorURL: "a URL", - }, - }, { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, - }, - }, - }, - expMsg: map[string]string{ - "parse_mode": "HTML", - "text": "__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\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", - }, - expMsgError: nil, - }, { - name: "Truncate long message", - settings: `{ - "bottoken": "abcdefgh0123456789", - "chatid": "someid", - "message": "{{ .CommonLabels.alertname }}" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": model.LabelValue(strings.Repeat("1", 4097))}, - }, - }, - }, - expMsg: map[string]string{ - "parse_mode": "HTML", - "text": strings.Repeat("1", 4096-1) + "…", - }, - expMsgError: nil, - }, { - name: "Error in initing", - settings: `{}`, - expInitError: `could not find Bot Token in settings`, - }, { - name: "Invalid parse mode", - settings: `{ - "bottoken": "abcdefgh0123456789", - "chatid": "someid", - "parse_mode": "test" - }`, - expInitError: "unknown parse_mode, must be Markdown, MarkdownV2, HTML or None", - }, - } - - 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) - - notificationService := mockNotificationService() - - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ - Name: "telegram_tests", - Type: "telegram", - Settings: settingsJSON, - SecureSettings: secureSettings, - }, - ImageStore: images, - NotificationService: notificationService, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - Template: tmpl, - Logger: &FakeLogger{}, - } - - n, err := NewTelegramNotifier(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 := n.Notify(ctx, c.alerts...) - require.NoError(t, err) - require.True(t, ok) - - msg, err := n.buildTelegramMessage(ctx, c.alerts) - if c.expMsgError != nil { - require.Error(t, err) - require.Equal(t, c.expMsgError.Error(), err.Error()) - return - } - require.NoError(t, err) - require.Equal(t, c.expMsg, msg) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/template_data.go b/pkg/services/ngalert/notifier/channels/template_data.go deleted file mode 100644 index 8679444be1c..00000000000 --- a/pkg/services/ngalert/notifier/channels/template_data.go +++ /dev/null @@ -1,206 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "net/url" - "path" - "sort" - "strings" - "time" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - - ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" -) - -type ExtendedAlert struct { - Status string `json:"status"` - Labels template.KV `json:"labels"` - Annotations template.KV `json:"annotations"` - StartsAt time.Time `json:"startsAt"` - EndsAt time.Time `json:"endsAt"` - GeneratorURL string `json:"generatorURL"` - Fingerprint string `json:"fingerprint"` - SilenceURL string `json:"silenceURL"` - DashboardURL string `json:"dashboardURL"` - PanelURL string `json:"panelURL"` - Values map[string]float64 `json:"values"` - ValueString string `json:"valueString"` // TODO: Remove in Grafana 10 - ImageURL string `json:"imageURL,omitempty"` - EmbeddedImage string `json:"embeddedImage,omitempty"` -} - -type ExtendedAlerts []ExtendedAlert - -type ExtendedData struct { - Receiver string `json:"receiver"` - Status string `json:"status"` - Alerts ExtendedAlerts `json:"alerts"` - - GroupLabels template.KV `json:"groupLabels"` - CommonLabels template.KV `json:"commonLabels"` - CommonAnnotations template.KV `json:"commonAnnotations"` - - ExternalURL string `json:"externalURL"` -} - -func removePrivateItems(kv template.KV) template.KV { - for key := range kv { - if strings.HasPrefix(key, "__") && strings.HasSuffix(key, "__") { - kv = kv.Remove([]string{key}) - } - } - return kv -} - -func extendAlert(alert template.Alert, externalURL string, logger Logger) *ExtendedAlert { - // remove "private" annotations & labels so they don't show up in the template - extended := &ExtendedAlert{ - Status: alert.Status, - Labels: removePrivateItems(alert.Labels), - Annotations: removePrivateItems(alert.Annotations), - StartsAt: alert.StartsAt, - EndsAt: alert.EndsAt, - GeneratorURL: alert.GeneratorURL, - Fingerprint: alert.Fingerprint, - } - - // fill in some grafana-specific urls - if len(externalURL) == 0 { - return extended - } - u, err := url.Parse(externalURL) - if err != nil { - logger.Debug("failed to parse external URL while extending template data", "url", externalURL, "error", err.Error()) - return extended - } - externalPath := u.Path - dashboardUid := alert.Annotations[ngmodels.DashboardUIDAnnotation] - if len(dashboardUid) > 0 { - u.Path = path.Join(externalPath, "/d/", dashboardUid) - extended.DashboardURL = u.String() - panelId := alert.Annotations[ngmodels.PanelIDAnnotation] - if len(panelId) > 0 { - u.RawQuery = "viewPanel=" + panelId - extended.PanelURL = u.String() - } - - generatorUrl, err := url.Parse(extended.GeneratorURL) - if err != nil { - logger.Debug("failed to parse generator URL while extending template data", "url", extended.GeneratorURL, "err", err.Error()) - return extended - } - - dashboardUrl, err := url.Parse(extended.DashboardURL) - if err != nil { - logger.Debug("failed to parse dashboard URL while extending template data", "url", extended.DashboardURL, "err", err.Error()) - return extended - } - - orgId := alert.Annotations[ngmodels.OrgIDAnnotation] - if len(orgId) > 0 { - extended.DashboardURL = setOrgIdQueryParam(dashboardUrl, orgId) - extended.PanelURL = setOrgIdQueryParam(u, orgId) - extended.GeneratorURL = setOrgIdQueryParam(generatorUrl, orgId) - } - } - - if alert.Annotations != nil { - if s, ok := alert.Annotations[ngmodels.ValuesAnnotation]; ok { - if err := json.Unmarshal([]byte(s), &extended.Values); err != nil { - logger.Warn("failed to unmarshal values annotation", "error", err) - } - } - // TODO: Remove in Grafana 10 - extended.ValueString = alert.Annotations[ngmodels.ValueStringAnnotation] - } - - matchers := make([]string, 0) - for key, value := range alert.Labels { - if !(strings.HasPrefix(key, "__") && strings.HasSuffix(key, "__")) { - matchers = append(matchers, key+"="+value) - } - } - sort.Strings(matchers) - u.Path = path.Join(externalPath, "/alerting/silence/new") - - query := make(url.Values) - query.Add("alertmanager", "grafana") - for _, matcher := range matchers { - query.Add("matcher", matcher) - } - - u.RawQuery = query.Encode() - - extended.SilenceURL = u.String() - - return extended -} - -func setOrgIdQueryParam(url *url.URL, orgId string) string { - q := url.Query() - q.Set("orgId", orgId) - url.RawQuery = q.Encode() - - return url.String() -} - -func ExtendData(data *template.Data, logger Logger) *ExtendedData { - alerts := []ExtendedAlert{} - - for _, alert := range data.Alerts { - extendedAlert := extendAlert(alert, data.ExternalURL, logger) - alerts = append(alerts, *extendedAlert) - } - - extended := &ExtendedData{ - Receiver: data.Receiver, - Status: data.Status, - Alerts: alerts, - GroupLabels: data.GroupLabels, - CommonLabels: removePrivateItems(data.CommonLabels), - CommonAnnotations: removePrivateItems(data.CommonAnnotations), - - ExternalURL: data.ExternalURL, - } - return extended -} - -func TmplText(ctx context.Context, tmpl *template.Template, alerts []*types.Alert, l Logger, tmplErr *error) (func(string) string, *ExtendedData) { - promTmplData := notify.GetTemplateData(ctx, tmpl, alerts, l) - data := ExtendData(promTmplData, l) - - return func(name string) (s string) { - if *tmplErr != nil { - return - } - s, *tmplErr = tmpl.ExecuteTextString(name, data) - return s - }, data -} - -// Firing returns the subset of alerts that are firing. -func (as ExtendedAlerts) Firing() []ExtendedAlert { - res := []ExtendedAlert{} - for _, a := range as { - if a.Status == string(model.AlertFiring) { - res = append(res, a) - } - } - return res -} - -// Resolved returns the subset of alerts that are resolved. -func (as ExtendedAlerts) Resolved() []ExtendedAlert { - res := []ExtendedAlert{} - for _, a := range as { - if a.Status == string(model.AlertResolved) { - res = append(res, a) - } - } - return res -} diff --git a/pkg/services/ngalert/notifier/channels/testing.go b/pkg/services/ngalert/notifier/channels/testing.go index e8293e0f1e9..1247058db23 100644 --- a/pkg/services/ngalert/notifier/channels/testing.go +++ b/pkg/services/ngalert/notifier/channels/testing.go @@ -2,12 +2,11 @@ package channels import ( "context" - "encoding/base64" "fmt" - "os" "testing" "time" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/bus" @@ -18,25 +17,25 @@ import ( ) type fakeImageStore struct { - Images []*Image + Images []*channels.Image } // getImage returns an image with the same token. -func (f *fakeImageStore) GetImage(_ context.Context, token string) (*Image, error) { +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, ErrImageNotFound + 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) ImageStore { +func newFakeImageStore(n int) channels.ImageStore { s := fakeImageStore{} for i := 1; i <= n; i++ { - s.Images = append(s.Images, &Image{ + 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(), @@ -45,67 +44,6 @@ func newFakeImageStore(n int) ImageStore { return &s } -// newFakeImageStoreWithFile returns an image store with N test images. -// Each image has a token, path and a URL, where the path is 1x1 transparent -// PNG on disk. The test should call deleteFunc to delete the images from disk -// at the end of the test. -// nolint:deadcode,unused -func newFakeImageStoreWithFile(t *testing.T, n int) ImageStore { - var ( - files []string - s fakeImageStore - ) - - t.Cleanup(func() { - // remove all files from disk - for _, f := range files { - if err := os.Remove(f); err != nil { - t.Logf("failed to delete file: %s", err) - } - } - }) - - for i := 1; i <= n; i++ { - file, err := newTestImage() - if err != nil { - t.Fatalf("failed to create test image: %s", err) - } - files = append(files, file) - s.Images = append(s.Images, &Image{ - Token: fmt.Sprintf("test-image-%d", i), - Path: file, - URL: fmt.Sprintf("https://www.example.com/test-image-%d", i), - CreatedAt: time.Now().UTC(), - }) - } - - return &s -} - -// nolint:deadcode,unused -func newTestImage() (string, error) { - f, err := os.CreateTemp("", "test-image-*.png") - if err != nil { - return "", fmt.Errorf("failed to create temp image: %s", err) - } - - // 1x1 transparent PNG - b, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=") - if err != nil { - return f.Name(), fmt.Errorf("failed to decode PNG data: %s", err) - } - - if _, err := f.Write(b); err != nil { - return f.Name(), fmt.Errorf("failed to write to file: %s", err) - } - - if err := f.Close(); err != nil { - return f.Name(), fmt.Errorf("failed to close file: %s", err) - } - - return f.Name(), nil -} - // 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: @@ -128,16 +66,16 @@ func resetTimeNow() { } type notificationServiceMock struct { - Webhook SendWebhookSettings - EmailSync SendEmailSettings + Webhook channels.SendWebhookSettings + EmailSync channels.SendEmailSettings ShouldError error } -func (ns *notificationServiceMock) SendWebhook(ctx context.Context, cmd *SendWebhookSettings) 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 *SendEmailSettings) error { +func (ns *notificationServiceMock) SendEmail(ctx context.Context, cmd *channels.SendEmailSettings) error { ns.EmailSync = *cmd return ns.ShouldError } @@ -148,7 +86,7 @@ type emailSender struct { ns *notifications.NotificationService } -func (e emailSender) SendEmail(ctx context.Context, cmd *SendEmailSettings) error { +func (e emailSender) SendEmail(ctx context.Context, cmd *channels.SendEmailSettings) error { attached := make([]*models.SendEmailAttachFile, 0, len(cmd.AttachedFiles)) for _, file := range cmd.AttachedFiles { attached = append(attached, &models.SendEmailAttachFile{ diff --git a/pkg/services/ngalert/notifier/channels/threema.go b/pkg/services/ngalert/notifier/channels/threema.go deleted file mode 100644 index e8529081e6d..00000000000 --- a/pkg/services/ngalert/notifier/channels/threema.go +++ /dev/null @@ -1,166 +0,0 @@ -package channels - -import ( - "context" - "errors" - "fmt" - "net/url" - "path" - "strings" - - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" -) - -var ( - ThreemaGwBaseURL = "https://msgapi.threema.ch/send_simple" -) - -// ThreemaNotifier is responsible for sending -// alert notifications to Threema. -type ThreemaNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender - tmpl *template.Template - settings threemaSettings -} - -type threemaSettings struct { - GatewayID string `json:"gateway_id,omitempty" yaml:"gateway_id,omitempty"` - RecipientID string `json:"recipient_id,omitempty" yaml:"recipient_id,omitempty"` - APISecret string `json:"api_secret,omitempty" yaml:"api_secret,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` -} - -func buildThreemaSettings(fc FactoryConfig) (threemaSettings, error) { - settings := threemaSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - // GatewayID validaiton - if settings.GatewayID == "" { - return settings, errors.New("could not find Threema Gateway ID in settings") - } - if !strings.HasPrefix(settings.GatewayID, "*") { - return settings, errors.New("invalid Threema Gateway ID: Must start with a *") - } - if len(settings.GatewayID) != 8 { - return settings, errors.New("invalid Threema Gateway ID: Must be 8 characters long") - } - - // RecipientID validation - if settings.RecipientID == "" { - return settings, errors.New("could not find Threema Recipient ID in settings") - } - if len(settings.RecipientID) != 8 { - return settings, errors.New("invalid Threema Recipient ID: Must be 8 characters long") - } - settings.APISecret = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "api_secret", settings.APISecret) - if settings.APISecret == "" { - return settings, errors.New("could not find Threema API secret in settings") - } - - if settings.Description == "" { - settings.Description = DefaultMessageEmbed - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - - return settings, nil -} - -func ThreemaFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := NewThreemaNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -func NewThreemaNotifier(fc FactoryConfig) (*ThreemaNotifier, error) { - settings, err := buildThreemaSettings(fc) - if err != nil { - return nil, err - } - return &ThreemaNotifier{ - Base: NewBase(fc.Config), - log: fc.Logger, - images: fc.ImageStore, - ns: fc.NotificationService, - tmpl: fc.Template, - settings: settings, - }, nil -} - -// Notify send an alert notification to Threema -func (tn *ThreemaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - tn.log.Debug("sending threema alert notification", "from", tn.settings.GatewayID, "to", tn.settings.RecipientID) - - // Set up basic API request data - data := url.Values{} - data.Set("from", tn.settings.GatewayID) - data.Set("to", tn.settings.RecipientID) - data.Set("secret", tn.settings.APISecret) - data.Set("text", tn.buildMessage(ctx, as...)) - - cmd := &SendWebhookSettings{ - Url: ThreemaGwBaseURL, - Body: data.Encode(), - HttpMethod: "POST", - HttpHeader: map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - }, - } - if err := tn.ns.SendWebhook(ctx, cmd); err != nil { - tn.log.Error("Failed to send threema notification", "error", err, "webhook", tn.Name) - return false, err - } - - return true, nil -} - -func (tn *ThreemaNotifier) SendResolved() bool { - return !tn.GetDisableResolveMessage() -} - -func (tn *ThreemaNotifier) buildMessage(ctx context.Context, as ...*types.Alert) string { - var tmplErr error - tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) - - message := fmt.Sprintf("%s%s\n\n*Message:*\n%s\n*URL:* %s\n", - selectEmoji(as...), - tmpl(tn.settings.Title), - tmpl(tn.settings.Description), - path.Join(tn.tmpl.ExternalURL.String(), "/alerting/list"), - ) - - if tmplErr != nil { - tn.log.Warn("failed to template Threema message", "error", tmplErr.Error()) - } - - _ = withStoredImages(ctx, tn.log, tn.images, - func(_ int, image Image) error { - if image.URL != "" { - message += fmt.Sprintf("*Image:* %s\n", image.URL) - } - return nil - }, as...) - - return message -} - -func selectEmoji(as ...*types.Alert) string { - if types.Alerts(as...).Status() == model.AlertResolved { - return "\u2705 " // Check Mark Button - } - return "\u26A0\uFE0F " // Warning sign -} diff --git a/pkg/services/ngalert/notifier/channels/threema_test.go b/pkg/services/ngalert/notifier/channels/threema_test.go deleted file mode 100644 index 27725841099..00000000000 --- a/pkg/services/ngalert/notifier/channels/threema_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "net/url" - "testing" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestThreemaNotifier(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 - expMsg string - expInitError string - expMsgError error - }{ - { - name: "A single alert with an image", - settings: `{ - "gateway_id": "*1234567", - "recipient_id": "87654321", - "api_secret": "supersecret" - }`, - 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"}, - }, - }, - }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D++%28val1%29%0A%0A%2AMessage%3A%2A%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%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A%2AImage%3A%2A+https%3A%2F%2Fwww.example.com%2Ftest-image-1.jpg%0A&to=87654321", - expMsgError: nil, - }, { - name: "Multiple alerts with images", - settings: `{ - "gateway_id": "*1234567", - "recipient_id": "87654321", - "api_secret": "supersecret" - }`, - 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"}, - }, - }, - }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A2%5D++%0A%0A%2AMessage%3A%2A%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%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A%2AImage%3A%2A+https%3A%2F%2Fwww.example.com%2Ftest-image-1.jpg%0A%2AImage%3A%2A+https%3A%2F%2Fwww.example.com%2Ftest-image-2.jpg%0A&to=87654321", - expMsgError: nil, - }, { - name: "A single alert with an image and custom title and description", - settings: `{ - "gateway_id": "*1234567", - "recipient_id": "87654321", - "api_secret": "supersecret", - "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", "__alertImageToken__": "test-image-1"}, - }, - }, - }, - expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+customTitle+1%0A%0A%2AMessage%3A%2A%0AcustomDescription%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A%2AImage%3A%2A+https%3A%2F%2Fwww.example.com%2Ftest-image-1.jpg%0A&to=87654321", - expMsgError: nil, - }, { - name: "Invalid gateway id", - settings: `{ - "gateway_id": "12345678", - "recipient_id": "87654321", - "api_secret": "supersecret" - }`, - expInitError: `invalid Threema Gateway ID: Must start with a *`, - }, { - name: "Invalid receipent id", - settings: `{ - "gateway_id": "*1234567", - "recipient_id": "8765432", - "api_secret": "supersecret" - }`, - expInitError: `invalid Threema Recipient ID: Must be 8 characters long`, - }, { - name: "No API secret", - settings: `{ - "gateway_id": "*1234567", - "recipient_id": "87654321" - }`, - expInitError: `could not find Threema API secret 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 := FactoryConfig{ - Config: &NotificationChannelConfig{ - Name: "threema_testing", - Type: "threema", - Settings: settingsJSON, - SecureSettings: secureSettings, - }, - NotificationService: webhookSender, - ImageStore: images, - Template: tmpl, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - Logger: &FakeLogger{}, - } - - pn, err := NewThreemaNotifier(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.expMsg, webhookSender.Webhook.Body) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/util.go b/pkg/services/ngalert/notifier/channels/util.go index 8959c8af16a..21746df8fd6 100644 --- a/pkg/services/ngalert/notifier/channels/util.go +++ b/pkg/services/ngalert/notifier/channels/util.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/tls" - "encoding/json" "errors" "fmt" "io" @@ -14,53 +13,36 @@ import ( "os" "path" "path/filepath" - "strings" "time" - "github.com/prometheus/alertmanager/notify" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" - "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/util" ) -const ( - FooterIconURL = "https://grafana.com/static/assets/img/fav32.png" - ColorAlertFiring = "#D63232" - ColorAlertResolved = "#36a64f" - - // ImageStoreTimeout should be used by all callers for calles to `Images` - ImageStoreTimeout time.Duration = 500 * time.Millisecond -) - var ( // Provides current time. Can be overwritten in tests. timeNow = time.Now - - // ErrImagesDone is used to stop iteration of subsequent images. It should be - // returned from forEachFunc when either the intended image has been found or - // the maximum number of images has been iterated. - ErrImagesDone = errors.New("images done") - ErrImagesUnavailable = errors.New("alert screenshots are unavailable") ) -type forEachImageFunc func(index int, image Image) error +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 Logger, imageStore ImageStore, alert types.Alert) (*Image, error) { +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, ImageStoreTimeout) + ctx, cancelFunc := context.WithTimeout(ctx, channels.ImageStoreTimeout) defer cancelFunc() img, err := imageStore.GetImage(ctx, token) - if errors.Is(err, ErrImageNotFound) || errors.Is(err, ErrImagesUnavailable) { + 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) @@ -77,7 +59,7 @@ func getImage(ctx context.Context, l Logger, imageStore ImageStore, alert types. // 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 Logger, imageStore ImageStore, forEachFunc forEachImageFunc, alerts ...*types.Alert) error { +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) @@ -85,7 +67,7 @@ func withStoredImages(ctx context.Context, l Logger, imageStore ImageStore, forE return err } else if img != nil { if err := forEachFunc(index, *img); err != nil { - if errors.Is(err, ErrImagesDone) { + if errors.Is(err, channels.ErrImagesDone) { return nil } logger.Error("Failed to attach image to notification", "error", err) @@ -104,7 +86,7 @@ func openImage(path string) (io.ReadCloser, error) { fp := filepath.Clean(path) _, err := os.Stat(fp) if os.IsNotExist(err) || os.IsPermission(err) { - return nil, ErrImageNotFound + return nil, channels.ErrImageNotFound } f, err := os.Open(fp) @@ -122,17 +104,10 @@ func getTokenFromAnnotations(annotations model.LabelSet) string { return "" } -type UnavailableImageStore struct{} - -// Get returns the image with the corresponding token, or ErrImageNotFound. -func (u *UnavailableImageStore) GetImage(ctx context.Context, token string) (*Image, error) { - return nil, ErrImagesUnavailable -} - type receiverInitError struct { Reason string Err error - Cfg NotificationChannelConfig + Cfg channels.NotificationChannelConfig } func (e receiverInitError) Error() string { @@ -153,27 +128,9 @@ func (e receiverInitError) Unwrap() error { return e.Err } func getAlertStatusColor(status model.AlertStatus) string { if status == model.AlertFiring { - return ColorAlertFiring + return channels.ColorAlertFiring } - return ColorAlertResolved -} - -type NotificationChannel interface { - notify.Notifier - notify.ResolvedSender -} -type NotificationChannelConfig struct { - OrgID int64 // only used internally - UID string `json:"uid"` - Name string `json:"name"` - Type string `json:"type"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Settings json.RawMessage `json:"settings"` - SecureSettings map[string][]byte `json:"secureSettings"` -} - -func (c NotificationChannelConfig) unmarshalSettings(v interface{}) error { - return json.Unmarshal(c.Settings, v) + return channels.ColorAlertResolved } type httpCfg struct { @@ -184,7 +141,7 @@ type httpCfg struct { // sendHTTPRequest sends an HTTP request. // Stubbable by tests. -var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger Logger) ([]byte, error) { +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) @@ -238,7 +195,7 @@ var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logge return respBody, nil } -func joinUrlPath(base, additionalPath string, logger Logger) string { +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()) @@ -255,106 +212,3 @@ func joinUrlPath(base, additionalPath string, logger Logger) string { var GetBoundary = func() string { return "" } - -type CommaSeparatedStrings []string - -func (r *CommaSeparatedStrings) UnmarshalJSON(b []byte) error { - var str string - if err := json.Unmarshal(b, &str); err != nil { - return err - } - if len(str) > 0 { - res := CommaSeparatedStrings(splitCommaDelimitedString(str)) - *r = res - } - return nil -} - -func (r *CommaSeparatedStrings) MarshalJSON() ([]byte, error) { - if r == nil { - return nil, nil - } - str := strings.Join(*r, ",") - return json.Marshal(str) -} - -func (r *CommaSeparatedStrings) UnmarshalYAML(b []byte) error { - var str string - if err := yaml.Unmarshal(b, &str); err != nil { - return err - } - if len(str) > 0 { - res := CommaSeparatedStrings(splitCommaDelimitedString(str)) - *r = res - } - return nil -} - -func (r *CommaSeparatedStrings) MarshalYAML() ([]byte, error) { - if r == nil { - return nil, nil - } - str := strings.Join(*r, ",") - return yaml.Marshal(str) -} - -func splitCommaDelimitedString(str string) []string { - split := strings.Split(str, ",") - res := make([]string, 0, len(split)) - for _, s := range split { - if tr := strings.TrimSpace(s); tr != "" { - res = append(res, tr) - } - } - return res -} - -// Copied from https://github.com/prometheus/alertmanager/blob/main/notify/util.go, please remove once we're on-par with upstream. -// truncationMarker is the character used to represent a truncation. -const truncationMarker = "…" - -// Copied from https://github.com/prometheus/alertmanager/blob/main/notify/util.go, please remove once we're on-par with upstream. -// TruncateInrunes truncates a string to fit the given size in Runes. -func TruncateInRunes(s string, n int) (string, bool) { - r := []rune(s) - if len(r) <= n { - return s, false - } - - if n <= 3 { - return string(r[:n]), true - } - - return string(r[:n-1]) + truncationMarker, true -} - -// TruncateInBytes truncates a string to fit the given size in Bytes. -// TODO: This is more advanced than the upstream's TruncateInBytes. We should consider upstreaming this, and removing it from here. -func TruncateInBytes(s string, n int) (string, bool) { - // First, measure the string the w/o a to-rune conversion. - if len(s) <= n { - return s, false - } - - // The truncationMarker itself is 3 bytes, we can't return any part of the string when it's less than 3. - if n <= 3 { - switch n { - case 3: - return truncationMarker, true - default: - return strings.Repeat(".", n), true - } - } - - // Now, to ensure we don't butcher the string we need to remove using runes. - r := []rune(s) - truncationTarget := n - 3 - - // Next, let's truncate the runes to the lower possible number. - truncatedRunes := r[:truncationTarget] - for len(string(truncatedRunes)) > truncationTarget { - truncatedRunes = r[:len(truncatedRunes)-1] - } - - return string(truncatedRunes) + truncationMarker, true -} diff --git a/pkg/services/ngalert/notifier/channels/util_test.go b/pkg/services/ngalert/notifier/channels/util_test.go index d851e7933f9..eeeeead901b 100644 --- a/pkg/services/ngalert/notifier/channels/util_test.go +++ b/pkg/services/ngalert/notifier/channels/util_test.go @@ -10,6 +10,8 @@ import ( "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" ) @@ -28,7 +30,7 @@ func TestWithStoredImages(t *testing.T) { }, }, }} - imageStore := &fakeImageStore{Images: []*Image{{ + imageStore := &fakeImageStore{Images: []*channels.Image{{ Token: "test-image-1", URL: "https://www.example.com/test-image-1.jpg", CreatedAt: time.Now().UTC(), @@ -44,7 +46,7 @@ func TestWithStoredImages(t *testing.T) { ) // should iterate all images - err = withStoredImages(ctx, &FakeLogger{}, imageStore, func(index int, image Image) error { + err = withStoredImages(ctx, &channels.FakeLogger{}, imageStore, func(index int, image channels.Image) error { i += 1 return nil }, alerts...) @@ -53,9 +55,9 @@ func TestWithStoredImages(t *testing.T) { // should iterate just the first image i = 0 - err = withStoredImages(ctx, &FakeLogger{}, imageStore, func(index int, image Image) error { + err = withStoredImages(ctx, &channels.FakeLogger{}, imageStore, func(index int, image channels.Image) error { i += 1 - return ErrImagesDone + return channels.ErrImagesDone }, alerts...) require.NoError(t, err) assert.Equal(t, 1, i) diff --git a/pkg/services/ngalert/notifier/channels/victorops.go b/pkg/services/ngalert/notifier/channels/victorops.go index 54ef7a03ea2..d608f436d9b 100644 --- a/pkg/services/ngalert/notifier/channels/victorops.go +++ b/pkg/services/ngalert/notifier/channels/victorops.go @@ -13,6 +13,8 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/setting" ) @@ -34,9 +36,9 @@ type victorOpsSettings struct { Description string `json:"description,omitempty" yaml:"description,omitempty"` } -func buildVictorOpsSettings(fc FactoryConfig) (victorOpsSettings, error) { +func buildVictorOpsSettings(fc channels.FactoryConfig) (victorOpsSettings, error) { settings := victorOpsSettings{} - err := fc.Config.unmarshalSettings(&settings) + err := json.Unmarshal(fc.Config.Settings, &settings) if err != nil { return settings, fmt.Errorf("failed to unmarshal settings: %w", err) } @@ -47,15 +49,15 @@ func buildVictorOpsSettings(fc FactoryConfig) (victorOpsSettings, error) { settings.MessageType = victoropsAlertStateCritical } if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed + settings.Title = channels.DefaultMessageTitleEmbed } if settings.Description == "" { - settings.Description = DefaultMessageEmbed + settings.Description = channels.DefaultMessageEmbed } return settings, nil } -func VictorOpsFactory(fc FactoryConfig) (NotificationChannel, error) { +func VictorOpsFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) { notifier, err := NewVictoropsNotifier(fc) if err != nil { return nil, receiverInitError{ @@ -68,13 +70,13 @@ func VictorOpsFactory(fc FactoryConfig) (NotificationChannel, error) { // NewVictoropsNotifier creates an instance of VictoropsNotifier that // handles posting notifications to Victorops REST API -func NewVictoropsNotifier(fc FactoryConfig) (*VictoropsNotifier, error) { +func NewVictoropsNotifier(fc channels.FactoryConfig) (*VictoropsNotifier, error) { settings, err := buildVictorOpsSettings(fc) if err != nil { return nil, err } return &VictoropsNotifier{ - Base: NewBase(fc.Config), + Base: channels.NewBase(fc.Config), log: fc.Logger, images: fc.ImageStore, ns: fc.NotificationService, @@ -87,10 +89,10 @@ func NewVictoropsNotifier(fc FactoryConfig) (*VictoropsNotifier, error) { // and handles notification process by formatting POST body according to // Victorops specifications (http://victorops.force.com/knowledgebase/articles/Integration/Alert-Ingestion-API-Documentation/) type VictoropsNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender + *channels.Base + log channels.Logger + images channels.ImageStore + ns channels.WebhookSender tmpl *template.Template settings victorOpsSettings } @@ -100,7 +102,7 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo vn.log.Debug("sending notification", "notification", vn.Name) var tmplErr error - tmpl, _ := TmplText(ctx, vn.tmpl, as, vn.log, &tmplErr) + tmpl, _ := channels.TmplText(ctx, vn.tmpl, as, vn.log, &tmplErr) messageType := buildMessageType(vn.log, tmpl, vn.settings.MessageType, as...) @@ -109,7 +111,7 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo return false, err } - stateMessage, truncated := TruncateInRunes(tmpl(vn.settings.Description), victorOpsMaxMessageLenRunes) + stateMessage, truncated := channels.TruncateInRunes(tmpl(vn.settings.Description), victorOpsMaxMessageLenRunes) if truncated { vn.log.Warn("Truncated stateMessage", "incident", groupKey, "max_runes", victorOpsMaxMessageLenRunes) } @@ -130,10 +132,10 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo } _ = withStoredImages(ctx, vn.log, vn.images, - func(index int, image Image) error { + func(index int, image channels.Image) error { if image.URL != "" { bodyJSON["image_url"] = image.URL - return ErrImagesDone + return channels.ErrImagesDone } return nil }, as...) @@ -151,8 +153,8 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo if err != nil { return false, err } - cmd := &SendWebhookSettings{ - Url: u, + cmd := &channels.SendWebhookSettings{ + URL: u, Body: string(b), } @@ -168,7 +170,7 @@ func (vn *VictoropsNotifier) SendResolved() bool { return !vn.GetDisableResolveMessage() } -func buildMessageType(l Logger, tmpl func(string) string, msgType string, as ...*types.Alert) string { +func buildMessageType(l channels.Logger, tmpl func(string) string, msgType string, as ...*types.Alert) string { if types.Alerts(as...).Status() == model.AlertResolved { return victoropsAlertStateRecovery } diff --git a/pkg/services/ngalert/notifier/channels/victorops_test.go b/pkg/services/ngalert/notifier/channels/victorops_test.go index c7c4f70a359..f8b56db1d51 100644 --- a/pkg/services/ngalert/notifier/channels/victorops_test.go +++ b/pkg/services/ngalert/notifier/channels/victorops_test.go @@ -11,6 +11,8 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/setting" ) @@ -189,7 +191,7 @@ func TestVictoropsNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON := json.RawMessage(c.settings) - m := &NotificationChannelConfig{ + m := &channels.NotificationChannelConfig{ Name: "victorops_testing", Type: "victorops", Settings: settingsJSON, @@ -197,12 +199,12 @@ func TestVictoropsNotifier(t *testing.T) { webhookSender := mockNotificationService() - fc := FactoryConfig{ + fc := channels.FactoryConfig{ Config: m, NotificationService: webhookSender, ImageStore: images, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &channels.FakeLogger{}, } pn, err := NewVictoropsNotifier(fc) @@ -225,7 +227,7 @@ func TestVictoropsNotifier(t *testing.T) { require.NoError(t, err) require.True(t, ok) - require.NotEmpty(t, webhookSender.Webhook.Url) + require.NotEmpty(t, webhookSender.Webhook.URL) // Remove the non-constant timestamp data := make(map[string]interface{}) diff --git a/pkg/services/ngalert/notifier/channels/webex.go b/pkg/services/ngalert/notifier/channels/webex.go deleted file mode 100644 index 784a7eef25d..00000000000 --- a/pkg/services/ngalert/notifier/channels/webex.go +++ /dev/null @@ -1,163 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" -) - -const webexAPIURL = "https://webexapis.com/v1/messages" - -// WebexNotifier is responsible for sending alert notifications as webex messages. -type WebexNotifier struct { - *Base - ns WebhookSender - log Logger - images ImageStore - tmpl *template.Template - orgID int64 - settings *webexSettings -} - -// PLEASE do not touch these settings without taking a look at what we support as part of -// https://github.com/prometheus/alertmanager/blob/main/notify/webex/webex.go -// Currently, the Alerting team is unifying channels and (upstream) receivers - any discrepancy is detrimental to that. -type webexSettings struct { - Message string `json:"message,omitempty" yaml:"message,omitempty"` - RoomID string `json:"room_id,omitempty" yaml:"room_id,omitempty"` - APIURL string `json:"api_url,omitempty" yaml:"api_url,omitempty"` - Token string `json:"bot_token" yaml:"bot_token"` -} - -func buildWebexSettings(factoryConfig FactoryConfig) (*webexSettings, error) { - settings := &webexSettings{} - err := factoryConfig.Config.unmarshalSettings(&settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - if settings.APIURL == "" { - settings.APIURL = webexAPIURL - } - - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - - settings.Token = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "bot_token", settings.Token) - - u, err := url.Parse(settings.APIURL) - if err != nil { - return nil, fmt.Errorf("invalid URL %q", settings.APIURL) - } - settings.APIURL = u.String() - - return settings, err -} - -func WebexFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := buildWebexNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -// buildWebexSettings is the constructor for the Webex notifier. -func buildWebexNotifier(factoryConfig FactoryConfig) (*WebexNotifier, error) { - settings, err := buildWebexSettings(factoryConfig) - if err != nil { - return nil, err - } - - return &WebexNotifier{ - Base: NewBase(factoryConfig.Config), - orgID: factoryConfig.Config.OrgID, - log: factoryConfig.Logger, - ns: factoryConfig.NotificationService, - images: factoryConfig.ImageStore, - tmpl: factoryConfig.Template, - settings: settings, - }, nil -} - -// WebexMessage defines the JSON object to send to Webex endpoints. -type WebexMessage struct { - RoomID string `json:"roomId,omitempty"` - Message string `json:"markdown"` - Files []string `json:"files,omitempty"` -} - -// Notify implements the Notifier interface. -func (wn *WebexNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - var tmplErr error - tmpl, data := TmplText(ctx, wn.tmpl, as, wn.log, &tmplErr) - - message, truncated := TruncateInBytes(tmpl(wn.settings.Message), 4096) - if truncated { - wn.log.Warn("Webex message too long, truncating message", "OriginalMessage", wn.settings.Message) - } - - if tmplErr != nil { - wn.log.Warn("Failed to template webex message", "Error", tmplErr.Error()) - tmplErr = nil - } - - msg := &WebexMessage{ - RoomID: wn.settings.RoomID, - Message: message, - Files: []string{}, - } - - // Augment our Alert data with ImageURLs if available. - _ = withStoredImages(ctx, wn.log, wn.images, func(index int, image Image) error { - // Cisco Webex only supports a single image per request: https://developer.webex.com/docs/basics#message-attachments - if image.HasURL() { - data.Alerts[index].ImageURL = image.URL - msg.Files = append(msg.Files, image.URL) - return ErrImagesDone - } - - return nil - }, as...) - - body, err := json.Marshal(msg) - if err != nil { - return false, err - } - - parsedURL := tmpl(wn.settings.APIURL) - if tmplErr != nil { - return false, tmplErr - } - - cmd := &SendWebhookSettings{ - Url: parsedURL, - Body: string(body), - HttpMethod: http.MethodPost, - } - - if wn.settings.Token != "" { - headers := make(map[string]string) - headers["Authorization"] = fmt.Sprintf("Bearer %s", wn.settings.Token) - cmd.HttpHeader = headers - } - - if err := wn.ns.SendWebhook(ctx, cmd); err != nil { - return false, err - } - - return true, nil -} - -func (wn *WebexNotifier) SendResolved() bool { - return !wn.GetDisableResolveMessage() -} diff --git a/pkg/services/ngalert/notifier/channels/webex_test.go b/pkg/services/ngalert/notifier/channels/webex_test.go deleted file mode 100644 index 3d3a89584df..00000000000 --- a/pkg/services/ngalert/notifier/channels/webex_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "strings" - "testing" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestWebexNotifier(t *testing.T) { - tmpl := templateForTests(t) - images := newFakeImageStoreWithFile(t, 2) - - 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: "A single alert with default template", - settings: `{ - "bot_token": "abcdefgh0123456789", - "room_id": "someid" - }`, - 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"}, - GeneratorURL: "a URL", - }, - }, - }, - expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, - expMsg: `{"roomId":"someid","markdown":"**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n","files":["https://www.example.com/test-image-1"]}`, - expMsgError: nil, - }, - { - name: "Multiple alerts with custom template", - settings: `{ - "bot_token": "abcdefgh0123456789", - "room_id": "someid", - "message": "__Custom Firing__\n{{len .Alerts.Firing}} Firing\n{{ template \"__text_alert_list\" .Alerts.Firing }}" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, - GeneratorURL: "a URL", - }, - }, { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, - }, - }, - }, - expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, - expMsg: `{"roomId":"someid","markdown":"__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=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\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval2\n","files":["https://www.example.com/test-image-1"]}`, - expMsgError: nil, - }, - { - name: "Truncate long message", - settings: `{ - "bot_token": "abcdefgh0123456789", - "room_id": "someid", - "message": "{{ .CommonLabels.alertname }}" - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": model.LabelValue(strings.Repeat("1", 4097))}, - }, - }, - }, - expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, - expMsg: fmt.Sprintf(`{"roomId":"someid","markdown":"%s…"}`, strings.Repeat("1", 4093)), - expMsgError: nil, - }, - { - name: "Error in initing", - settings: `{ "api_url": "ostgres://user:abc{DEf1=ghi@example.com:5432/db?sslmode=require" }`, - expInitError: `invalid URL "ostgres://user:abc{DEf1=ghi@example.com:5432/db?sslmode=require"`, - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - settingsJSON := json.RawMessage(c.settings) - secureSettings := make(map[string][]byte) - - notificationService := mockNotificationService() - - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ - Name: "webex_tests", - Type: "webex", - Settings: settingsJSON, - SecureSettings: secureSettings, - }, - ImageStore: images, - NotificationService: notificationService, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - Template: tmpl, - Logger: &FakeLogger{}, - } - - n, err := buildWebexNotifier(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 := n.Notify(ctx, c.alerts...) - require.NoError(t, err) - require.True(t, ok) - - require.NoError(t, err) - require.Equal(t, c.expHeaders, notificationService.Webhook.HttpHeader) - require.JSONEq(t, c.expMsg, notificationService.Webhook.Body) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/webhook.go b/pkg/services/ngalert/notifier/channels/webhook.go deleted file mode 100644 index 3515ba7faf1..00000000000 --- a/pkg/services/ngalert/notifier/channels/webhook.go +++ /dev/null @@ -1,224 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - - "github.com/grafana/grafana/pkg/models" -) - -// WebhookNotifier is responsible for sending -// alert notifications as webhooks. -type WebhookNotifier struct { - *Base - log Logger - ns WebhookSender - images ImageStore - tmpl *template.Template - orgID int64 - settings webhookSettings -} - -type webhookSettings struct { - URL string - HTTPMethod string - MaxAlerts int - // Authorization Header. - AuthorizationScheme string - AuthorizationCredentials string - // HTTP Basic Authentication. - User string - Password string - - Title string - Message string -} - -func buildWebhookSettings(factoryConfig FactoryConfig) (webhookSettings, error) { - settings := webhookSettings{} - rawSettings := struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - HTTPMethod string `json:"httpMethod,omitempty" yaml:"httpMethod,omitempty"` - MaxAlerts json.Number `json:"maxAlerts,omitempty" yaml:"maxAlerts,omitempty"` - AuthorizationScheme string `json:"authorization_scheme,omitempty" yaml:"authorization_scheme,omitempty"` - AuthorizationCredentials string `json:"authorization_credentials,omitempty" yaml:"authorization_credentials,omitempty"` - User string `json:"username,omitempty" yaml:"username,omitempty"` - Password string `json:"password,omitempty" yaml:"password,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - }{} - - err := factoryConfig.Config.unmarshalSettings(&rawSettings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if rawSettings.URL == "" { - return settings, errors.New("required field 'url' is not specified") - } - settings.URL = rawSettings.URL - - if rawSettings.HTTPMethod == "" { - rawSettings.HTTPMethod = http.MethodPost - } - settings.HTTPMethod = rawSettings.HTTPMethod - - if rawSettings.MaxAlerts != "" { - settings.MaxAlerts, _ = strconv.Atoi(rawSettings.MaxAlerts.String()) - } - - settings.User = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "username", rawSettings.User) - settings.Password = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "password", rawSettings.Password) - settings.AuthorizationCredentials = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "authorization_scheme", rawSettings.AuthorizationCredentials) - - if settings.AuthorizationCredentials != "" && settings.AuthorizationScheme == "" { - settings.AuthorizationScheme = "Bearer" - } - if settings.User != "" && settings.Password != "" && settings.AuthorizationScheme != "" && settings.AuthorizationCredentials != "" { - return settings, errors.New("both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted") - } - settings.Title = rawSettings.Title - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - settings.Message = rawSettings.Message - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - return settings, err -} - -func WebHookFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := buildWebhookNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -// buildWebhookNotifier is the constructor for -// the WebHook notifier. -func buildWebhookNotifier(factoryConfig FactoryConfig) (*WebhookNotifier, error) { - settings, err := buildWebhookSettings(factoryConfig) - if err != nil { - return nil, err - } - return &WebhookNotifier{ - Base: NewBase(factoryConfig.Config), - orgID: factoryConfig.Config.OrgID, - log: factoryConfig.Logger, - ns: factoryConfig.NotificationService, - images: factoryConfig.ImageStore, - tmpl: factoryConfig.Template, - settings: settings, - }, nil -} - -// WebhookMessage defines the JSON object send to webhook endpoints. -type WebhookMessage struct { - *ExtendedData - - // The protocol version. - Version string `json:"version"` - GroupKey string `json:"groupKey"` - TruncatedAlerts int `json:"truncatedAlerts"` - OrgID int64 `json:"orgId"` - Title string `json:"title"` - State string `json:"state"` - Message string `json:"message"` -} - -// Notify implements the Notifier interface. -func (wn *WebhookNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - groupKey, err := notify.ExtractGroupKey(ctx) - if err != nil { - return false, err - } - - as, numTruncated := truncateAlerts(wn.settings.MaxAlerts, as) - var tmplErr error - tmpl, data := TmplText(ctx, wn.tmpl, as, wn.log, &tmplErr) - - // Augment our Alert data with ImageURLs if available. - _ = withStoredImages(ctx, wn.log, wn.images, - func(index int, image Image) error { - if len(image.URL) != 0 { - data.Alerts[index].ImageURL = image.URL - } - return nil - }, - as...) - - msg := &WebhookMessage{ - Version: "1", - ExtendedData: data, - GroupKey: groupKey.String(), - TruncatedAlerts: numTruncated, - OrgID: wn.orgID, - Title: tmpl(wn.settings.Title), - Message: tmpl(wn.settings.Message), - } - if types.Alerts(as...).Status() == model.AlertFiring { - msg.State = string(models.AlertStateAlerting) - } else { - msg.State = string(models.AlertStateOK) - } - - if tmplErr != nil { - wn.log.Warn("failed to template webhook message", "error", tmplErr.Error()) - tmplErr = nil - } - - body, err := json.Marshal(msg) - if err != nil { - return false, err - } - - headers := make(map[string]string) - if wn.settings.AuthorizationScheme != "" && wn.settings.AuthorizationCredentials != "" { - headers["Authorization"] = fmt.Sprintf("%s %s", wn.settings.AuthorizationScheme, wn.settings.AuthorizationCredentials) - } - - parsedURL := tmpl(wn.settings.URL) - if tmplErr != nil { - return false, tmplErr - } - - cmd := &SendWebhookSettings{ - Url: parsedURL, - User: wn.settings.User, - Password: wn.settings.Password, - Body: string(body), - HttpMethod: wn.settings.HTTPMethod, - HttpHeader: headers, - } - - if err := wn.ns.SendWebhook(ctx, cmd); err != nil { - return false, err - } - - return true, nil -} - -func truncateAlerts(maxAlerts int, alerts []*types.Alert) ([]*types.Alert, int) { - if maxAlerts > 0 && len(alerts) > maxAlerts { - return alerts[:maxAlerts], len(alerts) - maxAlerts - } - - return alerts, 0 -} - -func (wn *WebhookNotifier) SendResolved() bool { - return !wn.GetDisableResolveMessage() -} diff --git a/pkg/services/ngalert/notifier/channels/webhook_test.go b/pkg/services/ngalert/notifier/channels/webhook_test.go deleted file mode 100644 index 25436a75f85..00000000000 --- a/pkg/services/ngalert/notifier/channels/webhook_test.go +++ /dev/null @@ -1,392 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "testing" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" - "github.com/stretchr/testify/require" -) - -func TestWebhookNotifier(t *testing.T) { - tmpl := templateForTests(t) - - externalURL, err := url.Parse("http://localhost") - require.NoError(t, err) - tmpl.ExternalURL = externalURL - - orgID := int64(1) - - cases := []struct { - name string - settings string - alerts []*types.Alert - - expMsg *WebhookMessage - expUrl string - expUsername string - expPassword string - expHeaders map[string]string - expHttpMethod string - expInitError string - expMsgError error - }{ - { - name: "Default config with one alert with custom message", - settings: `{"url": "http://localhost/test", "message": "Custom message"}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expUrl: "http://localhost/test", - expHttpMethod: "POST", - expMsg: &WebhookMessage{ - ExtendedData: &ExtendedData{ - Receiver: "my_receiver", - Status: "firing", - Alerts: ExtendedAlerts{ - { - Status: "firing", - Labels: template.KV{ - "alertname": "alert1", - "lbl1": "val1", - }, - Annotations: template.KV{ - "ann1": "annv1", - }, - Fingerprint: "fac0861a85de433a", - DashboardURL: "http://localhost/d/abcd", - PanelURL: "http://localhost/d/abcd?viewPanel=efgh", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", - }, - }, - GroupLabels: template.KV{ - "alertname": "", - }, - CommonLabels: template.KV{ - "alertname": "alert1", - "lbl1": "val1", - }, - CommonAnnotations: template.KV{ - "ann1": "annv1", - }, - ExternalURL: "http://localhost", - }, - Version: "1", - GroupKey: "alertname", - Title: "[FIRING:1] (val1)", - State: "alerting", - Message: "Custom message", - OrgID: orgID, - }, - expMsgError: nil, - expHeaders: map[string]string{}, - }, - { - name: "Custom config with multiple alerts with custom title", - settings: `{ - "url": "http://localhost/test1", - "title": "Alerts firing: {{ len .Alerts.Firing }}", - "username": "user1", - "password": "mysecret", - "httpMethod": "PUT", - "maxAlerts": "2" - }`, - 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"}, - }, - }, { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val3"}, - Annotations: model.LabelSet{"ann1": "annv3"}, - }, - }, - }, - expUrl: "http://localhost/test1", - expHttpMethod: "PUT", - expUsername: "user1", - expPassword: "mysecret", - expMsg: &WebhookMessage{ - ExtendedData: &ExtendedData{ - Receiver: "my_receiver", - Status: "firing", - Alerts: ExtendedAlerts{ - { - Status: "firing", - Labels: template.KV{ - "alertname": "alert1", - "lbl1": "val1", - }, - Annotations: template.KV{ - "ann1": "annv1", - }, - Fingerprint: "fac0861a85de433a", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", - }, { - Status: "firing", - Labels: template.KV{ - "alertname": "alert1", - "lbl1": "val2", - }, - Annotations: template.KV{ - "ann1": "annv2", - }, - Fingerprint: "fab6861a85d5eeb5", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2", - }, - }, - GroupLabels: template.KV{ - "alertname": "", - }, - CommonLabels: template.KV{ - "alertname": "alert1", - }, - CommonAnnotations: template.KV{}, - ExternalURL: "http://localhost", - }, - Version: "1", - GroupKey: "alertname", - TruncatedAlerts: 1, - Title: "Alerts firing: 2", - State: "alerting", - Message: "**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", - OrgID: orgID, - }, - expMsgError: nil, - expHeaders: map[string]string{}, - }, - { - name: "Default config, template variables in URL", - settings: `{"url": "http://localhost/test?numAlerts={{len .Alerts}}&status={{.Status}}"}`, - 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"}, - }, - }, - }, - expUrl: "http://localhost/test?numAlerts=2&status=firing", - expHttpMethod: "POST", - expMsg: &WebhookMessage{ - ExtendedData: &ExtendedData{ - Receiver: "my_receiver", - Status: "firing", - Alerts: ExtendedAlerts{ - { - Status: "firing", - Labels: template.KV{ - "alertname": "alert1", - "lbl1": "val1", - }, - Annotations: template.KV{ - "ann1": "annv1", - }, - Fingerprint: "fac0861a85de433a", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", - }, { - Status: "firing", - Labels: template.KV{ - "alertname": "alert1", - "lbl1": "val2", - }, - Annotations: template.KV{ - "ann1": "annv2", - }, - Fingerprint: "fab6861a85d5eeb5", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2", - }, - }, - GroupLabels: template.KV{ - "alertname": "", - }, - CommonLabels: template.KV{ - "alertname": "alert1", - }, - CommonAnnotations: template.KV{}, - ExternalURL: "http://localhost", - }, - Version: "1", - GroupKey: "alertname", - TruncatedAlerts: 0, - Title: "[FIRING:2] ", - State: "alerting", - Message: "**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", - OrgID: orgID, - }, - expMsgError: nil, - expHeaders: map[string]string{}, - }, - { - name: "with Authorization set", - settings: `{ - "url": "http://localhost/test1", - "authorization_credentials": "mysecret", - "httpMethod": "POST", - "maxAlerts": 2 - }`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsg: &WebhookMessage{ - ExtendedData: &ExtendedData{ - Receiver: "my_receiver", - Status: "firing", - Alerts: ExtendedAlerts{ - { - Status: "firing", - Labels: template.KV{ - "alertname": "alert1", - "lbl1": "val1", - }, - Annotations: template.KV{ - "ann1": "annv1", - }, - Fingerprint: "fac0861a85de433a", - DashboardURL: "http://localhost/d/abcd", - PanelURL: "http://localhost/d/abcd?viewPanel=efgh", - SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", - }, - }, - GroupLabels: template.KV{ - "alertname": "", - }, - CommonLabels: template.KV{ - "alertname": "alert1", - "lbl1": "val1", - }, - CommonAnnotations: template.KV{ - "ann1": "annv1", - }, - ExternalURL: "http://localhost", - }, - Version: "1", - GroupKey: "alertname", - Title: "[FIRING:1] (val1)", - State: "alerting", - Message: "**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", - OrgID: orgID, - }, - expUrl: "http://localhost/test1", - expHttpMethod: "POST", - expHeaders: map[string]string{"Authorization": "Bearer mysecret"}, - }, - { - name: "bad template in url", - settings: `{"url": "http://localhost/test1?numAlerts={{len Alerts}}"}`, - alerts: []*types.Alert{ - { - Alert: model.Alert{ - Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, - }, - }, - }, - expMsgError: fmt.Errorf("template: :1: function \"Alerts\" not defined"), - }, - { - name: "with both HTTP basic auth and Authorization Header set", - settings: `{ - "url": "http://localhost/test1", - "username": "user1", - "password": "mysecret", - "authorization_credentials": "mysecret", - "httpMethod": "POST", - "maxAlerts": "2" - }`, - expInitError: "both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted", - }, - { - name: "Error in initing", - settings: `{}`, - expInitError: `required field 'url' is not specified`, - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - settingsJSON := json.RawMessage(c.settings) - secureSettings := make(map[string][]byte) - - m := &NotificationChannelConfig{ - OrgID: orgID, - Name: "webhook_testing", - Type: "webhook", - Settings: settingsJSON, - SecureSettings: secureSettings, - } - - webhookSender := mockNotificationService() - - fc := FactoryConfig{ - Config: m, - NotificationService: webhookSender, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - ImageStore: &UnavailableImageStore{}, - Template: tmpl, - Logger: &FakeLogger{}, - } - - pn, err := buildWebhookNotifier(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": ""}) - ctx = notify.WithReceiverName(ctx, "my_receiver") - 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) - - expBody, err := json.Marshal(c.expMsg) - require.NoError(t, err) - - require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) - require.Equal(t, c.expUrl, webhookSender.Webhook.Url) - require.Equal(t, c.expUsername, webhookSender.Webhook.User) - require.Equal(t, c.expPassword, webhookSender.Webhook.Password) - require.Equal(t, c.expHttpMethod, webhookSender.Webhook.HttpMethod) - require.Equal(t, c.expHeaders, webhookSender.Webhook.HttpHeader) - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels/wecom.go b/pkg/services/ngalert/notifier/channels/wecom.go deleted file mode 100644 index 73a99c4a6ba..00000000000 --- a/pkg/services/ngalert/notifier/channels/wecom.go +++ /dev/null @@ -1,252 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "time" - - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "golang.org/x/sync/singleflight" -) - -var weComEndpoint = "https://qyapi.weixin.qq.com" - -const defaultWeComChannelType = "groupRobot" -const defaultWeComMsgType = WeComMsgTypeMarkdown -const defaultWeComToUser = "@all" - -type WeComMsgType string - -const WeComMsgTypeMarkdown WeComMsgType = "markdown" // use these in available_channels.go too -const WeComMsgTypeText WeComMsgType = "text" - -// IsValid checks wecom message type -func (mt WeComMsgType) IsValid() bool { - return mt == WeComMsgTypeMarkdown || mt == WeComMsgTypeText -} - -type wecomSettings struct { - channel string - EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"` - URL string `json:"url" yaml:"url"` - AgentID string `json:"agent_id,omitempty" yaml:"agent_id,omitempty"` - CorpID string `json:"corp_id,omitempty" yaml:"corp_id,omitempty"` - Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` - MsgType WeComMsgType `json:"msgtype,omitempty" yaml:"msgtype,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - ToUser string `json:"touser,omitempty" yaml:"touser,omitempty"` -} - -func buildWecomSettings(factoryConfig FactoryConfig) (wecomSettings, error) { - var settings = wecomSettings{ - channel: defaultWeComChannelType, - } - - err := factoryConfig.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - if len(settings.EndpointURL) == 0 { - settings.EndpointURL = weComEndpoint - } - - if !settings.MsgType.IsValid() { - settings.MsgType = defaultWeComMsgType - } - - if len(settings.Message) == 0 { - settings.Message = DefaultMessageEmbed - } - if len(settings.Title) == 0 { - settings.Title = DefaultMessageTitleEmbed - } - if len(settings.ToUser) == 0 { - settings.ToUser = defaultWeComToUser - } - - settings.URL = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "url", settings.URL) - settings.Secret = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "secret", settings.Secret) - - if len(settings.URL) == 0 && len(settings.Secret) == 0 { - return settings, errors.New("either url or secret is required") - } - - if len(settings.URL) == 0 { - settings.channel = "apiapp" - if len(settings.AgentID) == 0 { - return settings, errors.New("could not find AgentID in settings") - } - if len(settings.CorpID) == 0 { - return settings, errors.New("could not find CorpID in settings") - } - } - - return settings, nil -} - -func WeComFactory(fc FactoryConfig) (NotificationChannel, error) { - ch, err := buildWecomNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return ch, nil -} - -func buildWecomNotifier(factoryConfig FactoryConfig) (*WeComNotifier, error) { - settings, err := buildWecomSettings(factoryConfig) - if err != nil { - return nil, err - } - return &WeComNotifier{ - Base: NewBase(factoryConfig.Config), - tmpl: factoryConfig.Template, - log: factoryConfig.Logger, - ns: factoryConfig.NotificationService, - settings: settings, - }, nil -} - -// WeComNotifier is responsible for sending alert notifications to WeCom. -type WeComNotifier struct { - *Base - tmpl *template.Template - log Logger - ns WebhookSender - settings wecomSettings - tok *WeComAccessToken - tokExpireAt time.Time - group singleflight.Group -} - -// Notify send an alert notification to WeCom. -func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - w.log.Info("executing WeCom notification", "notification", w.Name) - - var tmplErr error - tmpl, _ := TmplText(ctx, w.tmpl, as, w.log, &tmplErr) - - bodyMsg := map[string]interface{}{ - "msgtype": w.settings.MsgType, - } - content := fmt.Sprintf("# %s\n%s\n", - tmpl(w.settings.Title), - tmpl(w.settings.Message), - ) - if w.settings.MsgType != defaultWeComMsgType { - content = fmt.Sprintf("%s\n%s\n", - tmpl(w.settings.Title), - tmpl(w.settings.Message), - ) - } - - msgType := string(w.settings.MsgType) - bodyMsg[msgType] = map[string]interface{}{ - "content": content, - } - - url := w.settings.URL - if w.settings.channel != defaultWeComChannelType { - bodyMsg["agentid"] = w.settings.AgentID - bodyMsg["touser"] = w.settings.ToUser - token, err := w.GetAccessToken(ctx) - if err != nil { - return false, err - } - url = fmt.Sprintf(w.settings.EndpointURL+"/cgi-bin/message/send?access_token=%s", token) - } - - body, err := json.Marshal(bodyMsg) - if err != nil { - return false, err - } - - if tmplErr != nil { - w.log.Warn("failed to template WeCom message", "error", tmplErr.Error()) - } - - cmd := &SendWebhookSettings{ - Url: url, - Body: string(body), - } - - if err = w.ns.SendWebhook(ctx, cmd); err != nil { - w.log.Error("failed to send WeCom webhook", "error", err, "notification", w.Name) - return false, err - } - - return true, nil -} - -// GetAccessToken returns the access token for apiapp -func (w *WeComNotifier) GetAccessToken(ctx context.Context) (string, error) { - t := w.tok - if w.tokExpireAt.Before(time.Now()) || w.tok == nil { - // avoid multiple calls when there are multiple alarms - tok, err, _ := w.group.Do("GetAccessToken", func() (interface{}, error) { - return w.getAccessToken(ctx) - }) - if err != nil { - return "", err - } - t = tok.(*WeComAccessToken) - // expire five minutes in advance to avoid using it when it is about to expire - w.tokExpireAt = time.Now().Add(time.Second * time.Duration(t.ExpireIn-300)) - w.tok = t - } - return t.AccessToken, nil -} - -type WeComAccessToken struct { - AccessToken string `json:"access_token"` - ErrMsg string `json:"errmsg"` - ErrCode int `json:"errcode"` - ExpireIn int `json:"expire_in"` -} - -func (w *WeComNotifier) getAccessToken(ctx context.Context) (*WeComAccessToken, error) { - geTokenURL := fmt.Sprintf(w.settings.EndpointURL+"/cgi-bin/gettoken?corpid=%s&corpsecret=%s", w.settings.CorpID, w.settings.Secret) - - request, err := http.NewRequestWithContext(ctx, http.MethodPost, geTokenURL, nil) - if err != nil { - return nil, err - } - - request.Header.Add("Content-Type", "application/json") - request.Header.Add("User-Agent", "Grafana") - - resp, err := http.DefaultClient.Do(request) - if err != nil { - return nil, err - } - - if resp.StatusCode/100 != 2 { - return nil, fmt.Errorf("WeCom returned statuscode invalid status code: %v", resp.Status) - } - defer func() { - _ = resp.Body.Close() - }() - - var accessToken WeComAccessToken - err = json.NewDecoder(resp.Body).Decode(&accessToken) - if err != nil { - return nil, err - } - - if accessToken.ErrCode != 0 { - return nil, fmt.Errorf("WeCom returned errmsg: %s", accessToken.ErrMsg) - } - return &accessToken, nil -} - -func (w *WeComNotifier) SendResolved() bool { - return !w.GetDisableResolveMessage() -} diff --git a/pkg/services/ngalert/notifier/channels/wecom_test.go b/pkg/services/ngalert/notifier/channels/wecom_test.go deleted file mode 100644 index e6a81f855a7..00000000000 --- a/pkg/services/ngalert/notifier/channels/wecom_test.go +++ /dev/null @@ -1,552 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "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" -) - -func TestWeComNotifier(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"}, - }, - }, - }, - expMsg: map[string]interface{}{ - "markdown": map[string]interface{}{ - "content": "# [FIRING:1] (val1)\n**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\n", - }, - "msgtype": "markdown", - }, - expMsgError: nil, - }, - { - name: "Custom config with multiple alerts", - settings: `{ - "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{}{ - "markdown": map[string]interface{}{ - "content": "# [FIRING:2] \n2 alerts are firing, 0 are resolved\n", - }, - "msgtype": "markdown", - }, - expMsgError: nil, - }, - { - name: "Custom title and message with multiple alerts", - settings: `{ - "url": "http://localhost", - "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved", - "title": "This notification is {{ .Status }}!" - }`, - 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{}{ - "markdown": map[string]interface{}{ - "content": "# This notification is firing!\n2 alerts are firing, 0 are resolved\n", - }, - "msgtype": "markdown", - }, - expMsgError: nil, - }, - { - name: "Error in initing", - settings: `{}`, - expInitError: `either url or secret is required`, - }, - { - name: "Use default if optional fields are explicitly empty", - settings: `{"url": "http://localhost", "message": "", "title": ""}`, - 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{}{ - "markdown": map[string]interface{}{ - "content": "# [FIRING:1] (val1)\n**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\n", - }, - "msgtype": "markdown", - }, - expMsgError: nil, - }, - { - name: "Use text are explicitly empty", - settings: `{"url": "http://localhost", "message": "", "title": "", "msgtype": "text"}`, - 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{}{ - "text": map[string]interface{}{ - "content": "[FIRING:1] (val1)\n**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\n", - }, - "msgtype": "text", - }, - expMsgError: nil, - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - settingsJSON := json.RawMessage(c.settings) - - m := &NotificationChannelConfig{ - Name: "wecom_testing", - Type: "wecom", - Settings: settingsJSON, - } - - webhookSender := mockNotificationService() - - fc := FactoryConfig{ - Config: m, - NotificationService: webhookSender, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - ImageStore: nil, - Template: tmpl, - Logger: &FakeLogger{}, - } - - pn, err := buildWecomNotifier(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) - - expBody, err := json.Marshal(c.expMsg) - require.NoError(t, err) - - require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) - }) - } -} - -// TestWeComNotifierAPIAPP Testing API Channels -func TestWeComNotifierAPIAPP(t *testing.T) { - tmpl := templateForTests(t) - - externalURL, err := url.Parse("http://localhost") - require.NoError(t, err) - tmpl.ExternalURL = externalURL - - tests := []struct { - name string - settings string - statusCode int - accessToken string - alerts []*types.Alert - expMsg map[string]interface{} - expInitError string - expMsgError error - }{ - { - name: "not AgentID", - settings: `{"secret": "secret"}`, - accessToken: "access_token", - expInitError: "could not find AgentID in settings", - }, - { - name: "not CorpID", - settings: `{"secret": "secret", "agent_id": "agent_id"}`, - accessToken: "access_token", - expInitError: "could not find CorpID in settings", - }, - { - name: "Default APIAPP config with one alert", - settings: `{"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id"}`, - accessToken: "access_token", - expInitError: "", - 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{}{ - "markdown": map[string]interface{}{ - "content": "# [FIRING:1] (val1)\n**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\n", - }, - "msgtype": "markdown", - "agentid": "agent_id", - "touser": "@all", - }, - }, - { - name: "Custom message(markdown) with multiple alert", - settings: `{ - "secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id", - "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"} - `, - accessToken: "access_token", - expInitError: "", - 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{}{ - "markdown": map[string]interface{}{ - "content": "# [FIRING:2] \n2 alerts are firing, 0 are resolved\n", - }, - "msgtype": "markdown", - "agentid": "agent_id", - "touser": "@all", - }, - expMsgError: nil, - }, - { - name: "Custom message(Text) with multiple alert", - settings: `{ - "secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id", - "msgtype": "text", - "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"} - `, - accessToken: "access_token", - expInitError: "", - 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{}{ - "text": map[string]interface{}{ - "content": "[FIRING:2] \n2 alerts are firing, 0 are resolved\n", - }, - "msgtype": "text", - "agentid": "agent_id", - "touser": "@all", - }, - expMsgError: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - accessToken := r.URL.Query().Get("access_token") - if accessToken != tt.accessToken { - t.Errorf("Expected access_token=%s got %s", tt.accessToken, accessToken) - return - } - - expBody, err := json.Marshal(tt.expMsg) - require.NoError(t, err) - - b, err := io.ReadAll(r.Body) - require.NoError(t, err) - require.JSONEq(t, string(expBody), string(b)) - })) - defer server.Close() - - m := &NotificationChannelConfig{ - Name: "wecom_testing", - Type: "wecom", - Settings: json.RawMessage(tt.settings), - } - - webhookSender := mockNotificationService() - - fc := FactoryConfig{ - Config: m, - NotificationService: webhookSender, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - ImageStore: nil, - Template: tmpl, - Logger: &FakeLogger{}, - } - - pn, err := buildWecomNotifier(fc) - if tt.expInitError != "" { - require.Equal(t, tt.expInitError, err.Error()) - return - } - require.NoError(t, err) - - ctx := notify.WithGroupKey(context.Background(), "alertname") - ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - - // Avoid calling GetAccessToken interfaces - pn.tokExpireAt = time.Now().Add(10 * time.Second) - pn.tok = &WeComAccessToken{AccessToken: tt.accessToken} - - ok, err := pn.Notify(ctx, tt.alerts...) - if tt.expMsgError != nil { - require.False(t, ok) - require.Error(t, err) - require.Equal(t, tt.expMsgError.Error(), err.Error()) - return - } - require.NoError(t, err) - require.True(t, ok) - - expBody, err := json.Marshal(tt.expMsg) - require.NoError(t, err) - - require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) - }) - } -} - -func TestWeComNotifier_GetAccessToken(t *testing.T) { - type fields struct { - tok *WeComAccessToken - tokExpireAt time.Time - corpid string - secret string - } - tests := []struct { - name string - fields fields - want string - wantErr assert.ErrorAssertionFunc - }{ - { - name: "no corpid", - fields: fields{ - tok: nil, - tokExpireAt: time.Now().Add(-time.Minute), - }, - want: "", - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.Error(t, err, i...) - }, - }, - { - name: "no corpsecret", - fields: fields{ - tok: nil, - tokExpireAt: time.Now().Add(-time.Minute), - }, - want: "", - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.Error(t, err, i...) - }, - }, - { - name: "get access token", - fields: fields{ - corpid: "corpid", - secret: "secret", - }, - want: "access_token", - wantErr: assert.NoError, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - corpid := r.URL.Query().Get("corpid") - corpsecret := r.URL.Query().Get("corpsecret") - - assert.Equal(t, corpid, tt.fields.corpid, fmt.Sprintf("Expected corpid=%s got %s", tt.fields.corpid, corpid)) - if len(corpid) == 0 { - w.WriteHeader(http.StatusBadRequest) - return - } - - assert.Equal(t, corpsecret, tt.fields.secret, fmt.Sprintf("Expected corpsecret=%s got %s", tt.fields.secret, corpsecret)) - if len(corpsecret) == 0 { - w.WriteHeader(http.StatusBadRequest) - return - } - - b, err := json.Marshal(map[string]interface{}{ - "errcode": 0, - "errmsg": "ok", - "access_token": tt.want, - "expires_in": 7200, - }) - assert.NoError(t, err) - w.WriteHeader(http.StatusOK) - _, err = w.Write(b) - assert.NoError(t, err) - })) - defer server.Close() - - w := &WeComNotifier{ - settings: wecomSettings{ - EndpointURL: server.URL, - CorpID: tt.fields.corpid, - Secret: tt.fields.secret, - }, - tok: tt.fields.tok, - tokExpireAt: tt.fields.tokExpireAt, - } - got, err := w.GetAccessToken(context.Background()) - if !tt.wantErr(t, err, "GetAccessToken()") { - return - } - assert.Equalf(t, tt.want, got, "GetAccessToken()") - }) - } -} - -func TestWeComFactory(t *testing.T) { - tests := []struct { - name string - settings string - wantErr assert.ErrorAssertionFunc - }{ - { - name: "null", - settings: "{}", - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.Contains(t, err.Error(), "either url or secret is required", i...) - }, - }, - { - name: "webhook url", - settings: `{"url": "https://example.com"}`, - wantErr: assert.NoError, - }, - { - name: "apiapp missing AgentID", - settings: `{"secret": "secret"}`, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.Contains(t, err.Error(), "could not find AgentID in settings", i...) - }, - }, - { - name: "apiapp missing CorpID", - settings: `{"secret": "secret", "agent_id": "agent_id"}`, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.Contains(t, err.Error(), "could not find CorpID in settings", i...) - }, - }, - { - name: "apiapp", - settings: `{"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id"}`, - wantErr: assert.NoError, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := &NotificationChannelConfig{ - Name: "wecom_testing", - Type: "wecom", - Settings: json.RawMessage(tt.settings), - } - - webhookSender := mockNotificationService() - - fc := FactoryConfig{ - Config: m, - NotificationService: webhookSender, - DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { - return fallback - }, - ImageStore: nil, - Logger: &FakeLogger{}, - } - - _, err := WeComFactory(fc) - if !tt.wantErr(t, err, fmt.Sprintf("WeComFactory(%v)", fc)) { - return - } - }) - } -} diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 64458d2df09..9cd228a5f55 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -3,7 +3,7 @@ package channels_config import ( "os" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" + "github.com/grafana/alerting/alerting/notifier/channels" ) // GetAvailableNotifiers returns the metadata of all the notification channels that can be configured. diff --git a/pkg/services/ngalert/notifier/images.go b/pkg/services/ngalert/notifier/images.go index c9555e6247a..222dc552837 100644 --- a/pkg/services/ngalert/notifier/images.go +++ b/pkg/services/ngalert/notifier/images.go @@ -4,8 +4,9 @@ import ( "context" "errors" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" "github.com/grafana/grafana/pkg/services/ngalert/store" ) diff --git a/pkg/services/ngalert/notifier/log.go b/pkg/services/ngalert/notifier/log.go index 84e6d31e2fd..fe59a0fec92 100644 --- a/pkg/services/ngalert/notifier/log.go +++ b/pkg/services/ngalert/notifier/log.go @@ -1,8 +1,9 @@ package notifier import ( + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" ) var LoggerFactory channels.LoggerFactory = func(ctx ...interface{}) channels.Logger { diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index e4c8e6c0f60..88a37763dd9 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -9,11 +9,11 @@ import ( "sync" "time" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/cluster" "github.com/prometheus/client_golang/prometheus" diff --git a/pkg/services/ngalert/notifier/sender.go b/pkg/services/ngalert/notifier/sender.go index 976ccc4cbe7..fd53694b399 100644 --- a/pkg/services/ngalert/notifier/sender.go +++ b/pkg/services/ngalert/notifier/sender.go @@ -3,8 +3,9 @@ package notifier import ( "context" + "github.com/grafana/alerting/alerting/notifier/channels" + "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" "github.com/grafana/grafana/pkg/services/notifications" ) @@ -14,12 +15,12 @@ type sender struct { func (s sender) SendWebhook(ctx context.Context, cmd *channels.SendWebhookSettings) error { return s.ns.SendWebhookSync(ctx, &models.SendWebhookSync{ - Url: cmd.Url, + Url: cmd.URL, User: cmd.User, Password: cmd.Password, Body: cmd.Body, - HttpMethod: cmd.HttpMethod, - HttpHeader: cmd.HttpHeader, + HttpMethod: cmd.HTTPMethod, + HttpHeader: cmd.HTTPHeader, ContentType: cmd.ContentType, Validation: cmd.Validation, }) diff --git a/pkg/services/sqlstore/migrations/ualert/ualert.go b/pkg/services/sqlstore/migrations/ualert/ualert.go index 2a346a21d46..38b281d4e68 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert.go @@ -14,8 +14,10 @@ import ( pb "github.com/prometheus/alertmanager/silence/silencepb" "xorm.io/xorm" + "github.com/grafana/alerting/alerting/notifier/channels" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" + ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -499,7 +501,7 @@ func (m *migration) validateAlertmanagerConfig(orgID int64, config *PostableUser } return fallback } - receiverFactory, exists := channels.Factory(gr.Type) + receiverFactory, exists := ngchannels.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 20937761e66..2ac1a8ed37b 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/grafana/alerting/alerting/notifier/channels" "github.com/prometheus/alertmanager/template" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" @@ -24,7 +25,7 @@ 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" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" + 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" @@ -718,22 +719,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 := channels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL, + os, opa, ot, opu, ogb, ol, oth := ngchannels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL, channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary, - channels.LineNotifyURL, channels.ThreemaGwBaseURL + ngchannels.LineNotifyURL, channels.ThreemaGwBaseURL originalTemplate := channels.DefaultTemplateString t.Cleanup(func() { - channels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL, + ngchannels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL, channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary, - channels.LineNotifyURL, channels.ThreemaGwBaseURL = os, opa, ot, opu, ogb, ol, oth + ngchannels.LineNotifyURL, channels.ThreemaGwBaseURL = os, opa, ot, opu, ogb, ol, oth channels.DefaultTemplateString = originalTemplate }) channels.DefaultTemplateString = channels.TemplateForTestsString - channels.SlackAPIEndpoint = fmt.Sprintf("http://%s/slack_recvX/slack_testX", mockChannel.server.Addr) + ngchannels.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) - channels.LineNotifyURL = fmt.Sprintf("http://%s/line_recv/line_test", mockChannel.server.Addr) + ngchannels.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" }