Alerting: import Grafana alerting package and update usages (#60490)

* update remaining notifiers to use alerting package
pull/60539/head
Yuri Tseretyan 3 years ago committed by GitHub
parent 9b21375d78
commit f0cabe14d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      go.mod
  2. 4
      go.sum
  3. 6
      pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go
  4. 5
      pkg/services/ngalert/notifier/alertmanager.go
  5. 23
      pkg/services/ngalert/notifier/channels/alertmanager.go
  6. 11
      pkg/services/ngalert/notifier/channels/alertmanager_test.go
  7. 22
      pkg/services/ngalert/notifier/channels/base.go
  8. 96
      pkg/services/ngalert/notifier/channels/default_template.go
  9. 219
      pkg/services/ngalert/notifier/channels/default_template_test.go
  10. 23
      pkg/services/ngalert/notifier/channels/dingding.go
  11. 9
      pkg/services/ngalert/notifier/channels/dingding_test.go
  12. 35
      pkg/services/ngalert/notifier/channels/discord.go
  13. 9
      pkg/services/ngalert/notifier/channels/discord_test.go
  14. 28
      pkg/services/ngalert/notifier/channels/email.go
  15. 15
      pkg/services/ngalert/notifier/channels/email_test.go
  16. 61
      pkg/services/ngalert/notifier/channels/factory.go
  17. 33
      pkg/services/ngalert/notifier/channels/googlechat.go
  18. 12
      pkg/services/ngalert/notifier/channels/googlechat_test.go
  19. 26
      pkg/services/ngalert/notifier/channels/images.go
  20. 34
      pkg/services/ngalert/notifier/channels/kafka.go
  21. 9
      pkg/services/ngalert/notifier/channels/kafka_test.go
  22. 27
      pkg/services/ngalert/notifier/channels/line.go
  23. 9
      pkg/services/ngalert/notifier/channels/line_test.go
  24. 45
      pkg/services/ngalert/notifier/channels/log.go
  25. 316
      pkg/services/ngalert/notifier/channels/opsgenie.go
  26. 279
      pkg/services/ngalert/notifier/channels/opsgenie_test.go
  27. 279
      pkg/services/ngalert/notifier/channels/pagerduty.go
  28. 318
      pkg/services/ngalert/notifier/channels/pagerduty_test.go
  29. 339
      pkg/services/ngalert/notifier/channels/pushover.go
  30. 273
      pkg/services/ngalert/notifier/channels/pushover_test.go
  31. 46
      pkg/services/ngalert/notifier/channels/sender.go
  32. 182
      pkg/services/ngalert/notifier/channels/sensugo.go
  33. 185
      pkg/services/ngalert/notifier/channels/sensugo_test.go
  34. 72
      pkg/services/ngalert/notifier/channels/slack.go
  35. 14
      pkg/services/ngalert/notifier/channels/slack_test.go
  36. 378
      pkg/services/ngalert/notifier/channels/teams.go
  37. 309
      pkg/services/ngalert/notifier/channels/teams_test.go
  38. 239
      pkg/services/ngalert/notifier/channels/telegram.go
  39. 162
      pkg/services/ngalert/notifier/channels/telegram_test.go
  40. 206
      pkg/services/ngalert/notifier/channels/template_data.go
  41. 84
      pkg/services/ngalert/notifier/channels/testing.go
  42. 166
      pkg/services/ngalert/notifier/channels/threema.go
  43. 161
      pkg/services/ngalert/notifier/channels/threema_test.go
  44. 172
      pkg/services/ngalert/notifier/channels/util.go
  45. 10
      pkg/services/ngalert/notifier/channels/util_test.go
  46. 38
      pkg/services/ngalert/notifier/channels/victorops.go
  47. 10
      pkg/services/ngalert/notifier/channels/victorops_test.go
  48. 163
      pkg/services/ngalert/notifier/channels/webex.go
  49. 145
      pkg/services/ngalert/notifier/channels/webex_test.go
  50. 224
      pkg/services/ngalert/notifier/channels/webhook.go
  51. 392
      pkg/services/ngalert/notifier/channels/webhook_test.go
  52. 252
      pkg/services/ngalert/notifier/channels/wecom.go
  53. 552
      pkg/services/ngalert/notifier/channels/wecom_test.go
  54. 2
      pkg/services/ngalert/notifier/channels_config/available_channels.go
  55. 3
      pkg/services/ngalert/notifier/images.go
  56. 3
      pkg/services/ngalert/notifier/log.go
  57. 2
      pkg/services/ngalert/notifier/multiorg_alertmanager.go
  58. 9
      pkg/services/ngalert/notifier/sender.go
  59. 6
      pkg/services/sqlstore/migrations/ualert/ualert.go
  60. 15
      pkg/tests/api/alerting/api_notification_channel_test.go

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

@ -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=

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

@ -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,

@ -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 != "" {

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

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

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

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

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

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

@ -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"

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

@ -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,

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

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

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

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

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

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

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

@ -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",

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

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

@ -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"`
}

@ -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 = "<not-sent>"
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, "<not-sent>", webhookSender.Webhook.Body)
} else {
require.JSONEq(t, c.expMsg, webhookSender.Webhook.Body)
}
})
}
}

@ -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"`
}

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

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

@ -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": "<userKey>",
"apiToken": "<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": "<userKey>",
"token": "<apiToken>",
"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": "<userKey>",
"apiToken": "<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": "<userKey>",
"token": "<apiToken>",
"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": "<userKey>",
"apiToken": "<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": "<userKey>",
"token": "<apiToken>",
"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": "<userKey>",
"apiToken": "<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": "<userKey>",
"token": "<apiToken>",
"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": "<apiToken>"
}`,
expInitError: `user key not found`,
}, {
name: "Missing api key",
settings: `{
"userKey": "<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)
}
})
}
}

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

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

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

@ -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 {

@ -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 {

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

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

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

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

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

@ -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{

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

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

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

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

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

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

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

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

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

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

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

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

@ -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.

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

@ -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 {

@ -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"

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

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

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

Loading…
Cancel
Save