mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Implement the Webex notifier (#58480)
* Alerting: Implement the Webex notifier Closes https://github.com/grafana/grafana/issues/11750 Signed-off-by: gotjosh <josue.abreu@gmail.com>
This commit is contained in:
parent
1c5039085b
commit
d748979048
@ -632,6 +632,15 @@ The following sections detail the supported settings and secure settings for eac
|
||||
| ---- |
|
||||
| url |
|
||||
|
||||
#### Alert notification `Cisco Webex Teams`
|
||||
|
||||
| Name | Secure setting |
|
||||
| --------- | -------------- |
|
||||
| message | |
|
||||
| room_id | |
|
||||
| api_url | |
|
||||
| bot_token | yes |
|
||||
|
||||
## Grafana Enterprise
|
||||
|
||||
Grafana Enterprise supports provisioning for the following resources:
|
||||
|
@ -44,6 +44,7 @@ The following table lists the contact point types supported by Grafana.
|
||||
| [Threema](https://threema.ch/) | `threema` | Supported | N/A |
|
||||
| [VictorOps](https://help.victorops.com/) | `victorops` | Supported | Supported |
|
||||
| [Webhook](#webhook) | `webhook` | Supported | Supported ([different format](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config)) |
|
||||
| [Cisco Webex Teams](#webex) | `webex` | Supported | Supported |
|
||||
| [WeCom](#wecom) | `wecom` | Supported | N/A |
|
||||
| [Zenduty](https://www.zenduty.com/) | `webhook` | Supported | N/A |
|
||||
|
||||
|
@ -79,6 +79,7 @@ Images in notifications are supported in the following notifiers and additional
|
||||
| Threema | No | No |
|
||||
| VictorOps | No | No |
|
||||
| Webhook | No | Yes |
|
||||
| Cisco Webex Teams | No | Yes |
|
||||
|
||||
Include images from URL refers to using the external image store.
|
||||
|
||||
|
2
go.mod
2
go.mod
@ -48,7 +48,7 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/go-stack/stack v1.8.1
|
||||
github.com/gobwas/glob v0.2.3
|
||||
github.com/gofrs/uuid v4.3.0+incompatible // indirect
|
||||
github.com/gofrs/uuid v4.3.0+incompatible
|
||||
github.com/gogo/protobuf v1.3.2
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/golang/snappy v0.0.4
|
||||
|
@ -5,9 +5,10 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
)
|
||||
|
||||
type FactoryConfig struct {
|
||||
@ -65,6 +66,7 @@ var receiverFactories = map[string]func(FactoryConfig) (NotificationChannel, err
|
||||
"victorops": VictorOpsFactory,
|
||||
"webhook": WebHookFactory,
|
||||
"wecom": WeComFactory,
|
||||
"webex": WebexFactory,
|
||||
}
|
||||
|
||||
func Factory(receiverType string) (func(FactoryConfig) (NotificationChannel, error), bool) {
|
||||
|
211
pkg/services/ngalert/notifier/channels/webex.go
Normal file
211
pkg/services/ngalert/notifier/channels/webex.go
Normal file
@ -0,0 +1,211 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
|
||||
"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 webexAPIURL = "https://webexapis.com/v1/messages"
|
||||
|
||||
// WebexNotifier is responsible for sending alert notifications as webex messages.
|
||||
type WebexNotifier struct {
|
||||
*Base
|
||||
ns notifications.WebhookSender
|
||||
log log.Logger
|
||||
images ImageStore
|
||||
tmpl *template.Template
|
||||
orgID int64
|
||||
settings *webexSettings
|
||||
}
|
||||
|
||||
// PLEASE do not touch these settings without taking a look at what we support as part of
|
||||
// https://github.com/prometheus/alertmanager/blob/main/notify/webex/webex.go
|
||||
// Currently, the Alerting team is unifying channels and (upstream) receivers - any discrepancy is detrimental to that.
|
||||
type webexSettings struct {
|
||||
Message string `json:"message,omitempty" yaml:"message,omitempty"`
|
||||
RoomID string `json:"room_id,omitempty" yaml:"room_id,omitempty"`
|
||||
APIURL string `json:"api_url,omitempty" yaml:"api_url,omitempty"`
|
||||
Token string `json:"bot_token" yaml:"bot_token"`
|
||||
}
|
||||
|
||||
func buildWebexSettings(factoryConfig FactoryConfig) (*webexSettings, error) {
|
||||
settings := &webexSettings{}
|
||||
err := factoryConfig.Config.unmarshalSettings(&settings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal settings: %w", err)
|
||||
}
|
||||
|
||||
if settings.APIURL == "" {
|
||||
settings.APIURL = webexAPIURL
|
||||
}
|
||||
|
||||
if settings.Message == "" {
|
||||
settings.Message = DefaultMessageEmbed
|
||||
}
|
||||
|
||||
settings.Token = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "bot_token", settings.Token)
|
||||
|
||||
u, err := url.Parse(settings.APIURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL %q", settings.APIURL)
|
||||
}
|
||||
settings.APIURL = u.String()
|
||||
|
||||
return settings, err
|
||||
}
|
||||
|
||||
func WebexFactory(fc FactoryConfig) (NotificationChannel, error) {
|
||||
notifier, err := buildWebexNotifier(fc)
|
||||
if err != nil {
|
||||
return nil, receiverInitError{
|
||||
Reason: err.Error(),
|
||||
Cfg: *fc.Config,
|
||||
}
|
||||
}
|
||||
return notifier, nil
|
||||
}
|
||||
|
||||
// buildWebexSettings is the constructor for the Webex notifier.
|
||||
func buildWebexNotifier(factoryConfig FactoryConfig) (*WebexNotifier, error) {
|
||||
settings, err := buildWebexSettings(factoryConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger := log.New("alerting.notifier.webex")
|
||||
|
||||
return &WebexNotifier{
|
||||
Base: NewBase(&models.AlertNotification{
|
||||
Uid: factoryConfig.Config.UID,
|
||||
Name: factoryConfig.Config.Name,
|
||||
Type: factoryConfig.Config.Type,
|
||||
DisableResolveMessage: factoryConfig.Config.DisableResolveMessage,
|
||||
Settings: factoryConfig.Config.Settings,
|
||||
}),
|
||||
orgID: factoryConfig.Config.OrgID,
|
||||
log: logger,
|
||||
ns: factoryConfig.NotificationService,
|
||||
images: factoryConfig.ImageStore,
|
||||
tmpl: factoryConfig.Template,
|
||||
settings: settings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WebexMessage defines the JSON object to send to Webex endpoints.
|
||||
type WebexMessage struct {
|
||||
RoomID string `json:"roomId,omitempty"`
|
||||
Message string `json:"markdown"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (wn *WebexNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
var tmplErr error
|
||||
tmpl, data := TmplText(ctx, wn.tmpl, as, wn.log, &tmplErr)
|
||||
|
||||
message, truncated := TruncateInBytes(tmpl(wn.settings.Message), 4096)
|
||||
if truncated {
|
||||
wn.log.Warn("Webex message too long, truncating message", "OriginalMessage", wn.settings.Message)
|
||||
}
|
||||
|
||||
if tmplErr != nil {
|
||||
wn.log.Warn("Failed to template webex message", "Error", tmplErr.Error())
|
||||
tmplErr = nil
|
||||
}
|
||||
|
||||
msg := &WebexMessage{
|
||||
RoomID: wn.settings.RoomID,
|
||||
Message: message,
|
||||
Files: []string{},
|
||||
}
|
||||
|
||||
// Augment our Alert data with ImageURLs if available.
|
||||
_ = withStoredImages(ctx, wn.log, wn.images, func(index int, image ngmodels.Image) error {
|
||||
// Cisco Webex only supports a single image per request: https://developer.webex.com/docs/basics#message-attachments
|
||||
if image.HasURL() {
|
||||
data.Alerts[index].ImageURL = image.URL
|
||||
msg.Files = append(msg.Files, image.URL)
|
||||
return ErrImagesDone
|
||||
}
|
||||
|
||||
return nil
|
||||
}, as...)
|
||||
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
parsedURL := tmpl(wn.settings.APIURL)
|
||||
if tmplErr != nil {
|
||||
return false, tmplErr
|
||||
}
|
||||
|
||||
cmd := &models.SendWebhookSync{
|
||||
Url: parsedURL,
|
||||
Body: string(body),
|
||||
HttpMethod: http.MethodPost,
|
||||
}
|
||||
|
||||
if wn.settings.Token != "" {
|
||||
headers := make(map[string]string)
|
||||
headers["Authorization"] = fmt.Sprintf("Bearer %s", wn.settings.Token)
|
||||
cmd.HttpHeader = headers
|
||||
}
|
||||
|
||||
if err := wn.ns.SendWebhookSync(ctx, cmd); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (wn *WebexNotifier) SendResolved() bool {
|
||||
return !wn.GetDisableResolveMessage()
|
||||
}
|
||||
|
||||
// Copied from https://github.com/prometheus/alertmanager/blob/main/notify/util.go, please remove once we're on-par with upstream.
|
||||
// truncationMarker is the character used to represent a truncation.
|
||||
const truncationMarker = "…"
|
||||
|
||||
// TruncateInBytes truncates a string to fit the given size in Bytes.
|
||||
func TruncateInBytes(s string, n int) (string, bool) {
|
||||
// First, measure the string the w/o a to-rune conversion.
|
||||
if len(s) <= n {
|
||||
return s, false
|
||||
}
|
||||
|
||||
// The truncationMarker itself is 3 bytes, we can't return any part of the string when it's less than 3.
|
||||
if n <= 3 {
|
||||
switch n {
|
||||
case 3:
|
||||
return truncationMarker, true
|
||||
default:
|
||||
return strings.Repeat(".", n), true
|
||||
}
|
||||
}
|
||||
|
||||
// Now, to ensure we don't butcher the string we need to remove using runes.
|
||||
r := []rune(s)
|
||||
truncationTarget := n - 3
|
||||
|
||||
// Next, let's truncate the runes to the lower possible number.
|
||||
truncatedRunes := r[:truncationTarget]
|
||||
for len(string(truncatedRunes)) > truncationTarget {
|
||||
truncatedRunes = r[:len(truncatedRunes)-1]
|
||||
}
|
||||
|
||||
return string(truncatedRunes) + truncationMarker, true
|
||||
}
|
148
pkg/services/ngalert/notifier/channels/webex_test.go
Normal file
148
pkg/services/ngalert/notifier/channels/webex_test.go
Normal file
@ -0,0 +1,148 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/fakes"
|
||||
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
|
||||
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWebexNotifier(t *testing.T) {
|
||||
tmpl := templateForTests(t)
|
||||
images := newFakeImageStoreWithFile(t, 2)
|
||||
|
||||
externalURL, err := url.Parse("http://localhost")
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL = externalURL
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
settings string
|
||||
alerts []*types.Alert
|
||||
expHeaders map[string]string
|
||||
expMsg string
|
||||
expInitError string
|
||||
expMsgError error
|
||||
}{
|
||||
{
|
||||
name: "A single alert with default template",
|
||||
settings: `{
|
||||
"bot_token": "abcdefgh0123456789",
|
||||
"room_id": "someid"
|
||||
}`,
|
||||
alerts: []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"},
|
||||
GeneratorURL: "a URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"},
|
||||
expMsg: `{"roomId":"someid","markdown":"**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n","files":["https://www.example.com/test-image-1"]}`,
|
||||
expMsgError: nil,
|
||||
},
|
||||
{
|
||||
name: "Multiple alerts with custom template",
|
||||
settings: `{
|
||||
"bot_token": "abcdefgh0123456789",
|
||||
"room_id": "someid",
|
||||
"message": "__Custom Firing__\n{{len .Alerts.Firing}} Firing\n{{ template \"__text_alert_list\" .Alerts.Firing }}"
|
||||
}`,
|
||||
alerts: []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
|
||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"},
|
||||
GeneratorURL: "a URL",
|
||||
},
|
||||
}, {
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
|
||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"},
|
||||
expMsg: `{"roomId":"someid","markdown":"__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval2\n","files":["https://www.example.com/test-image-1"]}`,
|
||||
expMsgError: nil,
|
||||
},
|
||||
{
|
||||
name: "Truncate long message",
|
||||
settings: `{
|
||||
"bot_token": "abcdefgh0123456789",
|
||||
"room_id": "someid",
|
||||
"message": "{{ .CommonLabels.alertname }}"
|
||||
}`,
|
||||
alerts: []*types.Alert{
|
||||
{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": model.LabelValue(strings.Repeat("1", 4097))},
|
||||
},
|
||||
},
|
||||
},
|
||||
expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"},
|
||||
expMsg: fmt.Sprintf(`{"roomId":"someid","markdown":"%s…"}`, strings.Repeat("1", 4093)),
|
||||
expMsgError: nil,
|
||||
},
|
||||
{
|
||||
name: "Error in initing",
|
||||
settings: `{ "api_url": "ostgres://user:abc{DEf1=ghi@example.com:5432/db?sslmode=require" }`,
|
||||
expInitError: `invalid URL "ostgres://user:abc{DEf1=ghi@example.com:5432/db?sslmode=require"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
settingsJSON, err := simplejson.NewJson([]byte(c.settings))
|
||||
require.NoError(t, err)
|
||||
secureSettings := make(map[string][]byte)
|
||||
|
||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
decryptFn := secretsService.GetDecryptedValue
|
||||
notificationService := mockNotificationService()
|
||||
|
||||
fc := FactoryConfig{
|
||||
Config: &NotificationChannelConfig{
|
||||
Name: "webex_tests",
|
||||
Type: "webex",
|
||||
Settings: settingsJSON,
|
||||
SecureSettings: secureSettings,
|
||||
},
|
||||
ImageStore: images,
|
||||
NotificationService: notificationService,
|
||||
DecryptFunc: decryptFn,
|
||||
Template: tmpl,
|
||||
}
|
||||
|
||||
n, err := buildWebexNotifier(fc)
|
||||
if c.expInitError != "" {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, c.expInitError, err.Error())
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
||||
ok, err := n.Notify(ctx, c.alerts...)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expHeaders, notificationService.Webhook.HttpHeader)
|
||||
require.JSONEq(t, c.expMsg, notificationService.Webhook.Body)
|
||||
})
|
||||
}
|
||||
}
|
@ -1101,5 +1101,49 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "webex",
|
||||
Name: "Cisco Webex Teams",
|
||||
Description: "Sends notifications to Cisco Webex Teams",
|
||||
Heading: "Webex settings",
|
||||
Info: "Notifications can be configured for any Cisco Webex Teams",
|
||||
Options: []NotifierOption{
|
||||
{
|
||||
Label: "Cisco Webex API URL",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "https://api.ciscospark.com/v1/messages",
|
||||
Description: "API endpoint at which we'll send webhooks to.",
|
||||
PropertyName: "api_url",
|
||||
},
|
||||
{
|
||||
Label: "Room ID",
|
||||
Description: "The room ID to send messages to.",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: "GMtOWY0ZGJkNzMyMGFl",
|
||||
PropertyName: "room_id",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Bot Token",
|
||||
Description: "Non-expiring access token of the bot that will post messages on our behalf.",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: `GMtOWY0ZGJkNzMyMGFl-12535454-123213`,
|
||||
PropertyName: "bot_token",
|
||||
Secure: true,
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Message Template",
|
||||
Description: "Message template to use. Markdown is supported.",
|
||||
Element: ElementTypeInput,
|
||||
InputType: InputTypeText,
|
||||
Placeholder: `{{ template "default.message" . }}`,
|
||||
PropertyName: "message",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user