From ccd160a75ebb11f7a41160afcf0917b84bdd57c5 Mon Sep 17 00:00:00 2001 From: Joe Blubaugh Date: Mon, 23 May 2022 23:08:28 +0800 Subject: [PATCH] Alerting: Add image url or file attachment to email notifications. (#49381) If an image token is present in an alert instance, the email notifier will attempt to find a public URL for the image token. If found, it will add that to the email as the `ImageLink` field. If only local file data is available, the notifier will attach the file to the outgoing email using the `EmbeddedImage` field. --- .../ngalert/notifier/channels/email.go | 42 ++++++++++++++++++- .../ngalert/notifier/channels/email_test.go | 4 +- .../ngalert/notifier/channels/factory.go | 1 + .../ngalert/notifier/channels/utils.go | 4 ++ pkg/services/ngalert/notifier/testing.go | 4 ++ pkg/services/ngalert/store/image.go | 10 +++++ 6 files changed, 61 insertions(+), 4 deletions(-) diff --git a/pkg/services/ngalert/notifier/channels/email.go b/pkg/services/ngalert/notifier/channels/email.go index c87b88e2049..3120c267516 100644 --- a/pkg/services/ngalert/notifier/channels/email.go +++ b/pkg/services/ngalert/notifier/channels/email.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/url" + "os" "path" "github.com/prometheus/alertmanager/template" @@ -24,6 +25,7 @@ type EmailNotifier struct { Message string log log.Logger ns notifications.EmailSender + images ImageStore tmpl *template.Template } @@ -42,7 +44,7 @@ func EmailFactory(fc FactoryConfig) (NotificationChannel, error) { Cfg: *fc.Config, } } - return NewEmailNotifier(cfg, fc.NotificationService, fc.Template), nil + return NewEmailNotifier(cfg, fc.NotificationService, fc.ImageStore, fc.Template), nil } func NewEmailConfig(config *NotificationChannelConfig) (*EmailConfig, error) { @@ -62,7 +64,7 @@ func NewEmailConfig(config *NotificationChannelConfig) (*EmailConfig, error) { // NewEmailNotifier is the constructor function // for the EmailNotifier. -func NewEmailNotifier(config *EmailConfig, ns notifications.EmailSender, t *template.Template) *EmailNotifier { +func NewEmailNotifier(config *EmailConfig, ns notifications.EmailSender, images ImageStore, t *template.Template) *EmailNotifier { return &EmailNotifier{ Base: NewBase(&models.AlertNotification{ Uid: config.UID, @@ -76,6 +78,7 @@ func NewEmailNotifier(config *EmailConfig, ns notifications.EmailSender, t *temp Message: config.Message, log: log.New("alerting.notifier.email"), ns: ns, + images: images, tmpl: t, } } @@ -121,6 +124,41 @@ func (en *EmailNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, }, } + // TODO: modify the email sender code to support multiple file or image URL + // fields. We cannot use images from every alert yet. + imgToken := getTokenFromAnnotations(as[0].Annotations) + if len(imgToken) != 0 { + timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout) + imgURL, err := en.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. + en.log.Warn("failed to retrieve image url from store", "error", err) + } + } else if len(imgURL) > 0 { + cmd.Data["ImageLink"] = imgURL + } else { // Try to upload + timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout) + imgPath, err := en.images.GetFilepath(timeoutCtx, imgToken) + cancel() + if err != nil { + if !errors.Is(err, ErrImagesUnavailable) { + // Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist. + en.log.Warn("failed to retrieve image url from store", "error", err) + } + } else if len(imgPath) != 0 { + file, err := os.Stat(imgPath) + if err == nil { + cmd.EmbeddedFiles = []string{imgPath} + cmd.Data["EmbeddedImage"] = file.Name() + } else { + en.log.Warn("failed to access email notification image attachment data", "error", err) + } + } + } + } + if tmplErr != nil { en.log.Warn("failed to template email message", "err", tmplErr.Error()) } diff --git a/pkg/services/ngalert/notifier/channels/email_test.go b/pkg/services/ngalert/notifier/channels/email_test.go index a363ea2b9e6..35954447f7e 100644 --- a/pkg/services/ngalert/notifier/channels/email_test.go +++ b/pkg/services/ngalert/notifier/channels/email_test.go @@ -52,7 +52,7 @@ func TestEmailNotifier(t *testing.T) { Settings: settingsJSON, }) require.NoError(t, err) - emailNotifier := NewEmailNotifier(cfg, emailSender, tmpl) + emailNotifier := NewEmailNotifier(cfg, emailSender, &UnavailableImageStore{}, tmpl) alerts := []*types.Alert{ { @@ -289,7 +289,7 @@ func createSut(t *testing.T, messageTmpl string, emailTmpl *template.Template, n Settings: settingsJSON, }) require.NoError(t, err) - emailNotifier := NewEmailNotifier(cfg, ns, emailTmpl) + emailNotifier := NewEmailNotifier(cfg, ns, &UnavailableImageStore{}, emailTmpl) return emailNotifier } diff --git a/pkg/services/ngalert/notifier/channels/factory.go b/pkg/services/ngalert/notifier/channels/factory.go index 69523fffc96..35334786d87 100644 --- a/pkg/services/ngalert/notifier/channels/factory.go +++ b/pkg/services/ngalert/notifier/channels/factory.go @@ -22,6 +22,7 @@ type FactoryConfig struct { // A specialization of store.ImageStore, to avoid an import loop. type ImageStore interface { GetURL(ctx context.Context, token string) (string, error) + GetFilepath(ctx context.Context, token string) (string, error) GetData(ctx context.Context, token string) (io.ReadCloser, error) } diff --git a/pkg/services/ngalert/notifier/channels/utils.go b/pkg/services/ngalert/notifier/channels/utils.go index 172b648ba3b..688d13f6a3c 100644 --- a/pkg/services/ngalert/notifier/channels/utils.go +++ b/pkg/services/ngalert/notifier/channels/utils.go @@ -51,6 +51,10 @@ func (n *UnavailableImageStore) GetURL(ctx context.Context, token string) (strin return "", ErrImagesUnavailable } +func (n *UnavailableImageStore) GetFilepath(ctx context.Context, token string) (string, error) { + return "", ErrImagesUnavailable +} + func (n *UnavailableImageStore) GetData(ctx context.Context, token string) (io.ReadCloser, error) { return nil, ErrImagesUnavailable } diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index e24f293d58b..1cfc41d1aee 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -23,6 +23,10 @@ func (f *FakeConfigStore) GetURL(ctx context.Context, token string) (string, err return "", store.ErrImageNotFound } +func (f *FakeConfigStore) GetFilepath(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) { diff --git a/pkg/services/ngalert/store/image.go b/pkg/services/ngalert/store/image.go index c05e49462bd..49a3781ee8c 100644 --- a/pkg/services/ngalert/store/image.go +++ b/pkg/services/ngalert/store/image.go @@ -41,6 +41,8 @@ type ImageStore interface { GetURL(ctx context.Context, token string) (string, error) + GetFilepath(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) @@ -99,6 +101,14 @@ func (st *DBstore) GetURL(ctx context.Context, token string) (string, error) { return img.URL, nil } +func (st *DBstore) GetFilepath(ctx context.Context, token string) (string, error) { + img, err := st.GetImage(ctx, token) + if err != nil { + return "", err + } + return img.Path, 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