Alerting: Add support for images in Pushover alerts (#51372)

This commit is contained in:
George Robinson 2022-06-28 09:40:01 +01:00 committed by GitHub
parent 5053468c65
commit f04dfc589c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 128 additions and 97 deletions

View File

@ -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(),
}

View File

@ -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": "<userKey>",
"apiToken": "<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": "<userKey>",
"token": "<apiToken>",
"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": "<userKey>",
"token": "<apiToken>",
"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": "<userKey>",
"token": "<apiToken>",
"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": "<userKey>",
"token": "<apiToken>",
"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)