mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Attach screenshot data to Slack notifications. (#49374)
This change extracts screenshot data from alert messages via a private annotation `__alertScreenshotToken__` and attaches a URL to a Slack message or uploads the data to an image upload endpoint if needed. This change also implements a few foundational functions for use in other notifiers.
This commit is contained in:
@@ -85,7 +85,7 @@ func (e *EmbeddedContactPoint) Valid(decryptFunc channels.GetDecryptedValueFn) e
|
||||
cfg, _ := channels.NewFactoryConfig(&channels.NotificationChannelConfig{
|
||||
Settings: e.Settings,
|
||||
Type: e.Type,
|
||||
}, nil, decryptFunc, nil)
|
||||
}, nil, decryptFunc, nil, nil)
|
||||
if _, err := factory(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ func (s *ScreenshotImageService) NewImage(ctx context.Context, r *ngmodels.Alert
|
||||
DashboardUID: *r.DashboardUID,
|
||||
PanelID: *r.PanelID,
|
||||
})
|
||||
// TODO: Check for screenshot upload failures. These images should still be
|
||||
// stored because we have a local disk path that could be useful.
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to take screenshot: %w", err)
|
||||
}
|
||||
|
||||
@@ -86,11 +86,16 @@ type ClusterPeer interface {
|
||||
WaitReady(context.Context) error
|
||||
}
|
||||
|
||||
type AlertingStore interface {
|
||||
store.AlertingStore
|
||||
channels.ImageStore
|
||||
}
|
||||
|
||||
type Alertmanager struct {
|
||||
logger log.Logger
|
||||
|
||||
Settings *setting.Cfg
|
||||
Store store.AlertingStore
|
||||
Store AlertingStore
|
||||
fileStore *FileStore
|
||||
Metrics *metrics.Alertmanager
|
||||
NotificationService notifications.Service
|
||||
@@ -128,7 +133,7 @@ type Alertmanager struct {
|
||||
decryptFn channels.GetDecryptedValueFn
|
||||
}
|
||||
|
||||
func newAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store store.AlertingStore, kvStore kvstore.KVStore,
|
||||
func newAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store AlertingStore, kvStore kvstore.KVStore,
|
||||
peer ClusterPeer, decryptFn channels.GetDecryptedValueFn, ns notifications.Service, m *metrics.Alertmanager) (*Alertmanager, error) {
|
||||
am := &Alertmanager{
|
||||
Settings: cfg,
|
||||
@@ -499,7 +504,7 @@ func (am *Alertmanager) buildReceiverIntegration(r *apimodels.PostableGrafanaRec
|
||||
SecureSettings: secureSettings,
|
||||
}
|
||||
)
|
||||
factoryConfig, err := channels.NewFactoryConfig(cfg, am.NotificationService, am.decryptFn, tmpl)
|
||||
factoryConfig, err := channels.NewFactoryConfig(cfg, am.NotificationService, am.decryptFn, tmpl, am.Store)
|
||||
if err != nil {
|
||||
return nil, InvalidReceiverError{
|
||||
Receiver: r,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
@@ -12,11 +14,19 @@ type FactoryConfig struct {
|
||||
Config *NotificationChannelConfig
|
||||
NotificationService notifications.Service
|
||||
DecryptFunc GetDecryptedValueFn
|
||||
Template *template.Template
|
||||
ImageStore ImageStore
|
||||
// Used to retrieve image URLs for messages, or data for uploads.
|
||||
Template *template.Template
|
||||
}
|
||||
|
||||
// A specialization of store.ImageStore, to avoid an import loop.
|
||||
type ImageStore interface {
|
||||
GetURL(ctx context.Context, token string) (string, error)
|
||||
GetData(ctx context.Context, token string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func NewFactoryConfig(config *NotificationChannelConfig, notificationService notifications.Service,
|
||||
decryptFunc GetDecryptedValueFn, template *template.Template) (FactoryConfig, error) {
|
||||
decryptFunc GetDecryptedValueFn, template *template.Template, imageStore ImageStore) (FactoryConfig, error) {
|
||||
if config.Settings == nil {
|
||||
return FactoryConfig{}, errors.New("no settings supplied")
|
||||
}
|
||||
@@ -25,11 +35,16 @@ func NewFactoryConfig(config *NotificationChannelConfig, notificationService not
|
||||
if config.SecureSettings == nil {
|
||||
config.SecureSettings = map[string][]byte{}
|
||||
}
|
||||
|
||||
if imageStore == nil {
|
||||
imageStore = &UnavailableImageStore{}
|
||||
}
|
||||
return FactoryConfig{
|
||||
Config: config,
|
||||
NotificationService: notificationService,
|
||||
DecryptFunc: decryptFunc,
|
||||
Template: template,
|
||||
ImageStore: imageStore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
@@ -23,15 +26,19 @@ import (
|
||||
)
|
||||
|
||||
var SlackAPIEndpoint = "https://slack.com/api/chat.postMessage"
|
||||
var SlackImageAPIEndpoint = "https://slack.com/api/files.upload"
|
||||
|
||||
// SlackNotifier is responsible for sending
|
||||
// alert notification to Slack.
|
||||
type SlackNotifier struct {
|
||||
*Base
|
||||
log log.Logger
|
||||
tmpl *template.Template
|
||||
log log.Logger
|
||||
tmpl *template.Template
|
||||
images ImageStore
|
||||
webhookSender notifications.WebhookSender
|
||||
|
||||
URL *url.URL
|
||||
ImageUploadURL string
|
||||
Username string
|
||||
IconEmoji string
|
||||
IconURL string
|
||||
@@ -47,6 +54,7 @@ type SlackNotifier struct {
|
||||
type SlackConfig struct {
|
||||
*NotificationChannelConfig
|
||||
URL *url.URL
|
||||
ImageUploadURL string
|
||||
Username string
|
||||
IconEmoji string
|
||||
IconURL string
|
||||
@@ -60,19 +68,22 @@ type SlackConfig struct {
|
||||
}
|
||||
|
||||
func SlackFactory(fc FactoryConfig) (NotificationChannel, error) {
|
||||
cfg, err := NewSlackConfig(fc.Config, fc.DecryptFunc)
|
||||
cfg, err := NewSlackConfig(fc)
|
||||
if err != nil {
|
||||
return nil, receiverInitError{
|
||||
Reason: err.Error(),
|
||||
Cfg: *fc.Config,
|
||||
}
|
||||
}
|
||||
return NewSlackNotifier(cfg, fc.Template), nil
|
||||
return NewSlackNotifier(cfg, fc.ImageStore, fc.NotificationService, fc.Template), nil
|
||||
}
|
||||
|
||||
func NewSlackConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedValueFn) (*SlackConfig, error) {
|
||||
endpointURL := config.Settings.Get("endpointUrl").MustString(SlackAPIEndpoint)
|
||||
slackURL := decryptFunc(context.Background(), config.SecureSettings, "url", config.Settings.Get("url").MustString())
|
||||
func NewSlackConfig(factoryConfig FactoryConfig) (*SlackConfig, error) {
|
||||
channelConfig := factoryConfig.Config
|
||||
decryptFunc := factoryConfig.DecryptFunc
|
||||
endpointURL := channelConfig.Settings.Get("endpointUrl").MustString(SlackAPIEndpoint)
|
||||
imageUploadURL := channelConfig.Settings.Get("imageUploadUrl").MustString(SlackImageAPIEndpoint)
|
||||
slackURL := decryptFunc(context.Background(), channelConfig.SecureSettings, "url", channelConfig.Settings.Get("url").MustString())
|
||||
if slackURL == "" {
|
||||
slackURL = endpointURL
|
||||
}
|
||||
@@ -80,19 +91,19 @@ func NewSlackConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedV
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL %q", slackURL)
|
||||
}
|
||||
recipient := strings.TrimSpace(config.Settings.Get("recipient").MustString())
|
||||
recipient := strings.TrimSpace(channelConfig.Settings.Get("recipient").MustString())
|
||||
if recipient == "" && apiURL.String() == SlackAPIEndpoint {
|
||||
return nil, errors.New("recipient must be specified when using the Slack chat API")
|
||||
}
|
||||
mentionChannel := config.Settings.Get("mentionChannel").MustString()
|
||||
mentionChannel := channelConfig.Settings.Get("mentionChannel").MustString()
|
||||
if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" {
|
||||
return nil, fmt.Errorf("invalid value for mentionChannel: %q", mentionChannel)
|
||||
}
|
||||
token := decryptFunc(context.Background(), config.SecureSettings, "token", config.Settings.Get("token").MustString())
|
||||
token := decryptFunc(context.Background(), channelConfig.SecureSettings, "token", channelConfig.Settings.Get("token").MustString())
|
||||
if token == "" && apiURL.String() == SlackAPIEndpoint {
|
||||
return nil, errors.New("token must be specified when using the Slack chat API")
|
||||
}
|
||||
mentionUsersStr := config.Settings.Get("mentionUsers").MustString()
|
||||
mentionUsersStr := channelConfig.Settings.Get("mentionUsers").MustString()
|
||||
mentionUsers := []string{}
|
||||
for _, u := range strings.Split(mentionUsersStr, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
@@ -100,7 +111,7 @@ func NewSlackConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedV
|
||||
mentionUsers = append(mentionUsers, u)
|
||||
}
|
||||
}
|
||||
mentionGroupsStr := config.Settings.Get("mentionGroups").MustString()
|
||||
mentionGroupsStr := channelConfig.Settings.Get("mentionGroups").MustString()
|
||||
mentionGroups := []string{}
|
||||
for _, g := range strings.Split(mentionGroupsStr, ",") {
|
||||
g = strings.TrimSpace(g)
|
||||
@@ -109,23 +120,28 @@ func NewSlackConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedV
|
||||
}
|
||||
}
|
||||
return &SlackConfig{
|
||||
NotificationChannelConfig: config,
|
||||
Recipient: strings.TrimSpace(config.Settings.Get("recipient").MustString()),
|
||||
MentionChannel: config.Settings.Get("mentionChannel").MustString(),
|
||||
NotificationChannelConfig: channelConfig,
|
||||
Recipient: strings.TrimSpace(channelConfig.Settings.Get("recipient").MustString()),
|
||||
MentionChannel: channelConfig.Settings.Get("mentionChannel").MustString(),
|
||||
MentionUsers: mentionUsers,
|
||||
MentionGroups: mentionGroups,
|
||||
URL: apiURL,
|
||||
Username: config.Settings.Get("username").MustString("Grafana"),
|
||||
IconEmoji: config.Settings.Get("icon_emoji").MustString(),
|
||||
IconURL: config.Settings.Get("icon_url").MustString(),
|
||||
ImageUploadURL: imageUploadURL,
|
||||
Username: channelConfig.Settings.Get("username").MustString("Grafana"),
|
||||
IconEmoji: channelConfig.Settings.Get("icon_emoji").MustString(),
|
||||
IconURL: channelConfig.Settings.Get("icon_url").MustString(),
|
||||
Token: token,
|
||||
Text: config.Settings.Get("text").MustString(`{{ template "default.message" . }}`),
|
||||
Title: config.Settings.Get("title").MustString(DefaultMessageTitleEmbed),
|
||||
Text: channelConfig.Settings.Get("text").MustString(`{{ template "default.message" . }}`),
|
||||
Title: channelConfig.Settings.Get("title").MustString(DefaultMessageTitleEmbed),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewSlackNotifier is the constructor for the Slack notifier
|
||||
func NewSlackNotifier(config *SlackConfig, t *template.Template) *SlackNotifier {
|
||||
func NewSlackNotifier(config *SlackConfig,
|
||||
images ImageStore,
|
||||
webhookSender notifications.WebhookSender,
|
||||
t *template.Template,
|
||||
) *SlackNotifier {
|
||||
return &SlackNotifier{
|
||||
Base: NewBase(&models.AlertNotification{
|
||||
Uid: config.UID,
|
||||
@@ -135,6 +151,7 @@ func NewSlackNotifier(config *SlackConfig, t *template.Template) *SlackNotifier
|
||||
Settings: config.Settings,
|
||||
}),
|
||||
URL: config.URL,
|
||||
ImageUploadURL: config.ImageUploadURL,
|
||||
Recipient: config.Recipient,
|
||||
MentionUsers: config.MentionUsers,
|
||||
MentionGroups: config.MentionGroups,
|
||||
@@ -145,6 +162,8 @@ func NewSlackNotifier(config *SlackConfig, t *template.Template) *SlackNotifier
|
||||
Token: config.Token,
|
||||
Text: config.Text,
|
||||
Title: config.Title,
|
||||
images: images,
|
||||
webhookSender: webhookSender,
|
||||
log: log.New("alerting.notifier.slack"),
|
||||
tmpl: t,
|
||||
}
|
||||
@@ -165,6 +184,7 @@ type attachment struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
TitleLink string `json:"title_link,omitempty"`
|
||||
Text string `json:"text"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
Fallback string `json:"fallback"`
|
||||
Fields []config.SlackField `json:"fields,omitempty"`
|
||||
Footer string `json:"footer"`
|
||||
@@ -174,8 +194,8 @@ type attachment struct {
|
||||
}
|
||||
|
||||
// Notify sends an alert notification to Slack.
|
||||
func (sn *SlackNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
msg, err := sn.buildSlackMessage(ctx, as)
|
||||
func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
|
||||
msg, err := sn.buildSlackMessage(ctx, alerts)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("build slack message: %w", err)
|
||||
}
|
||||
@@ -205,6 +225,37 @@ func (sn *SlackNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
|
||||
if err := sendSlackRequest(request, sn.log); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Try to upload if we have an image path but no image URL. This uploads the file
|
||||
// immediately after the message. A bit of a hack, but it doesn't require the
|
||||
// user to have an image host set up.
|
||||
// TODO: how many image files should we upload? In what order? Should we
|
||||
// assume the alerts array is already sorted?
|
||||
// TODO: We need a refactoring so we don't do two database reads for the same data.
|
||||
// TODO: Should we process all alerts' annotations? We can only have on image.
|
||||
// TODO: Should we guard out-of-bounds errors here? Callers should prevent that from happening, imo
|
||||
imgToken := getTokenFromAnnotations(alerts[0].Annotations)
|
||||
dbContext, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
|
||||
imgData, err := sn.images.GetData(dbContext, imgToken)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrImagesUnavailable) {
|
||||
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
|
||||
sn.log.Warn("Error reading screenshot data from ImageStore: %v", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Nothing for us to do.
|
||||
_ = imgData.Close()
|
||||
}()
|
||||
|
||||
err = sn.slackFileUpload(ctx, imgData, sn.Recipient, sn.Token)
|
||||
if err != nil {
|
||||
sn.log.Warn("Error reading screenshot data from ImageStore: %v", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -275,11 +326,26 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Aler
|
||||
|
||||
ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log)
|
||||
|
||||
// TODO: Should we process all alerts' annotations? We can only have on image.
|
||||
// TODO: Should we guard out-of-bounds errors here? Callers should prevent that from happening, imo
|
||||
imgToken := getTokenFromAnnotations(as[0].Annotations)
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
|
||||
imgURL, err := sn.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.
|
||||
sn.log.Warn("failed to retrieve image url from store", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := &slackMessage{
|
||||
Channel: tmpl(sn.Recipient),
|
||||
Username: tmpl(sn.Username),
|
||||
IconEmoji: tmpl(sn.IconEmoji),
|
||||
IconURL: tmpl(sn.IconURL),
|
||||
// TODO: We should use the Block Kit API instead:
|
||||
// https://api.slack.com/messaging/composing/layouts#when-to-use-attachments
|
||||
Attachments: []attachment{
|
||||
{
|
||||
Color: getAlertStatusColor(alerts.Status()),
|
||||
@@ -287,6 +353,7 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Aler
|
||||
Fallback: tmpl(sn.Title),
|
||||
Footer: "Grafana v" + setting.BuildVersion,
|
||||
FooterIcon: FooterIconURL,
|
||||
ImageURL: imgURL,
|
||||
Ts: time.Now().Unix(),
|
||||
TitleLink: ruleURL,
|
||||
Text: tmpl(sn.Text),
|
||||
@@ -339,3 +406,59 @@ func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Aler
|
||||
func (sn *SlackNotifier) SendResolved() bool {
|
||||
return !sn.GetDisableResolveMessage()
|
||||
}
|
||||
|
||||
func (sn *SlackNotifier) slackFileUpload(ctx context.Context, data io.Reader, recipient, token string) error {
|
||||
sn.log.Info("Uploading to slack via file.upload API")
|
||||
headers, uploadBody, err := sn.generateFileUploadBody(data, token, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := &models.SendWebhookSync{
|
||||
Url: sn.ImageUploadURL, Body: uploadBody.String(), HttpHeader: headers, HttpMethod: "POST",
|
||||
}
|
||||
if err := sn.webhookSender.SendWebhookSync(ctx, cmd); err != nil {
|
||||
sn.log.Error("Failed to upload slack image", "error", err, "webhook", "file.upload")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sn *SlackNotifier) generateFileUploadBody(data io.Reader, token string, recipient string) (map[string]string, bytes.Buffer, error) {
|
||||
// Slack requires all POSTs to files.upload to present
|
||||
// an "application/x-www-form-urlencoded" encoded querystring
|
||||
// See https://api.slack.com/methods/files.upload
|
||||
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
|
||||
sn.log.Warn("Failed to close multipart writer", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// TODO: perhaps we should pass the filename through to here to use the local name.
|
||||
// https://github.com/grafana/grafana/issues/49375
|
||||
fw, err := w.CreateFormFile("file", fmt.Sprintf("screenshot-%v", rand.Intn(2e6)))
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
if _, err := io.Copy(fw, data); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
// Add the authorization token
|
||||
if err := w.WriteField("token", token); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
// Add the channel(s) to POST to
|
||||
if err := w.WriteField("channels", recipient); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, b, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
headers := map[string]string{
|
||||
"Content-Type": w.FormDataContentType(),
|
||||
"Authorization": "auth_token=\"" + token + "\"",
|
||||
}
|
||||
return headers, b, nil
|
||||
}
|
||||
|
||||
@@ -204,16 +204,21 @@ func TestSlackNotifier(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
secureSettings := make(map[string][]byte)
|
||||
|
||||
m := &NotificationChannelConfig{
|
||||
Name: "slack_testing",
|
||||
Type: "slack",
|
||||
Settings: settingsJSON,
|
||||
SecureSettings: secureSettings,
|
||||
}
|
||||
|
||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
decryptFn := secretsService.GetDecryptedValue
|
||||
cfg, err := NewSlackConfig(m, decryptFn)
|
||||
fc := FactoryConfig{
|
||||
Config: &NotificationChannelConfig{
|
||||
Name: "slack_testing",
|
||||
Type: "slack",
|
||||
Settings: settingsJSON,
|
||||
SecureSettings: secureSettings,
|
||||
},
|
||||
ImageStore: &UnavailableImageStore{},
|
||||
NotificationService: mockNotificationService(),
|
||||
DecryptFunc: decryptFn,
|
||||
}
|
||||
|
||||
cfg, err := NewSlackConfig(fc)
|
||||
if c.expInitError != "" {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, c.expInitError, err.Error())
|
||||
@@ -246,7 +251,7 @@ func TestSlackNotifier(t *testing.T) {
|
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
||||
pn := NewSlackNotifier(cfg, tmpl)
|
||||
pn := NewSlackNotifier(cfg, fc.ImageStore, fc.NotificationService, tmpl)
|
||||
ok, err := pn.Notify(ctx, c.alerts...)
|
||||
if c.expMsgError != nil {
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
@@ -25,13 +27,34 @@ const (
|
||||
FooterIconURL = "https://grafana.com/assets/img/fav32.png"
|
||||
ColorAlertFiring = "#D63232"
|
||||
ColorAlertResolved = "#36a64f"
|
||||
|
||||
// ImageStoreTimeout should be used by all callers for calles to `Images`
|
||||
ImageStoreTimeout time.Duration = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
// Provides current time. Can be overwritten in tests.
|
||||
timeNow = time.Now
|
||||
timeNow = time.Now
|
||||
ErrImagesUnavailable = errors.New("alert screenshots are unavailable")
|
||||
)
|
||||
|
||||
func getTokenFromAnnotations(annotations model.LabelSet) string {
|
||||
if value, ok := annotations[models.ScreenshotTokenAnnotation]; ok {
|
||||
return string(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type UnavailableImageStore struct{}
|
||||
|
||||
func (n *UnavailableImageStore) GetURL(ctx context.Context, token string) (string, error) {
|
||||
return "", ErrImagesUnavailable
|
||||
}
|
||||
|
||||
func (n *UnavailableImageStore) GetData(ctx context.Context, token string) (io.ReadCloser, error) {
|
||||
return nil, ErrImagesUnavailable
|
||||
}
|
||||
|
||||
type receiverInitError struct {
|
||||
Reason string
|
||||
Err error
|
||||
|
||||
@@ -44,7 +44,7 @@ type MultiOrgAlertmanager struct {
|
||||
peer ClusterPeer
|
||||
settleCancel context.CancelFunc
|
||||
|
||||
configStore store.AlertingStore
|
||||
configStore AlertingStore
|
||||
orgStore store.OrgStore
|
||||
kvStore kvstore.KVStore
|
||||
|
||||
@@ -54,7 +54,7 @@ type MultiOrgAlertmanager struct {
|
||||
ns notifications.Service
|
||||
}
|
||||
|
||||
func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore store.AlertingStore, orgStore store.OrgStore,
|
||||
func NewMultiOrgAlertmanager(cfg *setting.Cfg, configStore AlertingStore, orgStore store.OrgStore,
|
||||
kvStore kvstore.KVStore, provStore provisioning.ProvisioningStore, decryptFn channels.GetDecryptedValueFn,
|
||||
m *metrics.MultiOrgAlertmanager, ns notifications.Service, l log.Logger, s secrets.Service,
|
||||
) (*MultiOrgAlertmanager, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -18,6 +19,16 @@ type FakeConfigStore struct {
|
||||
configs map[int64]*models.AlertConfiguration
|
||||
}
|
||||
|
||||
func (f *FakeConfigStore) GetURL(ctx context.Context, token string) (string, error) {
|
||||
return "", store.ErrImageNotFound
|
||||
}
|
||||
|
||||
// Returns an io.ReadCloser that reads out the image data for the provided
|
||||
// token, if available. May return ErrImageNotFound.
|
||||
func (f *FakeConfigStore) GetData(ctx context.Context, token string) (io.ReadCloser, error) {
|
||||
return nil, store.ErrImageNotFound
|
||||
}
|
||||
|
||||
func NewFakeConfigStore(t *testing.T, configs map[int64]*models.AlertConfiguration) FakeConfigStore {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
@@ -36,6 +38,12 @@ type ImageStore interface {
|
||||
|
||||
// Saves the image or returns an error.
|
||||
SaveImage(ctx context.Context, img *Image) error
|
||||
|
||||
GetURL(ctx context.Context, token string) (string, error)
|
||||
|
||||
// Returns an io.ReadCloser that reads out the image data for the provided
|
||||
// token, if available. May return ErrImageNotFound.
|
||||
GetData(ctx context.Context, token string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
func (st DBstore) GetImage(ctx context.Context, token string) (*Image, error) {
|
||||
@@ -83,6 +91,35 @@ func (st DBstore) SaveImage(ctx context.Context, img *Image) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (st *DBstore) GetURL(ctx context.Context, token string) (string, error) {
|
||||
img, err := st.GetImage(ctx, token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return img.URL, nil
|
||||
}
|
||||
|
||||
func (st *DBstore) GetData(ctx context.Context, token string) (io.ReadCloser, error) {
|
||||
// TODO: Should we support getting data from image.URL? One could configure
|
||||
// the system to upload to S3 while still reading data for notifiers like
|
||||
// Slack that take multipart uploads.
|
||||
img, err := st.GetImage(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(img.Path) == 0 {
|
||||
return nil, ErrImageNotFound
|
||||
}
|
||||
|
||||
f, err := os.Open(img.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (st DBstore) DeleteExpiredImages(ctx context.Context) error {
|
||||
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
|
||||
@@ -475,7 +475,7 @@ func (m *migration) validateAlertmanagerConfig(orgID int64, config *PostableUser
|
||||
if !exists {
|
||||
return fmt.Errorf("notifier %s is not supported", gr.Type)
|
||||
}
|
||||
factoryConfig, err := channels.NewFactoryConfig(cfg, nil, decryptFunc, nil)
|
||||
factoryConfig, err := channels.NewFactoryConfig(cfg, nil, decryptFunc, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user