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:
gotjosh 2022-11-11 17:27:13 +00:00 committed by GitHub
parent 1c5039085b
commit d748979048
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 418 additions and 2 deletions

View File

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

View File

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

View File

@ -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
View File

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

View File

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

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

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

View File

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