From 12c25759da24895009ac522157825c45c4f8ace3 Mon Sep 17 00:00:00 2001 From: Joe Blubaugh Date: Mon, 23 May 2022 14:24:20 +0800 Subject: [PATCH] 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. --- .../definitions/provisioning_contactpoints.go | 2 +- pkg/services/ngalert/image/service.go | 2 + pkg/services/ngalert/notifier/alertmanager.go | 11 +- .../ngalert/notifier/channels/factory.go | 19 +- .../ngalert/notifier/channels/slack.go | 169 +++++++++++++++--- .../ngalert/notifier/channels/slack_test.go | 23 ++- .../ngalert/notifier/channels/utils.go | 25 ++- .../ngalert/notifier/multiorg_alertmanager.go | 4 +- pkg/services/ngalert/notifier/testing.go | 11 ++ pkg/services/ngalert/store/image.go | 37 ++++ .../sqlstore/migrations/ualert/ualert.go | 2 +- 11 files changed, 263 insertions(+), 42 deletions(-) diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go index 52f331b7b50..32c42932a79 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.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 } diff --git a/pkg/services/ngalert/image/service.go b/pkg/services/ngalert/image/service.go index 10de73a987d..4af73af9a2f 100644 --- a/pkg/services/ngalert/image/service.go +++ b/pkg/services/ngalert/image/service.go @@ -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) } diff --git a/pkg/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index a42d72527a9..7d1ff55c89b 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -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, diff --git a/pkg/services/ngalert/notifier/channels/factory.go b/pkg/services/ngalert/notifier/channels/factory.go index 03059480a6b..69523fffc96 100644 --- a/pkg/services/ngalert/notifier/channels/factory.go +++ b/pkg/services/ngalert/notifier/channels/factory.go @@ -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 } diff --git a/pkg/services/ngalert/notifier/channels/slack.go b/pkg/services/ngalert/notifier/channels/slack.go index 05fe3a48b61..9a5e9e67e68 100644 --- a/pkg/services/ngalert/notifier/channels/slack.go +++ b/pkg/services/ngalert/notifier/channels/slack.go @@ -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 +} diff --git a/pkg/services/ngalert/notifier/channels/slack_test.go b/pkg/services/ngalert/notifier/channels/slack_test.go index 2389d7f85aa..95e7d3db1ec 100644 --- a/pkg/services/ngalert/notifier/channels/slack_test.go +++ b/pkg/services/ngalert/notifier/channels/slack_test.go @@ -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) diff --git a/pkg/services/ngalert/notifier/channels/utils.go b/pkg/services/ngalert/notifier/channels/utils.go index 4334a2a5262..172b648ba3b 100644 --- a/pkg/services/ngalert/notifier/channels/utils.go +++ b/pkg/services/ngalert/notifier/channels/utils.go @@ -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 diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 6764619121d..0d5475b0efe 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -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) { diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 79f35e464bc..e24f293d58b 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -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() diff --git a/pkg/services/ngalert/store/image.go b/pkg/services/ngalert/store/image.go index 4dcdea571d2..c05e49462bd 100644 --- a/pkg/services/ngalert/store/image.go +++ b/pkg/services/ngalert/store/image.go @@ -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 { diff --git a/pkg/services/sqlstore/migrations/ualert/ualert.go b/pkg/services/sqlstore/migrations/ualert/ualert.go index 8ecadd7bfc0..7d118270d9b 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert.go @@ -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 }