Alerting: Attach screenshot data to Slack notifications. (#49374)

This change extracts screenshot data from alert messages via a private annotation `__alertScreenshotToken__` and attaches a URL to a Slack message or uploads the data to an image upload endpoint if needed.

This change also implements a few foundational functions for use in other notifiers.
pull/49378/head
Joe Blubaugh 3 years ago committed by GitHub
parent 5645d7a5e3
commit 12c25759da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go
  2. 2
      pkg/services/ngalert/image/service.go
  3. 11
      pkg/services/ngalert/notifier/alertmanager.go
  4. 19
      pkg/services/ngalert/notifier/channels/factory.go
  5. 169
      pkg/services/ngalert/notifier/channels/slack.go
  6. 23
      pkg/services/ngalert/notifier/channels/slack_test.go
  7. 25
      pkg/services/ngalert/notifier/channels/utils.go
  8. 4
      pkg/services/ngalert/notifier/multiorg_alertmanager.go
  9. 11
      pkg/services/ngalert/notifier/testing.go
  10. 37
      pkg/services/ngalert/store/image.go
  11. 2
      pkg/services/sqlstore/migrations/ualert/ualert.go

@ -85,7 +85,7 @@ func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) e
cfg, _ := channels.NewFactoryConfig(&channels.NotificationChannelConfig{
Settings: e.Settings,
Type: e.Type,
}, nil, decryptFunc, nil)
}, nil, decryptFunc, nil, nil)
if _, err := factory(cfg); err != nil {
return err
}

@ -96,6 +96,8 @@ func (s *ScreenshotImageService) NewImage(ctx context.Context, r *ngmodels.Alert
DashboardUID: *r.DashboardUID,
PanelID: *r.PanelID,
})
// TODO: Check for screenshot upload failures. These images should still be
// stored because we have a local disk path that could be useful.
if err != nil {
return nil, fmt.Errorf("failed to take screenshot: %w", err)
}

@ -86,11 +86,16 @@ type ClusterPeer interface {
WaitReady(context.Context) error
}
type AlertingStore interface {
store.AlertingStore
channels.ImageStore
}
type Alertmanager struct {
logger log.Logger
Settings *setting.Cfg
Store store.AlertingStore
Store AlertingStore
fileStore *FileStore
Metrics *metrics.Alertmanager
NotificationService notifications.Service
@ -128,7 +133,7 @@ type Alertmanager struct {
decryptFn channels.GetDecryptedValueFn
}
func newAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store store.AlertingStore, kvStore kvstore.KVStore,
func newAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store AlertingStore, kvStore kvstore.KVStore,
peer ClusterPeer, decryptFn channels.GetDecryptedValueFn, ns notifications.Service, m *metrics.Alertmanager) (*Alertmanager, error) {
am := &Alertmanager{
Settings: cfg,
@ -499,7 +504,7 @@ func (am *Alertmanager) buildReceiverIntegration(r *apimodels.PostableGrafanaRec
SecureSettings: secureSettings,
}
)
factoryConfig, err := channels.NewFactoryConfig(cfg, am.NotificationService, am.decryptFn, tmpl)
factoryConfig, err := channels.NewFactoryConfig(cfg, am.NotificationService, am.decryptFn, tmpl, am.Store)
if err != nil {
return nil, InvalidReceiverError{
Receiver: r,

@ -1,7 +1,9 @@
package channels
import (
"context"
"errors"
"io"
"strings"
"github.com/grafana/grafana/pkg/services/notifications"
@ -12,11 +14,19 @@ type FactoryConfig struct {
Config *NotificationChannelConfig
NotificationService notifications.Service
DecryptFunc GetDecryptedValueFn
Template *template.Template
ImageStore ImageStore
// Used to retrieve image URLs for messages, or data for uploads.
Template *template.Template
}
// A specialization of store.ImageStore, to avoid an import loop.
type ImageStore interface {
GetURL(ctx context.Context, token string) (string, error)
GetData(ctx context.Context, token string) (io.ReadCloser, error)
}
func NewFactoryConfig(config *NotificationChannelConfig, notificationService notifications.Service,
decryptFunc GetDecryptedValueFn, template *template.Template) (FactoryConfig, error) {
decryptFunc GetDecryptedValueFn, template *template.Template, imageStore ImageStore) (FactoryConfig, error) {
if config.Settings == nil {
return FactoryConfig{}, errors.New("no settings supplied")
}
@ -25,11 +35,16 @@ func NewFactoryConfig(config *NotificationChannelConfig, notificationService not
if config.SecureSettings == nil {
config.SecureSettings = map[string][]byte{}
}
if imageStore == nil {
imageStore = &UnavailableImageStore{}
}
return FactoryConfig{
Config: config,
NotificationService: notificationService,
DecryptFunc: decryptFunc,
Template: template,
ImageStore: imageStore,
}, nil
}

@ -8,6 +8,8 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"mime/multipart"
"net"
"net/http"
"net/url"
@ -16,6 +18,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/setting"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/template"
@ -23,15 +26,19 @@ import (
)
var SlackAPIEndpoint = "https://slack.com/api/chat.postMessage"
var SlackImageAPIEndpoint = "https://slack.com/api/files.upload"
// SlackNotifier is responsible for sending
// alert notification to Slack.
type SlackNotifier struct {
*Base
log log.Logger
tmpl *template.Template
log log.Logger
tmpl *template.Template
images ImageStore
webhookSender notifications.WebhookSender
URL *url.URL
ImageUploadURL string
Username string
IconEmoji string
IconURL string
@ -47,6 +54,7 @@ type SlackNotifier struct {
type SlackConfig struct {
*NotificationChannelConfig
URL *url.URL
ImageUploadURL string
Username string
IconEmoji string
IconURL string
@ -60,19 +68,22 @@ type SlackConfig struct {
}
func SlackFactory(fc FactoryConfig) (NotificationChannel, error) {
cfg, err := NewSlackConfig(fc.Config, fc.DecryptFunc)
cfg, err := NewSlackConfig(fc)
if err != nil {
return nil, receiverInitError{
Reason: err.Error(),
Cfg: *fc.Config,
}
}
return NewSlackNotifier(cfg, fc.Template), nil
return NewSlackNotifier(cfg, fc.ImageStore, fc.NotificationService, fc.Template), nil
}
func NewSlackConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedValueFn) (*SlackConfig, error) {
endpointURL := config.Settings.Get("endpointUrl").MustString(SlackAPIEndpoint)
slackURL := decryptFunc(context.Background(), config.SecureSettings, "url", config.Settings.Get("url").MustString())
func NewSlackConfig(factoryConfig FactoryConfig) (*SlackConfig, error) {
channelConfig := factoryConfig.Config
decryptFunc := factoryConfig.DecryptFunc
endpointURL := channelConfig.Settings.Get("endpointUrl").MustString(SlackAPIEndpoint)
imageUploadURL := channelConfig.Settings.Get("imageUploadUrl").MustString(SlackImageAPIEndpoint)
slackURL := decryptFunc(context.Background(), channelConfig.SecureSettings, "url", channelConfig.Settings.Get("url").MustString())
if slackURL == "" {
slackURL = endpointURL
}
@ -80,19 +91,19 @@ func NewSlackConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedV
if err != nil {
return nil, fmt.Errorf("invalid URL %q", slackURL)
}
recipient := strings.TrimSpace(config.Settings.Get("recipient").MustString())
recipient := strings.TrimSpace(channelConfig.Settings.Get("recipient").MustString())
if recipient == "" && apiURL.String() == SlackAPIEndpoint {
return nil, errors.New("recipient must be specified when using the Slack chat API")
}
mentionChannel := config.Settings.Get("mentionChannel").MustString()
mentionChannel := channelConfig.Settings.Get("mentionChannel").MustString()
if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" {
return nil, fmt.Errorf("invalid value for mentionChannel: %q", mentionChannel)
}
token := decryptFunc(context.Background(), config.SecureSettings, "token", config.Settings.Get("token").MustString())
token := decryptFunc(context.Background(), channelConfig.SecureSettings, "token", channelConfig.Settings.Get("token").MustString())
if token == "" && apiURL.String() == SlackAPIEndpoint {
return nil, errors.New("token must be specified when using the Slack chat API")
}
mentionUsersStr := config.Settings.Get("mentionUsers").MustString()
mentionUsersStr := channelConfig.Settings.Get("mentionUsers").MustString()
mentionUsers := []string{}
for _, u := range strings.Split(mentionUsersStr, ",") {
u = strings.TrimSpace(u)
@ -100,7 +111,7 @@ func NewSlackConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedV
mentionUsers = append(mentionUsers, u)
}
}
mentionGroupsStr := config.Settings.Get("mentionGroups").MustString()
mentionGroupsStr := channelConfig.Settings.Get("mentionGroups").MustString()
mentionGroups := []string{}
for _, g := range strings.Split(mentionGroupsStr, ",") {
g = strings.TrimSpace(g)
@ -109,23 +120,28 @@ func NewSlackConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedV
}
}
return &SlackConfig{
NotificationChannelConfig: config,
Recipient: strings.TrimSpace(config.Settings.Get("recipient").MustString()),
MentionChannel: config.Settings.Get("mentionChannel").MustString(),
NotificationChannelConfig: channelConfig,
Recipient: strings.TrimSpace(channelConfig.Settings.Get("recipient").MustString()),
MentionChannel: channelConfig.Settings.Get("mentionChannel").MustString(),
MentionUsers: mentionUsers,
MentionGroups: mentionGroups,
URL: apiURL,
Username: config.Settings.Get("username").MustString("Grafana"),
IconEmoji: config.Settings.Get("icon_emoji").MustString(),
IconURL: config.Settings.Get("icon_url").MustString(),
ImageUploadURL: imageUploadURL,
Username: channelConfig.Settings.Get("username").MustString("Grafana"),
IconEmoji: channelConfig.Settings.Get("icon_emoji").MustString(),
IconURL: channelConfig.Settings.Get("icon_url").MustString(),
Token: token,
Text: config.Settings.Get("text").MustString(`{{ template "default.message" . }}`),
Title: config.Settings.Get("title").MustString(DefaultMessageTitleEmbed),
Text: channelConfig.Settings.Get("text").MustString(`{{ template "default.message" . }}`),
Title: channelConfig.Settings.Get("title").MustString(DefaultMessageTitleEmbed),
}, nil
}
// NewSlackNotifier is the constructor for the Slack notifier
func NewSlackNotifier(config *SlackConfig, t *template.Template) *SlackNotifier {
func NewSlackNotifier(config *SlackConfig,
images ImageStore,
webhookSender notifications.WebhookSender,
t *template.Template,
) *SlackNotifier {
return &SlackNotifier{
Base: NewBase(&models.AlertNotification{
Uid: config.UID,
@ -135,6 +151,7 @@ func NewSlackNotifier(config *SlackConfig, t *template.Template) *SlackNotifier
Settings: config.Settings,
}),
URL: config.URL,
ImageUploadURL: config.ImageUploadURL,
Recipient: config.Recipient,
MentionUsers: config.MentionUsers,
MentionGroups: config.MentionGroups,
@ -145,6 +162,8 @@ func NewSlackNotifier(config *SlackConfig, t *template.Template) *SlackNotifier
Token: config.Token,
Text: config.Text,
Title: config.Title,
images: images,
webhookSender: webhookSender,
log: log.New("alerting.notifier.slack"),
tmpl: t,
}
@ -165,6 +184,7 @@ 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"`
@ -174,8 +194,8 @@ type attachment struct {
}
// Notify sends an alert notification to Slack.
func (sn *SlackNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
msg, err := sn.buildSlackMessage(ctx, as)
func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
msg, err := sn.buildSlackMessage(ctx, alerts)
if err != nil {
return false, fmt.Errorf("build slack message: %w", err)
}
@ -205,6 +225,37 @@ func (sn *SlackNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
if err := sendSlackRequest(request, sn.log); err != nil {
return false, err
}
// Try to upload if we have an image path but no image URL. This uploads the file
// immediately after the message. A bit of a hack, but it doesn't require the
// user to have an image host set up.
// TODO: how many image files should we upload? In what order? Should we
// assume the alerts array is already sorted?
// TODO: We need a refactoring so we don't do two database reads for the same data.
// TODO: Should we process all alerts' annotations? We can only have on image.
// TODO: Should we guard out-of-bounds errors here? Callers should prevent that from happening, imo
imgToken := getTokenFromAnnotations(alerts[0].Annotations)
dbContext, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
imgData, err := sn.images.GetData(dbContext, imgToken)
cancel()
if err != nil {
if !errors.Is(err, ErrImagesUnavailable) {
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
sn.log.Warn("Error reading screenshot data from ImageStore: %v", err)
}
return true, nil
}
defer func() {
// Nothing for us to do.
_ = imgData.Close()
}()
err = sn.slackFileUpload(ctx, imgData, sn.Recipient, sn.Token)
if err != nil {
sn.log.Warn("Error reading screenshot data from ImageStore: %v", err)
}
return true, nil
}
@ -275,11 +326,26 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Aler
ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log)
// TODO: Should we process all alerts' annotations? We can only have on image.
// TODO: Should we guard out-of-bounds errors here? Callers should prevent that from happening, imo
imgToken := getTokenFromAnnotations(as[0].Annotations)
timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
imgURL, err := sn.images.GetURL(timeoutCtx, imgToken)
cancel()
if err != nil {
if !errors.Is(err, ErrImagesUnavailable) {
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
sn.log.Warn("failed to retrieve image url from store", "error", err)
}
}
req := &slackMessage{
Channel: tmpl(sn.Recipient),
Username: tmpl(sn.Username),
IconEmoji: tmpl(sn.IconEmoji),
IconURL: tmpl(sn.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(alerts.Status()),
@ -287,6 +353,7 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Aler
Fallback: tmpl(sn.Title),
Footer: "Grafana v" + setting.BuildVersion,
FooterIcon: FooterIconURL,
ImageURL: imgURL,
Ts: time.Now().Unix(),
TitleLink: ruleURL,
Text: tmpl(sn.Text),
@ -339,3 +406,59 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Aler
func (sn *SlackNotifier) SendResolved() bool {
return !sn.GetDisableResolveMessage()
}
func (sn *SlackNotifier) slackFileUpload(ctx context.Context, data io.Reader, recipient, token string) error {
sn.log.Info("Uploading to slack via file.upload API")
headers, uploadBody, err := sn.generateFileUploadBody(data, token, recipient)
if err != nil {
return err
}
cmd := &models.SendWebhookSync{
Url: sn.ImageUploadURL, Body: uploadBody.String(), HttpHeader: headers, HttpMethod: "POST",
}
if err := sn.webhookSender.SendWebhookSync(ctx, cmd); err != nil {
sn.log.Error("Failed to upload slack image", "error", err, "webhook", "file.upload")
return err
}
return nil
}
func (sn *SlackNotifier) generateFileUploadBody(data io.Reader, token string, recipient string) (map[string]string, bytes.Buffer, error) {
// Slack requires all POSTs to files.upload to present
// an "application/x-www-form-urlencoded" encoded querystring
// See https://api.slack.com/methods/files.upload
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
sn.log.Warn("Failed to close multipart writer", "err", err)
}
}()
// TODO: perhaps we should pass the filename through to here to use the local name.
// https://github.com/grafana/grafana/issues/49375
fw, err := w.CreateFormFile("file", fmt.Sprintf("screenshot-%v", rand.Intn(2e6)))
if err != nil {
return nil, b, err
}
if _, err := io.Copy(fw, data); err != nil {
return nil, b, err
}
// Add the authorization token
if err := w.WriteField("token", token); err != nil {
return nil, b, err
}
// Add the channel(s) to POST to
if err := w.WriteField("channels", recipient); err != nil {
return nil, b, err
}
if err := w.Close(); err != nil {
return nil, b, fmt.Errorf("failed to close multipart writer: %w", err)
}
headers := map[string]string{
"Content-Type": w.FormDataContentType(),
"Authorization": "auth_token=\"" + token + "\"",
}
return headers, b, nil
}

@ -204,16 +204,21 @@ func TestSlackNotifier(t *testing.T) {
require.NoError(t, err)
secureSettings := make(map[string][]byte)
m := &NotificationChannelConfig{
Name: "slack_testing",
Type: "slack",
Settings: settingsJSON,
SecureSettings: secureSettings,
}
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
decryptFn := secretsService.GetDecryptedValue
cfg, err := NewSlackConfig(m, decryptFn)
fc := FactoryConfig{
Config: &NotificationChannelConfig{
Name: "slack_testing",
Type: "slack",
Settings: settingsJSON,
SecureSettings: secureSettings,
},
ImageStore: &UnavailableImageStore{},
NotificationService: mockNotificationService(),
DecryptFunc: decryptFn,
}
cfg, err := NewSlackConfig(fc)
if c.expInitError != "" {
require.Error(t, err)
require.Equal(t, c.expInitError, err.Error())
@ -246,7 +251,7 @@ func TestSlackNotifier(t *testing.T) {
ctx := notify.WithGroupKey(context.Background(), "alertname")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
pn := NewSlackNotifier(cfg, tmpl)
pn := NewSlackNotifier(cfg, fc.ImageStore, fc.NotificationService, tmpl)
ok, err := pn.Notify(ctx, c.alerts...)
if c.expMsgError != nil {
require.Error(t, err)

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
@ -16,6 +17,7 @@ import (
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/components/simplejson"
@ -25,13 +27,34 @@ const (
FooterIconURL = "https://grafana.com/assets/img/fav32.png"
ColorAlertFiring = "#D63232"
ColorAlertResolved = "#36a64f"
// ImageStoreTimeout should be used by all callers for calles to `Images`
ImageStoreTimeout time.Duration = 500 * time.Millisecond
)
var (
// Provides current time. Can be overwritten in tests.
timeNow = time.Now
timeNow = time.Now
ErrImagesUnavailable = errors.New("alert screenshots are unavailable")
)
func getTokenFromAnnotations(annotations model.LabelSet) string {
if value, ok := annotations[models.ScreenshotTokenAnnotation]; ok {
return string(value)
}
return ""
}
type UnavailableImageStore struct{}
func (n *UnavailableImageStore) GetURL(ctx context.Context, token string) (string, error) {
return "", ErrImagesUnavailable
}
func (n *UnavailableImageStore) GetData(ctx context.Context, token string) (io.ReadCloser, error) {
return nil, ErrImagesUnavailable
}
type receiverInitError struct {
Reason string
Err error

@ -44,7 +44,7 @@ type MultiOrgAlertmanager struct {
peer ClusterPeer
settleCancel context.CancelFunc
configStore store.AlertingStore
configStore AlertingStore
orgStore store.OrgStore
kvStore kvstore.KVStore
@ -54,7 +54,7 @@ type MultiOrgAlertmanager struct {
ns notifications.Service
}
func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore store.AlertingStore, orgStore store.OrgStore,
func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore AlertingStore, orgStore store.OrgStore,
kvStore kvstore.KVStore, provStore provisioning.ProvisioningStore, decryptFn channels.GetDecryptedValueFn,
m *metrics.MultiOrgAlertmanager, ns notifications.Service, l log.Logger, s secrets.Service,
) (*MultiOrgAlertmanager, error) {

@ -5,6 +5,7 @@ import (
"crypto/md5"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
@ -18,6 +19,16 @@ type FakeConfigStore struct {
configs map[int64]*models.AlertConfiguration
}
func (f *FakeConfigStore) GetURL(ctx context.Context, token string) (string, error) {
return "", store.ErrImageNotFound
}
// Returns an io.ReadCloser that reads out the image data for the provided
// token, if available. May return ErrImageNotFound.
func (f *FakeConfigStore) GetData(ctx context.Context, token string) (io.ReadCloser, error) {
return nil, store.ErrImageNotFound
}
func NewFakeConfigStore(t *testing.T, configs map[int64]*models.AlertConfiguration) FakeConfigStore {
t.Helper()

@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"time"
"github.com/gofrs/uuid"
@ -36,6 +38,12 @@ type ImageStore interface {
// Saves the image or returns an error.
SaveImage(ctx context.Context, img *Image) error
GetURL(ctx context.Context, token string) (string, error)
// Returns an io.ReadCloser that reads out the image data for the provided
// token, if available. May return ErrImageNotFound.
GetData(ctx context.Context, token string) (io.ReadCloser, error)
}
func (st DBstore) GetImage(ctx context.Context, token string) (*Image, error) {
@ -83,6 +91,35 @@ func (st DBstore) SaveImage(ctx context.Context, img *Image) error {
})
}
func (st *DBstore) GetURL(ctx context.Context, token string) (string, error) {
img, err := st.GetImage(ctx, token)
if err != nil {
return "", err
}
return img.URL, nil
}
func (st *DBstore) GetData(ctx context.Context, token string) (io.ReadCloser, error) {
// TODO: Should we support getting data from image.URL? One could configure
// the system to upload to S3 while still reading data for notifiers like
// Slack that take multipart uploads.
img, err := st.GetImage(ctx, token)
if err != nil {
return nil, err
}
if len(img.Path) == 0 {
return nil, ErrImageNotFound
}
f, err := os.Open(img.Path)
if err != nil {
return nil, err
}
return f, nil
}
//nolint:unused
func (st DBstore) DeleteExpiredImages(ctx context.Context) error {
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {

@ -475,7 +475,7 @@ func (m *migration) validateAlertmanagerConfig(orgID int64, config *PostableUser
if !exists {
return fmt.Errorf("notifier %s is not supported", gr.Type)
}
factoryConfig, err := channels.NewFactoryConfig(cfg, nil, decryptFunc, nil)
factoryConfig, err := channels.NewFactoryConfig(cfg, nil, decryptFunc, nil, nil)
if err != nil {
return err
}

Loading…
Cancel
Save