Alerting: Attach image URLs or upload files to Discord notifications. (#49439)

* Images in discord

* Drop duplicated field initialization

* Fix tests

* Use the proper context
This commit is contained in:
Alexander Weaver 2022-05-23 17:28:16 -05:00 committed by GitHub
parent 33b2897552
commit d7c65d3323
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 156 additions and 15 deletions

View File

@ -1,14 +1,20 @@
package channels
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"strconv"
"strings"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
@ -21,6 +27,7 @@ type DiscordNotifier struct {
*Base
log log.Logger
ns notifications.WebhookSender
images ImageStore
tmpl *template.Template
Content string
AvatarURL string
@ -36,6 +43,16 @@ type DiscordConfig struct {
UseDiscordUsername bool
}
type discordAttachment struct {
url string
reader io.ReadCloser
name string
alertName string
state model.AlertStatus
}
const DiscordMaxEmbeds = 10
func NewDiscordConfig(config *NotificationChannelConfig) (*DiscordConfig, error) {
discordURL := config.Settings.Get("url").MustString()
if discordURL == "" {
@ -58,10 +75,10 @@ func DiscordFactory(fc FactoryConfig) (NotificationChannel, error) {
Cfg: *fc.Config,
}
}
return NewDiscordNotifier(cfg, fc.NotificationService, fc.Template), nil
return NewDiscordNotifier(cfg, fc.NotificationService, fc.ImageStore, fc.Template), nil
}
func NewDiscordNotifier(config *DiscordConfig, ns notifications.WebhookSender, t *template.Template) *DiscordNotifier {
func NewDiscordNotifier(config *DiscordConfig, ns notifications.WebhookSender, images ImageStore, t *template.Template) *DiscordNotifier {
return &DiscordNotifier{
Base: NewBase(&models.AlertNotification{
Uid: config.UID,
@ -76,6 +93,7 @@ func NewDiscordNotifier(config *DiscordConfig, ns notifications.WebhookSender, t
WebhookURL: config.WebhookURL,
log: log.New("alerting.notifier.discord"),
ns: ns,
images: images,
tmpl: t,
UseDiscordUsername: config.UseDiscordUsername,
}
@ -116,18 +134,33 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
"icon_url": "https://grafana.com/assets/img/fav32.png",
}
embed := simplejson.New()
embed.Set("title", tmpl(DefaultMessageTitleEmbed))
embed.Set("footer", footer)
embed.Set("type", "rich")
linkEmbed := simplejson.New()
linkEmbed.Set("title", tmpl(DefaultMessageTitleEmbed))
linkEmbed.Set("footer", footer)
linkEmbed.Set("type", "rich")
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0)
embed.Set("color", color)
linkEmbed.Set("color", color)
ruleURL := joinUrlPath(d.tmpl.ExternalURL.String(), "/alerting/list", d.log)
embed.Set("url", ruleURL)
linkEmbed.Set("url", ruleURL)
bodyJSON.Set("embeds", []interface{}{embed})
embeds := []interface{}{linkEmbed}
attachments := d.constructAttachments(ctx, as, DiscordMaxEmbeds-1)
for _, a := range attachments {
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0)
embed := map[string]interface{}{
"image": map[string]interface{}{
"url": a.url,
},
"color": color,
"title": a.alertName,
}
embeds = append(embeds, embed)
}
bodyJSON.Set("embeds", embeds)
if tmplErr != nil {
d.log.Warn("failed to template Discord message", "err", tmplErr.Error())
@ -144,11 +177,10 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
if err != nil {
return false, err
}
cmd := &models.SendWebhookSync{
Url: u,
HttpMethod: "POST",
ContentType: "application/json",
Body: string(body),
cmd, err := d.buildRequest(ctx, u, body, attachments)
if err != nil {
return false, err
}
if err := d.ns.SendWebhookSync(ctx, cmd); err != nil {
@ -161,3 +193,111 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
func (d DiscordNotifier) SendResolved() bool {
return !d.GetDisableResolveMessage()
}
func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.Alert, embedQuota int) []discordAttachment {
attachments := make([]discordAttachment, 0)
for i := range as {
if embedQuota == 0 {
break
}
imgToken := getTokenFromAnnotations(as[i].Annotations)
if len(imgToken) == 0 {
continue
}
timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
imgURL, err := d.images.GetURL(timeoutCtx, imgToken)
cancel()
if err != nil {
if !errors.Is(err, ErrImagesUnavailable) {
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
d.log.Warn("failed to retrieve image url from store", "error", err)
}
}
if len(imgURL) > 0 {
attachments = append(attachments, discordAttachment{
url: imgURL,
state: as[i].Status(),
alertName: as[i].Name(),
})
} else {
// Need to upload the file. Tell Discord that we're embedding an attachment.
timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
fp, err := d.images.GetFilepath(timeoutCtx, imgToken)
cancel()
if err != nil {
if !errors.Is(err, ErrImagesUnavailable) {
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
d.log.Warn("failed to retrieve image filepath from store", "error", err)
}
}
base := filepath.Base(fp)
url := fmt.Sprintf("attachment://%s", base)
timeoutCtx, cancel = context.WithTimeout(ctx, ImageStoreTimeout)
reader, err := d.images.GetData(timeoutCtx, imgToken)
cancel()
if err != nil {
if !errors.Is(err, ErrImagesUnavailable) {
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
d.log.Warn("failed to retrieve image data from store", "error", err)
}
}
attachments = append(attachments, discordAttachment{
url: url,
name: base,
reader: reader,
state: as[i].Status(),
alertName: as[i].Name(),
})
}
embedQuota++
}
return attachments
}
func (d DiscordNotifier) buildRequest(ctx context.Context, url string, body []byte, attachments []discordAttachment) (*models.SendWebhookSync, error) {
cmd := &models.SendWebhookSync{
Url: url,
HttpMethod: "POST",
}
if len(attachments) == 0 {
cmd.ContentType = "application/json"
cmd.Body = string(body)
return cmd, nil
}
var b bytes.Buffer
w := multipart.NewWriter(&b)
defer func() {
if err := w.Close(); err != nil {
// Shouldn't matter since we already close w explicitly on the non-error path
d.log.Warn("Failed to close multipart writer", "err", err)
}
}()
payload, err := w.CreateFormField("payload_json")
if err != nil {
return nil, err
}
if _, err := payload.Write(body); err != nil {
return nil, err
}
for _, a := range attachments {
part, err := w.CreateFormFile("", a.name)
if err != nil {
return nil, err
}
if _, err := io.Copy(part, a.reader); err != nil {
return nil, err
}
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
cmd.ContentType = w.FormDataContentType()
cmd.Body = b.String()
return cmd, nil
}

View File

@ -276,10 +276,11 @@ func TestDiscordNotifier(t *testing.T) {
return
}
require.NoError(t, err)
imageStore := &UnavailableImageStore{}
ctx := notify.WithGroupKey(context.Background(), "alertname")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
dn := NewDiscordNotifier(cfg, webhookSender, tmpl)
dn := NewDiscordNotifier(cfg, webhookSender, imageStore, tmpl)
ok, err := dn.Notify(ctx, c.alerts...)
if c.expMsgError != nil {
require.False(t, ok)