mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Use all notifiers from alerting repository (#60655)
This commit is contained in:
parent
542cccaecc
commit
f990be58cb
2
go.mod
2
go.mod
@ -59,7 +59,7 @@ require (
|
|||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
github.com/google/wire v0.5.0
|
github.com/google/wire v0.5.0
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/grafana/alerting v0.0.0-20221219210434-60ecaff51745
|
github.com/grafana/alerting v0.0.0-20221221211348-c5ab25d1cb8a
|
||||||
github.com/grafana/cuetsy v0.1.1
|
github.com/grafana/cuetsy v0.1.1
|
||||||
github.com/grafana/grafana-aws-sdk v0.11.0
|
github.com/grafana/grafana-aws-sdk v0.11.0
|
||||||
github.com/grafana/grafana-azure-sdk-go v1.5.1
|
github.com/grafana/grafana-azure-sdk-go v1.5.1
|
||||||
|
2
go.sum
2
go.sum
@ -1361,6 +1361,8 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm
|
|||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grafana/alerting v0.0.0-20221219210434-60ecaff51745 h1:6HIwDYa01WcVBdz7WXnidVXfGLRAzYFNKPPFFwg9OXE=
|
github.com/grafana/alerting v0.0.0-20221219210434-60ecaff51745 h1:6HIwDYa01WcVBdz7WXnidVXfGLRAzYFNKPPFFwg9OXE=
|
||||||
github.com/grafana/alerting v0.0.0-20221219210434-60ecaff51745/go.mod h1:A+ko8Ui4Ojw9oTi1WMCPH937mFUozN8Y41cqrOfNuy8=
|
github.com/grafana/alerting v0.0.0-20221219210434-60ecaff51745/go.mod h1:A+ko8Ui4Ojw9oTi1WMCPH937mFUozN8Y41cqrOfNuy8=
|
||||||
|
github.com/grafana/alerting v0.0.0-20221221211348-c5ab25d1cb8a h1:Ros/f8x4rPGiOyDEv0k0bDN26IDtaxn1qMs0RMN7daw=
|
||||||
|
github.com/grafana/alerting v0.0.0-20221221211348-c5ab25d1cb8a/go.mod h1:A+ko8Ui4Ojw9oTi1WMCPH937mFUozN8Y41cqrOfNuy8=
|
||||||
github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw=
|
github.com/grafana/codejen v0.0.3 h1:tAWxoTUuhgmEqxJPOLtJoxlPBbMULFwKFOcRsPRPXDw=
|
||||||
github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
|
github.com/grafana/codejen v0.0.3/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
|
||||||
github.com/grafana/cuetsy v0.1.1 h1:+1jaDDYCpvKlcOWJgBRbkc5+VZIClCEn5mbI+4PLZqM=
|
github.com/grafana/cuetsy v0.1.1 h1:+1jaDDYCpvKlcOWJgBRbkc5+VZIClCEn5mbI+4PLZqM=
|
||||||
|
@ -6,7 +6,6 @@ import (
|
|||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
"github.com/grafana/alerting/alerting/notifier/channels"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
|
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
@ -107,7 +106,7 @@ func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) e
|
|||||||
if e.Settings == nil {
|
if e.Settings == nil {
|
||||||
return fmt.Errorf("settings should not be empty")
|
return fmt.Errorf("settings should not be empty")
|
||||||
}
|
}
|
||||||
factory, exists := ngchannels.Factory(e.Type)
|
factory, exists := channels_config.Factory(e.Type)
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("unknown type '%s'", e.Type)
|
return fmt.Errorf("unknown type '%s'", e.Type)
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ import (
|
|||||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||||
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
|
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||||
"github.com/grafana/grafana/pkg/services/notifications"
|
"github.com/grafana/grafana/pkg/services/notifications"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
@ -522,7 +522,7 @@ func (am *Alertmanager) buildReceiverIntegration(r *apimodels.PostableGrafanaRec
|
|||||||
Err: err,
|
Err: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
receiverFactory, exists := ngchannels.Factory(r.Type)
|
receiverFactory, exists := channels_config.Factory(r.Type)
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, InvalidReceiverError{
|
return nil, InvalidReceiverError{
|
||||||
Receiver: r,
|
Receiver: r,
|
||||||
|
@ -1,138 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AlertmanagerConfig struct {
|
|
||||||
*channels.NotificationChannelConfig
|
|
||||||
URLs []*url.URL
|
|
||||||
BasicAuthUser string
|
|
||||||
BasicAuthPassword string
|
|
||||||
}
|
|
||||||
|
|
||||||
type alertmanagerSettings struct {
|
|
||||||
URLs []*url.URL
|
|
||||||
User string
|
|
||||||
Password string
|
|
||||||
}
|
|
||||||
|
|
||||||
func AlertmanagerFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
|
||||||
ch, err := buildAlertmanagerNotifier(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, receiverInitError{
|
|
||||||
Reason: err.Error(),
|
|
||||||
Cfg: *fc.Config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAlertmanagerNotifier(fc channels.FactoryConfig) (*AlertmanagerNotifier, error) {
|
|
||||||
var settings struct {
|
|
||||||
URL channels.CommaSeparatedStrings `json:"url,omitempty" yaml:"url,omitempty"`
|
|
||||||
User string `json:"basicAuthUser,omitempty" yaml:"basicAuthUser,omitempty"`
|
|
||||||
Password string `json:"basicAuthPassword,omitempty" yaml:"basicAuthPassword,omitempty"`
|
|
||||||
}
|
|
||||||
err := json.Unmarshal(fc.Config.Settings, &settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
urls := make([]*url.URL, 0, len(settings.URL))
|
|
||||||
for _, uS := range settings.URL {
|
|
||||||
uS = strings.TrimSpace(uS)
|
|
||||||
if uS == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
uS = strings.TrimSuffix(uS, "/") + "/api/v1/alerts"
|
|
||||||
u, err := url.Parse(uS)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid url property in settings: %w", err)
|
|
||||||
}
|
|
||||||
urls = append(urls, u)
|
|
||||||
}
|
|
||||||
if len(settings.URL) == 0 || len(urls) == 0 {
|
|
||||||
return nil, errors.New("could not find url property in settings")
|
|
||||||
}
|
|
||||||
settings.Password = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "basicAuthPassword", settings.Password)
|
|
||||||
|
|
||||||
return &AlertmanagerNotifier{
|
|
||||||
Base: channels.NewBase(fc.Config),
|
|
||||||
images: fc.ImageStore,
|
|
||||||
settings: alertmanagerSettings{
|
|
||||||
URLs: urls,
|
|
||||||
User: settings.User,
|
|
||||||
Password: settings.Password,
|
|
||||||
},
|
|
||||||
logger: fc.Logger,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AlertmanagerNotifier sends alert notifications to the alert manager
|
|
||||||
type AlertmanagerNotifier struct {
|
|
||||||
*channels.Base
|
|
||||||
images channels.ImageStore
|
|
||||||
settings alertmanagerSettings
|
|
||||||
logger channels.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify sends alert notifications to Alertmanager.
|
|
||||||
func (n *AlertmanagerNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
|
||||||
n.logger.Debug("sending Alertmanager alert", "alertmanager", n.Name)
|
|
||||||
if len(as) == 0 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = withStoredImages(ctx, n.logger, n.images,
|
|
||||||
func(index int, image channels.Image) error {
|
|
||||||
// If there is an image for this alert and the image has been uploaded
|
|
||||||
// to a public URL then include it as an annotation
|
|
||||||
if image.URL != "" {
|
|
||||||
as[index].Annotations["image"] = model.LabelValue(image.URL)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, as...)
|
|
||||||
|
|
||||||
body, err := json.Marshal(as)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
lastErr error
|
|
||||||
numErrs int
|
|
||||||
)
|
|
||||||
for _, u := range n.settings.URLs {
|
|
||||||
if _, err := sendHTTPRequest(ctx, u, httpCfg{
|
|
||||||
user: n.settings.User,
|
|
||||||
password: n.settings.Password,
|
|
||||||
body: body,
|
|
||||||
}, n.logger); err != nil {
|
|
||||||
n.logger.Warn("failed to send to Alertmanager", "error", err, "alertmanager", n.Name, "url", u.String())
|
|
||||||
lastErr = err
|
|
||||||
numErrs++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if numErrs == len(n.settings.URLs) {
|
|
||||||
// All attempts to send alerts have failed
|
|
||||||
n.logger.Warn("all attempts to send to Alertmanager failed", "alertmanager", n.Name)
|
|
||||||
return false, fmt.Errorf("failed to send alert to Alertmanager: %w", lastErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *AlertmanagerNotifier) SendResolved() bool {
|
|
||||||
return !n.GetDisableResolveMessage()
|
|
||||||
}
|
|
@ -1,222 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewAlertmanagerNotifier(t *testing.T) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
|
|
||||||
externalURL, err := url.Parse("http://localhost")
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expectedInitError string
|
|
||||||
receiverName string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Error in initing: missing URL",
|
|
||||||
settings: `{}`,
|
|
||||||
expectedInitError: `could not find url property in settings`,
|
|
||||||
}, {
|
|
||||||
name: "Error in initing: invalid URL",
|
|
||||||
settings: `{
|
|
||||||
"url": "://alertmanager.com"
|
|
||||||
}`,
|
|
||||||
expectedInitError: `invalid url property in settings: parse "://alertmanager.com/api/v1/alerts": missing protocol scheme`,
|
|
||||||
receiverName: "Alertmanager",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Error in initing: empty URL",
|
|
||||||
settings: `{
|
|
||||||
"url": ""
|
|
||||||
}`,
|
|
||||||
expectedInitError: `could not find url property in settings`,
|
|
||||||
receiverName: "Alertmanager",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Error in initing: null URL",
|
|
||||||
settings: `{
|
|
||||||
"url": null
|
|
||||||
}`,
|
|
||||||
expectedInitError: `could not find url property in settings`,
|
|
||||||
receiverName: "Alertmanager",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Error in initing: one of multiple URLs is invalid",
|
|
||||||
settings: `{
|
|
||||||
"url": "https://alertmanager-01.com,://url"
|
|
||||||
}`,
|
|
||||||
expectedInitError: "invalid url property in settings: parse \"://url/api/v1/alerts\": missing protocol scheme",
|
|
||||||
receiverName: "Alertmanager",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
secureSettings := make(map[string][]byte)
|
|
||||||
|
|
||||||
m := &channels.NotificationChannelConfig{
|
|
||||||
Name: c.receiverName,
|
|
||||||
Type: "prometheus-alertmanager",
|
|
||||||
Settings: json.RawMessage(c.settings),
|
|
||||||
SecureSettings: secureSettings,
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptFn := func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
fc := channels.FactoryConfig{
|
|
||||||
Config: m,
|
|
||||||
DecryptFunc: decryptFn,
|
|
||||||
ImageStore: &channels.UnavailableImageStore{},
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
}
|
|
||||||
sn, err := buildAlertmanagerNotifier(fc)
|
|
||||||
if c.expectedInitError != "" {
|
|
||||||
require.ErrorContains(t, err, c.expectedInitError)
|
|
||||||
} else {
|
|
||||||
require.NotNil(t, sn)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertmanagerNotifier_Notify(t *testing.T) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
|
|
||||||
images := newFakeImageStore(1)
|
|
||||||
|
|
||||||
externalURL, err := url.Parse("http://localhost")
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expectedError string
|
|
||||||
sendHTTPRequestError error
|
|
||||||
receiverName string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Default config with one alert",
|
|
||||||
settings: `{"url": "https://alertmanager.com"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
receiverName: "Alertmanager",
|
|
||||||
}, {
|
|
||||||
name: "Default config with one alert with image URL",
|
|
||||||
settings: `{"url": "https://alertmanager.com"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1"},
|
|
||||||
Annotations: model.LabelSet{"__alertImageToken__": "test-image-1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
receiverName: "Alertmanager",
|
|
||||||
}, {
|
|
||||||
name: "Default config with one alert with empty receiver name",
|
|
||||||
settings: `{"url": "https://alertmanager.com"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: "Error sending to Alertmanager",
|
|
||||||
settings: `{
|
|
||||||
"url": "https://alertmanager.com"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedError: "failed to send alert to Alertmanager: expected error",
|
|
||||||
sendHTTPRequestError: errors.New("expected error"),
|
|
||||||
receiverName: "Alertmanager",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
settingsJSON := json.RawMessage(c.settings)
|
|
||||||
require.NoError(t, err)
|
|
||||||
secureSettings := make(map[string][]byte)
|
|
||||||
|
|
||||||
m := &channels.NotificationChannelConfig{
|
|
||||||
Name: c.receiverName,
|
|
||||||
Type: "prometheus-alertmanager",
|
|
||||||
Settings: settingsJSON,
|
|
||||||
SecureSettings: secureSettings,
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptFn := func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
fc := channels.FactoryConfig{
|
|
||||||
Config: m,
|
|
||||||
DecryptFunc: decryptFn,
|
|
||||||
ImageStore: images,
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
}
|
|
||||||
sn, err := buildAlertmanagerNotifier(fc)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var body []byte
|
|
||||||
origSendHTTPRequest := sendHTTPRequest
|
|
||||||
t.Cleanup(func() {
|
|
||||||
sendHTTPRequest = origSendHTTPRequest
|
|
||||||
})
|
|
||||||
sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger channels.Logger) ([]byte, error) {
|
|
||||||
body = cfg.body
|
|
||||||
return nil, c.sendHTTPRequestError
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
|
||||||
ok, err := sn.Notify(ctx, c.alerts...)
|
|
||||||
|
|
||||||
if c.sendHTTPRequestError != nil {
|
|
||||||
require.EqualError(t, err, c.expectedError)
|
|
||||||
require.False(t, ok)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, ok)
|
|
||||||
expBody, err := json.Marshal(c.alerts)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.JSONEq(t, string(expBody), string(body))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func templateForTests(t *testing.T) *template.Template {
|
|
||||||
f, err := os.CreateTemp("/tmp", "template")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer func(f *os.File) {
|
|
||||||
_ = f.Close()
|
|
||||||
}(f)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
require.NoError(t, os.RemoveAll(f.Name()))
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = f.WriteString(channels.TemplateForTestsString)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
tmpl, err := template.FromGlobs(f.Name())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return tmpl
|
|
||||||
}
|
|
@ -1,160 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultDingdingMsgType = "link"
|
|
||||||
|
|
||||||
type dingDingSettings struct {
|
|
||||||
URL string `json:"url,omitempty" yaml:"url,omitempty"`
|
|
||||||
MessageType string `json:"msgType,omitempty" yaml:"msgType,omitempty"`
|
|
||||||
Title string `json:"title,omitempty" yaml:"title,omitempty"`
|
|
||||||
Message string `json:"message,omitempty" yaml:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDingDingSettings(fc channels.FactoryConfig) (*dingDingSettings, error) {
|
|
||||||
var settings dingDingSettings
|
|
||||||
err := json.Unmarshal(fc.Config.Settings, &settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
if settings.URL == "" {
|
|
||||||
return nil, errors.New("could not find url property in settings")
|
|
||||||
}
|
|
||||||
if settings.MessageType == "" {
|
|
||||||
settings.MessageType = defaultDingdingMsgType
|
|
||||||
}
|
|
||||||
if settings.Title == "" {
|
|
||||||
settings.Title = channels.DefaultMessageTitleEmbed
|
|
||||||
}
|
|
||||||
if settings.Message == "" {
|
|
||||||
settings.Message = channels.DefaultMessageEmbed
|
|
||||||
}
|
|
||||||
return &settings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func DingDingFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
|
||||||
n, err := newDingDingNotifier(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, receiverInitError{
|
|
||||||
Reason: err.Error(),
|
|
||||||
Cfg: *fc.Config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newDingDingNotifier is the constructor for the Dingding notifier
|
|
||||||
func newDingDingNotifier(fc channels.FactoryConfig) (*DingDingNotifier, error) {
|
|
||||||
settings, err := buildDingDingSettings(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &DingDingNotifier{
|
|
||||||
Base: channels.NewBase(fc.Config),
|
|
||||||
log: fc.Logger,
|
|
||||||
ns: fc.NotificationService,
|
|
||||||
tmpl: fc.Template,
|
|
||||||
settings: *settings,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DingDingNotifier is responsible for sending alert notifications to ding ding.
|
|
||||||
type DingDingNotifier struct {
|
|
||||||
*channels.Base
|
|
||||||
log channels.Logger
|
|
||||||
ns channels.WebhookSender
|
|
||||||
tmpl *template.Template
|
|
||||||
settings dingDingSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify sends the alert notification to dingding.
|
|
||||||
func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
|
||||||
dd.log.Info("sending dingding")
|
|
||||||
|
|
||||||
msgUrl := buildDingDingURL(dd)
|
|
||||||
|
|
||||||
var tmplErr error
|
|
||||||
tmpl, _ := channels.TmplText(ctx, dd.tmpl, as, dd.log, &tmplErr)
|
|
||||||
|
|
||||||
message := tmpl(dd.settings.Message)
|
|
||||||
title := tmpl(dd.settings.Title)
|
|
||||||
|
|
||||||
msgType := tmpl(dd.settings.MessageType)
|
|
||||||
b, err := buildBody(msgUrl, msgType, title, message)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if tmplErr != nil {
|
|
||||||
dd.log.Warn("failed to template DingDing message", "error", tmplErr.Error())
|
|
||||||
tmplErr = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
u := tmpl(dd.settings.URL)
|
|
||||||
if tmplErr != nil {
|
|
||||||
dd.log.Warn("failed to template DingDing URL", "error", tmplErr.Error(), "fallback", dd.settings.URL)
|
|
||||||
u = dd.settings.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := &channels.SendWebhookSettings{URL: u, Body: b}
|
|
||||||
|
|
||||||
if err := dd.ns.SendWebhook(ctx, cmd); err != nil {
|
|
||||||
return false, fmt.Errorf("send notification to dingding: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dd *DingDingNotifier) SendResolved() bool {
|
|
||||||
return !dd.GetDisableResolveMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDingDingURL(dd *DingDingNotifier) string {
|
|
||||||
q := url.Values{
|
|
||||||
"pc_slide": {"false"},
|
|
||||||
"url": {joinUrlPath(dd.tmpl.ExternalURL.String(), "/alerting/list", dd.log)},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use special link to auto open the message url outside Dingding
|
|
||||||
// Refer: https://open-doc.dingtalk.com/docs/doc.htm?treeId=385&articleId=104972&docType=1#s9
|
|
||||||
return "dingtalk://dingtalkclient/page/link?" + q.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildBody(msgUrl string, msgType string, title string, msg string) (string, error) {
|
|
||||||
var bodyMsg map[string]interface{}
|
|
||||||
if msgType == "actionCard" {
|
|
||||||
bodyMsg = map[string]interface{}{
|
|
||||||
"msgtype": "actionCard",
|
|
||||||
"actionCard": map[string]string{
|
|
||||||
"text": msg,
|
|
||||||
"title": title,
|
|
||||||
"singleTitle": "More",
|
|
||||||
"singleURL": msgUrl,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
bodyMsg = map[string]interface{}{
|
|
||||||
"msgtype": "link",
|
|
||||||
"link": map[string]string{
|
|
||||||
"text": msg,
|
|
||||||
"title": title,
|
|
||||||
"messageUrl": msgUrl,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(bodyMsg)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(body), nil
|
|
||||||
}
|
|
@ -1,207 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDingdingNotifier(t *testing.T) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
|
|
||||||
externalURL, err := url.Parse("http://localhost")
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expMsg map[string]interface{}
|
|
||||||
expInitError string
|
|
||||||
expMsgError error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Default config with one alert",
|
|
||||||
settings: `{"url": "http://localhost"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__values__": "{\"A\": 1234}", "__value_string__": "1234"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"msgtype": "link",
|
|
||||||
"link": map[string]interface{}{
|
|
||||||
"messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist",
|
|
||||||
"text": "**Firing**\n\nValue: A=1234\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
"title": "[FIRING:1] (val1)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Custom config with multiple alerts",
|
|
||||||
settings: `{
|
|
||||||
"url": "http://localhost",
|
|
||||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved",
|
|
||||||
"msgType": "actionCard"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"actionCard": map[string]interface{}{
|
|
||||||
"singleTitle": "More",
|
|
||||||
"singleURL": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist",
|
|
||||||
"text": "2 alerts are firing, 0 are resolved",
|
|
||||||
"title": "[FIRING:2] ",
|
|
||||||
},
|
|
||||||
"msgtype": "actionCard",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Default config with one alert and custom title and description",
|
|
||||||
settings: `{"url": "http://localhost", "title": "Alerts firing: {{ len .Alerts.Firing }}", "message": "customMessage"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__values__": "{\"A\": 1234}", "__value_string__": "1234"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"msgtype": "link",
|
|
||||||
"link": map[string]interface{}{
|
|
||||||
"messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist",
|
|
||||||
"text": "customMessage",
|
|
||||||
"title": "Alerts firing: 1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Missing field in template",
|
|
||||||
settings: `{
|
|
||||||
"url": "http://localhost",
|
|
||||||
"message": "I'm a custom template {{ .NotAField }} bad template",
|
|
||||||
"msgType": "actionCard"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"link": map[string]interface{}{
|
|
||||||
"messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist",
|
|
||||||
"text": "I'm a custom template ",
|
|
||||||
"title": "",
|
|
||||||
},
|
|
||||||
"msgtype": "link",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Invalid template",
|
|
||||||
settings: `{
|
|
||||||
"url": "http://localhost",
|
|
||||||
"message": "I'm a custom template {{ {.NotAField }} bad template",
|
|
||||||
"msgType": "actionCard"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"link": map[string]interface{}{
|
|
||||||
"messageUrl": "dingtalk://dingtalkclient/page/link?pc_slide=false&url=http%3A%2F%2Flocalhost%2Falerting%2Flist",
|
|
||||||
"text": "",
|
|
||||||
"title": "",
|
|
||||||
},
|
|
||||||
"msgtype": "link",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Error in initing",
|
|
||||||
settings: `{}`,
|
|
||||||
expInitError: `could not find url property in settings`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
webhookSender := mockNotificationService()
|
|
||||||
fc := channels.FactoryConfig{
|
|
||||||
Config: &channels.NotificationChannelConfig{
|
|
||||||
Name: "dingding_testing",
|
|
||||||
Type: "dingding",
|
|
||||||
Settings: json.RawMessage(c.settings),
|
|
||||||
},
|
|
||||||
// TODO: allow changing the associated values for different tests.
|
|
||||||
NotificationService: webhookSender,
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
}
|
|
||||||
pn, err := newDingDingNotifier(fc)
|
|
||||||
if c.expInitError != "" {
|
|
||||||
require.Equal(t, c.expInitError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
|
||||||
ok, err := pn.Notify(ctx, c.alerts...)
|
|
||||||
if c.expMsgError != nil {
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, c.expMsgError.Error(), err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
require.NotEmpty(t, webhookSender.Webhook.URL)
|
|
||||||
|
|
||||||
expBody, err := json.Marshal(c.expMsg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,341 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Constants and models are set according to the official documentation https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params
|
|
||||||
|
|
||||||
type discordEmbedType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
discordRichEmbed discordEmbedType = "rich"
|
|
||||||
|
|
||||||
discordMaxEmbeds = 10
|
|
||||||
discordMaxMessageLen = 2000
|
|
||||||
)
|
|
||||||
|
|
||||||
type discordMessage struct {
|
|
||||||
Username string `json:"username,omitempty"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
AvatarURL string `json:"avatar_url,omitempty"`
|
|
||||||
Embeds []discordLinkEmbed `json:"embeds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// discordLinkEmbed implements https://discord.com/developers/docs/resources/channel#embed-object
|
|
||||||
type discordLinkEmbed struct {
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Type discordEmbedType `json:"type,omitempty"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
Color int64 `json:"color,omitempty"`
|
|
||||||
|
|
||||||
Footer *discordFooter `json:"footer,omitempty"`
|
|
||||||
|
|
||||||
Image *discordImage `json:"image,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// discordFooter implements https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
|
||||||
type discordFooter struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
IconURL string `json:"icon_url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// discordImage implements https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
|
||||||
type discordImage struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiscordNotifier struct {
|
|
||||||
*channels.Base
|
|
||||||
log channels.Logger
|
|
||||||
ns channels.WebhookSender
|
|
||||||
images channels.ImageStore
|
|
||||||
tmpl *template.Template
|
|
||||||
settings *discordSettings
|
|
||||||
appVersion string
|
|
||||||
}
|
|
||||||
|
|
||||||
type discordSettings struct {
|
|
||||||
Title string `json:"title,omitempty" yaml:"title,omitempty"`
|
|
||||||
Message string `json:"message,omitempty" yaml:"message,omitempty"`
|
|
||||||
AvatarURL string `json:"avatar_url,omitempty" yaml:"avatar_url,omitempty"`
|
|
||||||
WebhookURL string `json:"url,omitempty" yaml:"url,omitempty"`
|
|
||||||
UseDiscordUsername bool `json:"use_discord_username,omitempty" yaml:"use_discord_username,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDiscordSettings(fc channels.FactoryConfig) (*discordSettings, error) {
|
|
||||||
var settings discordSettings
|
|
||||||
err := json.Unmarshal(fc.Config.Settings, &settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
if settings.WebhookURL == "" {
|
|
||||||
return nil, errors.New("could not find webhook url property in settings")
|
|
||||||
}
|
|
||||||
if settings.Title == "" {
|
|
||||||
settings.Title = channels.DefaultMessageTitleEmbed
|
|
||||||
}
|
|
||||||
if settings.Message == "" {
|
|
||||||
settings.Message = channels.DefaultMessageEmbed
|
|
||||||
}
|
|
||||||
return &settings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type discordAttachment struct {
|
|
||||||
url string
|
|
||||||
reader io.ReadCloser
|
|
||||||
name string
|
|
||||||
alertName string
|
|
||||||
state model.AlertStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
func DiscordFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
|
||||||
dn, err := newDiscordNotifier(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, receiverInitError{
|
|
||||||
Reason: err.Error(),
|
|
||||||
Cfg: *fc.Config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDiscordNotifier(fc channels.FactoryConfig) (*DiscordNotifier, error) {
|
|
||||||
settings, err := buildDiscordSettings(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &DiscordNotifier{
|
|
||||||
Base: channels.NewBase(fc.Config),
|
|
||||||
log: fc.Logger,
|
|
||||||
ns: fc.NotificationService,
|
|
||||||
images: fc.ImageStore,
|
|
||||||
tmpl: fc.Template,
|
|
||||||
settings: settings,
|
|
||||||
appVersion: fc.GrafanaBuildVersion,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
|
||||||
alerts := types.Alerts(as...)
|
|
||||||
|
|
||||||
var msg discordMessage
|
|
||||||
|
|
||||||
if !d.settings.UseDiscordUsername {
|
|
||||||
msg.Username = "Grafana"
|
|
||||||
}
|
|
||||||
|
|
||||||
var tmplErr error
|
|
||||||
tmpl, _ := channels.TmplText(ctx, d.tmpl, as, d.log, &tmplErr)
|
|
||||||
|
|
||||||
msg.Content = tmpl(d.settings.Message)
|
|
||||||
if tmplErr != nil {
|
|
||||||
d.log.Warn("failed to template Discord notification content", "error", tmplErr.Error())
|
|
||||||
// Reset tmplErr for templating other fields.
|
|
||||||
tmplErr = nil
|
|
||||||
}
|
|
||||||
truncatedMsg, truncated := channels.TruncateInRunes(msg.Content, discordMaxMessageLen)
|
|
||||||
if truncated {
|
|
||||||
key, err := notify.ExtractGroupKey(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
d.log.Warn("Truncated content", "key", key, "max_runes", discordMaxMessageLen)
|
|
||||||
msg.Content = truncatedMsg
|
|
||||||
}
|
|
||||||
|
|
||||||
if d.settings.AvatarURL != "" {
|
|
||||||
msg.AvatarURL = tmpl(d.settings.AvatarURL)
|
|
||||||
if tmplErr != nil {
|
|
||||||
d.log.Warn("failed to template Discord Avatar URL", "error", tmplErr.Error(), "fallback", d.settings.AvatarURL)
|
|
||||||
msg.AvatarURL = d.settings.AvatarURL
|
|
||||||
tmplErr = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
footer := &discordFooter{
|
|
||||||
Text: "Grafana v" + d.appVersion,
|
|
||||||
IconURL: "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
var linkEmbed discordLinkEmbed
|
|
||||||
|
|
||||||
linkEmbed.Title = tmpl(d.settings.Title)
|
|
||||||
if tmplErr != nil {
|
|
||||||
d.log.Warn("failed to template Discord notification title", "error", tmplErr.Error())
|
|
||||||
// Reset tmplErr for templating other fields.
|
|
||||||
tmplErr = nil
|
|
||||||
}
|
|
||||||
linkEmbed.Footer = footer
|
|
||||||
linkEmbed.Type = discordRichEmbed
|
|
||||||
|
|
||||||
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0)
|
|
||||||
linkEmbed.Color = color
|
|
||||||
|
|
||||||
ruleURL := joinUrlPath(d.tmpl.ExternalURL.String(), "/alerting/list", d.log)
|
|
||||||
linkEmbed.URL = ruleURL
|
|
||||||
|
|
||||||
embeds := []discordLinkEmbed{linkEmbed}
|
|
||||||
|
|
||||||
attachments := d.constructAttachments(ctx, as, discordMaxEmbeds-1)
|
|
||||||
for _, a := range attachments {
|
|
||||||
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0)
|
|
||||||
embed := discordLinkEmbed{
|
|
||||||
Image: &discordImage{
|
|
||||||
URL: a.url,
|
|
||||||
},
|
|
||||||
Color: color,
|
|
||||||
Title: a.alertName,
|
|
||||||
}
|
|
||||||
embeds = append(embeds, embed)
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.Embeds = embeds
|
|
||||||
|
|
||||||
if tmplErr != nil {
|
|
||||||
d.log.Warn("failed to template Discord message", "error", tmplErr.Error())
|
|
||||||
tmplErr = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
u := tmpl(d.settings.WebhookURL)
|
|
||||||
if tmplErr != nil {
|
|
||||||
d.log.Warn("failed to template Discord URL", "error", tmplErr.Error(), "fallback", d.settings.WebhookURL)
|
|
||||||
u = d.settings.WebhookURL
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(msg)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd, err := d.buildRequest(u, body, attachments)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.ns.SendWebhook(ctx, cmd); err != nil {
|
|
||||||
d.log.Error("failed to send notification to Discord", "error", err)
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d DiscordNotifier) SendResolved() bool {
|
|
||||||
return !d.GetDisableResolveMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.Alert, embedQuota int) []discordAttachment {
|
|
||||||
attachments := make([]discordAttachment, 0)
|
|
||||||
|
|
||||||
_ = withStoredImages(ctx, d.log, d.images,
|
|
||||||
func(index int, image channels.Image) error {
|
|
||||||
if embedQuota < 1 {
|
|
||||||
return channels.ErrImagesDone
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(image.URL) > 0 {
|
|
||||||
attachments = append(attachments, discordAttachment{
|
|
||||||
url: image.URL,
|
|
||||||
state: as[index].Status(),
|
|
||||||
alertName: as[index].Name(),
|
|
||||||
})
|
|
||||||
embedQuota--
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a local file, but no public URL, upload the image as an attachment.
|
|
||||||
if len(image.Path) > 0 {
|
|
||||||
base := filepath.Base(image.Path)
|
|
||||||
url := fmt.Sprintf("attachment://%s", base)
|
|
||||||
reader, err := openImage(image.Path)
|
|
||||||
if err != nil && !errors.Is(err, channels.ErrImageNotFound) {
|
|
||||||
d.log.Warn("failed to retrieve image data from store", "error", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
attachments = append(attachments, discordAttachment{
|
|
||||||
url: url,
|
|
||||||
name: base,
|
|
||||||
reader: reader,
|
|
||||||
state: as[index].Status(),
|
|
||||||
alertName: as[index].Name(),
|
|
||||||
})
|
|
||||||
embedQuota--
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
as...,
|
|
||||||
)
|
|
||||||
|
|
||||||
return attachments
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d DiscordNotifier) buildRequest(url string, body []byte, attachments []discordAttachment) (*channels.SendWebhookSettings, error) {
|
|
||||||
cmd := &channels.SendWebhookSettings{
|
|
||||||
URL: url,
|
|
||||||
HTTPMethod: "POST",
|
|
||||||
}
|
|
||||||
if len(attachments) == 0 {
|
|
||||||
cmd.ContentType = "application/json"
|
|
||||||
cmd.Body = string(body)
|
|
||||||
return cmd, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var b bytes.Buffer
|
|
||||||
w := multipart.NewWriter(&b)
|
|
||||||
defer func() {
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
// Shouldn't matter since we already close w explicitly on the non-error path
|
|
||||||
d.log.Warn("failed to close multipart writer", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
payload, err := w.CreateFormField("payload_json")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := payload.Write(body); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range attachments {
|
|
||||||
if a.reader != nil { // We have an image to upload.
|
|
||||||
err = func() error {
|
|
||||||
defer func() { _ = a.reader.Close() }()
|
|
||||||
part, err := w.CreateFormFile("", a.name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = io.Copy(part, a.reader)
|
|
||||||
return err
|
|
||||||
}()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.ContentType = w.FormDataContentType()
|
|
||||||
cmd.Body = b.String()
|
|
||||||
return cmd, nil
|
|
||||||
}
|
|
@ -1,363 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDiscordNotifier(t *testing.T) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
|
|
||||||
externalURL, err := url.Parse("http://localhost")
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
appVersion := fmt.Sprintf("%d.0.0", rand.Uint32())
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expMsg map[string]interface{}
|
|
||||||
expInitError string
|
|
||||||
expMsgError error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Default config with one alert",
|
|
||||||
settings: `{"url": "http://localhost"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "[FIRING:1] (val1)",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
"username": "Grafana",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Default config with one alert and custom title",
|
|
||||||
settings: `{"url": "http://localhost", "title": "Alerts firing: {{ len .Alerts.Firing }}"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "Alerts firing: 1",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
"username": "Grafana",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing field in template",
|
|
||||||
settings: `{
|
|
||||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"url": "http://localhost",
|
|
||||||
"message": "I'm a custom template {{ .NotAField }} bad template"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"content": "I'm a custom template ",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "[FIRING:1] (val1)",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
"username": "Grafana",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid message template",
|
|
||||||
settings: `{
|
|
||||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"url": "http://localhost",
|
|
||||||
"message": "{{ template \"invalid.template\" }}"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"content": "",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "[FIRING:1] (val1)",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
"username": "Grafana",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid avatar URL template",
|
|
||||||
settings: `{
|
|
||||||
"avatar_url": "{{ invalid } }}",
|
|
||||||
"url": "http://localhost",
|
|
||||||
"message": "valid message"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"avatar_url": "{{ invalid } }}",
|
|
||||||
"content": "valid message",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "[FIRING:1] (val1)",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
"username": "Grafana",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invalid URL template",
|
|
||||||
settings: `{
|
|
||||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"url": "http://localhost?q={{invalid }}}",
|
|
||||||
"message": "valid message"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"content": "valid message",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "[FIRING:1] (val1)",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
"username": "Grafana",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Custom config with multiple alerts",
|
|
||||||
settings: `{
|
|
||||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"url": "http://localhost",
|
|
||||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"avatar_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"content": "2 alerts are firing, 0 are resolved",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "[FIRING:2] ",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
"username": "Grafana",
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Error in initialization",
|
|
||||||
settings: `{}`,
|
|
||||||
expInitError: `could not find webhook url property in settings`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Default config with one alert, use default discord username",
|
|
||||||
settings: `{
|
|
||||||
"url": "http://localhost",
|
|
||||||
"use_discord_username": true
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "[FIRING:1] (val1)",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Should truncate too long messages",
|
|
||||||
settings: fmt.Sprintf(`{
|
|
||||||
"url": "http://localhost",
|
|
||||||
"use_discord_username": true,
|
|
||||||
"message": "%s"
|
|
||||||
}`, strings.Repeat("Y", discordMaxMessageLen+rand.Intn(100)+1)),
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: map[string]interface{}{
|
|
||||||
"content": strings.Repeat("Y", discordMaxMessageLen-1) + "…",
|
|
||||||
"embeds": []interface{}{map[string]interface{}{
|
|
||||||
"color": 1.4037554e+07,
|
|
||||||
"footer": map[string]interface{}{
|
|
||||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
"text": "Grafana v" + appVersion,
|
|
||||||
},
|
|
||||||
"title": "[FIRING:1] (val1)",
|
|
||||||
"url": "http://localhost/alerting/list",
|
|
||||||
"type": "rich",
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
webhookSender := mockNotificationService()
|
|
||||||
imageStore := &channels.UnavailableImageStore{}
|
|
||||||
|
|
||||||
fc := channels.FactoryConfig{
|
|
||||||
Config: &channels.NotificationChannelConfig{
|
|
||||||
Name: "discord_testing",
|
|
||||||
Type: "discord",
|
|
||||||
Settings: json.RawMessage(c.settings),
|
|
||||||
},
|
|
||||||
ImageStore: imageStore,
|
|
||||||
// TODO: allow changing the associated values for different tests.
|
|
||||||
NotificationService: webhookSender,
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
GrafanaBuildVersion: appVersion,
|
|
||||||
}
|
|
||||||
|
|
||||||
dn, err := newDiscordNotifier(fc)
|
|
||||||
if c.expInitError != "" {
|
|
||||||
require.Equal(t, c.expInitError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
|
||||||
ok, err := dn.Notify(ctx, c.alerts...)
|
|
||||||
if c.expMsgError != nil {
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, c.expMsgError.Error(), err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
expBody, err := json.Marshal(c.expMsg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,175 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EmailNotifier is responsible for sending
|
|
||||||
// alert notifications over email.
|
|
||||||
type EmailNotifier struct {
|
|
||||||
*channels.Base
|
|
||||||
log channels.Logger
|
|
||||||
ns channels.EmailSender
|
|
||||||
images channels.ImageStore
|
|
||||||
tmpl *template.Template
|
|
||||||
settings *emailSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
type emailSettings struct {
|
|
||||||
SingleEmail bool
|
|
||||||
Addresses []string
|
|
||||||
Message string
|
|
||||||
Subject string
|
|
||||||
}
|
|
||||||
|
|
||||||
func EmailFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
|
||||||
notifier, err := buildEmailNotifier(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, receiverInitError{
|
|
||||||
Reason: err.Error(),
|
|
||||||
Cfg: *fc.Config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return notifier, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildEmailSettings(fc channels.FactoryConfig) (*emailSettings, error) {
|
|
||||||
type emailSettingsRaw struct {
|
|
||||||
SingleEmail bool `json:"singleEmail,omitempty"`
|
|
||||||
Addresses string `json:"addresses,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Subject string `json:"subject,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var settings emailSettingsRaw
|
|
||||||
err := json.Unmarshal(fc.Config.Settings, &settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
if settings.Addresses == "" {
|
|
||||||
return nil, errors.New("could not find addresses in settings")
|
|
||||||
}
|
|
||||||
// split addresses with a few different ways
|
|
||||||
addresses := splitEmails(settings.Addresses)
|
|
||||||
|
|
||||||
if settings.Subject == "" {
|
|
||||||
settings.Subject = channels.DefaultMessageTitleEmbed
|
|
||||||
}
|
|
||||||
|
|
||||||
return &emailSettings{
|
|
||||||
SingleEmail: settings.SingleEmail,
|
|
||||||
Message: settings.Message,
|
|
||||||
Subject: settings.Subject,
|
|
||||||
Addresses: addresses,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildEmailNotifier(fc channels.FactoryConfig) (*EmailNotifier, error) {
|
|
||||||
settings, err := buildEmailSettings(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &EmailNotifier{
|
|
||||||
Base: channels.NewBase(fc.Config),
|
|
||||||
log: fc.Logger,
|
|
||||||
ns: fc.NotificationService,
|
|
||||||
images: fc.ImageStore,
|
|
||||||
tmpl: fc.Template,
|
|
||||||
settings: settings,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify sends the alert notification.
|
|
||||||
func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
|
|
||||||
var tmplErr error
|
|
||||||
tmpl, data := channels.TmplText(ctx, en.tmpl, alerts, en.log, &tmplErr)
|
|
||||||
|
|
||||||
subject := tmpl(en.settings.Subject)
|
|
||||||
alertPageURL := en.tmpl.ExternalURL.String()
|
|
||||||
ruleURL := en.tmpl.ExternalURL.String()
|
|
||||||
u, err := url.Parse(en.tmpl.ExternalURL.String())
|
|
||||||
if err == nil {
|
|
||||||
basePath := u.Path
|
|
||||||
u.Path = path.Join(basePath, "/alerting/list")
|
|
||||||
ruleURL = u.String()
|
|
||||||
u.RawQuery = "alertState=firing&view=state"
|
|
||||||
alertPageURL = u.String()
|
|
||||||
} else {
|
|
||||||
en.log.Debug("failed to parse external URL", "url", en.tmpl.ExternalURL.String(), "error", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extend alerts data with images, if available.
|
|
||||||
var embeddedFiles []string
|
|
||||||
_ = withStoredImages(ctx, en.log, en.images,
|
|
||||||
func(index int, image channels.Image) error {
|
|
||||||
if len(image.URL) != 0 {
|
|
||||||
data.Alerts[index].ImageURL = image.URL
|
|
||||||
} else if len(image.Path) != 0 {
|
|
||||||
_, err := os.Stat(image.Path)
|
|
||||||
if err == nil {
|
|
||||||
data.Alerts[index].EmbeddedImage = filepath.Base(image.Path)
|
|
||||||
embeddedFiles = append(embeddedFiles, image.Path)
|
|
||||||
} else {
|
|
||||||
en.log.Warn("failed to get image file for email attachment", "file", image.Path, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, alerts...)
|
|
||||||
|
|
||||||
cmd := &channels.SendEmailSettings{
|
|
||||||
Subject: subject,
|
|
||||||
Data: map[string]interface{}{
|
|
||||||
"Title": subject,
|
|
||||||
"Message": tmpl(en.settings.Message),
|
|
||||||
"Status": data.Status,
|
|
||||||
"Alerts": data.Alerts,
|
|
||||||
"GroupLabels": data.GroupLabels,
|
|
||||||
"CommonLabels": data.CommonLabels,
|
|
||||||
"CommonAnnotations": data.CommonAnnotations,
|
|
||||||
"ExternalURL": data.ExternalURL,
|
|
||||||
"RuleUrl": ruleURL,
|
|
||||||
"AlertPageUrl": alertPageURL,
|
|
||||||
},
|
|
||||||
EmbeddedFiles: embeddedFiles,
|
|
||||||
To: en.settings.Addresses,
|
|
||||||
SingleEmail: en.settings.SingleEmail,
|
|
||||||
Template: "ng_alert_notification",
|
|
||||||
}
|
|
||||||
|
|
||||||
if tmplErr != nil {
|
|
||||||
en.log.Warn("failed to template email message", "error", tmplErr.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := en.ns.SendEmail(ctx, cmd); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (en *EmailNotifier) SendResolved() bool {
|
|
||||||
return !en.GetDisableResolveMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitEmails(emails string) []string {
|
|
||||||
return strings.FieldsFunc(emails, func(r rune) bool {
|
|
||||||
switch r {
|
|
||||||
case ',', ';', '\n':
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,225 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEmailNotifier_Init(t *testing.T) {
|
|
||||||
testCase := []struct {
|
|
||||||
Name string
|
|
||||||
Config json.RawMessage
|
|
||||||
Expected *emailSettings
|
|
||||||
ExpectedError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Name: "error if JSON is empty",
|
|
||||||
Config: json.RawMessage(`{}`),
|
|
||||||
ExpectedError: "could not find addresses in settings",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "should split addresses separated by semicolon",
|
|
||||||
Config: json.RawMessage(`{
|
|
||||||
"addresses": "someops@example.com;somedev@example.com"
|
|
||||||
}`),
|
|
||||||
Expected: &emailSettings{
|
|
||||||
SingleEmail: false,
|
|
||||||
Addresses: []string{
|
|
||||||
"someops@example.com",
|
|
||||||
"somedev@example.com",
|
|
||||||
},
|
|
||||||
Message: "",
|
|
||||||
Subject: channels.DefaultMessageTitleEmbed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "should split addresses separated by comma",
|
|
||||||
Config: json.RawMessage(`{
|
|
||||||
"addresses": "someops@example.com,somedev@example.com"
|
|
||||||
}`),
|
|
||||||
Expected: &emailSettings{
|
|
||||||
SingleEmail: false,
|
|
||||||
Addresses: []string{
|
|
||||||
"someops@example.com",
|
|
||||||
"somedev@example.com",
|
|
||||||
},
|
|
||||||
Message: "",
|
|
||||||
Subject: channels.DefaultMessageTitleEmbed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "should split addresses separated by new-line",
|
|
||||||
Config: json.RawMessage(`{
|
|
||||||
"addresses": "someops@example.com\nsomedev@example.com"
|
|
||||||
}`),
|
|
||||||
Expected: &emailSettings{
|
|
||||||
SingleEmail: false,
|
|
||||||
Addresses: []string{
|
|
||||||
"someops@example.com",
|
|
||||||
"somedev@example.com",
|
|
||||||
},
|
|
||||||
Message: "",
|
|
||||||
Subject: channels.DefaultMessageTitleEmbed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "should split addresses separated by mixed separators",
|
|
||||||
Config: json.RawMessage(`{
|
|
||||||
"addresses": "someops@example.com\nsomedev@example.com;somedev2@example.com,somedev3@example.com"
|
|
||||||
}`),
|
|
||||||
Expected: &emailSettings{
|
|
||||||
SingleEmail: false,
|
|
||||||
Addresses: []string{
|
|
||||||
"someops@example.com",
|
|
||||||
"somedev@example.com",
|
|
||||||
"somedev2@example.com",
|
|
||||||
"somedev3@example.com",
|
|
||||||
},
|
|
||||||
Message: "",
|
|
||||||
Subject: channels.DefaultMessageTitleEmbed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "should split addresses separated by mixed separators",
|
|
||||||
Config: json.RawMessage(`{
|
|
||||||
"addresses": "someops@example.com\nsomedev@example.com;somedev2@example.com,somedev3@example.com"
|
|
||||||
}`),
|
|
||||||
Expected: &emailSettings{
|
|
||||||
SingleEmail: false,
|
|
||||||
Addresses: []string{
|
|
||||||
"someops@example.com",
|
|
||||||
"somedev@example.com",
|
|
||||||
"somedev2@example.com",
|
|
||||||
"somedev3@example.com",
|
|
||||||
},
|
|
||||||
Message: "",
|
|
||||||
Subject: channels.DefaultMessageTitleEmbed,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "should parse all settings",
|
|
||||||
Config: json.RawMessage(`{
|
|
||||||
"singleEmail": true,
|
|
||||||
"addresses": "someops@example.com",
|
|
||||||
"message": "test-message",
|
|
||||||
"subject": "test-subject"
|
|
||||||
}`),
|
|
||||||
Expected: &emailSettings{
|
|
||||||
SingleEmail: true,
|
|
||||||
Addresses: []string{
|
|
||||||
"someops@example.com",
|
|
||||||
},
|
|
||||||
Message: "test-message",
|
|
||||||
Subject: "test-subject",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range testCase {
|
|
||||||
t.Run(test.Name, func(t *testing.T) {
|
|
||||||
cfg := &channels.NotificationChannelConfig{
|
|
||||||
Name: "ops",
|
|
||||||
Type: "email",
|
|
||||||
Settings: test.Config,
|
|
||||||
}
|
|
||||||
settings, err := buildEmailSettings(channels.FactoryConfig{Config: cfg})
|
|
||||||
if test.ExpectedError != "" {
|
|
||||||
require.ErrorContains(t, err, test.ExpectedError)
|
|
||||||
} else {
|
|
||||||
require.Equal(t, *test.Expected, *settings)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmailNotifier_Notify(t *testing.T) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
|
|
||||||
externalURL, err := url.Parse("http://localhost/base")
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
|
|
||||||
t.Run("with the correct settings it should not fail and produce the expected command", func(t *testing.T) {
|
|
||||||
jsonData := `{
|
|
||||||
"addresses": "someops@example.com;somedev@example.com",
|
|
||||||
"message": "{{ template \"default.title\" . }}"
|
|
||||||
}`
|
|
||||||
|
|
||||||
emailSender := mockNotificationService()
|
|
||||||
|
|
||||||
fc := channels.FactoryConfig{
|
|
||||||
Config: &channels.NotificationChannelConfig{
|
|
||||||
Name: "ops",
|
|
||||||
Type: "email",
|
|
||||||
Settings: json.RawMessage(jsonData),
|
|
||||||
},
|
|
||||||
NotificationService: emailSender,
|
|
||||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string {
|
|
||||||
return fallback
|
|
||||||
},
|
|
||||||
ImageStore: &channels.UnavailableImageStore{},
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
}
|
|
||||||
|
|
||||||
emailNotifier, err := EmailFactory(fc)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
alerts := []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "AlwaysFiring", "severity": "warning"},
|
|
||||||
Annotations: model.LabelSet{"runbook_url": "http://fix.me", "__dashboardUid__": "abc", "__panelId__": "5"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := emailNotifier.Notify(context.Background(), alerts...)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
expected := map[string]interface{}{
|
|
||||||
"subject": emailSender.EmailSync.Subject,
|
|
||||||
"to": emailSender.EmailSync.To,
|
|
||||||
"single_email": emailSender.EmailSync.SingleEmail,
|
|
||||||
"template": emailSender.EmailSync.Template,
|
|
||||||
"data": emailSender.EmailSync.Data,
|
|
||||||
}
|
|
||||||
require.Equal(t, map[string]interface{}{
|
|
||||||
"subject": "[FIRING:1] (AlwaysFiring warning)",
|
|
||||||
"to": []string{"someops@example.com", "somedev@example.com"},
|
|
||||||
"single_email": false,
|
|
||||||
"template": "ng_alert_notification",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"Title": "[FIRING:1] (AlwaysFiring warning)",
|
|
||||||
"Message": "[FIRING:1] (AlwaysFiring warning)",
|
|
||||||
"Status": "firing",
|
|
||||||
"Alerts": channels.ExtendedAlerts{
|
|
||||||
channels.ExtendedAlert{
|
|
||||||
Status: "firing",
|
|
||||||
Labels: template.KV{"alertname": "AlwaysFiring", "severity": "warning"},
|
|
||||||
Annotations: template.KV{"runbook_url": "http://fix.me"},
|
|
||||||
Fingerprint: "15a37193dce72bab",
|
|
||||||
SilenceURL: "http://localhost/base/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DAlwaysFiring&matcher=severity%3Dwarning",
|
|
||||||
DashboardURL: "http://localhost/base/d/abc",
|
|
||||||
PanelURL: "http://localhost/base/d/abc?viewPanel=5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"GroupLabels": template.KV{},
|
|
||||||
"CommonLabels": template.KV{"alertname": "AlwaysFiring", "severity": "warning"},
|
|
||||||
"CommonAnnotations": template.KV{"runbook_url": "http://fix.me"},
|
|
||||||
"ExternalURL": "http://localhost/base",
|
|
||||||
"RuleUrl": "http://localhost/base/alerting/list",
|
|
||||||
"AlertPageUrl": "http://localhost/base/alerting/list?alertState=firing&view=state",
|
|
||||||
},
|
|
||||||
}, expected)
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,285 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GoogleChatNotifier is responsible for sending
|
|
||||||
// alert notifications to Google chat.
|
|
||||||
type GoogleChatNotifier struct {
|
|
||||||
*channels.Base
|
|
||||||
log channels.Logger
|
|
||||||
ns channels.WebhookSender
|
|
||||||
images channels.ImageStore
|
|
||||||
tmpl *template.Template
|
|
||||||
settings *googleChatSettings
|
|
||||||
appVersion string
|
|
||||||
}
|
|
||||||
|
|
||||||
type googleChatSettings struct {
|
|
||||||
URL string `json:"url,omitempty" yaml:"url,omitempty"`
|
|
||||||
Title string `json:"title,omitempty" yaml:"title,omitempty"`
|
|
||||||
Message string `json:"message,omitempty" yaml:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGoogleChatSettings(fc channels.FactoryConfig) (*googleChatSettings, error) {
|
|
||||||
var settings googleChatSettings
|
|
||||||
err := json.Unmarshal(fc.Config.Settings, &settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.URL == "" {
|
|
||||||
return nil, errors.New("could not find url property in settings")
|
|
||||||
}
|
|
||||||
if settings.Title == "" {
|
|
||||||
settings.Title = channels.DefaultMessageTitleEmbed
|
|
||||||
}
|
|
||||||
if settings.Message == "" {
|
|
||||||
settings.Message = channels.DefaultMessageEmbed
|
|
||||||
}
|
|
||||||
return &settings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GoogleChatFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
|
||||||
gcn, err := newGoogleChatNotifier(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, receiverInitError{
|
|
||||||
Reason: err.Error(),
|
|
||||||
Cfg: *fc.Config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return gcn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newGoogleChatNotifier(fc channels.FactoryConfig) (*GoogleChatNotifier, error) {
|
|
||||||
settings, err := buildGoogleChatSettings(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &GoogleChatNotifier{
|
|
||||||
Base: channels.NewBase(fc.Config),
|
|
||||||
log: fc.Logger,
|
|
||||||
ns: fc.NotificationService,
|
|
||||||
images: fc.ImageStore,
|
|
||||||
tmpl: fc.Template,
|
|
||||||
settings: settings,
|
|
||||||
appVersion: fc.GrafanaBuildVersion,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify send an alert notification to Google Chat.
|
|
||||||
func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
|
||||||
gcn.log.Debug("executing Google Chat notification")
|
|
||||||
|
|
||||||
var tmplErr error
|
|
||||||
tmpl, _ := channels.TmplText(ctx, gcn.tmpl, as, gcn.log, &tmplErr)
|
|
||||||
|
|
||||||
var widgets []widget
|
|
||||||
|
|
||||||
if msg := tmpl(gcn.settings.Message); msg != "" {
|
|
||||||
// Add a text paragraph widget for the message if there is a message.
|
|
||||||
// Google Chat API doesn't accept an empty text property.
|
|
||||||
widgets = append(widgets, textParagraphWidget{Text: text{Text: msg}})
|
|
||||||
}
|
|
||||||
|
|
||||||
if tmplErr != nil {
|
|
||||||
gcn.log.Warn("failed to template Google Chat message", "error", tmplErr.Error())
|
|
||||||
tmplErr = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ruleURL := joinUrlPath(gcn.tmpl.ExternalURL.String(), "/alerting/list", gcn.log)
|
|
||||||
if gcn.isUrlAbsolute(ruleURL) {
|
|
||||||
// Add a button widget (link to Grafana).
|
|
||||||
widgets = append(widgets, buttonWidget{
|
|
||||||
Buttons: []button{
|
|
||||||
{
|
|
||||||
TextButton: textButton{
|
|
||||||
Text: "OPEN IN GRAFANA",
|
|
||||||
OnClick: onClick{
|
|
||||||
OpenLink: openLink{
|
|
||||||
URL: ruleURL,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
gcn.log.Warn("Grafana external URL setting is missing or invalid. Skipping 'open in grafana' button to prevent Google from displaying empty alerts.", "ruleURL", ruleURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add text paragraph widget for the build version and timestamp.
|
|
||||||
widgets = append(widgets, textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "Grafana v" + gcn.appVersion + " | " + (timeNow()).Format(time.RFC822),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
title := tmpl(gcn.settings.Title)
|
|
||||||
// Nest the required structs.
|
|
||||||
res := &outerStruct{
|
|
||||||
PreviewText: title,
|
|
||||||
FallbackText: title,
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{Title: title},
|
|
||||||
Sections: []section{
|
|
||||||
{Widgets: widgets},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if screenshots := gcn.buildScreenshotCard(ctx, as); screenshots != nil {
|
|
||||||
res.Cards = append(res.Cards, *screenshots)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tmplErr != nil {
|
|
||||||
gcn.log.Warn("failed to template GoogleChat message", "error", tmplErr.Error())
|
|
||||||
tmplErr = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
u := tmpl(gcn.settings.URL)
|
|
||||||
if tmplErr != nil {
|
|
||||||
gcn.log.Warn("failed to template GoogleChat URL", "error", tmplErr.Error(), "fallback", gcn.settings.URL)
|
|
||||||
u = gcn.settings.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(res)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("marshal json: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := &channels.SendWebhookSettings{
|
|
||||||
URL: u,
|
|
||||||
HTTPMethod: "POST",
|
|
||||||
HTTPHeader: map[string]string{
|
|
||||||
"Content-Type": "application/json; charset=UTF-8",
|
|
||||||
},
|
|
||||||
Body: string(body),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := gcn.ns.SendWebhook(ctx, cmd); err != nil {
|
|
||||||
gcn.log.Error("Failed to send Google Hangouts Chat alert", "error", err, "webhook", gcn.Name)
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gcn *GoogleChatNotifier) SendResolved() bool {
|
|
||||||
return !gcn.GetDisableResolveMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gcn *GoogleChatNotifier) isUrlAbsolute(urlToCheck string) bool {
|
|
||||||
parsed, err := url.Parse(urlToCheck)
|
|
||||||
if err != nil {
|
|
||||||
gcn.log.Warn("could not parse URL", "urlToCheck", urlToCheck)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.IsAbs()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (gcn *GoogleChatNotifier) buildScreenshotCard(ctx context.Context, alerts []*types.Alert) *card {
|
|
||||||
card := card{
|
|
||||||
Header: header{Title: "Screenshots"},
|
|
||||||
Sections: []section{},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = withStoredImages(ctx, gcn.log, gcn.images,
|
|
||||||
func(index int, image channels.Image) error {
|
|
||||||
if len(image.URL) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
section := section{
|
|
||||||
Widgets: []widget{
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: fmt.Sprintf("%s: %s", alerts[index].Status(), alerts[index].Name()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
imageWidget{Image: imageData{ImageURL: image.URL}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
card.Sections = append(card.Sections, section)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}, alerts...)
|
|
||||||
|
|
||||||
if len(card.Sections) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &card
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structs used to build a custom Google Hangouts Chat message card.
|
|
||||||
// See: https://developers.google.com/hangouts/chat/reference/message-formats/cards
|
|
||||||
type outerStruct struct {
|
|
||||||
PreviewText string `json:"previewText"`
|
|
||||||
FallbackText string `json:"fallbackText"`
|
|
||||||
Cards []card `json:"cards"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type card struct {
|
|
||||||
Header header `json:"header"`
|
|
||||||
Sections []section `json:"sections"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type header struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type section struct {
|
|
||||||
Widgets []widget `json:"widgets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// "generic" widget used to add different types of widgets (buttonWidget, textParagraphWidget, imageWidget)
|
|
||||||
type widget interface{}
|
|
||||||
|
|
||||||
type buttonWidget struct {
|
|
||||||
Buttons []button `json:"buttons"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type textParagraphWidget struct {
|
|
||||||
Text text `json:"textParagraph"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type imageWidget struct {
|
|
||||||
Image imageData `json:"image"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type imageData struct {
|
|
||||||
ImageURL string `json:"imageUrl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type text struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type button struct {
|
|
||||||
TextButton textButton `json:"textButton"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type textButton struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
OnClick onClick `json:"onClick"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type onClick struct {
|
|
||||||
OpenLink openLink `json:"openLink"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type openLink struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
@ -1,511 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGoogleChatNotifier(t *testing.T) {
|
|
||||||
constNow := time.Now()
|
|
||||||
defer mockTimeNow(constNow)()
|
|
||||||
appVersion := fmt.Sprintf("%d.0.0", rand.Uint32())
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expMsg *outerStruct
|
|
||||||
expInitError string
|
|
||||||
expMsgError error
|
|
||||||
externalURL string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "One alert",
|
|
||||||
settings: `{"url": "http://localhost"}`,
|
|
||||||
externalURL: "http://localhost",
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: &outerStruct{
|
|
||||||
PreviewText: "[FIRING:1] (val1)",
|
|
||||||
FallbackText: "[FIRING:1] (val1)",
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
},
|
|
||||||
Sections: []section{
|
|
||||||
{
|
|
||||||
Widgets: []widget{
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
buttonWidget{
|
|
||||||
Buttons: []button{
|
|
||||||
{
|
|
||||||
TextButton: textButton{
|
|
||||||
Text: "OPEN IN GRAFANA",
|
|
||||||
OnClick: onClick{
|
|
||||||
OpenLink: openLink{
|
|
||||||
URL: "http://localhost/alerting/list",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
// RFC822 only has the minute, hence it works in most cases.
|
|
||||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Multiple alerts",
|
|
||||||
settings: `{"url": "http://localhost"}`,
|
|
||||||
externalURL: "http://localhost",
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: &outerStruct{
|
|
||||||
PreviewText: "[FIRING:2] ",
|
|
||||||
FallbackText: "[FIRING:2] ",
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{
|
|
||||||
Title: "[FIRING:2] ",
|
|
||||||
},
|
|
||||||
Sections: []section{
|
|
||||||
{
|
|
||||||
Widgets: []widget{
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
buttonWidget{
|
|
||||||
Buttons: []button{
|
|
||||||
{
|
|
||||||
TextButton: textButton{
|
|
||||||
Text: "OPEN IN GRAFANA",
|
|
||||||
OnClick: onClick{
|
|
||||||
OpenLink: openLink{
|
|
||||||
URL: "http://localhost/alerting/list",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Error in initing",
|
|
||||||
settings: `{}`,
|
|
||||||
externalURL: "http://localhost",
|
|
||||||
expInitError: `could not find url property in settings`,
|
|
||||||
}, {
|
|
||||||
name: "Customized message",
|
|
||||||
settings: `{"url": "http://localhost", "message": "I'm a custom template and you have {{ len .Alerts.Firing }} firing alert."}`,
|
|
||||||
externalURL: "http://localhost",
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: &outerStruct{
|
|
||||||
PreviewText: "[FIRING:1] (val1)",
|
|
||||||
FallbackText: "[FIRING:1] (val1)",
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
},
|
|
||||||
Sections: []section{
|
|
||||||
{
|
|
||||||
Widgets: []widget{
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "I'm a custom template and you have 1 firing alert.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
buttonWidget{
|
|
||||||
Buttons: []button{
|
|
||||||
{
|
|
||||||
TextButton: textButton{
|
|
||||||
Text: "OPEN IN GRAFANA",
|
|
||||||
OnClick: onClick{
|
|
||||||
OpenLink: openLink{
|
|
||||||
URL: "http://localhost/alerting/list",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
// RFC822 only has the minute, hence it works in most cases.
|
|
||||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Customized title",
|
|
||||||
settings: `{"url": "http://localhost", "title": "Alerts firing: {{ len .Alerts.Firing }}"}`,
|
|
||||||
externalURL: "http://localhost",
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: &outerStruct{
|
|
||||||
PreviewText: "Alerts firing: 1",
|
|
||||||
FallbackText: "Alerts firing: 1",
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{
|
|
||||||
Title: "Alerts firing: 1",
|
|
||||||
},
|
|
||||||
Sections: []section{
|
|
||||||
{
|
|
||||||
Widgets: []widget{
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
buttonWidget{
|
|
||||||
Buttons: []button{
|
|
||||||
{
|
|
||||||
TextButton: textButton{
|
|
||||||
Text: "OPEN IN GRAFANA",
|
|
||||||
OnClick: onClick{
|
|
||||||
OpenLink: openLink{
|
|
||||||
URL: "http://localhost/alerting/list",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
// RFC822 only has the minute, hence it works in most cases.
|
|
||||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Missing field in template",
|
|
||||||
settings: `{"url": "http://localhost", "message": "I'm a custom template {{ .NotAField }} bad template"}`,
|
|
||||||
externalURL: "http://localhost",
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: &outerStruct{
|
|
||||||
PreviewText: "[FIRING:1] (val1)",
|
|
||||||
FallbackText: "[FIRING:1] (val1)",
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
},
|
|
||||||
Sections: []section{
|
|
||||||
{
|
|
||||||
Widgets: []widget{
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "I'm a custom template ",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
buttonWidget{
|
|
||||||
Buttons: []button{
|
|
||||||
{
|
|
||||||
TextButton: textButton{
|
|
||||||
Text: "OPEN IN GRAFANA",
|
|
||||||
OnClick: onClick{
|
|
||||||
OpenLink: openLink{
|
|
||||||
URL: "http://localhost/alerting/list",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
// RFC822 only has the minute, hence it works in most cases.
|
|
||||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Invalid template",
|
|
||||||
settings: `{"url": "http://localhost", "message": "I'm a custom template {{ {.NotAField }} bad template"}`,
|
|
||||||
externalURL: "http://localhost",
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: &outerStruct{
|
|
||||||
PreviewText: "[FIRING:1] (val1)",
|
|
||||||
FallbackText: "[FIRING:1] (val1)",
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
},
|
|
||||||
Sections: []section{
|
|
||||||
{
|
|
||||||
Widgets: []widget{
|
|
||||||
buttonWidget{
|
|
||||||
Buttons: []button{
|
|
||||||
{
|
|
||||||
TextButton: textButton{
|
|
||||||
Text: "OPEN IN GRAFANA",
|
|
||||||
OnClick: onClick{
|
|
||||||
OpenLink: openLink{
|
|
||||||
URL: "http://localhost/alerting/list",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
// RFC822 only has the minute, hence it works in most cases.
|
|
||||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsgError: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty external URL",
|
|
||||||
settings: `{ "url": "http://localhost" }`, // URL in settings = googlechat url
|
|
||||||
externalURL: "", // external URL = URL of grafana from configuration
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: &outerStruct{
|
|
||||||
PreviewText: "[FIRING:1] (val1)",
|
|
||||||
FallbackText: "[FIRING:1] (val1)",
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
},
|
|
||||||
Sections: []section{
|
|
||||||
{
|
|
||||||
Widgets: []widget{
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\n",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// No button widget here since the external URL is not absolute
|
|
||||||
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
// RFC822 only has the minute, hence it works in most cases.
|
|
||||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Relative external URL",
|
|
||||||
settings: `{ "url": "http://localhost" }`, // URL in settings = googlechat url
|
|
||||||
externalURL: "/grafana", // external URL = URL of grafana from configuration
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expMsg: &outerStruct{
|
|
||||||
PreviewText: "[FIRING:1] (val1)",
|
|
||||||
FallbackText: "[FIRING:1] (val1)",
|
|
||||||
Cards: []card{
|
|
||||||
{
|
|
||||||
Header: header{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
},
|
|
||||||
Sections: []section{
|
|
||||||
{
|
|
||||||
Widgets: []widget{
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: /grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: /grafana/d/abcd\nPanel: /grafana/d/abcd?viewPanel=efgh\n",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// No button widget here since the external URL is not absolute
|
|
||||||
|
|
||||||
textParagraphWidget{
|
|
||||||
Text: text{
|
|
||||||
// RFC822 only has the minute, hence it works in most cases.
|
|
||||||
Text: "Grafana v" + appVersion + " | " + constNow.Format(time.RFC822),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
|
|
||||||
externalURL, err := url.Parse(c.externalURL)
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
|
|
||||||
webhookSender := mockNotificationService()
|
|
||||||
imageStore := &channels.UnavailableImageStore{}
|
|
||||||
|
|
||||||
fc := channels.FactoryConfig{
|
|
||||||
Config: &channels.NotificationChannelConfig{
|
|
||||||
Name: "googlechat_testing",
|
|
||||||
Type: "googlechat",
|
|
||||||
Settings: json.RawMessage(c.settings),
|
|
||||||
},
|
|
||||||
ImageStore: imageStore,
|
|
||||||
NotificationService: webhookSender,
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
GrafanaBuildVersion: appVersion,
|
|
||||||
}
|
|
||||||
|
|
||||||
pn, err := newGoogleChatNotifier(fc)
|
|
||||||
if c.expInitError != "" {
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, c.expInitError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
|
||||||
ok, err := pn.Notify(ctx, c.alerts...)
|
|
||||||
if c.expMsgError != nil {
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, c.expMsgError.Error(), err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
require.NotEmpty(t, webhookSender.Webhook.URL)
|
|
||||||
|
|
||||||
expBody, err := json.Marshal(c.expMsg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,208 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type kafkaBody struct {
|
|
||||||
Records []kafkaRecordEnvelope `json:"records"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type kafkaRecordEnvelope struct {
|
|
||||||
Value kafkaRecord `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type kafkaRecord struct {
|
|
||||||
Description string `json:"description"`
|
|
||||||
Client string `json:"client,omitempty"`
|
|
||||||
Details string `json:"details,omitempty"`
|
|
||||||
AlertState models.AlertStateType `json:"alert_state,omitempty"`
|
|
||||||
ClientURL string `json:"client_url,omitempty"`
|
|
||||||
Contexts []kafkaContext `json:"contexts,omitempty"`
|
|
||||||
IncidentKey string `json:"incident_key,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type kafkaContext struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Source string `json:"src"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// KafkaNotifier is responsible for sending
|
|
||||||
// alert notifications to Kafka.
|
|
||||||
type KafkaNotifier struct {
|
|
||||||
*channels.Base
|
|
||||||
log channels.Logger
|
|
||||||
images channels.ImageStore
|
|
||||||
ns channels.WebhookSender
|
|
||||||
tmpl *template.Template
|
|
||||||
settings *kafkaSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
type kafkaSettings struct {
|
|
||||||
Endpoint string `json:"kafkaRestProxy,omitempty" yaml:"kafkaRestProxy,omitempty"`
|
|
||||||
Topic string `json:"kafkaTopic,omitempty" yaml:"kafkaTopic,omitempty"`
|
|
||||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
|
||||||
Details string `json:"details,omitempty" yaml:"details,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildKafkaSettings(fc channels.FactoryConfig) (*kafkaSettings, error) {
|
|
||||||
var settings kafkaSettings
|
|
||||||
err := json.Unmarshal(fc.Config.Settings, &settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.Endpoint == "" {
|
|
||||||
return nil, errors.New("could not find kafka rest proxy endpoint property in settings")
|
|
||||||
}
|
|
||||||
if settings.Topic == "" {
|
|
||||||
return nil, errors.New("could not find kafka topic property in settings")
|
|
||||||
}
|
|
||||||
if settings.Description == "" {
|
|
||||||
settings.Description = channels.DefaultMessageTitleEmbed
|
|
||||||
}
|
|
||||||
if settings.Details == "" {
|
|
||||||
settings.Details = channels.DefaultMessageEmbed
|
|
||||||
}
|
|
||||||
return &settings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func KafkaFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
|
||||||
ch, err := newKafkaNotifier(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, receiverInitError{
|
|
||||||
Reason: err.Error(),
|
|
||||||
Cfg: *fc.Config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newKafkaNotifier is the constructor function for the Kafka notifier.
|
|
||||||
func newKafkaNotifier(fc channels.FactoryConfig) (*KafkaNotifier, error) {
|
|
||||||
settings, err := buildKafkaSettings(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &KafkaNotifier{
|
|
||||||
Base: channels.NewBase(fc.Config),
|
|
||||||
log: fc.Logger,
|
|
||||||
images: fc.ImageStore,
|
|
||||||
ns: fc.NotificationService,
|
|
||||||
tmpl: fc.Template,
|
|
||||||
settings: settings,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify sends the alert notification.
|
|
||||||
func (kn *KafkaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
|
||||||
var tmplErr error
|
|
||||||
tmpl, _ := channels.TmplText(ctx, kn.tmpl, as, kn.log, &tmplErr)
|
|
||||||
|
|
||||||
topicURL := strings.TrimRight(kn.settings.Endpoint, "/") + "/topics/" + tmpl(kn.settings.Topic)
|
|
||||||
|
|
||||||
body, err := kn.buildBody(ctx, tmpl, as...)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if tmplErr != nil {
|
|
||||||
kn.log.Warn("failed to template Kafka message", "error", tmplErr.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := &channels.SendWebhookSettings{
|
|
||||||
URL: topicURL,
|
|
||||||
Body: body,
|
|
||||||
HTTPMethod: "POST",
|
|
||||||
HTTPHeader: map[string]string{
|
|
||||||
"Content-Type": "application/vnd.kafka.json.v2+json",
|
|
||||||
"Accept": "application/vnd.kafka.v2+json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = kn.ns.SendWebhook(ctx, cmd); err != nil {
|
|
||||||
kn.log.Error("Failed to send notification to Kafka", "error", err, "body", body)
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (kn *KafkaNotifier) SendResolved() bool {
|
|
||||||
return !kn.GetDisableResolveMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (kn *KafkaNotifier) buildBody(ctx context.Context, tmpl func(string) string, as ...*types.Alert) (string, error) {
|
|
||||||
var record kafkaRecord
|
|
||||||
record.Client = "Grafana"
|
|
||||||
record.Description = tmpl(kn.settings.Description)
|
|
||||||
record.Details = tmpl(kn.settings.Details)
|
|
||||||
|
|
||||||
state := buildState(as...)
|
|
||||||
kn.log.Debug("notifying Kafka", "alert_state", state)
|
|
||||||
record.AlertState = state
|
|
||||||
|
|
||||||
ruleURL := joinUrlPath(kn.tmpl.ExternalURL.String(), "/alerting/list", kn.log)
|
|
||||||
record.ClientURL = ruleURL
|
|
||||||
|
|
||||||
contexts := buildContextImages(ctx, kn.log, kn.images, as...)
|
|
||||||
if len(contexts) > 0 {
|
|
||||||
record.Contexts = contexts
|
|
||||||
}
|
|
||||||
|
|
||||||
groupKey, err := notify.ExtractGroupKey(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
record.IncidentKey = groupKey.Hash()
|
|
||||||
|
|
||||||
records := kafkaBody{
|
|
||||||
Records: []kafkaRecordEnvelope{
|
|
||||||
{Value: record},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(records)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(body), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildState(as ...*types.Alert) models.AlertStateType {
|
|
||||||
// We are using the state from 7.x to not break kafka.
|
|
||||||
// TODO: should we switch to the new ones?
|
|
||||||
if types.Alerts(as...).Status() == model.AlertResolved {
|
|
||||||
return models.AlertStateOK
|
|
||||||
}
|
|
||||||
return models.AlertStateAlerting
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildContextImages(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, as ...*types.Alert) []kafkaContext {
|
|
||||||
var contexts []kafkaContext
|
|
||||||
_ = withStoredImages(ctx, l, imageStore,
|
|
||||||
func(_ int, image channels.Image) error {
|
|
||||||
if image.URL != "" {
|
|
||||||
contexts = append(contexts, kafkaContext{
|
|
||||||
Type: "image",
|
|
||||||
Source: image.URL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, as...)
|
|
||||||
return contexts
|
|
||||||
}
|
|
@ -1,156 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestKafkaNotifier(t *testing.T) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
|
|
||||||
images := newFakeImageStore(2)
|
|
||||||
|
|
||||||
externalURL, err := url.Parse("http://localhost")
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expUrl, expMsg string
|
|
||||||
expInitError string
|
|
||||||
expMsgError error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "A single alert with image and custom description and details",
|
|
||||||
settings: `{
|
|
||||||
"kafkaRestProxy": "http://localhost",
|
|
||||||
"kafkaTopic": "sometopic",
|
|
||||||
"description": "customDescription",
|
|
||||||
"details": "customDetails"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expUrl: "http://localhost/topics/sometopic",
|
|
||||||
expMsg: `{
|
|
||||||
"records": [
|
|
||||||
{
|
|
||||||
"value": {
|
|
||||||
"alert_state": "alerting",
|
|
||||||
"client": "Grafana",
|
|
||||||
"client_url": "http://localhost/alerting/list",
|
|
||||||
"contexts": [{"type": "image", "src": "https://www.example.com/test-image-1.jpg"}],
|
|
||||||
"description": "customDescription",
|
|
||||||
"details": "customDetails",
|
|
||||||
"incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`,
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Multiple alerts with images with default description and details",
|
|
||||||
settings: `{
|
|
||||||
"kafkaRestProxy": "http://localhost",
|
|
||||||
"kafkaTopic": "sometopic"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expUrl: "http://localhost/topics/sometopic",
|
|
||||||
expMsg: `{
|
|
||||||
"records": [
|
|
||||||
{
|
|
||||||
"value": {
|
|
||||||
"alert_state": "alerting",
|
|
||||||
"client": "Grafana",
|
|
||||||
"client_url": "http://localhost/alerting/list",
|
|
||||||
"contexts": [{"type": "image", "src": "https://www.example.com/test-image-1.jpg"}, {"type": "image", "src": "https://www.example.com/test-image-2.jpg"}],
|
|
||||||
"description": "[FIRING:2] ",
|
|
||||||
"details": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n",
|
|
||||||
"incident_key": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`,
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Endpoint missing",
|
|
||||||
settings: `{"kafkaTopic": "sometopic"}`,
|
|
||||||
expInitError: `could not find kafka rest proxy endpoint property in settings`,
|
|
||||||
}, {
|
|
||||||
name: "Topic missing",
|
|
||||||
settings: `{"kafkaRestProxy": "http://localhost"}`,
|
|
||||||
expInitError: `could not find kafka topic property in settings`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
webhookSender := mockNotificationService()
|
|
||||||
|
|
||||||
fc := channels.FactoryConfig{
|
|
||||||
Config: &channels.NotificationChannelConfig{
|
|
||||||
Name: "kafka_testing",
|
|
||||||
Type: "kafka",
|
|
||||||
Settings: json.RawMessage(c.settings),
|
|
||||||
},
|
|
||||||
ImageStore: images,
|
|
||||||
// TODO: allow changing the associated values for different tests.
|
|
||||||
NotificationService: webhookSender,
|
|
||||||
DecryptFunc: nil,
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
}
|
|
||||||
|
|
||||||
pn, err := newKafkaNotifier(fc)
|
|
||||||
if c.expInitError != "" {
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, c.expInitError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
|
||||||
|
|
||||||
ok, err := pn.Notify(ctx, c.alerts...)
|
|
||||||
if c.expMsgError != nil {
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, c.expMsgError.Error(), err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
require.Equal(t, c.expUrl, webhookSender.Webhook.URL)
|
|
||||||
require.JSONEq(t, c.expMsg, webhookSender.Webhook.Body)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,129 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
LineNotifyURL string = "https://notify-api.line.me/api/notify"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LineNotifier is responsible for sending
|
|
||||||
// alert notifications to LINE.
|
|
||||||
type LineNotifier struct {
|
|
||||||
*channels.Base
|
|
||||||
log channels.Logger
|
|
||||||
ns channels.WebhookSender
|
|
||||||
tmpl *template.Template
|
|
||||||
settings *lineSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
type lineSettings struct {
|
|
||||||
Token string `json:"token,omitempty" yaml:"token,omitempty"`
|
|
||||||
Title string `json:"title,omitempty" yaml:"title,omitempty"`
|
|
||||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildLineSettings(fc channels.FactoryConfig) (*lineSettings, error) {
|
|
||||||
var settings lineSettings
|
|
||||||
err := json.Unmarshal(fc.Config.Settings, &settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
settings.Token = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "token", settings.Token)
|
|
||||||
if settings.Token == "" {
|
|
||||||
return nil, errors.New("could not find token in settings")
|
|
||||||
}
|
|
||||||
if settings.Title == "" {
|
|
||||||
settings.Title = channels.DefaultMessageTitleEmbed
|
|
||||||
}
|
|
||||||
if settings.Description == "" {
|
|
||||||
settings.Description = channels.DefaultMessageEmbed
|
|
||||||
}
|
|
||||||
return &settings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func LineFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
|
||||||
n, err := newLineNotifier(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, receiverInitError{
|
|
||||||
Reason: err.Error(),
|
|
||||||
Cfg: *fc.Config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newLineNotifier is the constructor for the LINE notifier
|
|
||||||
func newLineNotifier(fc channels.FactoryConfig) (*LineNotifier, error) {
|
|
||||||
settings, err := buildLineSettings(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &LineNotifier{
|
|
||||||
Base: channels.NewBase(fc.Config),
|
|
||||||
log: fc.Logger,
|
|
||||||
ns: fc.NotificationService,
|
|
||||||
tmpl: fc.Template,
|
|
||||||
settings: settings,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify send an alert notification to LINE
|
|
||||||
func (ln *LineNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
|
||||||
ln.log.Debug("executing line notification", "notification", ln.Name)
|
|
||||||
|
|
||||||
body := ln.buildMessage(ctx, as...)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Add("message", body)
|
|
||||||
|
|
||||||
cmd := &channels.SendWebhookSettings{
|
|
||||||
URL: LineNotifyURL,
|
|
||||||
HTTPMethod: "POST",
|
|
||||||
HTTPHeader: map[string]string{
|
|
||||||
"Authorization": fmt.Sprintf("Bearer %s", ln.settings.Token),
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
||||||
},
|
|
||||||
Body: form.Encode(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ln.ns.SendWebhook(ctx, cmd); err != nil {
|
|
||||||
ln.log.Error("failed to send notification to LINE", "error", err, "body", body)
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ln *LineNotifier) SendResolved() bool {
|
|
||||||
return !ln.GetDisableResolveMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ln *LineNotifier) buildMessage(ctx context.Context, as ...*types.Alert) string {
|
|
||||||
ruleURL := path.Join(ln.tmpl.ExternalURL.String(), "/alerting/list")
|
|
||||||
|
|
||||||
var tmplErr error
|
|
||||||
tmpl, _ := channels.TmplText(ctx, ln.tmpl, as, ln.log, &tmplErr)
|
|
||||||
|
|
||||||
body := fmt.Sprintf(
|
|
||||||
"%s\n%s\n\n%s",
|
|
||||||
tmpl(ln.settings.Title),
|
|
||||||
ruleURL,
|
|
||||||
tmpl(ln.settings.Description),
|
|
||||||
)
|
|
||||||
if tmplErr != nil {
|
|
||||||
ln.log.Warn("failed to template Line message", "error", tmplErr.Error())
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
@ -1,140 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLineNotifier(t *testing.T) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
|
|
||||||
externalURL, err := url.Parse("http://localhost")
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expHeaders map[string]string
|
|
||||||
expMsg string
|
|
||||||
expInitError string
|
|
||||||
expMsgError error
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "One alert",
|
|
||||||
settings: `{"token": "sometoken"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expHeaders: map[string]string{
|
|
||||||
"Authorization": "Bearer sometoken",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
||||||
},
|
|
||||||
expMsg: "message=%5BFIRING%3A1%5D++%28val1%29%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A",
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Multiple alerts",
|
|
||||||
settings: `{"token": "sometoken"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv2"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expHeaders: map[string]string{
|
|
||||||
"Authorization": "Bearer sometoken",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
||||||
},
|
|
||||||
expMsg: "message=%5BFIRING%3A2%5D++%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval2%0A",
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "One alert custom title and description",
|
|
||||||
settings: `{"token": "sometoken", "title": "customTitle {{ .Alerts.Firing | len }}", "description": "customDescription"}`,
|
|
||||||
alerts: []*types.Alert{
|
|
||||||
{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expHeaders: map[string]string{
|
|
||||||
"Authorization": "Bearer sometoken",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
||||||
},
|
|
||||||
expMsg: "message=customTitle+1%0Ahttp%3A%2Flocalhost%2Falerting%2Flist%0A%0AcustomDescription",
|
|
||||||
expMsgError: nil,
|
|
||||||
}, {
|
|
||||||
name: "Token missing",
|
|
||||||
settings: `{}`,
|
|
||||||
expInitError: `could not find token in settings`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
t.Run(c.name, func(t *testing.T) {
|
|
||||||
settingsJSON := json.RawMessage(c.settings)
|
|
||||||
secureSettings := make(map[string][]byte)
|
|
||||||
webhookSender := mockNotificationService()
|
|
||||||
|
|
||||||
fc := channels.FactoryConfig{
|
|
||||||
Config: &channels.NotificationChannelConfig{
|
|
||||||
Name: "line_testing",
|
|
||||||
Type: "line",
|
|
||||||
Settings: settingsJSON,
|
|
||||||
SecureSettings: secureSettings,
|
|
||||||
},
|
|
||||||
// TODO: allow changing the associated values for different tests.
|
|
||||||
NotificationService: webhookSender,
|
|
||||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string {
|
|
||||||
return fallback
|
|
||||||
},
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
}
|
|
||||||
pn, err := newLineNotifier(fc)
|
|
||||||
if c.expInitError != "" {
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, c.expInitError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
|
||||||
ok, err := pn.Notify(ctx, c.alerts...)
|
|
||||||
if c.expMsgError != nil {
|
|
||||||
require.False(t, ok)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Equal(t, c.expMsgError.Error(), err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, ok)
|
|
||||||
|
|
||||||
require.Equal(t, c.expHeaders, webhookSender.Webhook.HTTPHeader)
|
|
||||||
require.Equal(t, c.expMsg, webhookSender.Webhook.Body)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,597 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/prometheus/alertmanager/config"
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// maxImagesPerThreadTs is the maximum number of images that can be posted as
|
|
||||||
// replies to the same thread_ts. It should prevent tokens from exceeding the
|
|
||||||
// rate limits for files.upload https://api.slack.com/docs/rate-limits#tier_t2
|
|
||||||
maxImagesPerThreadTs = 5
|
|
||||||
maxImagesPerThreadTsMessage = "There are more images than can be shown here. To see the panels for all firing and resolved alerts please check Grafana"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
slackClient = &http.Client{
|
|
||||||
Timeout: time.Second * 30,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{
|
|
||||||
Renegotiation: tls.RenegotiateFreelyAsClient,
|
|
||||||
},
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
DialContext: (&net.Dialer{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}).DialContext,
|
|
||||||
TLSHandshakeTimeout: 5 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
var SlackAPIEndpoint = "https://slack.com/api/chat.postMessage"
|
|
||||||
|
|
||||||
type sendFunc func(ctx context.Context, req *http.Request, logger channels.Logger) (string, error)
|
|
||||||
|
|
||||||
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
|
|
||||||
const slackMaxTitleLenRunes = 1024
|
|
||||||
|
|
||||||
// SlackNotifier is responsible for sending
|
|
||||||
// alert notification to Slack.
|
|
||||||
type SlackNotifier struct {
|
|
||||||
*channels.Base
|
|
||||||
log channels.Logger
|
|
||||||
tmpl *template.Template
|
|
||||||
images channels.ImageStore
|
|
||||||
webhookSender channels.WebhookSender
|
|
||||||
sendFn sendFunc
|
|
||||||
settings slackSettings
|
|
||||||
appVersion string
|
|
||||||
}
|
|
||||||
|
|
||||||
type slackSettings struct {
|
|
||||||
EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"`
|
|
||||||
URL string `json:"url,omitempty" yaml:"url,omitempty"`
|
|
||||||
Token string `json:"token,omitempty" yaml:"token,omitempty"`
|
|
||||||
Recipient string `json:"recipient,omitempty" yaml:"recipient,omitempty"`
|
|
||||||
Text string `json:"text,omitempty" yaml:"text,omitempty"`
|
|
||||||
Title string `json:"title,omitempty" yaml:"title,omitempty"`
|
|
||||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
|
||||||
IconEmoji string `json:"icon_emoji,omitempty" yaml:"icon_emoji,omitempty"`
|
|
||||||
IconURL string `json:"icon_url,omitempty" yaml:"icon_url,omitempty"`
|
|
||||||
MentionChannel string `json:"mentionChannel,omitempty" yaml:"mentionChannel,omitempty"`
|
|
||||||
MentionUsers channels.CommaSeparatedStrings `json:"mentionUsers,omitempty" yaml:"mentionUsers,omitempty"`
|
|
||||||
MentionGroups channels.CommaSeparatedStrings `json:"mentionGroups,omitempty" yaml:"mentionGroups,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// isIncomingWebhook returns true if the settings are for an incoming webhook.
|
|
||||||
func isIncomingWebhook(s slackSettings) bool {
|
|
||||||
return s.Token == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadURL returns the upload URL for Slack.
|
|
||||||
func uploadURL(s slackSettings) (string, error) {
|
|
||||||
u, err := url.Parse(s.URL)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse URL: %w", err)
|
|
||||||
}
|
|
||||||
dir, _ := path.Split(u.Path)
|
|
||||||
u.Path = path.Join(dir, "files.upload")
|
|
||||||
return u.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SlackFactory creates a new NotificationChannel that sends notifications to Slack.
|
|
||||||
func SlackFactory(fc channels.FactoryConfig) (channels.NotificationChannel, error) {
|
|
||||||
ch, err := buildSlackNotifier(fc)
|
|
||||||
if err != nil {
|
|
||||||
return nil, receiverInitError{
|
|
||||||
Reason: err.Error(),
|
|
||||||
Cfg: *fc.Config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSlackNotifier(factoryConfig channels.FactoryConfig) (*SlackNotifier, error) {
|
|
||||||
decryptFunc := factoryConfig.DecryptFunc
|
|
||||||
var settings slackSettings
|
|
||||||
err := json.Unmarshal(factoryConfig.Config.Settings, &settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.EndpointURL == "" {
|
|
||||||
settings.EndpointURL = SlackAPIEndpoint
|
|
||||||
}
|
|
||||||
slackURL := decryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "url", settings.URL)
|
|
||||||
if slackURL == "" {
|
|
||||||
slackURL = settings.EndpointURL
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL, err := url.Parse(slackURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid URL %q", slackURL)
|
|
||||||
}
|
|
||||||
settings.URL = apiURL.String()
|
|
||||||
|
|
||||||
settings.Recipient = strings.TrimSpace(settings.Recipient)
|
|
||||||
if settings.Recipient == "" && settings.URL == SlackAPIEndpoint {
|
|
||||||
return nil, errors.New("recipient must be specified when using the Slack chat API")
|
|
||||||
}
|
|
||||||
if settings.MentionChannel != "" && settings.MentionChannel != "here" && settings.MentionChannel != "channel" {
|
|
||||||
return nil, fmt.Errorf("invalid value for mentionChannel: %q", settings.MentionChannel)
|
|
||||||
}
|
|
||||||
settings.Token = decryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "token", settings.Token)
|
|
||||||
if settings.Token == "" && settings.URL == SlackAPIEndpoint {
|
|
||||||
return nil, errors.New("token must be specified when using the Slack chat API")
|
|
||||||
}
|
|
||||||
if settings.Username == "" {
|
|
||||||
settings.Username = "Grafana"
|
|
||||||
}
|
|
||||||
if settings.Text == "" {
|
|
||||||
settings.Text = channels.DefaultMessageEmbed
|
|
||||||
}
|
|
||||||
if settings.Title == "" {
|
|
||||||
settings.Title = channels.DefaultMessageTitleEmbed
|
|
||||||
}
|
|
||||||
return &SlackNotifier{
|
|
||||||
Base: channels.NewBase(factoryConfig.Config),
|
|
||||||
settings: settings,
|
|
||||||
|
|
||||||
images: factoryConfig.ImageStore,
|
|
||||||
webhookSender: factoryConfig.NotificationService,
|
|
||||||
sendFn: sendSlackRequest,
|
|
||||||
log: factoryConfig.Logger,
|
|
||||||
tmpl: factoryConfig.Template,
|
|
||||||
appVersion: factoryConfig.GrafanaBuildVersion,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// slackMessage is the slackMessage for sending a slack notification.
|
|
||||||
type slackMessage struct {
|
|
||||||
Channel string `json:"channel,omitempty"`
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
Username string `json:"username,omitempty"`
|
|
||||||
IconEmoji string `json:"icon_emoji,omitempty"`
|
|
||||||
IconURL string `json:"icon_url,omitempty"`
|
|
||||||
Attachments []attachment `json:"attachments"`
|
|
||||||
Blocks []map[string]interface{} `json:"blocks,omitempty"`
|
|
||||||
ThreadTs string `json:"thread_ts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// attachment is used to display a richly-formatted message block.
|
|
||||||
type attachment struct {
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
TitleLink string `json:"title_link,omitempty"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
|
||||||
Fallback string `json:"fallback"`
|
|
||||||
Fields []config.SlackField `json:"fields,omitempty"`
|
|
||||||
Footer string `json:"footer"`
|
|
||||||
FooterIcon string `json:"footer_icon"`
|
|
||||||
Color string `json:"color,omitempty"`
|
|
||||||
Ts int64 `json:"ts,omitempty"`
|
|
||||||
Pretext string `json:"pretext,omitempty"`
|
|
||||||
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify sends an alert notification to Slack.
|
|
||||||
func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
|
|
||||||
sn.log.Debug("Creating slack message", "alerts", len(alerts))
|
|
||||||
|
|
||||||
m, err := sn.createSlackMessage(ctx, alerts)
|
|
||||||
if err != nil {
|
|
||||||
sn.log.Error("Failed to create Slack message", "err", err)
|
|
||||||
return false, fmt.Errorf("failed to create Slack message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
thread_ts, err := sn.sendSlackMessage(ctx, m)
|
|
||||||
if err != nil {
|
|
||||||
sn.log.Error("Failed to send Slack message", "err", err)
|
|
||||||
return false, fmt.Errorf("failed to send Slack message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not upload images if using an incoming webhook as incoming webhooks cannot upload files
|
|
||||||
if !isIncomingWebhook(sn.settings) {
|
|
||||||
if err := withStoredImages(ctx, sn.log, sn.images, func(index int, image channels.Image) error {
|
|
||||||
// If we have exceeded the maximum number of images for this thread_ts
|
|
||||||
// then tell the recipient and stop iterating subsequent images
|
|
||||||
if index >= maxImagesPerThreadTs {
|
|
||||||
if _, err := sn.sendSlackMessage(ctx, &slackMessage{
|
|
||||||
Channel: sn.settings.Recipient,
|
|
||||||
Text: maxImagesPerThreadTsMessage,
|
|
||||||
ThreadTs: thread_ts,
|
|
||||||
}); err != nil {
|
|
||||||
sn.log.Error("Failed to send Slack message", "err", err)
|
|
||||||
}
|
|
||||||
return channels.ErrImagesDone
|
|
||||||
}
|
|
||||||
comment := initialCommentForImage(alerts[index])
|
|
||||||
return sn.uploadImage(ctx, image, sn.settings.Recipient, comment, thread_ts)
|
|
||||||
}, alerts...); err != nil {
|
|
||||||
// Do not return an error here as we might have exceeded the rate limit for uploading files
|
|
||||||
sn.log.Error("Failed to upload image", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendSlackRequest sends a request to the Slack API.
|
|
||||||
// Stubbable by tests.
|
|
||||||
var sendSlackRequest = func(ctx context.Context, req *http.Request, logger channels.Logger) (string, error) {
|
|
||||||
resp, err := slackClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to send request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if err := resp.Body.Close(); err != nil {
|
|
||||||
logger.Warn("Failed to close response body", "err", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if resp.StatusCode < http.StatusOK {
|
|
||||||
logger.Error("Unexpected 1xx response", "status", resp.StatusCode)
|
|
||||||
return "", fmt.Errorf("unexpected 1xx status code: %d", resp.StatusCode)
|
|
||||||
} else if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
|
||||||
logger.Error("Unexpected 3xx response", "status", resp.StatusCode)
|
|
||||||
return "", fmt.Errorf("unexpected 3xx status code: %d", resp.StatusCode)
|
|
||||||
} else if resp.StatusCode >= http.StatusInternalServerError {
|
|
||||||
logger.Error("Unexpected 5xx response", "status", resp.StatusCode)
|
|
||||||
return "", fmt.Errorf("unexpected 5xx status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := resp.Header.Get("Content-Type")
|
|
||||||
// If the response is text/html it could be the response to an incoming webhook
|
|
||||||
if strings.HasPrefix(content, "text/html") {
|
|
||||||
return handleSlackIncomingWebhookResponse(resp, logger)
|
|
||||||
} else {
|
|
||||||
return handleSlackJSONResponse(resp, logger)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSlackIncomingWebhookResponse(resp *http.Response, logger channels.Logger) (string, error) {
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incoming webhooks return the string "ok" on success
|
|
||||||
if bytes.Equal(b, []byte("ok")) {
|
|
||||||
logger.Debug("The incoming webhook was successful")
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("Incoming webhook was unsuccessful", "status", resp.StatusCode, "body", string(b))
|
|
||||||
|
|
||||||
// There are a number of known errors that we can check. The documentation incoming webhooks
|
|
||||||
// errors can be found at https://api.slack.com/messaging/webhooks#handling_errors and
|
|
||||||
// https://api.slack.com/changelog/2016-05-17-changes-to-errors-for-incoming-webhooks
|
|
||||||
if bytes.Equal(b, []byte("user_not_found")) {
|
|
||||||
return "", errors.New("the user does not exist or is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Equal(b, []byte("channel_not_found")) {
|
|
||||||
return "", errors.New("the channel does not exist or is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Equal(b, []byte("channel_is_archived")) {
|
|
||||||
return "", errors.New("cannot send an incoming webhook for an archived channel")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Equal(b, []byte("posting_to_general_channel_denied")) {
|
|
||||||
return "", errors.New("cannot send an incoming webhook to the #general channel")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Equal(b, []byte("no_service")) {
|
|
||||||
return "", errors.New("the incoming webhook is either disabled, removed, or invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Equal(b, []byte("no_text")) {
|
|
||||||
return "", errors.New("cannot send an incoming webhook without a message")
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("failed incoming webhook: %s", string(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSlackJSONResponse(resp *http.Response, logger channels.Logger) (string, error) {
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(b) == 0 {
|
|
||||||
logger.Error("Expected JSON but got empty response")
|
|
||||||
return "", errors.New("unexpected empty response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slack responds to some requests with a JSON document, that might contain an error.
|
|
||||||
result := struct {
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Ts string `json:"ts"`
|
|
||||||
Err string `json:"error"`
|
|
||||||
}{}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(b, &result); err != nil {
|
|
||||||
logger.Error("Failed to unmarshal response", "body", string(b), "err", err)
|
|
||||||
return "", fmt.Errorf("failed to unmarshal response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.OK {
|
|
||||||
logger.Error("The request was unsuccessful", "body", string(b), "err", result.Err)
|
|
||||||
return "", fmt.Errorf("failed to send request: %s", result.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("The request was successful")
|
|
||||||
return result.Ts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types.Alert) (*slackMessage, error) {
|
|
||||||
var tmplErr error
|
|
||||||
tmpl, _ := channels.TmplText(ctx, sn.tmpl, alerts, sn.log, &tmplErr)
|
|
||||||
|
|
||||||
ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log)
|
|
||||||
|
|
||||||
title, truncated := channels.TruncateInRunes(tmpl(sn.settings.Title), slackMaxTitleLenRunes)
|
|
||||||
if truncated {
|
|
||||||
key, err := notify.ExtractGroupKey(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sn.log.Warn("Truncated title", "key", key, "max_runes", slackMaxTitleLenRunes)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &slackMessage{
|
|
||||||
Channel: tmpl(sn.settings.Recipient),
|
|
||||||
Username: tmpl(sn.settings.Username),
|
|
||||||
IconEmoji: tmpl(sn.settings.IconEmoji),
|
|
||||||
IconURL: tmpl(sn.settings.IconURL),
|
|
||||||
// TODO: We should use the Block Kit API instead:
|
|
||||||
// https://api.slack.com/messaging/composing/layouts#when-to-use-attachments
|
|
||||||
Attachments: []attachment{
|
|
||||||
{
|
|
||||||
Color: getAlertStatusColor(types.Alerts(alerts...).Status()),
|
|
||||||
Title: title,
|
|
||||||
Fallback: title,
|
|
||||||
Footer: "Grafana v" + sn.appVersion,
|
|
||||||
FooterIcon: channels.FooterIconURL,
|
|
||||||
Ts: time.Now().Unix(),
|
|
||||||
TitleLink: ruleURL,
|
|
||||||
Text: tmpl(sn.settings.Text),
|
|
||||||
Fields: nil, // TODO. Should be a config.
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if isIncomingWebhook(sn.settings) {
|
|
||||||
// Incoming webhooks cannot upload files, instead share images via their URL
|
|
||||||
_ = withStoredImages(ctx, sn.log, sn.images, func(index int, image channels.Image) error {
|
|
||||||
if image.URL != "" {
|
|
||||||
req.Attachments[0].ImageURL = image.URL
|
|
||||||
return channels.ErrImagesDone
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}, alerts...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tmplErr != nil {
|
|
||||||
sn.log.Warn("failed to template Slack message", "error", tmplErr.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
mentionsBuilder := strings.Builder{}
|
|
||||||
appendSpace := func() {
|
|
||||||
if mentionsBuilder.Len() > 0 {
|
|
||||||
mentionsBuilder.WriteString(" ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mentionChannel := strings.TrimSpace(sn.settings.MentionChannel)
|
|
||||||
if mentionChannel != "" {
|
|
||||||
mentionsBuilder.WriteString(fmt.Sprintf("<!%s|%s>", mentionChannel, mentionChannel))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sn.settings.MentionGroups) > 0 {
|
|
||||||
appendSpace()
|
|
||||||
for _, g := range sn.settings.MentionGroups {
|
|
||||||
mentionsBuilder.WriteString(fmt.Sprintf("<!subteam^%s>", tmpl(g)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sn.settings.MentionUsers) > 0 {
|
|
||||||
appendSpace()
|
|
||||||
for _, u := range sn.settings.MentionUsers {
|
|
||||||
mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", tmpl(u)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if mentionsBuilder.Len() > 0 {
|
|
||||||
// Use markdown-formatted pretext for any mentions.
|
|
||||||
req.Attachments[0].MrkdwnIn = []string{"pretext"}
|
|
||||||
req.Attachments[0].Pretext = mentionsBuilder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
return req, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sn *SlackNotifier) sendSlackMessage(ctx context.Context, m *slackMessage) (string, error) {
|
|
||||||
b, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to marshal Slack message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sn.log.Debug("sending Slack API request", "url", sn.settings.URL, "data", string(b))
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, sn.settings.URL, bytes.NewReader(b))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
request.Header.Set("User-Agent", "Grafana")
|
|
||||||
if sn.settings.Token == "" {
|
|
||||||
if sn.settings.URL == SlackAPIEndpoint {
|
|
||||||
panic("Token should be set when using the Slack chat API")
|
|
||||||
}
|
|
||||||
sn.log.Debug("Looks like we are using an incoming webhook, no Authorization header required")
|
|
||||||
} else {
|
|
||||||
sn.log.Debug("Looks like we are using the Slack API, have set the Bearer token for this request")
|
|
||||||
request.Header.Set("Authorization", "Bearer "+sn.settings.Token)
|
|
||||||
}
|
|
||||||
|
|
||||||
thread_ts, err := sn.sendFn(ctx, request, sn.log)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return thread_ts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createImageMultipart returns the mutlipart/form-data request and headers for files.upload.
|
|
||||||
// It returns an error if the image does not exist or there was an error preparing the
|
|
||||||
// multipart form.
|
|
||||||
func (sn *SlackNotifier) createImageMultipart(image channels.Image, channel, comment, thread_ts string) (http.Header, []byte, error) {
|
|
||||||
buf := bytes.Buffer{}
|
|
||||||
w := multipart.NewWriter(&buf)
|
|
||||||
defer func() {
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
sn.log.Error("Failed to close multipart writer", "err", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
f, err := os.Open(image.Path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
sn.log.Error("Failed to close image file reader", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
fw, err := w.CreateFormFile("file", image.Path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to create form file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.Copy(fw, f); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to copy file to form: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.WriteField("channels", channel); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to write channels to form: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.WriteField("initial_comment", comment); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to write initial_comment to form: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.WriteField("thread_ts", thread_ts); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to write thread_ts to form: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
b := buf.Bytes()
|
|
||||||
headers := http.Header{}
|
|
||||||
headers.Set("Content-Type", w.FormDataContentType())
|
|
||||||
return headers, b, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sn *SlackNotifier) sendMultipart(ctx context.Context, headers http.Header, data io.Reader) error {
|
|
||||||
sn.log.Debug("Sending multipart request to files.upload")
|
|
||||||
|
|
||||||
u, err := uploadURL(sn.settings)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get URL for files.upload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, u, data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
for k, v := range headers {
|
|
||||||
req.Header[k] = v
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+sn.settings.Token)
|
|
||||||
|
|
||||||
if _, err := sn.sendFn(ctx, req, sn.log); err != nil {
|
|
||||||
return fmt.Errorf("failed to send request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadImage shares the image to the channel names or IDs. It returns an error if the file
|
|
||||||
// does not exist, or if there was an error either preparing or sending the multipart/form-data
|
|
||||||
// request.
|
|
||||||
func (sn *SlackNotifier) uploadImage(ctx context.Context, image channels.Image, channel, comment, thread_ts string) error {
|
|
||||||
sn.log.Debug("Uploadimg image", "image", image.Token)
|
|
||||||
headers, data, err := sn.createImageMultipart(image, channel, comment, thread_ts)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create multipart form: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sn.sendMultipart(ctx, headers, bytes.NewReader(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sn *SlackNotifier) SendResolved() bool {
|
|
||||||
return !sn.GetDisableResolveMessage()
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialCommentForImage returns the initial comment for the image.
|
|
||||||
// Here is an example of the initial comment for an alert called
|
|
||||||
// AlertName with two labels:
|
|
||||||
//
|
|
||||||
// Resolved|Firing: AlertName, Labels: A=B, C=D
|
|
||||||
//
|
|
||||||
// where Resolved|Firing and Labels is in bold text.
|
|
||||||
func initialCommentForImage(alert *types.Alert) string {
|
|
||||||
sb := strings.Builder{}
|
|
||||||
|
|
||||||
if alert.Resolved() {
|
|
||||||
sb.WriteString("*Resolved*:")
|
|
||||||
} else {
|
|
||||||
sb.WriteString("*Firing*:")
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(" ")
|
|
||||||
sb.WriteString(alert.Name())
|
|
||||||
sb.WriteString(", ")
|
|
||||||
|
|
||||||
sb.WriteString("*Labels*: ")
|
|
||||||
|
|
||||||
var n int
|
|
||||||
for k, v := range alert.Labels {
|
|
||||||
sb.WriteString(string(k))
|
|
||||||
sb.WriteString(" = ")
|
|
||||||
sb.WriteString(string(v))
|
|
||||||
if n < len(alert.Labels)-1 {
|
|
||||||
sb.WriteString(", ")
|
|
||||||
n += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
@ -1,578 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math/rand"
|
|
||||||
"mime"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/prometheus/alertmanager/notify"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
)
|
|
||||||
|
|
||||||
var appVersion = fmt.Sprintf("%d.0.0", rand.Uint32())
|
|
||||||
|
|
||||||
func TestSlackIncomingWebhook(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expectedMessage *slackMessage
|
|
||||||
expectedError string
|
|
||||||
settings string
|
|
||||||
}{{
|
|
||||||
name: "Message is sent",
|
|
||||||
settings: `{
|
|
||||||
"icon_emoji": ":emoji:",
|
|
||||||
"recipient": "#test",
|
|
||||||
"url": "https://example.com/hooks/xxxx"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
expectedMessage: &slackMessage{
|
|
||||||
Channel: "#test",
|
|
||||||
Username: "Grafana",
|
|
||||||
IconEmoji: ":emoji:",
|
|
||||||
Attachments: []attachment{
|
|
||||||
{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
TitleLink: "http://localhost/alerting/list",
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n",
|
|
||||||
Fallback: "[FIRING:1] (val1)",
|
|
||||||
Fields: nil,
|
|
||||||
Footer: "Grafana v" + appVersion,
|
|
||||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
Color: "#D63232",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: "Message is sent with image URL",
|
|
||||||
settings: `{
|
|
||||||
"icon_emoji": ":emoji:",
|
|
||||||
"recipient": "#test",
|
|
||||||
"url": "https://example.com/hooks/xxxx"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-with-url"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
expectedMessage: &slackMessage{
|
|
||||||
Channel: "#test",
|
|
||||||
Username: "Grafana",
|
|
||||||
IconEmoji: ":emoji:",
|
|
||||||
Attachments: []attachment{
|
|
||||||
{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
TitleLink: "http://localhost/alerting/list",
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
Fallback: "[FIRING:1] (val1)",
|
|
||||||
Fields: nil,
|
|
||||||
Footer: "Grafana v" + appVersion,
|
|
||||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
Color: "#D63232",
|
|
||||||
ImageURL: "https://www.example.com/test.png",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: "Message is sent and image on local disk is ignored",
|
|
||||||
settings: `{
|
|
||||||
"icon_emoji": ":emoji:",
|
|
||||||
"recipient": "#test",
|
|
||||||
"url": "https://example.com/hooks/xxxx"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-on-disk"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
expectedMessage: &slackMessage{
|
|
||||||
Channel: "#test",
|
|
||||||
Username: "Grafana",
|
|
||||||
IconEmoji: ":emoji:",
|
|
||||||
Attachments: []attachment{
|
|
||||||
{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
TitleLink: "http://localhost/alerting/list",
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
Fallback: "[FIRING:1] (val1)",
|
|
||||||
Fields: nil,
|
|
||||||
Footer: "Grafana v" + appVersion,
|
|
||||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
Color: "#D63232",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
notifier, recorder, err := setupSlackForTests(t, test.settings)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ctx = notify.WithGroupKey(ctx, "alertname")
|
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
|
||||||
|
|
||||||
ok, err := notifier.Notify(ctx, test.alerts...)
|
|
||||||
if test.expectedError != "" {
|
|
||||||
assert.EqualError(t, err, test.expectedError)
|
|
||||||
assert.False(t, ok)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.True(t, ok)
|
|
||||||
|
|
||||||
// When sending a notification to an Incoming Webhook there should a single request.
|
|
||||||
// This is different from PostMessage where some content, such as images, are sent
|
|
||||||
// as replies to the original message
|
|
||||||
require.Len(t, recorder.requests, 1)
|
|
||||||
|
|
||||||
// Get the request and check that it's sending to the URL of the Incoming Webhook
|
|
||||||
r := recorder.requests[0]
|
|
||||||
assert.Equal(t, notifier.settings.URL, r.URL.String())
|
|
||||||
|
|
||||||
// Check that the request contains the expected message
|
|
||||||
b, err := io.ReadAll(r.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
message := slackMessage{}
|
|
||||||
require.NoError(t, json.Unmarshal(b, &message))
|
|
||||||
for i, v := range message.Attachments {
|
|
||||||
// Need to update the ts as these cannot be set in the test definition
|
|
||||||
test.expectedMessage.Attachments[i].Ts = v.Ts
|
|
||||||
}
|
|
||||||
assert.Equal(t, *test.expectedMessage, message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSlackPostMessage(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
alerts []*types.Alert
|
|
||||||
expectedMessage *slackMessage
|
|
||||||
expectedReplies []interface{} // can contain either slackMessage or map[string]struct{} for multipart/form-data
|
|
||||||
expectedError string
|
|
||||||
settings string
|
|
||||||
}{{
|
|
||||||
name: "Message is sent",
|
|
||||||
settings: `{
|
|
||||||
"icon_emoji": ":emoji:",
|
|
||||||
"recipient": "#test",
|
|
||||||
"token": "1234"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
expectedMessage: &slackMessage{
|
|
||||||
Channel: "#test",
|
|
||||||
Username: "Grafana",
|
|
||||||
IconEmoji: ":emoji:",
|
|
||||||
Attachments: []attachment{
|
|
||||||
{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
TitleLink: "http://localhost/alerting/list",
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
Fallback: "[FIRING:1] (val1)",
|
|
||||||
Fields: nil,
|
|
||||||
Footer: "Grafana v" + appVersion,
|
|
||||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
Color: "#D63232",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: "Message is sent with two firing alerts",
|
|
||||||
settings: `{
|
|
||||||
"title": "{{ .Alerts.Firing | len }} firing, {{ .Alerts.Resolved | len }} resolved",
|
|
||||||
"icon_emoji": ":emoji:",
|
|
||||||
"recipient": "#test",
|
|
||||||
"token": "1234"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv2"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
expectedMessage: &slackMessage{
|
|
||||||
Channel: "#test",
|
|
||||||
Username: "Grafana",
|
|
||||||
IconEmoji: ":emoji:",
|
|
||||||
Attachments: []attachment{
|
|
||||||
{
|
|
||||||
Title: "2 firing, 0 resolved",
|
|
||||||
TitleLink: "http://localhost/alerting/list",
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n",
|
|
||||||
Fallback: "2 firing, 0 resolved",
|
|
||||||
Fields: nil,
|
|
||||||
Footer: "Grafana v" + appVersion,
|
|
||||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
Color: "#D63232",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: "Message is sent and image is uploaded",
|
|
||||||
settings: `{
|
|
||||||
"icon_emoji": ":emoji:",
|
|
||||||
"recipient": "#test",
|
|
||||||
"token": "1234"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "image-on-disk"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
expectedMessage: &slackMessage{
|
|
||||||
Channel: "#test",
|
|
||||||
Username: "Grafana",
|
|
||||||
IconEmoji: ":emoji:",
|
|
||||||
Attachments: []attachment{
|
|
||||||
{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
TitleLink: "http://localhost/alerting/list",
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n",
|
|
||||||
Fallback: "[FIRING:1] (val1)",
|
|
||||||
Fields: nil,
|
|
||||||
Footer: "Grafana v" + appVersion,
|
|
||||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
Color: "#D63232",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expectedReplies: []interface{}{
|
|
||||||
// check that the following parts are present in the multipart/form-data
|
|
||||||
map[string]struct{}{
|
|
||||||
"file": {},
|
|
||||||
"channels": {},
|
|
||||||
"initial_comment": {},
|
|
||||||
"thread_ts": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: "Message is sent to custom URL",
|
|
||||||
settings: `{
|
|
||||||
"icon_emoji": ":emoji:",
|
|
||||||
"recipient": "#test",
|
|
||||||
"endpointUrl": "https://example.com/api",
|
|
||||||
"token": "1234"
|
|
||||||
}`,
|
|
||||||
alerts: []*types.Alert{{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
|
||||||
Annotations: model.LabelSet{"ann1": "annv1"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
expectedMessage: &slackMessage{
|
|
||||||
Channel: "#test",
|
|
||||||
Username: "Grafana",
|
|
||||||
IconEmoji: ":emoji:",
|
|
||||||
Attachments: []attachment{
|
|
||||||
{
|
|
||||||
Title: "[FIRING:1] (val1)",
|
|
||||||
TitleLink: "http://localhost/alerting/list",
|
|
||||||
Text: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n",
|
|
||||||
Fallback: "[FIRING:1] (val1)",
|
|
||||||
Fields: nil,
|
|
||||||
Footer: "Grafana v" + appVersion,
|
|
||||||
FooterIcon: "https://grafana.com/static/assets/img/fav32.png",
|
|
||||||
Color: "#D63232",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
notifier, recorder, err := setupSlackForTests(t, test.settings)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
ctx = notify.WithGroupKey(ctx, "alertname")
|
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
|
||||||
|
|
||||||
ok, err := notifier.Notify(ctx, test.alerts...)
|
|
||||||
if test.expectedError != "" {
|
|
||||||
assert.EqualError(t, err, test.expectedError)
|
|
||||||
assert.False(t, ok)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.True(t, ok)
|
|
||||||
|
|
||||||
// When sending a notification via PostMessage some content, such as images,
|
|
||||||
// are sent as replies to the original message
|
|
||||||
require.Len(t, recorder.requests, len(test.expectedReplies)+1)
|
|
||||||
|
|
||||||
// Get the request and check that it's sending to the URL
|
|
||||||
r := recorder.requests[0]
|
|
||||||
assert.Equal(t, notifier.settings.URL, r.URL.String())
|
|
||||||
|
|
||||||
// Check that the request contains the expected message
|
|
||||||
b, err := io.ReadAll(r.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
message := slackMessage{}
|
|
||||||
require.NoError(t, json.Unmarshal(b, &message))
|
|
||||||
for i, v := range message.Attachments {
|
|
||||||
// Need to update the ts as these cannot be set in the test definition
|
|
||||||
test.expectedMessage.Attachments[i].Ts = v.Ts
|
|
||||||
}
|
|
||||||
assert.Equal(t, *test.expectedMessage, message)
|
|
||||||
|
|
||||||
// Check that the replies match expectations
|
|
||||||
for i := 1; i < len(recorder.requests); i++ {
|
|
||||||
r = recorder.requests[i]
|
|
||||||
assert.Equal(t, "https://slack.com/api/files.upload", r.URL.String())
|
|
||||||
|
|
||||||
media, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
if media == "multipart/form-data" {
|
|
||||||
// Some replies are file uploads, so check the multipart form
|
|
||||||
checkMultipart(t, test.expectedReplies[i-1].(map[string]struct{}), r.Body, params["boundary"])
|
|
||||||
} else {
|
|
||||||
b, err = io.ReadAll(r.Body)
|
|
||||||
require.NoError(t, err)
|
|
||||||
message = slackMessage{}
|
|
||||||
require.NoError(t, json.Unmarshal(b, &message))
|
|
||||||
assert.Equal(t, test.expectedReplies[i-1], message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// slackRequestRecorder is used in tests to record all requests.
|
|
||||||
type slackRequestRecorder struct {
|
|
||||||
requests []*http.Request
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *slackRequestRecorder) fn(_ context.Context, r *http.Request, _ channels.Logger) (string, error) {
|
|
||||||
s.requests = append(s.requests, r)
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkMulipart checks that each part is present, but not its contents
|
|
||||||
func checkMultipart(t *testing.T, expected map[string]struct{}, r io.Reader, boundary string) {
|
|
||||||
m := multipart.NewReader(r, boundary)
|
|
||||||
visited := make(map[string]struct{})
|
|
||||||
for {
|
|
||||||
part, err := m.NextPart()
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
require.NoError(t, err)
|
|
||||||
visited[part.FormName()] = struct{}{}
|
|
||||||
}
|
|
||||||
assert.Equal(t, expected, visited)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRequestRecorder, error) {
|
|
||||||
tmpl := templateForTests(t)
|
|
||||||
externalURL, err := url.Parse("http://localhost")
|
|
||||||
require.NoError(t, err)
|
|
||||||
tmpl.ExternalURL = externalURL
|
|
||||||
|
|
||||||
f, err := os.Create(t.TempDir() + "test.png")
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = f.Close()
|
|
||||||
if err := os.Remove(f.Name()); err != nil {
|
|
||||||
t.Logf("failed to delete test file: %s", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
images := &fakeImageStore{
|
|
||||||
Images: []*channels.Image{{
|
|
||||||
Token: "image-on-disk",
|
|
||||||
Path: f.Name(),
|
|
||||||
}, {
|
|
||||||
Token: "image-with-url",
|
|
||||||
URL: "https://www.example.com/test.png",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
notificationService := mockNotificationService()
|
|
||||||
|
|
||||||
c := channels.FactoryConfig{
|
|
||||||
Config: &channels.NotificationChannelConfig{
|
|
||||||
Name: "slack_testing",
|
|
||||||
Type: "slack",
|
|
||||||
Settings: json.RawMessage(settings),
|
|
||||||
SecureSettings: make(map[string][]byte),
|
|
||||||
},
|
|
||||||
ImageStore: images,
|
|
||||||
NotificationService: notificationService,
|
|
||||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string {
|
|
||||||
return fallback
|
|
||||||
},
|
|
||||||
Template: tmpl,
|
|
||||||
Logger: &channels.FakeLogger{},
|
|
||||||
GrafanaBuildVersion: appVersion,
|
|
||||||
}
|
|
||||||
|
|
||||||
sn, err := buildSlackNotifier(c)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sr := &slackRequestRecorder{}
|
|
||||||
sn.sendFn = sr.fn
|
|
||||||
return sn, sr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateSlackNotifierFromConfig(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
expectedError string
|
|
||||||
}{{
|
|
||||||
name: "Missing token",
|
|
||||||
settings: `{
|
|
||||||
"recipient": "#testchannel"
|
|
||||||
}`,
|
|
||||||
expectedError: "token must be specified when using the Slack chat API",
|
|
||||||
}, {
|
|
||||||
name: "Missing recipient",
|
|
||||||
settings: `{
|
|
||||||
"token": "1234"
|
|
||||||
}`,
|
|
||||||
expectedError: "recipient must be specified when using the Slack chat API",
|
|
||||||
}}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
n, _, err := setupSlackForTests(t, test.settings)
|
|
||||||
if test.expectedError != "" {
|
|
||||||
assert.Nil(t, n)
|
|
||||||
assert.EqualError(t, err, test.expectedError)
|
|
||||||
} else {
|
|
||||||
assert.NotNil(t, n)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendSlackRequest(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
response string
|
|
||||||
statusCode int
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Example error",
|
|
||||||
response: `{
|
|
||||||
"ok": false,
|
|
||||||
"error": "too_many_attachments"
|
|
||||||
}`,
|
|
||||||
statusCode: http.StatusBadRequest,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Non 200 status code, no response body",
|
|
||||||
statusCode: http.StatusMovedPermanently,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Success case, normal response body",
|
|
||||||
response: `{
|
|
||||||
"ok": true,
|
|
||||||
"channel": "C1H9RESGL",
|
|
||||||
"ts": "1503435956.000247",
|
|
||||||
"message": {
|
|
||||||
"text": "Here's a message for you",
|
|
||||||
"username": "ecto1",
|
|
||||||
"bot_id": "B19LU7CSY",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"text": "This is an attachment",
|
|
||||||
"id": 1,
|
|
||||||
"fallback": "This is an attachment's fallback"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"type": "message",
|
|
||||||
"subtype": "bot_message",
|
|
||||||
"ts": "1503435956.000247"
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
statusCode: http.StatusOK,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "No response body",
|
|
||||||
statusCode: http.StatusOK,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Success case, unexpected response body",
|
|
||||||
statusCode: http.StatusOK,
|
|
||||||
response: `{"test": true}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Success case, ok: true",
|
|
||||||
statusCode: http.StatusOK,
|
|
||||||
response: `{"ok": true}`,
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "200 status code, error in body",
|
|
||||||
statusCode: http.StatusOK,
|
|
||||||
response: `{"ok": false, "error": "test error"}`,
|
|
||||||
expectError: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(tt *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(test.statusCode)
|
|
||||||
_, err := w.Write([]byte(test.response))
|
|
||||||
require.NoError(tt, err)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
|
|
||||||
require.NoError(tt, err)
|
|
||||||
|
|
||||||
_, err = sendSlackRequest(context.Background(), req, &channels.FakeLogger{})
|
|
||||||
if !test.expectError {
|
|
||||||
require.NoError(tt, err)
|
|
||||||
} else {
|
|
||||||
require.Error(tt, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeImageStore struct {
|
|
||||||
Images []*channels.Image
|
|
||||||
}
|
|
||||||
|
|
||||||
// getImage returns an image with the same token.
|
|
||||||
func (f *fakeImageStore) GetImage(_ context.Context, token string) (*channels.Image, error) {
|
|
||||||
for _, img := range f.Images {
|
|
||||||
if img.Token == token {
|
|
||||||
return img, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, channels.ErrImageNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFakeImageStore returns an image store with N test images.
|
|
||||||
// Each image has a token and a URL, but does not have a file on disk.
|
|
||||||
func newFakeImageStore(n int) channels.ImageStore {
|
|
||||||
s := fakeImageStore{}
|
|
||||||
for i := 1; i <= n; i++ {
|
|
||||||
s.Images = append(s.Images, &channels.Image{
|
|
||||||
Token: fmt.Sprintf("test-image-%d", i),
|
|
||||||
URL: fmt.Sprintf("https://www.example.com/test-image-%d.jpg", i),
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return &s
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockTimeNow replaces function timeNow to return constant time.
|
|
||||||
// It returns a function that resets the variable back to its original value.
|
|
||||||
// This allows usage of this function with defer:
|
|
||||||
//
|
|
||||||
// func Test (t *testing.T) {
|
|
||||||
// now := time.Now()
|
|
||||||
// defer mockTimeNow(now)()
|
|
||||||
// ...
|
|
||||||
// }
|
|
||||||
func mockTimeNow(constTime time.Time) func() {
|
|
||||||
timeNow = func() time.Time {
|
|
||||||
return constTime
|
|
||||||
}
|
|
||||||
return resetTimeNow
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetTimeNow resets the global variable timeNow to the default value, which is time.Now
|
|
||||||
func resetTimeNow() {
|
|
||||||
timeNow = time.Now
|
|
||||||
}
|
|
||||||
|
|
||||||
type notificationServiceMock struct {
|
|
||||||
Webhook channels.SendWebhookSettings
|
|
||||||
EmailSync channels.SendEmailSettings
|
|
||||||
ShouldError error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ns *notificationServiceMock) SendWebhook(ctx context.Context, cmd *channels.SendWebhookSettings) error {
|
|
||||||
ns.Webhook = *cmd
|
|
||||||
return ns.ShouldError
|
|
||||||
}
|
|
||||||
func (ns *notificationServiceMock) SendEmail(ctx context.Context, cmd *channels.SendEmailSettings) error {
|
|
||||||
ns.EmailSync = *cmd
|
|
||||||
return ns.ShouldError
|
|
||||||
}
|
|
||||||
|
|
||||||
func mockNotificationService() *notificationServiceMock { return ¬ificationServiceMock{} }
|
|
@ -1,213 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Provides current time. Can be overwritten in tests.
|
|
||||||
timeNow = time.Now
|
|
||||||
)
|
|
||||||
|
|
||||||
type forEachImageFunc func(index int, image channels.Image) error
|
|
||||||
|
|
||||||
// getImage returns the image for the alert or an error. It returns a nil
|
|
||||||
// image if the alert does not have an image token or the image does not exist.
|
|
||||||
func getImage(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, alert types.Alert) (*channels.Image, error) {
|
|
||||||
token := getTokenFromAnnotations(alert.Annotations)
|
|
||||||
if token == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancelFunc := context.WithTimeout(ctx, channels.ImageStoreTimeout)
|
|
||||||
defer cancelFunc()
|
|
||||||
|
|
||||||
img, err := imageStore.GetImage(ctx, token)
|
|
||||||
if errors.Is(err, channels.ErrImageNotFound) || errors.Is(err, channels.ErrImagesUnavailable) {
|
|
||||||
return nil, nil
|
|
||||||
} else if err != nil {
|
|
||||||
l.Warn("failed to get image with token", "token", token, "error", err)
|
|
||||||
return nil, err
|
|
||||||
} else {
|
|
||||||
return img, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withStoredImages retrieves the image for each alert and then calls forEachFunc
|
|
||||||
// with the index of the alert and the retrieved image struct. If the alert does
|
|
||||||
// not have an image token, or the image does not exist then forEachFunc will not be
|
|
||||||
// called for that alert. If forEachFunc returns an error, withStoredImages will return
|
|
||||||
// the error and not iterate the remaining alerts. A forEachFunc can return ErrImagesDone
|
|
||||||
// to stop the iteration of remaining alerts if the intended image or maximum number of
|
|
||||||
// images have been found.
|
|
||||||
func withStoredImages(ctx context.Context, l channels.Logger, imageStore channels.ImageStore, forEachFunc forEachImageFunc, alerts ...*types.Alert) error {
|
|
||||||
for index, alert := range alerts {
|
|
||||||
logger := l.New("alert", alert.String())
|
|
||||||
img, err := getImage(ctx, logger, imageStore, *alert)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
} else if img != nil {
|
|
||||||
if err := forEachFunc(index, *img); err != nil {
|
|
||||||
if errors.Is(err, channels.ErrImagesDone) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
logger.Error("Failed to attach image to notification", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The path argument here comes from reading internal image storage, not user
|
|
||||||
// input, so we ignore the security check here.
|
|
||||||
//
|
|
||||||
//nolint:gosec
|
|
||||||
func openImage(path string) (io.ReadCloser, error) {
|
|
||||||
fp := filepath.Clean(path)
|
|
||||||
_, err := os.Stat(fp)
|
|
||||||
if os.IsNotExist(err) || os.IsPermission(err) {
|
|
||||||
return nil, channels.ErrImageNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Open(fp)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTokenFromAnnotations(annotations model.LabelSet) string {
|
|
||||||
if value, ok := annotations[models.ImageTokenAnnotation]; ok {
|
|
||||||
return string(value)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type receiverInitError struct {
|
|
||||||
Reason string
|
|
||||||
Err error
|
|
||||||
Cfg channels.NotificationChannelConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e receiverInitError) Error() string {
|
|
||||||
name := ""
|
|
||||||
if e.Cfg.Name != "" {
|
|
||||||
name = fmt.Sprintf("%q ", e.Cfg.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
s := fmt.Sprintf("failed to validate receiver %sof type %q: %s", name, e.Cfg.Type, e.Reason)
|
|
||||||
if e.Err != nil {
|
|
||||||
return fmt.Sprintf("%s: %s", s, e.Err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e receiverInitError) Unwrap() error { return e.Err }
|
|
||||||
|
|
||||||
func getAlertStatusColor(status model.AlertStatus) string {
|
|
||||||
if status == model.AlertFiring {
|
|
||||||
return channels.ColorAlertFiring
|
|
||||||
}
|
|
||||||
return channels.ColorAlertResolved
|
|
||||||
}
|
|
||||||
|
|
||||||
type httpCfg struct {
|
|
||||||
body []byte
|
|
||||||
user string
|
|
||||||
password string
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendHTTPRequest sends an HTTP request.
|
|
||||||
// Stubbable by tests.
|
|
||||||
var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger channels.Logger) ([]byte, error) {
|
|
||||||
var reader io.Reader
|
|
||||||
if len(cfg.body) > 0 {
|
|
||||||
reader = bytes.NewReader(cfg.body)
|
|
||||||
}
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
|
||||||
}
|
|
||||||
if cfg.user != "" && cfg.password != "" {
|
|
||||||
request.SetBasicAuth(cfg.user, cfg.password)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
request.Header.Set("User-Agent", "Grafana")
|
|
||||||
netTransport := &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{
|
|
||||||
Renegotiation: tls.RenegotiateFreelyAsClient,
|
|
||||||
},
|
|
||||||
Proxy: http.ProxyFromEnvironment,
|
|
||||||
DialContext: (&net.Dialer{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}).DialContext,
|
|
||||||
TLSHandshakeTimeout: 5 * time.Second,
|
|
||||||
}
|
|
||||||
netClient := &http.Client{
|
|
||||||
Timeout: time.Second * 30,
|
|
||||||
Transport: netTransport,
|
|
||||||
}
|
|
||||||
resp, err := netClient.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
if err := resp.Body.Close(); err != nil {
|
|
||||||
logger.Warn("failed to close response body", "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode/100 != 2 {
|
|
||||||
logger.Warn("HTTP request failed", "url", request.URL.String(), "statusCode", resp.Status, "body",
|
|
||||||
string(respBody))
|
|
||||||
return nil, fmt.Errorf("failed to send HTTP request - status code %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("sending HTTP request succeeded", "url", request.URL.String(), "statusCode", resp.Status)
|
|
||||||
return respBody, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinUrlPath(base, additionalPath string, logger channels.Logger) string {
|
|
||||||
u, err := url.Parse(base)
|
|
||||||
if err != nil {
|
|
||||||
logger.Debug("failed to parse URL while joining URL", "url", base, "error", err.Error())
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
u.Path = path.Join(u.Path, additionalPath)
|
|
||||||
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBoundary is used for overriding the behaviour for tests
|
|
||||||
// and set a boundary for multipart body. DO NOT set this outside tests.
|
|
||||||
var GetBoundary = func() string {
|
|
||||||
return ""
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
package channels
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/prometheus/alertmanager/types"
|
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWithStoredImages(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
alerts := []*types.Alert{{
|
|
||||||
Alert: model.Alert{
|
|
||||||
Annotations: model.LabelSet{
|
|
||||||
models.ImageTokenAnnotation: "test-image-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
Alert: model.Alert{
|
|
||||||
Annotations: model.LabelSet{
|
|
||||||
models.ImageTokenAnnotation: "test-image-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
imageStore := &fakeImageStore{Images: []*channels.Image{{
|
|
||||||
Token: "test-image-1",
|
|
||||||
URL: "https://www.example.com/test-image-1.jpg",
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
}, {
|
|
||||||
Token: "test-image-2",
|
|
||||||
URL: "https://www.example.com/test-image-2.jpg",
|
|
||||||
CreatedAt: time.Now().UTC(),
|
|
||||||
}}}
|
|
||||||
|
|
||||||
var (
|
|
||||||
err error
|
|
||||||
i int
|
|
||||||
)
|
|
||||||
|
|
||||||
// should iterate all images
|
|
||||||
err = withStoredImages(ctx, &channels.FakeLogger{}, imageStore, func(index int, image channels.Image) error {
|
|
||||||
i += 1
|
|
||||||
return nil
|
|
||||||
}, alerts...)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 2, i)
|
|
||||||
|
|
||||||
// should iterate just the first image
|
|
||||||
i = 0
|
|
||||||
err = withStoredImages(ctx, &channels.FakeLogger{}, imageStore, func(index int, image channels.Image) error {
|
|
||||||
i += 1
|
|
||||||
return channels.ErrImagesDone
|
|
||||||
}, alerts...)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, i)
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
package channels
|
package channels_config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@ -7,18 +7,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var receiverFactories = map[string]func(channels.FactoryConfig) (channels.NotificationChannel, error){
|
var receiverFactories = map[string]func(channels.FactoryConfig) (channels.NotificationChannel, error){
|
||||||
"prometheus-alertmanager": AlertmanagerFactory,
|
"prometheus-alertmanager": channels.AlertmanagerFactory,
|
||||||
"dingding": DingDingFactory,
|
"dingding": channels.DingDingFactory,
|
||||||
"discord": DiscordFactory,
|
"discord": channels.DiscordFactory,
|
||||||
"email": EmailFactory,
|
"email": channels.EmailFactory,
|
||||||
"googlechat": GoogleChatFactory,
|
"googlechat": channels.GoogleChatFactory,
|
||||||
"kafka": KafkaFactory,
|
"kafka": channels.KafkaFactory,
|
||||||
"line": LineFactory,
|
"line": channels.LineFactory,
|
||||||
"opsgenie": channels.OpsgenieFactory,
|
"opsgenie": channels.OpsgenieFactory,
|
||||||
"pagerduty": channels.PagerdutyFactory,
|
"pagerduty": channels.PagerdutyFactory,
|
||||||
"pushover": channels.PushoverFactory,
|
"pushover": channels.PushoverFactory,
|
||||||
"sensugo": channels.SensuGoFactory,
|
"sensugo": channels.SensuGoFactory,
|
||||||
"slack": SlackFactory,
|
"slack": channels.SlackFactory,
|
||||||
"teams": channels.TeamsFactory,
|
"teams": channels.TeamsFactory,
|
||||||
"telegram": channels.TelegramFactory,
|
"telegram": channels.TelegramFactory,
|
||||||
"threema": channels.ThreemaFactory,
|
"threema": channels.ThreemaFactory,
|
@ -16,7 +16,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/bus"
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
|
|
||||||
"github.com/grafana/grafana/pkg/services/notifications"
|
"github.com/grafana/grafana/pkg/services/notifications"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
)
|
)
|
||||||
@ -217,7 +216,7 @@ func createSut(t *testing.T, messageTmpl string, subjectTmpl string, emailTmpl *
|
|||||||
Template: emailTmpl,
|
Template: emailTmpl,
|
||||||
Logger: &channels.FakeLogger{},
|
Logger: &channels.FakeLogger{},
|
||||||
}
|
}
|
||||||
emailNotifier, err := ngchannels.EmailFactory(fc)
|
emailNotifier, err := channels.EmailFactory(fc)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return emailNotifier
|
return emailNotifier
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,7 @@ import (
|
|||||||
"github.com/grafana/alerting/alerting/notifier/channels"
|
"github.com/grafana/alerting/alerting/notifier/channels"
|
||||||
|
|
||||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||||
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
|
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@ -501,7 +501,7 @@ func (m *migration) validateAlertmanagerConfig(orgID int64, config *PostableUser
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
receiverFactory, exists := ngchannels.Factory(gr.Type)
|
receiverFactory, exists := channels_config.Factory(gr.Type)
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("notifier %s is not supported", gr.Type)
|
return fmt.Errorf("notifier %s is not supported", gr.Type)
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||||
ngchannels "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels"
|
|
||||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
@ -719,22 +718,22 @@ func TestIntegrationNotificationChannels(t *testing.T) {
|
|||||||
mockChannel.responses["slack_recvX"] = `{"ok": true}`
|
mockChannel.responses["slack_recvX"] = `{"ok": true}`
|
||||||
|
|
||||||
// Overriding some URLs to send to the mock channel.
|
// Overriding some URLs to send to the mock channel.
|
||||||
os, opa, ot, opu, ogb, ol, oth := ngchannels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL,
|
os, opa, ot, opu, ogb, ol, oth := channels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL,
|
||||||
channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary,
|
channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary,
|
||||||
ngchannels.LineNotifyURL, channels.ThreemaGwBaseURL
|
channels.LineNotifyURL, channels.ThreemaGwBaseURL
|
||||||
originalTemplate := channels.DefaultTemplateString
|
originalTemplate := channels.DefaultTemplateString
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
ngchannels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL,
|
channels.SlackAPIEndpoint, channels.PagerdutyEventAPIURL,
|
||||||
channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary,
|
channels.TelegramAPIURL, channels.PushoverEndpoint, channels.GetBoundary,
|
||||||
ngchannels.LineNotifyURL, channels.ThreemaGwBaseURL = os, opa, ot, opu, ogb, ol, oth
|
channels.LineNotifyURL, channels.ThreemaGwBaseURL = os, opa, ot, opu, ogb, ol, oth
|
||||||
channels.DefaultTemplateString = originalTemplate
|
channels.DefaultTemplateString = originalTemplate
|
||||||
})
|
})
|
||||||
channels.DefaultTemplateString = channels.TemplateForTestsString
|
channels.DefaultTemplateString = channels.TemplateForTestsString
|
||||||
ngchannels.SlackAPIEndpoint = fmt.Sprintf("http://%s/slack_recvX/slack_testX", mockChannel.server.Addr)
|
channels.SlackAPIEndpoint = fmt.Sprintf("http://%s/slack_recvX/slack_testX", mockChannel.server.Addr)
|
||||||
channels.PagerdutyEventAPIURL = fmt.Sprintf("http://%s/pagerduty_recvX/pagerduty_testX", mockChannel.server.Addr)
|
channels.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.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.PushoverEndpoint = fmt.Sprintf("http://%s/pushover_recv/pushover_test", mockChannel.server.Addr)
|
||||||
ngchannels.LineNotifyURL = fmt.Sprintf("http://%s/line_recv/line_test", mockChannel.server.Addr)
|
channels.LineNotifyURL = fmt.Sprintf("http://%s/line_recv/line_test", mockChannel.server.Addr)
|
||||||
channels.ThreemaGwBaseURL = fmt.Sprintf("http://%s/threema_recv/threema_test", mockChannel.server.Addr)
|
channels.ThreemaGwBaseURL = fmt.Sprintf("http://%s/threema_recv/threema_test", mockChannel.server.Addr)
|
||||||
channels.GetBoundary = func() string { return "abcd" }
|
channels.GetBoundary = func() string { return "abcd" }
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user