diff --git a/pkg/services/ngalert/notifier/channels/pushover.go b/pkg/services/ngalert/notifier/channels/pushover.go index 45004139119..0f6fb170ec4 100644 --- a/pkg/services/ngalert/notifier/channels/pushover.go +++ b/pkg/services/ngalert/notifier/channels/pushover.go @@ -5,7 +5,9 @@ import ( "context" "errors" "fmt" + "io" "mime/multipart" + "os" "strconv" "github.com/prometheus/alertmanager/template" @@ -14,9 +16,14 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/notifications" ) +const ( + pushoverMaxFileSize = 1 << 21 // 2MB +) + var ( PushoverEndpoint = "https://api.pushover.net/1/messages.json" ) @@ -38,6 +45,7 @@ type PushoverNotifier struct { Message string tmpl *template.Template log log.Logger + images ImageStore ns notifications.WebhookSender } @@ -64,7 +72,7 @@ func PushoverFactory(fc FactoryConfig) (NotificationChannel, error) { Cfg: *fc.Config, } } - return NewPushoverNotifier(cfg, fc.NotificationService, fc.Template), nil + return NewPushoverNotifier(cfg, fc.ImageStore, fc.NotificationService, fc.Template), nil } func NewPushoverConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedValueFn) (*PushoverConfig, error) { @@ -103,7 +111,8 @@ func NewPushoverConfig(config *NotificationChannelConfig, decryptFunc GetDecrypt } // NewSlackNotifier is the constructor for the Slack notifier -func NewPushoverNotifier(config *PushoverConfig, ns notifications.WebhookSender, t *template.Template) *PushoverNotifier { +func NewPushoverNotifier(config *PushoverConfig, images ImageStore, + ns notifications.WebhookSender, t *template.Template) *PushoverNotifier { return &PushoverNotifier{ Base: NewBase(&models.AlertNotification{ Uid: config.UID, @@ -126,6 +135,7 @@ func NewPushoverNotifier(config *PushoverConfig, ns notifications.WebhookSender, Message: config.Message, tmpl: t, log: log.New("alerting.notifier.pushover"), + images: images, ns: ns, } } @@ -157,114 +167,131 @@ func (pn *PushoverNotifier) SendResolved() bool { } func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Alert) (map[string]string, bytes.Buffer, error) { - var b bytes.Buffer - - ruleURL := joinUrlPath(pn.tmpl.ExternalURL.String(), "/alerting/list", pn.log) - - alerts := types.Alerts(as...) - - var tmplErr error - tmpl, _ := TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) - + b := bytes.Buffer{} w := multipart.NewWriter(&b) - boundary := GetBoundary() - if boundary != "" { + + // tests use a non-random boundary separator + if boundary := GetBoundary(); boundary != "" { err := w.SetBoundary(boundary) if err != nil { return nil, b, err } } - // Add the user token - err := w.WriteField("user", tmpl(pn.UserKey)) - if err != nil { - return nil, b, err + var tmplErr error + tmpl, _ := TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) + + if err := w.WriteField("user", tmpl(pn.UserKey)); err != nil { + return nil, b, fmt.Errorf("failed to write the user: %w", err) } - // Add the api token - err = w.WriteField("token", pn.APIToken) - if err != nil { - return nil, b, err + if err := w.WriteField("token", pn.APIToken); err != nil { + return nil, b, fmt.Errorf("failed to write the token: %w", err) } - // Add priority + status := types.Alerts(as...).Status() priority := pn.AlertingPriority - if alerts.Status() == model.AlertResolved { + if status == model.AlertResolved { priority = pn.OKPriority } - err = w.WriteField("priority", strconv.Itoa(priority)) - if err != nil { - return nil, b, err + if err := w.WriteField("priority", strconv.Itoa(priority)); err != nil { + return nil, b, fmt.Errorf("failed to write the priority: %w", err) } if priority == 2 { - err = w.WriteField("retry", strconv.Itoa(pn.Retry)) - if err != nil { - return nil, b, err + if err := w.WriteField("retry", strconv.Itoa(pn.Retry)); err != nil { + return nil, b, fmt.Errorf("failed to write retry: %w", err) } - err = w.WriteField("expire", strconv.Itoa(pn.Expire)) - if err != nil { - return nil, b, err + if err := w.WriteField("expire", strconv.Itoa(pn.Expire)); err != nil { + return nil, b, fmt.Errorf("failed to write expire: %w", err) } } - // Add device if pn.Device != "" { - err = w.WriteField("device", tmpl(pn.Device)) - if err != nil { - return nil, b, err + if err := w.WriteField("device", tmpl(pn.Device)); err != nil { + return nil, b, fmt.Errorf("failed to write the device: %w", err) } } - // Add sound - sound := tmpl(pn.AlertingSound) - if alerts.Status() == model.AlertResolved { + if err := w.WriteField("title", tmpl(DefaultMessageTitleEmbed)); err != nil { + return nil, b, fmt.Errorf("failed to write the title: %w", err) + } + + ruleURL := joinUrlPath(pn.tmpl.ExternalURL.String(), "/alerting/list", pn.log) + if err := w.WriteField("url", ruleURL); err != nil { + return nil, b, fmt.Errorf("failed to write the URL: %w", err) + } + + if err := w.WriteField("url_title", "Show alert rule"); err != nil { + return nil, b, fmt.Errorf("failed to write the URL title: %w", err) + } + + if err := w.WriteField("message", tmpl(pn.Message)); err != nil { + return nil, b, fmt.Errorf("failed write the message: %w", err) + } + + // Pushover supports at most one image attachment with a maximum size of pushoverMaxFileSize. + // If the image is larger than pushoverMaxFileSize then return an error. + _ = withStoredImages(ctx, pn.log, pn.images, func(index int, image *ngmodels.Image) error { + if image != nil { + f, err := os.Open(image.Path) + if err != nil { + return fmt.Errorf("failed to open the image: %w", err) + } + defer func() { + if err := f.Close(); err != nil { + pn.log.Error("failed to close the image", "file", image.Path) + } + }() + + fileInfo, err := f.Stat() + if err != nil { + return fmt.Errorf("failed to stat the image: %w", err) + } + + if fileInfo.Size() > pushoverMaxFileSize { + return fmt.Errorf("image would exceeded maximum file size: %d", fileInfo.Size()) + } + + fw, err := w.CreateFormFile("attachment", image.Path) + if err != nil { + return fmt.Errorf("failed to create form file for the image: %w", err) + } + + if _, err = io.Copy(fw, f); err != nil { + return fmt.Errorf("failed to copy the image to the form file: %w", err) + } + + return ErrImagesDone + } + return nil + }, as...) + + var sound string + if status == model.AlertResolved { sound = tmpl(pn.OKSound) + } else { + sound = tmpl(pn.AlertingSound) } if sound != "default" { - err = w.WriteField("sound", sound) - if err != nil { - return nil, b, err + if err := w.WriteField("sound", sound); err != nil { + return nil, b, fmt.Errorf("failed to write the sound: %w", err) } } - // Add title - err = w.WriteField("title", tmpl(DefaultMessageTitleEmbed)) - if err != nil { - return nil, b, err + // Mark the message as HTML + if err := w.WriteField("html", "1"); err != nil { + return nil, b, fmt.Errorf("failed to mark the message as HTML: %w", err) } - - // Add URL - err = w.WriteField("url", ruleURL) - if err != nil { - return nil, b, err - } - // Add URL title - err = w.WriteField("url_title", "Show alert rule") - if err != nil { - return nil, b, err - } - - // Add message - err = w.WriteField("message", tmpl(pn.Message)) - if err != nil { - return nil, b, err + if err := w.Close(); err != nil { + return nil, b, fmt.Errorf("failed to close the multipart request: %w", err) } if tmplErr != nil { pn.log.Warn("failed to template pushover message", "err", tmplErr.Error()) } - // Mark as html message - err = w.WriteField("html", "1") - if err != nil { - return nil, b, err - } - if err := w.Close(); err != nil { - return nil, b, err - } - headers := map[string]string{ "Content-Type": w.FormDataContentType(), } diff --git a/pkg/services/ngalert/notifier/channels/pushover_test.go b/pkg/services/ngalert/notifier/channels/pushover_test.go index 29e1a35e33d..2d69076a3b1 100644 --- a/pkg/services/ngalert/notifier/channels/pushover_test.go +++ b/pkg/services/ngalert/notifier/channels/pushover_test.go @@ -25,6 +25,8 @@ import ( func TestPushoverNotifier(t *testing.T) { tmpl := templateForTests(t) + images := newFakeImageStoreWithFile(t, 2) + externalURL, err := url.Parse("http://localhost") require.NoError(t, err) tmpl.ExternalURL = externalURL @@ -38,7 +40,7 @@ func TestPushoverNotifier(t *testing.T) { expMsgError error }{ { - name: "Correct config with one alert", + name: "Correct config with single alert", settings: `{ "userKey": "", "apiToken": "" @@ -47,20 +49,21 @@ func TestPushoverNotifier(t *testing.T) { { Alert: model.Alert{ Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertScreenshotToken__": "test-image-1"}, }, }, }, expMsg: map[string]string{ - "user": "", - "token": "", - "priority": "0", - "sound": "", - "title": "[FIRING:1] (val1)", - "url": "http://localhost/alerting/list", - "url_title": "Show alert rule", - "message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", - "html": "1", + "user": "", + "token": "", + "priority": "0", + "sound": "", + "title": "[FIRING:1] (val1)", + "url": "http://localhost/alerting/list", + "url_title": "Show alert rule", + "message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", + "html": "1", }, expMsgError: nil, }, @@ -82,28 +85,29 @@ func TestPushoverNotifier(t *testing.T) { { Alert: model.Alert{ Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__alertScreenshotToken__": "test-image-1"}, }, }, { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2"}, + Annotations: model.LabelSet{"ann1": "annv2", "__alertScreenshotToken__": "test-image-2"}, }, }, }, expMsg: map[string]string{ - "user": "", - "token": "", - "priority": "2", - "sound": "echo", - "title": "[FIRING:2] ", - "url": "http://localhost/alerting/list", - "url_title": "Show alert rule", - "message": "2 alerts are firing, 0 are resolved", - "html": "1", - "retry": "30", - "expire": "86400", - "device": "device", + "user": "", + "token": "", + "priority": "2", + "sound": "echo", + "title": "[FIRING:2] ", + "url": "http://localhost/alerting/list", + "url_title": "Show alert rule", + "message": "2 alerts are firing, 0 are resolved", + "attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", + "html": "1", + "retry": "30", + "expire": "86400", + "device": "device", }, expMsgError: nil, }, @@ -157,7 +161,7 @@ func TestPushoverNotifier(t *testing.T) { ctx := notify.WithGroupKey(context.Background(), "alertname") ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - pn := NewPushoverNotifier(cfg, webhookSender, tmpl) + pn := NewPushoverNotifier(cfg, images, webhookSender, tmpl) ok, err := pn.Notify(ctx, c.alerts...) if c.expMsgError != nil { require.Error(t, err)