diff --git a/pkg/services/ngalert/notifier/channels/sensugo.go b/pkg/services/ngalert/notifier/channels/sensugo.go index d24e1be75c2..1f9a656fd57 100644 --- a/pkg/services/ngalert/notifier/channels/sensugo.go +++ b/pkg/services/ngalert/notifier/channels/sensugo.go @@ -13,14 +13,16 @@ import ( "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" ) type SensuGoNotifier struct { *Base - log log.Logger - ns notifications.WebhookSender - tmpl *template.Template + log log.Logger + images ImageStore + ns notifications.WebhookSender + tmpl *template.Template URL string Entity string @@ -50,7 +52,7 @@ func SensuGoFactory(fc FactoryConfig) (NotificationChannel, error) { Cfg: *fc.Config, } } - return NewSensuGoNotifier(cfg, fc.NotificationService, fc.Template), nil + return NewSensuGoNotifier(cfg, fc.ImageStore, fc.NotificationService, fc.Template), nil } func NewSensuGoConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedValueFn) (*SensuGoConfig, error) { @@ -75,7 +77,7 @@ func NewSensuGoConfig(config *NotificationChannelConfig, decryptFunc GetDecrypte } // NewSensuGoNotifier is the constructor for the SensuGo notifier -func NewSensuGoNotifier(config *SensuGoConfig, ns notifications.WebhookSender, t *template.Template) *SensuGoNotifier { +func NewSensuGoNotifier(config *SensuGoConfig, images ImageStore, ns notifications.WebhookSender, t *template.Template) *SensuGoNotifier { return &SensuGoNotifier{ Base: NewBase(&models.AlertNotification{ Uid: config.UID, @@ -93,6 +95,7 @@ func NewSensuGoNotifier(config *SensuGoConfig, ns notifications.WebhookSender, t APIKey: config.APIKey, Message: config.Message, log: log.New("alerting.notifier.sensugo"), + images: images, ns: ns, tmpl: t, } @@ -134,7 +137,26 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool handlers = []string{tmpl(sn.Handler)} } + labels := make(map[string]string) + + var imageURL string + _ = withStoredImages(ctx, sn.log, sn.images, + func(index int, image *ngmodels.Image) error { + // If there is an image for this alert and the image has been uploaded + // to a public URL then add it to the request. We cannot add more than + // one image per request. + if image != nil && image.URL != "" && imageURL == "" { + imageURL = image.URL + } + return nil + }, as...) + if imageURL != "" { + labels["imageURL"] = imageURL + } + ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) + labels["ruleURL"] = ruleURL + bodyMsgType := map[string]interface{}{ "entity": map[string]interface{}{ "metadata": map[string]interface{}{ @@ -144,10 +166,8 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool }, "check": map[string]interface{}{ "metadata": map[string]interface{}{ - "name": check, - "labels": map[string]string{ - "ruleURL": ruleURL, - }, + "name": check, + "labels": labels, }, "output": tmpl(sn.Message), "issued": timeNow().Unix(), diff --git a/pkg/services/ngalert/notifier/channels/sensugo_test.go b/pkg/services/ngalert/notifier/channels/sensugo_test.go index 1a769c1de8b..ef69c264979 100644 --- a/pkg/services/ngalert/notifier/channels/sensugo_test.go +++ b/pkg/services/ngalert/notifier/channels/sensugo_test.go @@ -27,6 +27,9 @@ func TestSensuGoNotifier(t *testing.T) { require.NoError(t, err) tmpl.ExternalURL = externalURL + images, deleteFunc := newFakeImageStore(t) + defer deleteFunc() + cases := []struct { name string settings string @@ -42,7 +45,7 @@ func TestSensuGoNotifier(t *testing.T) { { Alert: model.Alert{ Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertScreenshotToken__": "test-image"}, }, }, }, @@ -57,7 +60,8 @@ func TestSensuGoNotifier(t *testing.T) { "metadata": map[string]interface{}{ "name": "default", "labels": map[string]string{ - "ruleURL": "http://localhost/alerting/list", + "imageURL": "https://www.example.com/test-image.jpg", + "ruleURL": "http://localhost/alerting/list", }, }, "output": "**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", @@ -84,12 +88,12 @@ func TestSensuGoNotifier(t *testing.T) { { Alert: model.Alert{ Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, - Annotations: model.LabelSet{"ann1": "annv1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__alertScreenshotToken__": "test-image"}, }, }, { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, - Annotations: model.LabelSet{"ann1": "annv2"}, + Annotations: model.LabelSet{"ann1": "annv2", "__alertScreenshotToken__": "test-image-2"}, }, }, }, @@ -104,7 +108,8 @@ func TestSensuGoNotifier(t *testing.T) { "metadata": map[string]interface{}{ "name": "grafana_rule_0", "labels": map[string]string{ - "ruleURL": "http://localhost/alerting/list", + "imageURL": "https://www.example.com/test-image.jpg", + "ruleURL": "http://localhost/alerting/list", }, }, "output": "2 alerts are firing, 0 are resolved", @@ -157,7 +162,7 @@ func TestSensuGoNotifier(t *testing.T) { ctx := notify.WithGroupKey(context.Background(), "alertname") ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - sn := NewSensuGoNotifier(cfg, webhookSender, tmpl) + sn := NewSensuGoNotifier(cfg, images, webhookSender, tmpl) ok, err := sn.Notify(ctx, c.alerts...) if c.expMsgError != nil { require.False(t, ok) diff --git a/pkg/services/ngalert/notifier/channels/testing.go b/pkg/services/ngalert/notifier/channels/testing.go index 9afc3aa6156..0f734808e6e 100644 --- a/pkg/services/ngalert/notifier/channels/testing.go +++ b/pkg/services/ngalert/notifier/channels/testing.go @@ -2,12 +2,61 @@ package channels import ( "context" + "encoding/base64" + "os" + "testing" "time" "github.com/grafana/grafana/pkg/models" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" ) +// deleteFunc deletes the fake image. +type deleteFunc func() + +// newFakeImageStore creates a fake image on disk and returns an image +// with a token, path and URL. The test should call deleteFunc to delete +// the image from disk at the end of the test. +func newFakeImageStore(t *testing.T) (ImageStore, deleteFunc) { + f, err := os.CreateTemp("", "ngalert-test-image-*.png") + if err != nil { + t.Fatalf("failed to create temp image: %s", err) + } + + // 1x1 transparent PNG + b, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=") + if err != nil { + t.Fatalf("failed to decode PNG data: %s", err) + } + + if _, err := f.Write(b); err != nil { + t.Fatalf("failed to write to file: %s", err) + } + + if err := f.Close(); err != nil { + t.Fatalf("failed to close file: %s", err) + } + + fn := func() { + if err := os.Remove(f.Name()); err != nil { + t.Fatalf("failed to delete file: %s", err) + } + } + + store := &fakeImageStore{ + Images: []*ngmodels.Image{ + { + Token: "test-image", + Path: f.Name(), + URL: "https://www.example.com/test-image.jpg", + CreatedAt: time.Now().UTC(), + }, + }, + } + + return store, fn +} + // mockTimeNow replaces function timeNow to return constant time. // It returns a function that resets the variable back to its original value. // This allows usage of this function with defer: