mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: import Grafana alerting package and update usages (#60490)
* update remaining notifiers to use alerting package
This commit is contained in:
parent
9b21375d78
commit
f0cabe14d5
1
go.mod
1
go.mod
@ -56,6 +56,7 @@ require (
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/google/wire v0.5.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/grafana/alerting v0.0.0-20221216210437-c818b1197cdd
|
||||
github.com/grafana/cuetsy v0.1.1
|
||||
github.com/grafana/grafana-aws-sdk v0.11.0
|
||||
github.com/grafana/grafana-azure-sdk-go v1.3.1
|
||||
|
4
go.sum
4
go.sum
@ -1369,6 +1369,10 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20221215195045-4dd9b084e84d h1:2uPWbeBhkBfS5wpwU7wtZGLVn/XML2EqtiCdKGOBzDA=
|
||||
github.com/grafana/alerting v0.0.0-20221215195045-4dd9b084e84d/go.mod h1:BO51roH8bMRpAqeWxvnGePyCQoqgk1TiNISYKfoyHzQ=
|
||||
github.com/grafana/alerting v0.0.0-20221216210437-c818b1197cdd h1:EiSgiWT16KVktYkZxblUqXPfueLcyLQf1oF5mTDh4NY=
|
||||
github.com/grafana/alerting v0.0.0-20221216210437-c818b1197cdd/go.mod h1:A+ko8Ui4Ojw9oTi1WMCPH937mFUozN8Y41cqrOfNuy8=
|
||||
github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw=
|
||||
github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
|
||||
github.com/grafana/cuetsy v0.1.1 h1:+1jaDDYCpvKlcOWJgBRbkc5+VZIClCEn5mbI+4PLZqM=
|
||||
|
@ -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…
Reference in New Issue
Block a user