mirror of https://github.com/grafana/grafana
[Alerting]: Add Pushover integration with the alert manager (#34371)
* [Alerting]: Add Pushover integration with the alert manager * lint * Set boundary only for tests * Remove title field * fix importspull/34150/head^2
parent
1d2febfa85
commit
a79a4838b8
@ -0,0 +1,250 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"fmt" |
||||
"mime/multipart" |
||||
"net/url" |
||||
"path" |
||||
"strconv" |
||||
|
||||
gokit_log "github.com/go-kit/kit/log" |
||||
"github.com/grafana/grafana/pkg/bus" |
||||
"github.com/grafana/grafana/pkg/infra/log" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
"github.com/grafana/grafana/pkg/services/alerting" |
||||
old_notifiers "github.com/grafana/grafana/pkg/services/alerting/notifiers" |
||||
"github.com/pkg/errors" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
const ( |
||||
PUSHOVERENDPOINT = "https://api.pushover.net/1/messages.json" |
||||
) |
||||
|
||||
// getBoundary is used for overriding the behaviour for tests
|
||||
// and set a boundary
|
||||
var getBoundary = func() string { |
||||
return "" |
||||
} |
||||
|
||||
// PushoverNotifier is responsible for sending
|
||||
// alert notifications to Pushover
|
||||
type PushoverNotifier struct { |
||||
old_notifiers.NotifierBase |
||||
UserKey string |
||||
APIToken string |
||||
AlertingPriority int |
||||
OKPriority int |
||||
Retry int |
||||
Expire int |
||||
Device string |
||||
AlertingSound string |
||||
OKSound string |
||||
Upload bool |
||||
Message string |
||||
tmpl *template.Template |
||||
log log.Logger |
||||
} |
||||
|
||||
// NewSlackNotifier is the constructor for the Slack notifier
|
||||
func NewPushoverNotifier(model *NotificationChannelConfig, t *template.Template) (*PushoverNotifier, error) { |
||||
userKey := model.DecryptedValue("userKey", model.Settings.Get("userKey").MustString()) |
||||
APIToken := model.DecryptedValue("apiToken", model.Settings.Get("apiToken").MustString()) |
||||
device := model.Settings.Get("device").MustString() |
||||
alertingPriority, err := strconv.Atoi(model.Settings.Get("priority").MustString("0")) // default Normal
|
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to convert alerting priority to integer: %w", err) |
||||
} |
||||
okPriority, err := strconv.Atoi(model.Settings.Get("okPriority").MustString("0")) // default Normal
|
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to convert OK priority to integer: %w", err) |
||||
} |
||||
retry, _ := strconv.Atoi(model.Settings.Get("retry").MustString()) |
||||
expire, _ := strconv.Atoi(model.Settings.Get("expire").MustString()) |
||||
alertingSound := model.Settings.Get("sound").MustString() |
||||
okSound := model.Settings.Get("okSound").MustString() |
||||
uploadImage := model.Settings.Get("uploadImage").MustBool(true) |
||||
|
||||
if userKey == "" { |
||||
return nil, alerting.ValidationError{Reason: "user key not found"} |
||||
} |
||||
if APIToken == "" { |
||||
return nil, alerting.ValidationError{Reason: "API token not found"} |
||||
} |
||||
return &PushoverNotifier{ |
||||
NotifierBase: old_notifiers.NewNotifierBase(&models.AlertNotification{ |
||||
Uid: model.UID, |
||||
Name: model.Name, |
||||
Type: model.Type, |
||||
DisableResolveMessage: model.DisableResolveMessage, |
||||
Settings: model.Settings, |
||||
SecureSettings: model.SecureSettings, |
||||
}), |
||||
UserKey: userKey, |
||||
APIToken: APIToken, |
||||
AlertingPriority: alertingPriority, |
||||
OKPriority: okPriority, |
||||
Retry: retry, |
||||
Expire: expire, |
||||
Device: device, |
||||
AlertingSound: alertingSound, |
||||
OKSound: okSound, |
||||
Upload: uploadImage, |
||||
Message: model.Settings.Get("message").MustString(`{{ template "default.message" .}}`), |
||||
tmpl: t, |
||||
log: log.New("alerting.notifier.pushover"), |
||||
}, nil |
||||
} |
||||
|
||||
// Notify sends an alert notification to Slack.
|
||||
func (pn *PushoverNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
headers, uploadBody, err := pn.genPushoverBody(ctx, as...) |
||||
if err != nil { |
||||
pn.log.Error("Failed to generate body for pushover", "error", err) |
||||
return false, err |
||||
} |
||||
|
||||
cmd := &models.SendWebhookSync{ |
||||
Url: PUSHOVERENDPOINT, |
||||
HttpMethod: "POST", |
||||
HttpHeader: headers, |
||||
Body: uploadBody.String(), |
||||
} |
||||
|
||||
if err := bus.DispatchCtx(ctx, cmd); err != nil { |
||||
pn.log.Error("Failed to send pushover notification", "error", err, "webhook", pn.Name) |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
func (pn *PushoverNotifier) SendResolved() bool { |
||||
return !pn.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Alert) (map[string]string, bytes.Buffer, error) { |
||||
var b bytes.Buffer |
||||
|
||||
u, err := url.Parse(pn.tmpl.ExternalURL.String()) |
||||
if err != nil { |
||||
return nil, b, fmt.Errorf("failed to parse ") |
||||
} |
||||
u.Path = path.Join(u.Path, "/alerting/list") |
||||
ruleURL := u.String() |
||||
|
||||
alerts := types.Alerts(as...) |
||||
|
||||
var tmplErr error |
||||
data := notify.GetTemplateData(ctx, pn.tmpl, as, gokit_log.NewNopLogger()) |
||||
tmpl := notify.TmplText(pn.tmpl, data, &tmplErr) |
||||
|
||||
w := multipart.NewWriter(&b) |
||||
boundary := getBoundary() |
||||
if boundary != "" { |
||||
err = w.SetBoundary(boundary) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
} |
||||
|
||||
// Add the user token
|
||||
err = w.WriteField("user", pn.UserKey) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
|
||||
// Add the api token
|
||||
err = w.WriteField("token", pn.APIToken) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
|
||||
// Add priority
|
||||
priority := pn.AlertingPriority |
||||
if alerts.Status() == model.AlertResolved { |
||||
priority = pn.OKPriority |
||||
} |
||||
err = w.WriteField("priority", strconv.Itoa(priority)) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
|
||||
if priority == 2 { |
||||
err = w.WriteField("retry", strconv.Itoa(pn.Retry)) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
|
||||
err = w.WriteField("expire", strconv.Itoa(pn.Expire)) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
} |
||||
|
||||
// Add device
|
||||
if pn.Device != "" { |
||||
err = w.WriteField("device", pn.Device) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
} |
||||
|
||||
// Add sound
|
||||
sound := pn.AlertingSound |
||||
if alerts.Status() == model.AlertResolved { |
||||
sound = pn.OKSound |
||||
} |
||||
if sound != "default" { |
||||
err = w.WriteField("sound", sound) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
} |
||||
|
||||
// Add title
|
||||
err = w.WriteField("title", tmpl(`{{ template "default.title" . }}`)) |
||||
if err != nil { |
||||
return nil, b, 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 tmplErr != nil { |
||||
return nil, b, errors.Wrap(tmplErr, "failed to template pushover message") |
||||
} |
||||
|
||||
// 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(), |
||||
} |
||||
|
||||
return headers, b, nil |
||||
} |
||||
@ -0,0 +1,203 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"mime/multipart" |
||||
"net/url" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/bus" |
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
"github.com/grafana/grafana/pkg/services/alerting" |
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestPushoverNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg map[string]string |
||||
expInitError error |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Correct config with one alert", |
||||
settings: `{ |
||||
"userKey": "<userKey>", |
||||
"apiToken": "<apiToken>" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]string{ |
||||
"user": "<userKey>", |
||||
"token": "<apiToken>", |
||||
"priority": "0", |
||||
"sound": "", |
||||
"title": "[FIRING:1] (rule uid val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"url_title": "Show alert rule", |
||||
"message": "\n**Firing**\nLabels:\n - alertname = alert1\n - __alert_rule_uid__ = rule uid\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: \n\n\n\n\n", |
||||
"html": "1", |
||||
}, |
||||
expInitError: nil, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Custom config with multiple alerts", |
||||
settings: `{ |
||||
"userKey": "<userKey>", |
||||
"apiToken": "<apiToken>", |
||||
"device": "device", |
||||
"priority": "2", |
||||
"okpriority": "0", |
||||
"retry": "30", |
||||
"expire": "86400", |
||||
"sound": "echo", |
||||
"oksound": "magic", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
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", |
||||
}, |
||||
expInitError: nil, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Missing user key", |
||||
settings: `{ |
||||
"apiToken": "<apiToken>" |
||||
}`, |
||||
expInitError: alerting.ValidationError{Reason: "user key not found"}, |
||||
}, { |
||||
name: "Missing api key", |
||||
settings: `{ |
||||
"userKey": "<userKey>" |
||||
}`, |
||||
expInitError: alerting.ValidationError{Reason: "API token not found"}, |
||||
}, { |
||||
name: "Error in building message", |
||||
settings: `{ |
||||
"apiToken": "<apiToken>", |
||||
"userKey": "<userKey>", |
||||
"message": "{{ .BrokenTemplate }" |
||||
}`, |
||||
expMsgError: errors.New("failed to template pushover message: template: :1: unexpected \"}\" in operand"), |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
origGetBoundary := getBoundary |
||||
boundary := "abcd" |
||||
getBoundary = func() string { |
||||
return boundary |
||||
} |
||||
t.Cleanup(func() { |
||||
getBoundary = origGetBoundary |
||||
}) |
||||
|
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON, err := simplejson.NewJson([]byte(c.settings)) |
||||
require.NoError(t, err) |
||||
|
||||
m := &NotificationChannelConfig{ |
||||
Name: "pushover_testing", |
||||
Type: "pushover", |
||||
Settings: settingsJSON, |
||||
} |
||||
|
||||
pn, err := NewPushoverNotifier(m, tmpl) |
||||
if c.expInitError != nil { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
body := "" |
||||
bus.AddHandlerCtx("test", func(ctx context.Context, webhook *models.SendWebhookSync) error { |
||||
body = webhook.Body |
||||
return nil |
||||
}) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := pn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.Error(t, err) |
||||
require.False(t, ok) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
bodyReader := multipart.NewReader(strings.NewReader(body), boundary) |
||||
for { |
||||
part, err := bodyReader.NextPart() |
||||
if part == nil || errors.Is(err, io.EOF) { |
||||
assert.Empty(t, c.expMsg, fmt.Sprintf("expected fields %v", c.expMsg)) |
||||
break |
||||
} |
||||
formField := part.FormName() |
||||
expected, ok := c.expMsg[formField] |
||||
assert.True(t, ok, fmt.Sprintf("unexpected field %s", formField)) |
||||
actual := []byte("") |
||||
if expected != "" { |
||||
buf := new(bytes.Buffer) |
||||
_, err := buf.ReadFrom(part) |
||||
require.NoError(t, err) |
||||
actual = buf.Bytes() |
||||
} |
||||
assert.Equal(t, expected, string(actual)) |
||||
delete(c.expMsg, formField) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue