[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 imports
This commit is contained in:
Sofia Papagiannaki 2021-05-19 17:48:46 +03:00 committed by GitHub
parent 1d2febfa85
commit a79a4838b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 1028 additions and 0 deletions

View File

@ -412,6 +412,8 @@ func (am *Alertmanager) buildReceiverIntegrations(receiver *apimodels.PostableAp
n, err = channels.NewEmailNotifier(cfg, tmpl) // Email notifier already has a default template.
case "pagerduty":
n, err = channels.NewPagerdutyNotifier(cfg, tmpl)
case "pushover":
n, err = channels.NewPushoverNotifier(cfg, tmpl)
case "slack":
n, err = channels.NewSlackNotifier(cfg, tmpl)
case "telegram":

View File

@ -4,6 +4,103 @@ import "github.com/grafana/grafana/pkg/services/alerting"
// GetAvailableNotifiers returns the metadata of all the notification channels that can be configured.
func GetAvailableNotifiers() []*alerting.NotifierPlugin {
pushoverSoundOptions := []alerting.SelectOption{
{
Value: "default",
Label: "Default",
},
{
Value: "pushover",
Label: "Pushover",
}, {
Value: "bike",
Label: "Bike",
}, {
Value: "bugle",
Label: "Bugle",
}, {
Value: "cashregister",
Label: "Cashregister",
}, {
Value: "classical",
Label: "Classical",
}, {
Value: "cosmic",
Label: "Cosmic",
}, {
Value: "falling",
Label: "Falling",
}, {
Value: "gamelan",
Label: "Gamelan",
}, {
Value: "incoming",
Label: "Incoming",
}, {
Value: "intermission",
Label: "Intermission",
}, {
Value: "magic",
Label: "Magic",
}, {
Value: "mechanical",
Label: "Mechanical",
}, {
Value: "pianobar",
Label: "Pianobar",
}, {
Value: "siren",
Label: "Siren",
}, {
Value: "spacealarm",
Label: "Spacealarm",
}, {
Value: "tugboat",
Label: "Tugboat",
}, {
Value: "alien",
Label: "Alien",
}, {
Value: "climb",
Label: "Climb",
}, {
Value: "persistent",
Label: "Persistent",
}, {
Value: "echo",
Label: "Echo",
}, {
Value: "updown",
Label: "Updown",
}, {
Value: "none",
Label: "None",
},
}
pushoverPriorityOptions := []alerting.SelectOption{
{
Value: "2",
Label: "Emergency",
},
{
Value: "1",
Label: "High",
},
{
Value: "0",
Label: "Normal",
},
{
Value: "-1",
Label: "Low",
},
{
Value: "-2",
Label: "Lowest",
},
}
return []*alerting.NotifierPlugin{
{
Type: "dingding",
@ -137,6 +234,85 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin {
},
},
},
{
Type: "pushover",
Name: "Pushover",
Description: "Sends HTTP POST request to the Pushover API",
Heading: "Pushover settings",
Options: []alerting.NotifierOption{
{
Label: "API Token",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "Application token",
PropertyName: "apiToken",
Required: true,
Secure: true,
},
{
Label: "User key(s)",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "comma-separated list",
PropertyName: "userKey",
Required: true,
Secure: true,
},
{
Label: "Device(s) (optional)",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "comma-separated list; leave empty to send to all devices",
PropertyName: "device",
},
{
Label: "Alerting priority",
Element: alerting.ElementTypeSelect,
SelectOptions: pushoverPriorityOptions,
PropertyName: "priority",
},
{
Label: "OK priority",
Element: alerting.ElementTypeSelect,
SelectOptions: pushoverPriorityOptions,
PropertyName: "okPriority",
},
{
Description: "How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.",
Label: "Retry (Only used for Emergency Priority)",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "minimum 30 seconds",
PropertyName: "retry",
},
{
Description: "How many seconds the alerting or OK notification will continue to be retried.",
Label: "Expire (Only used for Emergency Priority)",
Element: alerting.ElementTypeInput,
InputType: alerting.InputTypeText,
Placeholder: "maximum 86400 seconds",
PropertyName: "expire",
},
{
Label: "Alerting sound",
Element: alerting.ElementTypeSelect,
SelectOptions: pushoverSoundOptions,
PropertyName: "sound",
},
{
Label: "OK sound",
Element: alerting.ElementTypeSelect,
SelectOptions: pushoverSoundOptions,
PropertyName: "okSound",
},
{ // New in 8.0.
Label: "Message",
Element: alerting.ElementTypeTextArea,
Placeholder: `{{ template "default.message" . }}`,
PropertyName: "message",
},
},
},
{
Type: "slack",
Name: "Slack",

View File

@ -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
}

View File

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

View File

@ -287,6 +287,403 @@ var expAvailableChannelJsonOutput = `
}
]
},
{
"type": "pushover",
"name": "Pushover",
"description": "Sends HTTP POST request to the Pushover API",
"heading": "Pushover settings",
"info": "",
"options": [
{
"element": "input",
"inputType": "text",
"label": "API Token",
"description": "",
"placeholder": "Application token",
"propertyName": "apiToken",
"selectOptions": null,
"showWhen": {
"field": "",
"is": ""
},
"required": true,
"validationRule": "",
"secure": true
},
{
"element": "input",
"inputType": "text",
"label": "User key(s)",
"description": "",
"placeholder": "comma-separated list",
"propertyName": "userKey",
"selectOptions": null,
"showWhen": {
"field": "",
"is": ""
},
"required": true,
"validationRule": "",
"secure": true
},
{
"element": "input",
"inputType": "text",
"label": "Device(s) (optional)",
"description": "",
"placeholder": "comma-separated list; leave empty to send to all devices",
"propertyName": "device",
"selectOptions": null,
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false
},
{
"element": "select",
"inputType": "",
"label": "Alerting priority",
"description": "",
"placeholder": "",
"propertyName": "priority",
"selectOptions": [
{
"value": "2",
"label": "Emergency"
},
{
"value": "1",
"label": "High"
},
{
"value": "0",
"label": "Normal"
},
{
"value": "-1",
"label": "Low"
},
{
"value": "-2",
"label": "Lowest"
}
],
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false
},
{
"element": "select",
"inputType": "",
"label": "OK priority",
"description": "",
"placeholder": "",
"propertyName": "okPriority",
"selectOptions": [
{
"value": "2",
"label": "Emergency"
},
{
"value": "1",
"label": "High"
},
{
"value": "0",
"label": "Normal"
},
{
"value": "-1",
"label": "Low"
},
{
"value": "-2",
"label": "Lowest"
}
],
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false
},
{
"element": "input",
"inputType": "text",
"label": "Retry (Only used for Emergency Priority)",
"description": "How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.",
"placeholder": "minimum 30 seconds",
"propertyName": "retry",
"selectOptions": null,
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false
},
{
"element": "input",
"inputType": "text",
"label": "Expire (Only used for Emergency Priority)",
"description": "How many seconds the alerting or OK notification will continue to be retried.",
"placeholder": "maximum 86400 seconds",
"propertyName": "expire",
"selectOptions": null,
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false
},
{
"element": "select",
"inputType": "",
"label": "Alerting sound",
"description": "",
"placeholder": "",
"propertyName": "sound",
"selectOptions": [
{
"value": "default",
"label": "Default"
},
{
"value": "pushover",
"label": "Pushover"
},
{
"value": "bike",
"label": "Bike"
},
{
"value": "bugle",
"label": "Bugle"
},
{
"value": "cashregister",
"label": "Cashregister"
},
{
"value": "classical",
"label": "Classical"
},
{
"value": "cosmic",
"label": "Cosmic"
},
{
"value": "falling",
"label": "Falling"
},
{
"value": "gamelan",
"label": "Gamelan"
},
{
"value": "incoming",
"label": "Incoming"
},
{
"value": "intermission",
"label": "Intermission"
},
{
"value": "magic",
"label": "Magic"
},
{
"value": "mechanical",
"label": "Mechanical"
},
{
"value": "pianobar",
"label": "Pianobar"
},
{
"value": "siren",
"label": "Siren"
},
{
"value": "spacealarm",
"label": "Spacealarm"
},
{
"value": "tugboat",
"label": "Tugboat"
},
{
"value": "alien",
"label": "Alien"
},
{
"value": "climb",
"label": "Climb"
},
{
"value": "persistent",
"label": "Persistent"
},
{
"value": "echo",
"label": "Echo"
},
{
"value": "updown",
"label": "Updown"
},
{
"value": "none",
"label": "None"
}
],
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false
},
{
"element": "select",
"inputType": "",
"label": "OK sound",
"description": "",
"placeholder": "",
"propertyName": "okSound",
"selectOptions": [
{
"value": "default",
"label": "Default"
},
{
"value": "pushover",
"label": "Pushover"
},
{
"value": "bike",
"label": "Bike"
},
{
"value": "bugle",
"label": "Bugle"
},
{
"value": "cashregister",
"label": "Cashregister"
},
{
"value": "classical",
"label": "Classical"
},
{
"value": "cosmic",
"label": "Cosmic"
},
{
"value": "falling",
"label": "Falling"
},
{
"value": "gamelan",
"label": "Gamelan"
},
{
"value": "incoming",
"label": "Incoming"
},
{
"value": "intermission",
"label": "Intermission"
},
{
"value": "magic",
"label": "Magic"
},
{
"value": "mechanical",
"label": "Mechanical"
},
{
"value": "pianobar",
"label": "Pianobar"
},
{
"value": "siren",
"label": "Siren"
},
{
"value": "spacealarm",
"label": "Spacealarm"
},
{
"value": "tugboat",
"label": "Tugboat"
},
{
"value": "alien",
"label": "Alien"
},
{
"value": "climb",
"label": "Climb"
},
{
"value": "persistent",
"label": "Persistent"
},
{
"value": "echo",
"label": "Echo"
},
{
"value": "updown",
"label": "Updown"
},
{
"value": "none",
"label": "None"
}
],
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false
},
{
"element": "textarea",
"inputType": "",
"label": "Message",
"description": "",
"placeholder": "{{ template \"default.message\" . }}",
"propertyName": "message",
"selectOptions": null,
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false
}
]
},
{
"type": "slack",
"name": "Slack",