diff --git a/images/images.go b/images/images.go index 1c45af92..9b9bafee 100644 --- a/images/images.go +++ b/images/images.go @@ -17,7 +17,16 @@ var ( // the maximum number of images has been iterated. ErrImagesDone = errors.New("images done") + // ErrImagesNoPath is returned whenever an image is found but has no path on disk. + ErrImagesNoPath = errors.New("no path for image") + + // ErrImagesNoURL is returned whenever an image is found but has no URL. + ErrImagesNoURL = errors.New("no URL for image") + ErrImagesUnavailable = errors.New("alert screenshots are unavailable") + + // ErrNoImageForAlert is returned when no image is associated to a given alert. + ErrNoImageForAlert = errors.New("no image for alert") ) type Image struct { @@ -32,9 +41,24 @@ func (i Image) HasURL() bool { } type Provider interface { + // GetImage takes a token (identifier) and returns the image that token belongs to. + // Returns `ErrImageNotFound` if there's no image for said token. + // + // Deprecated: This method will be removed when all integrations use GetImageURL and/or GetRawImage, + // which allow integrations to get just the data they need for adding images to notifications. + // Use any of those two methods instead. GetImage(ctx context.Context, token string) (*Image, error) - GetImageURL(ctx context.Context, alert types.Alert) (string, error) - GetRawImage(ctx context.Context, alert types.Alert) (io.Reader, error) + + // GetImageURL returns the URL of an image associated with a given alert. + // - Returns `ErrImageNotFound` if no image is found. + // - Returns `ErrImagesNoURL` if the image doesn't have a URL. + GetImageURL(ctx context.Context, alert *types.Alert) (string, error) + + // GetRawImage returns an io.Reader to read the bytes of an image associated with a given alert + // and a string representing the filename. + // - Returns `ErrImageNotFound` if no image is found. + // - Returns `ErrImagesNoPath` if the image doesn't have a path on disk. + GetRawImage(ctx context.Context, alert *types.Alert) (io.ReadCloser, string, error) } type UnavailableProvider struct{} @@ -44,12 +68,10 @@ func (u *UnavailableProvider) GetImage(context.Context, string) (*Image, error) return nil, ErrImagesUnavailable } -// GetImageURL returns the URL of the image associated with a given alert. -func (u *UnavailableProvider) GetImageURL(context.Context, types.Alert) (string, error) { +func (u *UnavailableProvider) GetImageURL(context.Context, *types.Alert) (string, error) { return "", ErrImagesUnavailable } -// GetRawImage returns an io.Reader to read the bytes of the image associated with a given alert. -func (u *UnavailableProvider) GetRawImage(context.Context, types.Alert) (io.Reader, error) { - return nil, ErrImagesUnavailable +func (u *UnavailableProvider) GetRawImage(context.Context, *types.Alert) (io.ReadCloser, string, error) { + return nil, "", ErrImagesUnavailable } diff --git a/images/testing.go b/images/testing.go index 1c03adda..c537f6f3 100644 --- a/images/testing.go +++ b/images/testing.go @@ -1,12 +1,13 @@ package images import ( + "bytes" "context" "encoding/base64" "fmt" "io" "os" - "strings" + "path/filepath" "testing" "time" @@ -16,6 +17,7 @@ import ( type FakeProvider struct { Images []*Image + Bytes []byte } // GetImage returns an image with the same token. @@ -29,7 +31,7 @@ func (f *FakeProvider) GetImage(_ context.Context, token string) (*Image, error) } // GetImageURL returns the URL of the image associated with a given alert. -func (f *FakeProvider) GetImageURL(_ context.Context, alert types.Alert) (string, error) { +func (f *FakeProvider) GetImageURL(_ context.Context, alert *types.Alert) (string, error) { uri, err := getImageURI(alert) if err != nil { return "", err @@ -37,6 +39,9 @@ func (f *FakeProvider) GetImageURL(_ context.Context, alert types.Alert) (string for _, img := range f.Images { if img.Token == uri || img.URL == uri { + if !img.HasURL() { + return "", ErrImagesNoURL + } return img.URL, nil } } @@ -44,26 +49,27 @@ func (f *FakeProvider) GetImageURL(_ context.Context, alert types.Alert) (string } // GetRawImage returns an io.Reader to read the bytes of the image associated with a given alert. -func (f *FakeProvider) GetRawImage(_ context.Context, alert types.Alert) (io.Reader, error) { +func (f *FakeProvider) GetRawImage(_ context.Context, alert *types.Alert) (io.ReadCloser, string, error) { uri, err := getImageURI(alert) if err != nil { - return nil, err + return nil, "", err } uriString := string(uri) for _, img := range f.Images { if img.Token == uriString || img.URL == uriString { - return strings.NewReader("test"), nil + filename := filepath.Base(img.Path) + return io.NopCloser(bytes.NewReader(f.Bytes)), filename, nil } } - return nil, ErrImageNotFound + return nil, "", ErrImageNotFound } // getImageURI is a helper function to retrieve the image URI from the alert annotations as a string. -func getImageURI(alert types.Alert) (string, error) { +func getImageURI(alert *types.Alert) (string, error) { uri, ok := alert.Annotations[models.ImageTokenAnnotation] if !ok { - return "", fmt.Errorf("no image uri in annotations") + return "", ErrNoImageForAlert } return string(uri), nil } diff --git a/images/utils.go b/images/utils.go index fb4f0db1..14b74e37 100644 --- a/images/utils.go +++ b/images/utils.go @@ -3,9 +3,6 @@ package images import ( "context" "errors" - "io" - "os" - "path/filepath" "time" "github.com/prometheus/alertmanager/types" @@ -70,26 +67,6 @@ func WithStoredImages(ctx context.Context, l logging.Logger, imageProvider Provi return nil } -// OpenImage returns an the io representation of an image from the given path. -// The path argument here comes from reading internal image storage, not User -// input, so we ignore the security check here. -// -//nolint:gosec -func OpenImage(path string) (io.ReadCloser, error) { - fp := filepath.Clean(path) - _, err := os.Stat(fp) - if os.IsNotExist(err) || os.IsPermission(err) { - return nil, ErrImageNotFound - } - - f, err := os.Open(fp) - if err != nil { - return nil, err - } - - return f, nil -} - func getTokenFromAnnotations(annotations model.LabelSet) string { if value, ok := annotations[models.ImageTokenAnnotation]; ok { return string(value) diff --git a/notify/alerts.go b/notify/alerts.go index 54f83f1b..65f82d31 100644 --- a/notify/alerts.go +++ b/notify/alerts.go @@ -30,6 +30,8 @@ type Receiver = amv2.Receiver type PostableAlerts = amv2.PostableAlerts type PostableAlert = amv2.PostableAlert +type Alert = types.Alert + var OpenAPIAlertsToAlerts = v2.OpenAPIAlertsToAlerts func (am *GrafanaAlertmanager) GetAlerts(active, silenced, inhibited bool, filter []string, receivers string) (GettableAlerts, error) { diff --git a/notify/grafana_alertmanager_test.go b/notify/grafana_alertmanager_test.go index 1e8e154a..116ed893 100644 --- a/notify/grafana_alertmanager_test.go +++ b/notify/grafana_alertmanager_test.go @@ -10,7 +10,6 @@ import ( "github.com/go-kit/log" "github.com/go-openapi/strfmt" - "github.com/prometheus/alertmanager/api/v2/models" amv2 "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/provider/mem" @@ -51,33 +50,33 @@ func TestPutAlert(t *testing.T) { title: "Valid alerts with different start/end set", postableAlerts: amv2.PostableAlerts{ { // Start and end set. - Annotations: models.LabelSet{"msg": "Alert1 annotation"}, - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": "Alert1"}, + Annotations: amv2.LabelSet{"msg": "Alert1 annotation"}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": "Alert1"}, GeneratorURL: "http://localhost/url1", }, StartsAt: strfmt.DateTime(startTime), EndsAt: strfmt.DateTime(endTime), }, { // Only end is set. - Annotations: models.LabelSet{"msg": "Alert2 annotation"}, - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": "Alert2"}, + Annotations: amv2.LabelSet{"msg": "Alert2 annotation"}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": "Alert2"}, GeneratorURL: "http://localhost/url2", }, StartsAt: strfmt.DateTime{}, EndsAt: strfmt.DateTime(endTime), }, { // Only start is set. - Annotations: models.LabelSet{"msg": "Alert3 annotation"}, - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": "Alert3"}, + Annotations: amv2.LabelSet{"msg": "Alert3 annotation"}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": "Alert3"}, GeneratorURL: "http://localhost/url3", }, StartsAt: strfmt.DateTime(startTime), EndsAt: strfmt.DateTime{}, }, { // Both start and end are not set. - Annotations: models.LabelSet{"msg": "Alert4 annotation"}, - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": "Alert4"}, + Annotations: amv2.LabelSet{"msg": "Alert4 annotation"}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": "Alert4"}, GeneratorURL: "http://localhost/url4", }, StartsAt: strfmt.DateTime{}, @@ -131,9 +130,9 @@ func TestPutAlert(t *testing.T) { title: "Removing empty labels and annotations", postableAlerts: amv2.PostableAlerts{ { - Annotations: models.LabelSet{"msg": "Alert4 annotation", "empty": ""}, - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": "Alert4", "emptylabel": ""}, + Annotations: amv2.LabelSet{"msg": "Alert4 annotation", "empty": ""}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": "Alert4", "emptylabel": ""}, GeneratorURL: "http://localhost/url1", }, StartsAt: strfmt.DateTime{}, @@ -159,9 +158,9 @@ func TestPutAlert(t *testing.T) { title: "Allow spaces in label and annotation name", postableAlerts: amv2.PostableAlerts{ { - Annotations: models.LabelSet{"Dashboard URL": "http://localhost:3000"}, - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": "Alert4", "Spaced Label": "works"}, + Annotations: amv2.LabelSet{"Dashboard URL": "http://localhost:3000"}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": "Alert4", "Spaced Label": "works"}, GeneratorURL: "http://localhost/url1", }, StartsAt: strfmt.DateTime{}, @@ -187,8 +186,8 @@ func TestPutAlert(t *testing.T) { title: "Special characters in labels", postableAlerts: amv2.PostableAlerts{ { - Alert: models.Alert{ - Labels: models.LabelSet{"alertname$": "Alert1", "az3-- __...++!!!£@@312312": "1"}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname$": "Alert1", "az3-- __...++!!!£@@312312": "1"}, }, }, }, @@ -211,9 +210,9 @@ func TestPutAlert(t *testing.T) { title: "Special characters in annotations", postableAlerts: amv2.PostableAlerts{ { - Annotations: models.LabelSet{"az3-- __...++!!!£@@312312": "Alert4 annotation"}, - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": "Alert4"}, + Annotations: amv2.LabelSet{"az3-- __...++!!!£@@312312": "Alert4 annotation"}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": "Alert4"}, }, }, }, @@ -236,16 +235,16 @@ func TestPutAlert(t *testing.T) { title: "No labels after removing empty", postableAlerts: amv2.PostableAlerts{ { - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": ""}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": ""}, }, }, }, expError: &AlertValidationError{ Alerts: amv2.PostableAlerts{ { - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": ""}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": ""}, }, }, }, @@ -255,8 +254,8 @@ func TestPutAlert(t *testing.T) { title: "Start should be before end", postableAlerts: amv2.PostableAlerts{ { - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": ""}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": ""}, }, StartsAt: strfmt.DateTime(endTime), EndsAt: strfmt.DateTime(startTime), @@ -265,8 +264,8 @@ func TestPutAlert(t *testing.T) { expError: &AlertValidationError{ Alerts: amv2.PostableAlerts{ { - Alert: models.Alert{ - Labels: models.LabelSet{"alertname": ""}, + Alert: amv2.Alert{ + Labels: amv2.LabelSet{"alertname": ""}, }, StartsAt: strfmt.DateTime(endTime), EndsAt: strfmt.DateTime(startTime), @@ -353,10 +352,10 @@ func TestSilenceCleanup(t *testing.T) { dt := func(t time.Time) strfmt.DateTime { return strfmt.DateTime(t) } makeSilence := func(comment string, createdBy string, - startsAt, endsAt strfmt.DateTime, matchers models.Matchers) *PostableSilence { + startsAt, endsAt strfmt.DateTime, matchers amv2.Matchers) *PostableSilence { return &PostableSilence{ ID: "", - Silence: models.Silence{ + Silence: amv2.Silence{ Comment: &comment, CreatedBy: &createdBy, StartsAt: &startsAt, @@ -368,7 +367,7 @@ func TestSilenceCleanup(t *testing.T) { tru := true testString := "testName" - matchers := models.Matchers{&models.Matcher{Name: &testString, IsEqual: &tru, IsRegex: &tru, Value: &testString}} + matchers := amv2.Matchers{&amv2.Matcher{Name: &testString, IsEqual: &tru, IsRegex: &tru, Value: &testString}} // Create silences - one in the future, one currently active, one expired but // retained, one expired and not retained. silences := []*PostableSilence{ diff --git a/receivers/discord/discord.go b/receivers/discord/discord.go index 029b6458..e80aeb72 100644 --- a/receivers/discord/discord.go +++ b/receivers/discord/discord.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "mime/multipart" - "path/filepath" "strconv" "strings" @@ -202,50 +201,71 @@ func (d Notifier) SendResolved() bool { return !d.GetDisableResolveMessage() } -func (d Notifier) constructAttachments(ctx context.Context, as []*types.Alert, embedQuota int) []discordAttachment { - attachments := make([]discordAttachment, 0) +func (d Notifier) constructAttachments(ctx context.Context, alerts []*types.Alert, embedQuota int) []discordAttachment { + attachments := make([]discordAttachment, 0, embedQuota) + embedsUsed := 0 + for _, alert := range alerts { + // Check if the image limit has been reached at the start of each iteration. + if embedsUsed >= embedQuota { + d.log.Warn("Discord embed quota reached, not creating more attachments for this notification", "embedQuota", embedQuota) + break + } - _ = images.WithStoredImages(ctx, d.log, d.images, - func(index int, image images.Image) error { - if embedQuota < 1 { - return images.ErrImagesDone + attachment, err := d.getAttachment(ctx, alert) + if err != nil { + if errors.Is(err, images.ErrNoImageForAlert) { + // There's no image for this alert, continue. + continue } + d.log.Error("failed to create an attachment for Discord", "alert", alert, "error", err) + continue + } - if len(image.URL) > 0 { - attachments = append(attachments, discordAttachment{ - url: image.URL, - state: as[index].Status(), - alertName: as[index].Name(), - }) - embedQuota-- - return nil - } + // We got an attachment, either using the image URL or bytes. + attachments = append(attachments, attachment) + embedsUsed++ + } - // If we have a local file, but no public URL, upload the image as an attachment. - if len(image.Path) > 0 { - base := filepath.Base(image.Path) - url := fmt.Sprintf("attachment://%s", base) - reader, err := images.OpenImage(image.Path) - if err != nil && !errors.Is(err, images.ErrImageNotFound) { - d.log.Warn("failed to retrieve image data from store", "error", err) - return nil - } + return attachments +} - attachments = append(attachments, discordAttachment{ - url: url, - name: base, - reader: reader, - state: as[index].Status(), - alertName: as[index].Name(), - }) - embedQuota-- - } - return nil - }, - as..., - ) +// getAttachment takes an alert and generates a Discord attachment containing an image for it. +// If the image has no public URL, it uses the raw bytes for uploading directly to Discord. +func (d Notifier) getAttachment(ctx context.Context, alert *types.Alert) (discordAttachment, error) { + attachment, err := d.getAttachmentFromURL(ctx, alert) + if errors.Is(err, images.ErrImagesNoURL) { + // There's an image but it has no public URL, use the bytes for the attachment. + return d.getAttachmentFromBytes(ctx, alert) + } - return attachments + return attachment, err +} + +func (d Notifier) getAttachmentFromURL(ctx context.Context, alert *types.Alert) (discordAttachment, error) { + url, err := d.images.GetImageURL(ctx, alert) + if err != nil { + return discordAttachment{}, err + } + + return discordAttachment{ + url: url, + state: alert.Status(), + alertName: alert.Name(), + }, nil +} + +func (d Notifier) getAttachmentFromBytes(ctx context.Context, alert *types.Alert) (discordAttachment, error) { + r, name, err := d.images.GetRawImage(ctx, alert) + if err != nil { + return discordAttachment{}, err + } + return discordAttachment{ + url: "attachment://" + name, + name: name, + reader: r, + state: alert.Status(), + alertName: alert.Name(), + }, nil } func (d Notifier) buildRequest(url string, body []byte, attachments []discordAttachment) (*receivers.SendWebhookSettings, error) { diff --git a/receivers/discord/discord_test.go b/receivers/discord/discord_test.go index 005fd1b0..25349b79 100644 --- a/receivers/discord/discord_test.go +++ b/receivers/discord/discord_test.go @@ -1,13 +1,17 @@ package discord import ( + "bytes" "context" "encoding/json" "fmt" "math/rand" + "mime" + "mime/multipart" "net/url" "strings" "testing" + "time" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/types" @@ -16,6 +20,7 @@ import ( "github.com/grafana/alerting/images" "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/models" "github.com/grafana/alerting/receivers" "github.com/grafana/alerting/templates" ) @@ -347,12 +352,7 @@ func TestNotify(t *testing.T) { webhookSender := receivers.MockNotificationService() imageProvider := &images.UnavailableProvider{} dn := &Notifier{ - Base: &receivers.Base{ - Name: "", - Type: "", - UID: "", - DisableResolveMessage: false, - }, + Base: &receivers.Base{}, log: &logging.FakeLogger{}, ns: webhookSender, tmpl: tmpl, @@ -380,3 +380,251 @@ func TestNotify(t *testing.T) { }) } } + +func TestNotify_WithImages(t *testing.T) { + imageWithURL := images.Image{ + Token: "test-image-1", + URL: "https://www.example.com/test-image-1.jpg", + CreatedAt: time.Now().UTC(), + } + imageWithoutURL := images.Image{ + Token: "test-image-2", + Path: "/test/test2/test-image-2.jpg", + CreatedAt: time.Now().UTC(), + } + expectedBytes := []byte("test bytes") + imageProvider := &images.FakeProvider{Images: []*images.Image{&imageWithURL, &imageWithoutURL}, Bytes: expectedBytes} + tmpl := templates.ForTests(t) + + externalURL, err := url.Parse("http://localhost") + require.NoError(t, err) + tmpl.ExternalURL = externalURL + appVersion := fmt.Sprintf("%d.0.0", rand.Uint32()) + + cases := []struct { + name string + settings Config + alerts []*types.Alert + expMsg map[string]interface{} + expMsgError error + expBytes []byte + }{ + { + name: "Default config with one alert, one image with URL", + settings: Config{ + Title: templates.DefaultMessageTitleEmbed, + Message: templates.DefaultMessageEmbed, + AvatarURL: "", + WebhookURL: "http://localhost", + UseDiscordUsername: false, + }, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", models.ImageTokenAnnotation: model.LabelValue(imageWithURL.Token)}, + }, + }, + }, + expMsg: map[string]interface{}{ + "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "embeds": []interface{}{map[string]interface{}{ + "color": 1.4037554e+07, + "footer": map[string]interface{}{ + "icon_url": "https://grafana.com/static/assets/img/fav32.png", + "text": "Grafana v" + appVersion, + }, + "title": "[FIRING:1] (val1)", + "url": "http://localhost/alerting/list", + "type": "rich", + }, + map[string]interface{}{ + "image": map[string]interface{}{ + "url": imageWithURL.URL, + }, + "title": "alert1", + "color": 1.4037554e+07, + }}, + "username": "Grafana", + }, + expMsgError: nil, + }, + { + name: "Default config with one alert, one image without URL", + settings: Config{ + Title: templates.DefaultMessageTitleEmbed, + Message: templates.DefaultMessageEmbed, + AvatarURL: "", + WebhookURL: "http://localhost", + UseDiscordUsername: false, + }, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", models.ImageTokenAnnotation: model.LabelValue(imageWithoutURL.Token)}, + }, + }, + }, + expMsg: map[string]interface{}{ + "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "embeds": []interface{}{map[string]interface{}{ + "color": 1.4037554e+07, + "footer": map[string]interface{}{ + "icon_url": "https://grafana.com/static/assets/img/fav32.png", + "text": "Grafana v" + appVersion, + }, + "title": "[FIRING:1] (val1)", + "url": "http://localhost/alerting/list", + "type": "rich", + }, + map[string]interface{}{ + "image": map[string]interface{}{ + "url": "attachment://test-image-2.jpg", + }, + "title": "alert1", + "color": 1.4037554e+07, + }}, + "username": "Grafana", + }, + expMsgError: nil, + expBytes: expectedBytes, + }, + } + + for _, c := range cases { + t.Run(c.name, func(tt *testing.T) { + webhookSender := receivers.MockNotificationService() + dn := &Notifier{ + Base: &receivers.Base{}, + log: &logging.FakeLogger{}, + ns: webhookSender, + tmpl: tmpl, + settings: c.settings, + images: imageProvider, + appVersion: appVersion, + } + + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + ok, err := dn.Notify(ctx, c.alerts...) + if c.expMsgError != nil { + require.False(t, ok) + require.Error(t, err) + require.Equal(t, c.expMsgError.Error(), err.Error()) + return + } + require.NoError(tt, err) + require.True(tt, ok) + + expBody, err := json.Marshal(c.expMsg) + require.NoError(tt, err) + + mediaType, params, err := mime.ParseMediaType(webhookSender.Webhook.ContentType) + require.NoError(tt, err) + require.Equal(tt, "multipart/form-data", mediaType) + + reader := multipart.NewReader(strings.NewReader(webhookSender.Webhook.Body), params["boundary"]) + part, err := reader.NextPart() + require.NoError(tt, err) + require.Equal(tt, "payload_json", part.FormName()) + + buf := bytes.Buffer{} + _, err = buf.ReadFrom(part) + require.NoError(tt, err) + require.JSONEq(tt, string(expBody), buf.String()) + + if c.expBytes != nil { + buf.Reset() + part, err = reader.NextPart() + require.NoError(tt, err) + + _, err = buf.ReadFrom(part) + require.NoError(tt, err) + require.Equal(tt, c.expBytes, buf.Bytes()) + } + }) + } + + t.Run("embed quota should be considered", func(tt *testing.T) { + config := Config{ + Title: templates.DefaultMessageTitleEmbed, + Message: templates.DefaultMessageEmbed, + AvatarURL: "", + WebhookURL: "http://localhost", + UseDiscordUsername: false, + } + + // Create 10 alerts with an image each, Discord's embed limit is 10, and we should be using a maximum of 9 for images. + var alerts []*types.Alert + for i := 0; i < 15; i++ { + alertName := fmt.Sprintf("alert-%d", i) + alert := types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": model.LabelValue(alertName), "lbl1": "val"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", models.ImageTokenAnnotation: model.LabelValue(imageWithURL.URL)}, + }} + + alerts = append(alerts, &alert) + } + + expEmbeds := []interface{}{ + map[string]interface{}{ + "color": 1.4037554e+07, + "footer": map[string]interface{}{ + "icon_url": "https://grafana.com/static/assets/img/fav32.png", + "text": "Grafana v" + appVersion, + }, + "title": "[FIRING:15] ", + "url": "http://localhost/alerting/list", + "type": "rich", + }} + + for i := 0; i < 9; i++ { + imageEmbed := map[string]interface{}{ + "image": map[string]interface{}{ + "url": imageWithURL.URL, + }, + "title": fmt.Sprintf("alert-%d", i), + "color": 1.4037554e+07, + } + expEmbeds = append(expEmbeds, imageEmbed) + } + + webhookSender := receivers.MockNotificationService() + dn := &Notifier{ + Base: &receivers.Base{}, + log: &logging.FakeLogger{}, + ns: webhookSender, + tmpl: tmpl, + settings: config, + images: imageProvider, + appVersion: appVersion, + } + + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + ok, err := dn.Notify(ctx, alerts...) + require.NoError(tt, err) + require.True(tt, ok) + + mediaType, params, err := mime.ParseMediaType(webhookSender.Webhook.ContentType) + require.NoError(tt, err) + require.Equal(tt, "multipart/form-data", mediaType) + + reader := multipart.NewReader(strings.NewReader(webhookSender.Webhook.Body), params["boundary"]) + form, err := reader.ReadForm(32 << 20) // 32MB + require.NoError(tt, err) + + payload, ok := form.Value["payload_json"] + require.True(tt, ok) + + var payloadMap map[string]interface{} + require.NoError(tt, json.Unmarshal([]byte(payload[0]), &payloadMap)) + + embeds, ok := payloadMap["embeds"] + require.True(tt, ok) + require.Len(tt, embeds, 10) + require.Equal(tt, expEmbeds, embeds) + }) +}