From 06b42ff68bab8fc711a6ab4af4d3f3faf590e85f Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 13 Jan 2023 17:04:32 -0500 Subject: [PATCH] ^ This is a combination of 5 commits. ^ This is the 1st commit message: refactor notifier package move services to deciated packages split configurations and implementations ^ The commit message #2 will be skipped: ^ move factory from Grafana ^ The commit message #3 will be skipped: ^ rename settings to config ^ The commit message #4 will be skipped: ^ reaname TemplateForTests to FroTests to appease linter ^ The commit message #5 will be skipped: ^ lint --- alerting/crypto.go | 1 - alerting/mimir_alertmanager.go | 1 - alerting/notifier/channels/images.go | 26 -- alerting/notifier/channels/webhook.go | 222 ------------------ images/images.go | 40 ++++ .../notifier/channels => images}/testing.go | 54 +---- .../notifier/channels => logging}/log.go | 2 +- {alerting/models => models}/labels.go | 0 {alerting => notify}/alerts.go | 2 +- notify/crypto.go | 1 + notify/factory.go | 54 +++++ {alerting => notify}/grafana_alertmanager.go | 8 +- .../grafana_alertmanager_metrics.go | 2 +- .../grafana_alertmanager_test.go | 16 +- notify/mimir_alertmanager.go | 1 + {alerting => notify}/multiorg_alertmanager.go | 2 +- {alerting => notify}/receivers.go | 2 +- {alerting => notify}/receivers_test.go | 2 +- {alerting => notify}/silences.go | 2 +- {alerting => notify}/status.go | 2 +- {alerting => notify}/testing.go | 2 +- .../alertmanager}/alertmanager.go | 40 ++-- .../alertmanager}/alertmanager_test.go | 35 +-- .../notifier/channels => receivers}/base.go | 2 +- receivers/config_util.go | 61 +++++ receivers/dinding/config.go | 40 ++++ .../dinding}/dingding.go | 63 ++--- .../dinding}/dingding_test.go | 16 +- receivers/discord/config.go | 36 +++ .../channels => receivers/discord}/discord.go | 79 +++---- .../discord}/discord_test.go | 19 +- .../channels/sender.go => receivers/email.go | 22 +- receivers/email/config.go | 59 +++++ .../channels => receivers/email}/email.go | 87 ++----- .../email}/email_test.go | 51 ++-- .../channels => receivers}/factory.go | 29 ++- receivers/googlechat/config.go | 35 +++ .../googlechat}/googlechat.go | 68 ++---- .../googlechat}/googlechat_test.go | 31 ++- receivers/kafka/config.go | 67 ++++++ .../channels => receivers/kafka}/kafka.go | 130 +++------- .../kafka}/kafka_test.go | 18 +- receivers/line/config.go | 36 +++ .../channels => receivers/line}/line.go | 55 ++--- .../channels => receivers/line}/line_test.go | 16 +- receivers/opsgenie/config.go | 87 +++++++ .../opsgenie}/opsgenie.go | 129 +++------- .../opsgenie}/opsgenie_test.go | 19 +- receivers/pagerduty/config.go | 82 +++++++ .../pagerduty}/pagerduty.go | 119 +++------- .../pagerduty}/pagerduty_test.go | 24 +- receivers/pushover/config.go | 94 ++++++++ .../pushover}/pushover.go | 154 +++--------- .../pushover}/pushover_test.go | 24 +- receivers/sensugo/config.go | 40 ++++ .../channels => receivers/sensugo}/sensugo.go | 75 +++--- .../sensugo}/sensugo_test.go | 30 ++- receivers/slack/config.go | 18 ++ .../channels => receivers/slack}/slack.go | 106 ++++----- .../slack}/slack_test.go | 25 +- receivers/teams/config.go | 36 +++ .../channels => receivers/teams}/teams.go | 66 ++---- .../teams}/teams_test.go | 19 +- receivers/telegram/config.go | 60 +++++ .../telegram}/telegram.go | 92 ++------ .../telegram}/telegram_test.go | 18 +- receivers/testing.go | 22 ++ receivers/threema/config.go | 59 +++++ .../channels => receivers/threema}/threema.go | 86 ++----- .../threema}/threema_test.go | 18 +- .../notifier/channels => receivers}/util.go | 150 +++--------- .../channels => receivers}/util_test.go | 14 +- receivers/victorops/config.go | 43 ++++ .../victorops}/victorops.go | 82 +++---- .../victorops}/victorops_test.go | 18 +- receivers/webex/config.go | 52 ++++ .../channels => receivers/webex}/webex.go | 79 ++----- .../webex}/webex_test.go | 18 +- receivers/webhook.go | 18 ++ receivers/webhook/config.go | 81 +++++++ receivers/webhook/webhook.go | 156 ++++++++++++ .../webhook}/webhook_test.go | 35 +-- receivers/wecom/config.go | 88 +++++++ .../channels => receivers/wecom}/wecom.go | 112 ++------- .../wecom}/wecom_test.go | 36 +-- .../default_template.go | 4 +- .../default_template_test.go | 6 +- .../channels => templates}/template_data.go | 11 +- 88 files changed, 2240 insertions(+), 1802 deletions(-) delete mode 100644 alerting/crypto.go delete mode 100644 alerting/mimir_alertmanager.go delete mode 100644 alerting/notifier/channels/images.go delete mode 100644 alerting/notifier/channels/webhook.go create mode 100644 images/images.go rename {alerting/notifier/channels => images}/testing.go (60%) rename {alerting/notifier/channels => logging}/log.go (98%) rename {alerting/models => models}/labels.go (100%) rename {alerting => notify}/alerts.go (99%) create mode 100644 notify/crypto.go create mode 100644 notify/factory.go rename {alerting => notify}/grafana_alertmanager.go (98%) rename {alerting => notify}/grafana_alertmanager_metrics.go (96%) rename {alerting => notify}/grafana_alertmanager_test.go (98%) create mode 100644 notify/mimir_alertmanager.go rename {alerting => notify}/multiorg_alertmanager.go (96%) rename {alerting => notify}/receivers.go (99%) rename {alerting => notify}/receivers_test.go (99%) rename {alerting => notify}/silences.go (99%) rename {alerting => notify}/status.go (93%) rename {alerting => notify}/testing.go (97%) rename {alerting/notifier/channels => receivers/alertmanager}/alertmanager.go (72%) rename {alerting/notifier/channels => receivers/alertmanager}/alertmanager_test.go (87%) rename {alerting/notifier/channels => receivers}/base.go (96%) create mode 100644 receivers/config_util.go create mode 100644 receivers/dinding/config.go rename {alerting/notifier/channels => receivers/dinding}/dingding.go (62%) rename {alerting/notifier/channels => receivers/dinding}/dingding_test.go (94%) create mode 100644 receivers/discord/config.go rename {alerting/notifier/channels => receivers/discord}/discord.go (76%) rename {alerting/notifier/channels => receivers/discord}/discord_test.go (96%) rename alerting/notifier/channels/sender.go => receivers/email.go (57%) create mode 100644 receivers/email/config.go rename {alerting/notifier/channels => receivers/email}/email.go (60%) rename {alerting/notifier/channels => receivers/email}/email_test.go (84%) rename {alerting/notifier/channels => receivers}/factory.go (63%) create mode 100644 receivers/googlechat/config.go rename {alerting/notifier/channels => receivers/googlechat}/googlechat.go (78%) rename {alerting/notifier/channels => receivers/googlechat}/googlechat_test.go (95%) create mode 100644 receivers/kafka/config.go rename {alerting/notifier/channels => receivers/kafka}/kafka.go (63%) rename {alerting/notifier/channels => receivers/kafka}/kafka_test.go (97%) create mode 100644 receivers/line/config.go rename {alerting/notifier/channels => receivers/line}/line.go (59%) rename {alerting/notifier/channels => receivers/line}/line_test.go (93%) create mode 100644 receivers/opsgenie/config.go rename {alerting/notifier/channels => receivers/opsgenie}/opsgenie.go (62%) rename {alerting/notifier/channels => receivers/opsgenie}/opsgenie_test.go (95%) create mode 100644 receivers/pagerduty/config.go rename {alerting/notifier/channels => receivers/pagerduty}/pagerduty.go (61%) rename {alerting/notifier/channels => receivers/pagerduty}/pagerduty_test.go (95%) create mode 100644 receivers/pushover/config.go rename {alerting/notifier/channels => receivers/pushover}/pushover.go (56%) rename {alerting/notifier/channels => receivers/pushover}/pushover_test.go (94%) create mode 100644 receivers/sensugo/config.go rename {alerting/notifier/channels => receivers/sensugo}/sensugo.go (62%) rename {alerting/notifier/channels => receivers/sensugo}/sensugo_test.go (89%) create mode 100644 receivers/slack/config.go rename {alerting/notifier/channels => receivers/slack}/slack.go (83%) rename {alerting/notifier/channels => receivers/slack}/slack_test.go (96%) create mode 100644 receivers/teams/config.go rename {alerting/notifier/channels => receivers/teams}/teams.go (84%) rename {alerting/notifier/channels => receivers/teams}/teams_test.go (94%) create mode 100644 receivers/telegram/config.go rename {alerting/notifier/channels => receivers/telegram}/telegram.go (59%) rename {alerting/notifier/channels => receivers/telegram}/telegram_test.go (92%) create mode 100644 receivers/testing.go create mode 100644 receivers/threema/config.go rename {alerting/notifier/channels => receivers/threema}/threema.go (50%) rename {alerting/notifier/channels => receivers/threema}/threema_test.go (93%) rename {alerting/notifier/channels => receivers}/util.go (61%) rename {alerting/notifier/channels => receivers}/util_test.go (70%) create mode 100644 receivers/victorops/config.go rename {alerting/notifier/channels => receivers/victorops}/victorops.go (60%) rename {alerting/notifier/channels => receivers/victorops}/victorops_test.go (95%) create mode 100644 receivers/webex/config.go rename {alerting/notifier/channels => receivers/webex}/webex.go (52%) rename {alerting/notifier/channels => receivers/webex}/webex_test.go (92%) create mode 100644 receivers/webhook.go create mode 100644 receivers/webhook/config.go create mode 100644 receivers/webhook/webhook.go rename {alerting/notifier/channels => receivers/webhook}/webhook_test.go (94%) create mode 100644 receivers/wecom/config.go rename {alerting/notifier/channels => receivers/wecom}/wecom.go (54%) rename {alerting/notifier/channels => receivers/wecom}/wecom_test.go (95%) rename {alerting/notifier/channels => templates}/default_template.go (98%) rename {alerting/notifier/channels => templates}/default_template_test.go (98%) rename {alerting/notifier/channels => templates}/template_data.go (95%) diff --git a/alerting/crypto.go b/alerting/crypto.go deleted file mode 100644 index d806a5d6..00000000 --- a/alerting/crypto.go +++ /dev/null @@ -1 +0,0 @@ -package alerting diff --git a/alerting/mimir_alertmanager.go b/alerting/mimir_alertmanager.go deleted file mode 100644 index d806a5d6..00000000 --- a/alerting/mimir_alertmanager.go +++ /dev/null @@ -1 +0,0 @@ -package alerting diff --git a/alerting/notifier/channels/images.go b/alerting/notifier/channels/images.go deleted file mode 100644 index ceb723a2..00000000 --- a/alerting/notifier/channels/images.go +++ /dev/null @@ -1,26 +0,0 @@ -package channels - -import ( - "context" - "errors" - "time" -) - -var ( - ErrImageNotFound = errors.New("image not found") -) - -type Image struct { - Token string - Path string - URL string - CreatedAt time.Time -} - -func (i Image) HasURL() bool { - return i.URL != "" -} - -type ImageStore interface { - GetImage(ctx context.Context, token string) (*Image, error) -} diff --git a/alerting/notifier/channels/webhook.go b/alerting/notifier/channels/webhook.go deleted file mode 100644 index ad8a642e..00000000 --- a/alerting/notifier/channels/webhook.go +++ /dev/null @@ -1,222 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strconv" - - "github.com/prometheus/alertmanager/notify" - "github.com/prometheus/alertmanager/template" - "github.com/prometheus/alertmanager/types" - "github.com/prometheus/common/model" -) - -// WebhookNotifier is responsible for sending -// alert notifications as webhooks. -type WebhookNotifier struct { - *Base - log Logger - ns WebhookSender - images ImageStore - tmpl *template.Template - orgID int64 - settings webhookSettings -} - -type webhookSettings struct { - URL string - HTTPMethod string - MaxAlerts int - // Authorization Header. - AuthorizationScheme string - AuthorizationCredentials string - // HTTP Basic Authentication. - User string - Password string - - Title string - Message string -} - -func buildWebhookSettings(factoryConfig FactoryConfig) (webhookSettings, error) { - settings := webhookSettings{} - rawSettings := struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - HTTPMethod string `json:"httpMethod,omitempty" yaml:"httpMethod,omitempty"` - MaxAlerts json.Number `json:"maxAlerts,omitempty" yaml:"maxAlerts,omitempty"` - AuthorizationScheme string `json:"authorization_scheme,omitempty" yaml:"authorization_scheme,omitempty"` - AuthorizationCredentials string `json:"authorization_credentials,omitempty" yaml:"authorization_credentials,omitempty"` - User string `json:"username,omitempty" yaml:"username,omitempty"` - Password string `json:"password,omitempty" yaml:"password,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - }{} - - err := factoryConfig.Config.unmarshalSettings(&rawSettings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if rawSettings.URL == "" { - return settings, errors.New("required field 'url' is not specified") - } - settings.URL = rawSettings.URL - - if rawSettings.HTTPMethod == "" { - rawSettings.HTTPMethod = http.MethodPost - } - settings.HTTPMethod = rawSettings.HTTPMethod - - if rawSettings.MaxAlerts != "" { - settings.MaxAlerts, _ = strconv.Atoi(rawSettings.MaxAlerts.String()) - } - - settings.User = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "username", rawSettings.User) - settings.Password = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "password", rawSettings.Password) - settings.AuthorizationCredentials = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "authorization_credentials", rawSettings.AuthorizationCredentials) - - if settings.AuthorizationCredentials != "" && settings.AuthorizationScheme == "" { - settings.AuthorizationScheme = "Bearer" - } - if settings.User != "" && settings.Password != "" && settings.AuthorizationScheme != "" && settings.AuthorizationCredentials != "" { - return settings, errors.New("both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted") - } - settings.Title = rawSettings.Title - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - settings.Message = rawSettings.Message - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - return settings, err -} - -func WebHookFactory(fc FactoryConfig) (NotificationChannel, error) { - notifier, err := buildWebhookNotifier(fc) - if err != nil { - return nil, receiverInitError{ - Reason: err.Error(), - Cfg: *fc.Config, - } - } - return notifier, nil -} - -// buildWebhookNotifier is the constructor for -// the WebHook notifier. -func buildWebhookNotifier(factoryConfig FactoryConfig) (*WebhookNotifier, error) { - settings, err := buildWebhookSettings(factoryConfig) - if err != nil { - return nil, err - } - return &WebhookNotifier{ - Base: NewBase(factoryConfig.Config), - orgID: factoryConfig.Config.OrgID, - log: factoryConfig.Logger, - ns: factoryConfig.NotificationService, - images: factoryConfig.ImageStore, - tmpl: factoryConfig.Template, - settings: settings, - }, nil -} - -// WebhookMessage defines the JSON object send to webhook endpoints. -type WebhookMessage struct { - *ExtendedData - - // The protocol version. - Version string `json:"version"` - GroupKey string `json:"groupKey"` - TruncatedAlerts int `json:"truncatedAlerts"` - OrgID int64 `json:"orgId"` - Title string `json:"title"` - State string `json:"state"` - Message string `json:"message"` -} - -// Notify implements the Notifier interface. -func (wn *WebhookNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - groupKey, err := notify.ExtractGroupKey(ctx) - if err != nil { - return false, err - } - - as, numTruncated := truncateAlerts(wn.settings.MaxAlerts, as) - var tmplErr error - tmpl, data := TmplText(ctx, wn.tmpl, as, wn.log, &tmplErr) - - // Augment our Alert data with ImageURLs if available. - _ = withStoredImages(ctx, wn.log, wn.images, - func(index int, image Image) error { - if len(image.URL) != 0 { - data.Alerts[index].ImageURL = image.URL - } - return nil - }, - as...) - - msg := &WebhookMessage{ - Version: "1", - ExtendedData: data, - GroupKey: groupKey.String(), - TruncatedAlerts: numTruncated, - OrgID: wn.orgID, - Title: tmpl(wn.settings.Title), - Message: tmpl(wn.settings.Message), - } - if types.Alerts(as...).Status() == model.AlertFiring { - msg.State = string(AlertStateAlerting) - } else { - msg.State = string(AlertStateOK) - } - - if tmplErr != nil { - wn.log.Warn("failed to template webhook message", "error", tmplErr.Error()) - tmplErr = nil - } - - body, err := json.Marshal(msg) - if err != nil { - return false, err - } - - headers := make(map[string]string) - if wn.settings.AuthorizationScheme != "" && wn.settings.AuthorizationCredentials != "" { - headers["Authorization"] = fmt.Sprintf("%s %s", wn.settings.AuthorizationScheme, wn.settings.AuthorizationCredentials) - } - - parsedURL := tmpl(wn.settings.URL) - if tmplErr != nil { - return false, tmplErr - } - - cmd := &SendWebhookSettings{ - URL: parsedURL, - User: wn.settings.User, - Password: wn.settings.Password, - Body: string(body), - HTTPMethod: wn.settings.HTTPMethod, - HTTPHeader: headers, - } - - if err := wn.ns.SendWebhook(ctx, cmd); err != nil { - return false, err - } - - return true, nil -} - -func truncateAlerts(maxAlerts int, alerts []*types.Alert) ([]*types.Alert, int) { - if maxAlerts > 0 && len(alerts) > maxAlerts { - return alerts[:maxAlerts], len(alerts) - maxAlerts - } - - return alerts, 0 -} - -func (wn *WebhookNotifier) SendResolved() bool { - return !wn.GetDisableResolveMessage() -} diff --git a/images/images.go b/images/images.go new file mode 100644 index 00000000..9c56c1d6 --- /dev/null +++ b/images/images.go @@ -0,0 +1,40 @@ +package images + +import ( + "context" + "errors" + "time" +) + +var ( + ErrImageNotFound = errors.New("image not found") + + // ErrImagesDone is used to stop iteration of subsequent images. It should be + // returned from forEachFunc when either the intended image has been found or + // the maximum number of images has been iterated. + ErrImagesDone = errors.New("images done") + + ErrImagesUnavailable = errors.New("alert screenshots are unavailable") +) + +type Image struct { + Token string + Path string + URL string + CreatedAt time.Time +} + +func (i Image) HasURL() bool { + return i.URL != "" +} + +type ImageStore interface { + GetImage(ctx context.Context, token string) (*Image, error) +} + +type UnavailableImageStore struct{} + +// GetImage returns the image with the corresponding token, or ErrImageNotFound. +func (u *UnavailableImageStore) GetImage(ctx context.Context, token string) (*Image, error) { + return nil, ErrImagesUnavailable +} diff --git a/alerting/notifier/channels/testing.go b/images/testing.go similarity index 60% rename from alerting/notifier/channels/testing.go rename to images/testing.go index ea5baf11..d45390d6 100644 --- a/alerting/notifier/channels/testing.go +++ b/images/testing.go @@ -1,4 +1,4 @@ -package channels +package images import ( "context" @@ -9,12 +9,12 @@ import ( "time" ) -type fakeImageStore struct { +type FakeImageStore struct { Images []*Image } // getImage returns an image with the same token. -func (f *fakeImageStore) GetImage(_ context.Context, token string) (*Image, error) { +func (f *FakeImageStore) GetImage(_ context.Context, token string) (*Image, error) { for _, img := range f.Images { if img.Token == token { return img, nil @@ -25,8 +25,8 @@ func (f *fakeImageStore) GetImage(_ context.Context, token string) (*Image, erro // newFakeImageStore returns an image store with N test images. // Each image has a token and a URL, but does not have a file on disk. -func newFakeImageStore(n int) ImageStore { - s := fakeImageStore{} +func NewFakeImageStore(n int) ImageStore { + s := FakeImageStore{} for i := 1; i <= n; i++ { s.Images = append(s.Images, &Image{ Token: fmt.Sprintf("test-image-%d", i), @@ -37,15 +37,15 @@ func newFakeImageStore(n int) ImageStore { return &s } -// newFakeImageStoreWithFile returns an image store with N test images. +// NewFakeImageStoreWithFile returns an image store with N test images. // Each image has a token, path and a URL, where the path is 1x1 transparent // PNG on disk. The test should call deleteFunc to delete the images from disk // at the end of the test. // nolint:deadcode,unused -func newFakeImageStoreWithFile(t *testing.T, n int) ImageStore { +func NewFakeImageStoreWithFile(t *testing.T, n int) ImageStore { var ( files []string - s fakeImageStore + s FakeImageStore ) t.Cleanup(func() { @@ -74,27 +74,6 @@ func newFakeImageStoreWithFile(t *testing.T, n int) ImageStore { return &s } -// 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: -// -// func Test (t *testing.T) { -// now := time.Now() -// defer mockTimeNow(now)() -// ... -// } -func mockTimeNow(constTime time.Time) func() { - timeNow = func() time.Time { - return constTime - } - return resetTimeNow -} - -// resetTimeNow resets the global variable timeNow to the default value, which is time.Now -func resetTimeNow() { - timeNow = time.Now -} - // nolint:deadcode,unused func newTestImage() (string, error) { f, err := os.CreateTemp("", "test-image-*.png") @@ -118,20 +97,3 @@ func newTestImage() (string, error) { return f.Name(), nil } - -type notificationServiceMock struct { - Webhook SendWebhookSettings - EmailSync SendEmailSettings - ShouldError error -} - -func (ns *notificationServiceMock) SendWebhook(ctx context.Context, cmd *SendWebhookSettings) error { - ns.Webhook = *cmd - return ns.ShouldError -} -func (ns *notificationServiceMock) SendEmail(ctx context.Context, cmd *SendEmailSettings) error { - ns.EmailSync = *cmd - return ns.ShouldError -} - -func mockNotificationService() *notificationServiceMock { return ¬ificationServiceMock{} } diff --git a/alerting/notifier/channels/log.go b/logging/log.go similarity index 98% rename from alerting/notifier/channels/log.go rename to logging/log.go index 6fe9148e..36402d5d 100644 --- a/alerting/notifier/channels/log.go +++ b/logging/log.go @@ -1,4 +1,4 @@ -package channels +package logging type LoggerFactory func(ctx ...interface{}) Logger diff --git a/alerting/models/labels.go b/models/labels.go similarity index 100% rename from alerting/models/labels.go rename to models/labels.go diff --git a/alerting/alerts.go b/notify/alerts.go similarity index 99% rename from alerting/alerts.go rename to notify/alerts.go index aa62d72e..48ea0308 100644 --- a/alerting/alerts.go +++ b/notify/alerts.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "fmt" diff --git a/notify/crypto.go b/notify/crypto.go new file mode 100644 index 00000000..a3131f13 --- /dev/null +++ b/notify/crypto.go @@ -0,0 +1 @@ +package notify diff --git a/notify/factory.go b/notify/factory.go new file mode 100644 index 00000000..865e9190 --- /dev/null +++ b/notify/factory.go @@ -0,0 +1,54 @@ +package notify + +import ( + "strings" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/receivers/alertmanager" + "github.com/grafana/alerting/receivers/dinding" + "github.com/grafana/alerting/receivers/discord" + "github.com/grafana/alerting/receivers/email" + "github.com/grafana/alerting/receivers/googlechat" + "github.com/grafana/alerting/receivers/kafka" + "github.com/grafana/alerting/receivers/line" + "github.com/grafana/alerting/receivers/opsgenie" + "github.com/grafana/alerting/receivers/pagerduty" + "github.com/grafana/alerting/receivers/pushover" + "github.com/grafana/alerting/receivers/sensugo" + "github.com/grafana/alerting/receivers/slack" + "github.com/grafana/alerting/receivers/teams" + "github.com/grafana/alerting/receivers/telegram" + "github.com/grafana/alerting/receivers/threema" + "github.com/grafana/alerting/receivers/victorops" + "github.com/grafana/alerting/receivers/webex" + "github.com/grafana/alerting/receivers/webhook" + "github.com/grafana/alerting/receivers/wecom" +) + +var receiverFactories = map[string]func(receivers.FactoryConfig) (receivers.NotificationChannel, error){ + "prometheus-alertmanager": alertmanager.AlertmanagerFactory, + "dingding": dinding.DingDingFactory, + "discord": discord.DiscordFactory, + "email": email.EmailFactory, + "googlechat": googlechat.GoogleChatFactory, + "kafka": kafka.KafkaFactory, + "line": line.LineFactory, + "opsgenie": opsgenie.OpsgenieFactory, + "pagerduty": pagerduty.PagerdutyFactory, + "pushover": pushover.PushoverFactory, + "sensugo": sensugo.SensuGoFactory, + "slack": slack.SlackFactory, + "teams": teams.TeamsFactory, + "telegram": telegram.TelegramFactory, + "threema": threema.ThreemaFactory, + "victorops": victorops.VictorOpsFactory, + "webhook": webhook.WebHookFactory, + "wecom": wecom.WeComFactory, + "webex": webex.WebexFactory, +} + +func Factory(receiverType string) (func(receivers.FactoryConfig) (receivers.NotificationChannel, error), bool) { + receiverType = strings.ToLower(receiverType) + factory, exists := receiverFactories[receiverType] + return factory, exists +} diff --git a/alerting/grafana_alertmanager.go b/notify/grafana_alertmanager.go similarity index 98% rename from alerting/grafana_alertmanager.go rename to notify/grafana_alertmanager.go index cf7ed9c1..e43c081a 100644 --- a/alerting/grafana_alertmanager.go +++ b/notify/grafana_alertmanager.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "context" @@ -10,8 +10,6 @@ import ( "time" "unicode/utf8" - "github.com/grafana/alerting/alerting/models" - "github.com/go-kit/log" "github.com/go-kit/log/level" amv2 "github.com/prometheus/alertmanager/api/v2/models" @@ -30,6 +28,8 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/models" ) const ( @@ -365,7 +365,7 @@ func (am *GrafanaAlertmanager) ApplyConfig(cfg Configuration) (err error) { am.route = dispatch.NewRoute(cfg.RoutingTree(), nil) am.dispatcher = dispatch.NewDispatcher(am.alerts, am.route, routingStage, am.marker, am.timeoutFunc, cfg.DispatcherLimits(), am.logger, am.dispatcherMetrics) - //TODO: This has not been upstreamed yet. Should be aligned when https://github.com/prometheus/alertmanager/pull/3016 is merged. + // TODO: This has not been upstreamed yet. Should be aligned when https://github.com/prometheus/alertmanager/pull/3016 is merged. var receivers []*notify.Receiver activeReceivers := am.getActiveReceiversMap(am.route) for name := range integrationsMap { diff --git a/alerting/grafana_alertmanager_metrics.go b/notify/grafana_alertmanager_metrics.go similarity index 96% rename from alerting/grafana_alertmanager_metrics.go rename to notify/grafana_alertmanager_metrics.go index 3294159b..2f0e28e0 100644 --- a/alerting/grafana_alertmanager_metrics.go +++ b/notify/grafana_alertmanager_metrics.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "github.com/prometheus/alertmanager/api/metrics" diff --git a/alerting/grafana_alertmanager_test.go b/notify/grafana_alertmanager_test.go similarity index 98% rename from alerting/grafana_alertmanager_test.go rename to notify/grafana_alertmanager_test.go index 82ef8895..b63bb165 100644 --- a/alerting/grafana_alertmanager_test.go +++ b/notify/grafana_alertmanager_test.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "context" @@ -377,36 +377,36 @@ func (f *FakeConfig) DispatcherLimits() DispatcherLimits { } func (f *FakeConfig) InhibitRules() []*InhibitRule { - //TODO implement me + // TODO implement me panic("implement me") } func (f *FakeConfig) MuteTimeIntervals() []MuteTimeInterval { - //TODO implement me + // TODO implement me panic("implement me") } func (f *FakeConfig) ReceiverIntegrations() (map[string][]Integration, error) { - //TODO implement me + // TODO implement me panic("implement me") } func (f *FakeConfig) RoutingTree() *Route { - //TODO implement me + // TODO implement me panic("implement me") } func (f *FakeConfig) Templates() *Template { - //TODO implement me + // TODO implement me panic("implement me") } func (f *FakeConfig) Hash() [16]byte { - //TODO implement me + // TODO implement me panic("implement me") } func (f *FakeConfig) Raw() []byte { - //TODO implement me + // TODO implement me panic("implement me") } diff --git a/notify/mimir_alertmanager.go b/notify/mimir_alertmanager.go new file mode 100644 index 00000000..a3131f13 --- /dev/null +++ b/notify/mimir_alertmanager.go @@ -0,0 +1 @@ +package notify diff --git a/alerting/multiorg_alertmanager.go b/notify/multiorg_alertmanager.go similarity index 96% rename from alerting/multiorg_alertmanager.go rename to notify/multiorg_alertmanager.go index 2e0046a5..232c503b 100644 --- a/alerting/multiorg_alertmanager.go +++ b/notify/multiorg_alertmanager.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "context" diff --git a/alerting/receivers.go b/notify/receivers.go similarity index 99% rename from alerting/receivers.go rename to notify/receivers.go index 3b313c9c..77dd3dd0 100644 --- a/alerting/receivers.go +++ b/notify/receivers.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "context" diff --git a/alerting/receivers_test.go b/notify/receivers_test.go similarity index 99% rename from alerting/receivers_test.go rename to notify/receivers_test.go index 3a29d92a..e65daeed 100644 --- a/alerting/receivers_test.go +++ b/notify/receivers_test.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "context" diff --git a/alerting/silences.go b/notify/silences.go similarity index 99% rename from alerting/silences.go rename to notify/silences.go index 735cd51e..8f4c6db5 100644 --- a/alerting/silences.go +++ b/notify/silences.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "errors" diff --git a/alerting/status.go b/notify/status.go similarity index 93% rename from alerting/status.go rename to notify/status.go index 7dcdb08c..f2576981 100644 --- a/alerting/status.go +++ b/notify/status.go @@ -1,4 +1,4 @@ -package alerting +package notify // TODO(gotjosh): I don't think this is right, make sure you evaluate it. func (am *GrafanaAlertmanager) GetStatus() []byte { diff --git a/alerting/testing.go b/notify/testing.go similarity index 97% rename from alerting/testing.go rename to notify/testing.go index da37f1ed..b31e8674 100644 --- a/alerting/testing.go +++ b/notify/testing.go @@ -1,4 +1,4 @@ -package alerting +package notify import ( "testing" diff --git a/alerting/notifier/channels/alertmanager.go b/receivers/alertmanager/alertmanager.go similarity index 72% rename from alerting/notifier/channels/alertmanager.go rename to receivers/alertmanager/alertmanager.go index 89db0c89..58a00929 100644 --- a/alerting/notifier/channels/alertmanager.go +++ b/receivers/alertmanager/alertmanager.go @@ -1,4 +1,4 @@ -package channels +package alertmanager import ( "context" @@ -10,10 +10,14 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" ) type AlertmanagerConfig struct { - *NotificationChannelConfig + *receivers.NotificationChannelConfig URLs []*url.URL BasicAuthUser string BasicAuthPassword string @@ -25,10 +29,10 @@ type alertmanagerSettings struct { Password string } -func AlertmanagerFactory(fc FactoryConfig) (NotificationChannel, error) { +func AlertmanagerFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { ch, err := buildAlertmanagerNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -36,11 +40,11 @@ func AlertmanagerFactory(fc FactoryConfig) (NotificationChannel, error) { return ch, nil } -func buildAlertmanagerNotifier(fc FactoryConfig) (*AlertmanagerNotifier, error) { +func buildAlertmanagerNotifier(fc receivers.FactoryConfig) (*AlertmanagerNotifier, error) { var settings struct { - URL CommaSeparatedStrings `json:"url,omitempty" yaml:"url,omitempty"` - User string `json:"basicAuthUser,omitempty" yaml:"basicAuthUser,omitempty"` - Password string `json:"basicAuthPassword,omitempty" yaml:"basicAuthPassword,omitempty"` + URL receivers.CommaSeparatedStrings `json:"url,omitempty" yaml:"url,omitempty"` + User string `json:"basicAuthUser,omitempty" yaml:"basicAuthUser,omitempty"` + Password string `json:"basicAuthPassword,omitempty" yaml:"basicAuthPassword,omitempty"` } err := json.Unmarshal(fc.Config.Settings, &settings) if err != nil { @@ -66,7 +70,7 @@ func buildAlertmanagerNotifier(fc FactoryConfig) (*AlertmanagerNotifier, error) settings.Password = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "basicAuthPassword", settings.Password) return &AlertmanagerNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), images: fc.ImageStore, settings: alertmanagerSettings{ URLs: urls, @@ -79,10 +83,10 @@ func buildAlertmanagerNotifier(fc FactoryConfig) (*AlertmanagerNotifier, error) // AlertmanagerNotifier sends alert notifications to the alert manager type AlertmanagerNotifier struct { - *Base - images ImageStore + *receivers.Base + images images.ImageStore settings alertmanagerSettings - logger Logger + logger logging.Logger } // Notify sends alert notifications to Alertmanager. @@ -92,8 +96,8 @@ func (n *AlertmanagerNotifier) Notify(ctx context.Context, as ...*types.Alert) ( return true, nil } - _ = withStoredImages(ctx, n.logger, n.images, - func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, n.logger, n.images, + func(index int, image images.Image) error { // If there is an image for this alert and the image has been uploaded // to a public URL then include it as an annotation if image.URL != "" { @@ -112,10 +116,10 @@ func (n *AlertmanagerNotifier) Notify(ctx context.Context, as ...*types.Alert) ( numErrs int ) for _, u := range n.settings.URLs { - if _, err := sendHTTPRequest(ctx, u, httpCfg{ - user: n.settings.User, - password: n.settings.Password, - body: body, + if _, err := receivers.SendHTTPRequest(ctx, u, receivers.HttpCfg{ + User: n.settings.User, + Password: n.settings.Password, + Body: body, }, n.logger); err != nil { n.logger.Warn("failed to send to Alertmanager", "error", err, "alertmanager", n.Name, "url", u.String()) lastErr = err diff --git a/alerting/notifier/channels/alertmanager_test.go b/receivers/alertmanager/alertmanager_test.go similarity index 87% rename from alerting/notifier/channels/alertmanager_test.go rename to receivers/alertmanager/alertmanager_test.go index f589249b..1a0d92a7 100644 --- a/alerting/notifier/channels/alertmanager_test.go +++ b/receivers/alertmanager/alertmanager_test.go @@ -1,4 +1,4 @@ -package channels +package alertmanager import ( "context" @@ -11,10 +11,15 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestNewAlertmanagerNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -68,7 +73,7 @@ func TestNewAlertmanagerNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { secureSettings := make(map[string][]byte) - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ Name: c.receiverName, Type: "prometheus-alertmanager", Settings: json.RawMessage(c.settings), @@ -79,12 +84,12 @@ func TestNewAlertmanagerNotifier(t *testing.T) { return fallback } - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, DecryptFunc: decryptFn, - ImageStore: &UnavailableImageStore{}, + ImageStore: &images.UnavailableImageStore{}, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } sn, err := buildAlertmanagerNotifier(fc) if c.expectedInitError != "" { @@ -97,9 +102,9 @@ func TestNewAlertmanagerNotifier(t *testing.T) { } func TestAlertmanagerNotifier_Notify(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) - images := newFakeImageStore(1) + images := receivers.NewFakeImageStore(1) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -172,7 +177,7 @@ func TestAlertmanagerNotifier_Notify(t *testing.T) { require.NoError(t, err) secureSettings := make(map[string][]byte) - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ Name: c.receiverName, Type: "prometheus-alertmanager", Settings: settingsJSON, @@ -182,23 +187,23 @@ func TestAlertmanagerNotifier_Notify(t *testing.T) { decryptFn := func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { return fallback } - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, DecryptFunc: decryptFn, ImageStore: images, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } sn, err := buildAlertmanagerNotifier(fc) require.NoError(t, err) var body []byte - origSendHTTPRequest := sendHTTPRequest + origSendHTTPRequest := receivers.SendHTTPRequest t.Cleanup(func() { - sendHTTPRequest = origSendHTTPRequest + receivers.SendHTTPRequest = origSendHTTPRequest }) - sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger Logger) ([]byte, error) { - body = cfg.body + receivers.SendHTTPRequest = func(ctx context.Context, url *url.URL, cfg receivers.HttpCfg, logger logging.Logger) ([]byte, error) { + body = cfg.Body return nil, c.sendHTTPRequestError } diff --git a/alerting/notifier/channels/base.go b/receivers/base.go similarity index 96% rename from alerting/notifier/channels/base.go rename to receivers/base.go index 99a1aaee..de0e8c85 100644 --- a/alerting/notifier/channels/base.go +++ b/receivers/base.go @@ -1,4 +1,4 @@ -package channels +package receivers // Base is the base implementation of a notifier. It contains the common fields across all notifier types. type Base struct { diff --git a/receivers/config_util.go b/receivers/config_util.go new file mode 100644 index 00000000..8d17f09d --- /dev/null +++ b/receivers/config_util.go @@ -0,0 +1,61 @@ +package receivers + +import ( + "encoding/json" + "strings" + + "gopkg.in/yaml.v3" +) + +type CommaSeparatedStrings []string + +func (r *CommaSeparatedStrings) UnmarshalJSON(b []byte) error { + var str string + if err := json.Unmarshal(b, &str); err != nil { + return err + } + if len(str) > 0 { + res := CommaSeparatedStrings(splitCommaDelimitedString(str)) + *r = res + } + return nil +} + +func (r *CommaSeparatedStrings) MarshalJSON() ([]byte, error) { + if r == nil { + return nil, nil + } + str := strings.Join(*r, ",") + return json.Marshal(str) +} + +func (r *CommaSeparatedStrings) UnmarshalYAML(b []byte) error { + var str string + if err := yaml.Unmarshal(b, &str); err != nil { + return err + } + if len(str) > 0 { + res := CommaSeparatedStrings(splitCommaDelimitedString(str)) + *r = res + } + return nil +} + +func (r *CommaSeparatedStrings) MarshalYAML() ([]byte, error) { + if r == nil { + return nil, nil + } + str := strings.Join(*r, ",") + return yaml.Marshal(str) +} + +func splitCommaDelimitedString(str string) []string { + split := strings.Split(str, ",") + res := make([]string, 0, len(split)) + for _, s := range split { + if tr := strings.TrimSpace(s); tr != "" { + res = append(res, tr) + } + } + return res +} diff --git a/receivers/dinding/config.go b/receivers/dinding/config.go new file mode 100644 index 00000000..0156fd11 --- /dev/null +++ b/receivers/dinding/config.go @@ -0,0 +1,40 @@ +package dinding + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type DingDingConfig struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + MessageType string `json:"msgType,omitempty" yaml:"msgType,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` +} + +const defaultDingdingMsgType = "link" + +func BuildDingDingConfig(fc receivers.FactoryConfig) (*DingDingConfig, error) { + var settings DingDingConfig + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + if settings.URL == "" { + return nil, errors.New("could not find url property in settings") + } + if settings.MessageType == "" { + settings.MessageType = defaultDingdingMsgType + } + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + if settings.Message == "" { + settings.Message = templates.DefaultMessageEmbed + } + return &settings, nil +} diff --git a/alerting/notifier/channels/dingding.go b/receivers/dinding/dingding.go similarity index 62% rename from alerting/notifier/channels/dingding.go rename to receivers/dinding/dingding.go index 9c76395a..6797904b 100644 --- a/alerting/notifier/channels/dingding.go +++ b/receivers/dinding/dingding.go @@ -1,50 +1,23 @@ -package channels +package dinding import ( "context" "encoding/json" - "errors" "fmt" "net/url" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" -) - -const defaultDingdingMsgType = "link" - -type dingDingSettings struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - MessageType string `json:"msgType,omitempty" yaml:"msgType,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` -} -func buildDingDingSettings(fc FactoryConfig) (*dingDingSettings, error) { - var settings dingDingSettings - err := json.Unmarshal(fc.Config.Settings, &settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if settings.URL == "" { - return nil, errors.New("could not find url property in settings") - } - if settings.MessageType == "" { - settings.MessageType = defaultDingdingMsgType - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - return &settings, nil -} + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" +) -func DingDingFactory(fc FactoryConfig) (NotificationChannel, error) { +func DingDingFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { n, err := newDingDingNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -53,13 +26,13 @@ func DingDingFactory(fc FactoryConfig) (NotificationChannel, error) { } // newDingDingNotifier is the constructor for the Dingding notifier -func newDingDingNotifier(fc FactoryConfig) (*DingDingNotifier, error) { - settings, err := buildDingDingSettings(fc) +func newDingDingNotifier(fc receivers.FactoryConfig) (*DingDingNotifier, error) { + settings, err := BuildDingDingConfig(fc) if err != nil { return nil, err } return &DingDingNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, tmpl: fc.Template, @@ -69,11 +42,11 @@ func newDingDingNotifier(fc FactoryConfig) (*DingDingNotifier, error) { // DingDingNotifier is responsible for sending alert notifications to ding ding. type DingDingNotifier struct { - *Base - log Logger - ns WebhookSender + *receivers.Base + log logging.Logger + ns receivers.WebhookSender tmpl *template.Template - settings dingDingSettings + settings DingDingConfig } // Notify sends the alert notification to dingding. @@ -83,7 +56,7 @@ func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo dingDingURL := buildDingDingURL(dd) var tmplErr error - tmpl, _ := TmplText(ctx, dd.tmpl, as, dd.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, dd.tmpl, as, dd.log, &tmplErr) message := tmpl(dd.settings.Message) title := tmpl(dd.settings.Title) @@ -105,9 +78,9 @@ func (dd *DingDingNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo u = dd.settings.URL } - cmd := &SendWebhookSettings{URL: u, Body: b} + cmd := &receivers.WebhookSendSettings{URL: u, Body: b} - if err := dd.ns.SendWebhook(ctx, cmd); err != nil { + if err := dd.ns.Send(ctx, cmd); err != nil { return false, fmt.Errorf("send notification to dingding: %w", err) } @@ -121,7 +94,7 @@ func (dd *DingDingNotifier) SendResolved() bool { func buildDingDingURL(dd *DingDingNotifier) string { q := url.Values{ "pc_slide": {"false"}, - "url": {joinURLPath(dd.tmpl.ExternalURL.String(), "/alerting/list", dd.log)}, + "url": {receivers.JoinURLPath(dd.tmpl.ExternalURL.String(), "/alerting/list", dd.log)}, } // Use special link to auto open the message url outside Dingding diff --git a/alerting/notifier/channels/dingding_test.go b/receivers/dinding/dingding_test.go similarity index 94% rename from alerting/notifier/channels/dingding_test.go rename to receivers/dinding/dingding_test.go index 7265f37c..a7a7c7ef 100644 --- a/alerting/notifier/channels/dingding_test.go +++ b/receivers/dinding/dingding_test.go @@ -1,4 +1,4 @@ -package channels +package dinding import ( "context" @@ -10,10 +10,14 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestDingdingNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -164,9 +168,9 @@ func TestDingdingNotifier(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - webhookSender := mockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + webhookSender := receivers.MockNotificationService() + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "dingding_testing", Type: "dingding", Settings: json.RawMessage(c.settings), @@ -174,7 +178,7 @@ func TestDingdingNotifier(t *testing.T) { // TODO: allow changing the associated values for different tests. NotificationService: webhookSender, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := newDingDingNotifier(fc) if c.expInitError != "" { diff --git a/receivers/discord/config.go b/receivers/discord/config.go new file mode 100644 index 00000000..b2fb1027 --- /dev/null +++ b/receivers/discord/config.go @@ -0,0 +1,36 @@ +package discord + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type DiscordConfig struct { + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + AvatarURL string `json:"avatar_url,omitempty" yaml:"avatar_url,omitempty"` + WebhookURL string `json:"url,omitempty" yaml:"url,omitempty"` + UseDiscordUsername bool `json:"use_discord_username,omitempty" yaml:"use_discord_username,omitempty"` +} + +func BuildDiscordConfig(fc receivers.FactoryConfig) (*DiscordConfig, error) { + var settings DiscordConfig + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + if settings.WebhookURL == "" { + return nil, errors.New("could not find webhook url property in settings") + } + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + if settings.Message == "" { + settings.Message = templates.DefaultMessageEmbed + } + return &settings, nil +} diff --git a/alerting/notifier/channels/discord.go b/receivers/discord/discord.go similarity index 76% rename from alerting/notifier/channels/discord.go rename to receivers/discord/discord.go index bb55636c..33dd18bc 100644 --- a/alerting/notifier/channels/discord.go +++ b/receivers/discord/discord.go @@ -1,4 +1,4 @@ -package channels +package discord import ( "bytes" @@ -16,6 +16,11 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) // Constants and models are set according to the official documentation https://discord.com/developers/docs/resources/webhook#execute-webhook-jsonform-params @@ -60,41 +65,15 @@ type discordImage struct { } type DiscordNotifier struct { - *Base - log Logger - ns WebhookSender - images ImageStore + *receivers.Base + log logging.Logger + ns receivers.WebhookSender + images images.ImageStore tmpl *template.Template - settings *discordSettings + settings *DiscordConfig appVersion string } -type discordSettings struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - AvatarURL string `json:"avatar_url,omitempty" yaml:"avatar_url,omitempty"` - WebhookURL string `json:"url,omitempty" yaml:"url,omitempty"` - UseDiscordUsername bool `json:"use_discord_username,omitempty" yaml:"use_discord_username,omitempty"` -} - -func buildDiscordSettings(fc FactoryConfig) (*discordSettings, error) { - var settings discordSettings - err := json.Unmarshal(fc.Config.Settings, &settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if settings.WebhookURL == "" { - return nil, errors.New("could not find webhook url property in settings") - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - return &settings, nil -} - type discordAttachment struct { url string reader io.ReadCloser @@ -103,10 +82,10 @@ type discordAttachment struct { state model.AlertStatus } -func DiscordFactory(fc FactoryConfig) (NotificationChannel, error) { +func DiscordFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { dn, err := newDiscordNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -114,13 +93,13 @@ func DiscordFactory(fc FactoryConfig) (NotificationChannel, error) { return dn, nil } -func newDiscordNotifier(fc FactoryConfig) (*DiscordNotifier, error) { - settings, err := buildDiscordSettings(fc) +func newDiscordNotifier(fc receivers.FactoryConfig) (*DiscordNotifier, error) { + settings, err := BuildDiscordConfig(fc) if err != nil { return nil, err } return &DiscordNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, images: fc.ImageStore, @@ -140,7 +119,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, } var tmplErr error - tmpl, _ := TmplText(ctx, d.tmpl, as, d.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, d.tmpl, as, d.log, &tmplErr) msg.Content = tmpl(d.settings.Message) if tmplErr != nil { @@ -148,7 +127,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, // Reset tmplErr for templating other fields. tmplErr = nil } - truncatedMsg, truncated := TruncateInRunes(msg.Content, discordMaxMessageLen) + truncatedMsg, truncated := receivers.TruncateInRunes(msg.Content, discordMaxMessageLen) if truncated { key, err := notify.ExtractGroupKey(ctx) if err != nil { @@ -183,17 +162,17 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, linkEmbed.Footer = footer linkEmbed.Type = discordRichEmbed - color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0) + color, _ := strconv.ParseInt(strings.TrimLeft(receivers.GetAlertStatusColor(alerts.Status()), "#"), 16, 0) linkEmbed.Color = color - ruleURL := joinURLPath(d.tmpl.ExternalURL.String(), "/alerting/list", d.log) + ruleURL := receivers.JoinURLPath(d.tmpl.ExternalURL.String(), "/alerting/list", d.log) linkEmbed.URL = ruleURL embeds := []discordLinkEmbed{linkEmbed} attachments := d.constructAttachments(ctx, as, discordMaxEmbeds-1) for _, a := range attachments { - color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0) + color, _ := strconv.ParseInt(strings.TrimLeft(receivers.GetAlertStatusColor(alerts.Status()), "#"), 16, 0) embed := discordLinkEmbed{ Image: &discordImage{ URL: a.url, @@ -227,7 +206,7 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, return false, err } - if err := d.ns.SendWebhook(ctx, cmd); err != nil { + if err := d.ns.Send(ctx, cmd); err != nil { d.log.Error("failed to send notification to Discord", "error", err) return false, err } @@ -241,10 +220,10 @@ func (d DiscordNotifier) SendResolved() bool { func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.Alert, embedQuota int) []discordAttachment { attachments := make([]discordAttachment, 0) - _ = withStoredImages(ctx, d.log, d.images, - func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, d.log, d.images, + func(index int, image images.Image) error { if embedQuota < 1 { - return ErrImagesDone + return images.ErrImagesDone } if len(image.URL) > 0 { @@ -261,8 +240,8 @@ func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.A if len(image.Path) > 0 { base := filepath.Base(image.Path) url := fmt.Sprintf("attachment://%s", base) - reader, err := openImage(image.Path) - if err != nil && !errors.Is(err, ErrImageNotFound) { + reader, err := receivers.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 } @@ -284,8 +263,8 @@ func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.A return attachments } -func (d DiscordNotifier) buildRequest(url string, body []byte, attachments []discordAttachment) (*SendWebhookSettings, error) { - cmd := &SendWebhookSettings{ +func (d DiscordNotifier) buildRequest(url string, body []byte, attachments []discordAttachment) (*receivers.WebhookSendSettings, error) { + cmd := &receivers.WebhookSendSettings{ URL: url, HTTPMethod: "POST", } diff --git a/alerting/notifier/channels/discord_test.go b/receivers/discord/discord_test.go similarity index 96% rename from alerting/notifier/channels/discord_test.go rename to receivers/discord/discord_test.go index dd2a54ff..88e90fe7 100644 --- a/alerting/notifier/channels/discord_test.go +++ b/receivers/discord/discord_test.go @@ -1,4 +1,4 @@ -package channels +package discord import ( "context" @@ -13,10 +13,15 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestDiscordNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -317,11 +322,11 @@ func TestDiscordNotifier(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - webhookSender := mockNotificationService() - imageStore := &UnavailableImageStore{} + webhookSender := receivers.MockNotificationService() + imageStore := &images.UnavailableImageStore{} - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "discord_testing", Type: "discord", Settings: json.RawMessage(c.settings), @@ -330,7 +335,7 @@ func TestDiscordNotifier(t *testing.T) { // TODO: allow changing the associated values for different tests. NotificationService: webhookSender, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, GrafanaBuildVersion: appVersion, } diff --git a/alerting/notifier/channels/sender.go b/receivers/email.go similarity index 57% rename from alerting/notifier/channels/sender.go rename to receivers/email.go index 9de257b2..4c194bbb 100644 --- a/alerting/notifier/channels/sender.go +++ b/receivers/email.go @@ -1,18 +1,7 @@ -package channels +package receivers import "context" -type SendWebhookSettings struct { - URL string - User string - Password string - Body string - HTTPMethod string - HTTPHeader map[string]string - ContentType string - Validation func(body []byte, statusCode int) error -} - // SendEmailSettings is the command for sending emails type SendEmailSettings struct { To []string @@ -32,15 +21,6 @@ type SendEmailAttachFile struct { Content []byte } -type WebhookSender interface { - SendWebhook(ctx context.Context, cmd *SendWebhookSettings) error -} - type EmailSender interface { SendEmail(ctx context.Context, cmd *SendEmailSettings) error } - -type NotificationSender interface { - WebhookSender - EmailSender -} diff --git a/receivers/email/config.go b/receivers/email/config.go new file mode 100644 index 00000000..3cfda503 --- /dev/null +++ b/receivers/email/config.go @@ -0,0 +1,59 @@ +package email + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type EmailConfig struct { + SingleEmail bool + Addresses []string + Message string + Subject string +} + +func BuildEmailConfig(fc receivers.FactoryConfig) (*EmailConfig, error) { + type emailSettingsRaw struct { + SingleEmail bool `json:"singleEmail,omitempty"` + Addresses string `json:"addresses,omitempty"` + Message string `json:"message,omitempty"` + Subject string `json:"subject,omitempty"` + } + + var settings emailSettingsRaw + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + if settings.Addresses == "" { + return nil, errors.New("could not find addresses in settings") + } + // split addresses with a few different ways + addresses := splitEmails(settings.Addresses) + + if settings.Subject == "" { + settings.Subject = templates.DefaultMessageTitleEmbed + } + + return &EmailConfig{ + SingleEmail: settings.SingleEmail, + Message: settings.Message, + Subject: settings.Subject, + Addresses: addresses, + }, nil +} + +func splitEmails(emails string) []string { + return strings.FieldsFunc(emails, func(r rune) bool { + switch r { + case ',', ';', '\n': + return true + } + return false + }) +} diff --git a/alerting/notifier/channels/email.go b/receivers/email/email.go similarity index 60% rename from alerting/notifier/channels/email.go rename to receivers/email/email.go index f817aa22..21777c95 100644 --- a/alerting/notifier/channels/email.go +++ b/receivers/email/email.go @@ -1,42 +1,36 @@ -package channels +package email import ( "context" - "encoding/json" - "errors" - "fmt" "net/url" "os" "path" "path/filepath" - "strings" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) // EmailNotifier is responsible for sending // alert notifications over email. type EmailNotifier struct { - *Base - log Logger - ns EmailSender - images ImageStore + *receivers.Base + log logging.Logger + ns receivers.EmailSender + images images.ImageStore tmpl *template.Template - settings *emailSettings -} - -type emailSettings struct { - SingleEmail bool - Addresses []string - Message string - Subject string + settings *EmailConfig } -func EmailFactory(fc FactoryConfig) (NotificationChannel, error) { +func EmailFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := buildEmailNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -44,44 +38,13 @@ func EmailFactory(fc FactoryConfig) (NotificationChannel, error) { return notifier, nil } -func buildEmailSettings(fc FactoryConfig) (*emailSettings, error) { - type emailSettingsRaw struct { - SingleEmail bool `json:"singleEmail,omitempty"` - Addresses string `json:"addresses,omitempty"` - Message string `json:"message,omitempty"` - Subject string `json:"subject,omitempty"` - } - - var settings emailSettingsRaw - err := json.Unmarshal(fc.Config.Settings, &settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if settings.Addresses == "" { - return nil, errors.New("could not find addresses in settings") - } - // split addresses with a few different ways - addresses := splitEmails(settings.Addresses) - - if settings.Subject == "" { - settings.Subject = DefaultMessageTitleEmbed - } - - return &emailSettings{ - SingleEmail: settings.SingleEmail, - Message: settings.Message, - Subject: settings.Subject, - Addresses: addresses, - }, nil -} - -func buildEmailNotifier(fc FactoryConfig) (*EmailNotifier, error) { - settings, err := buildEmailSettings(fc) +func buildEmailNotifier(fc receivers.FactoryConfig) (*EmailNotifier, error) { + settings, err := BuildEmailConfig(fc) if err != nil { return nil, err } return &EmailNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, images: fc.ImageStore, @@ -93,7 +56,7 @@ func buildEmailNotifier(fc FactoryConfig) (*EmailNotifier, error) { // Notify sends the alert notification. func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { var tmplErr error - tmpl, data := TmplText(ctx, en.tmpl, alerts, en.log, &tmplErr) + tmpl, data := template2.TmplText(ctx, en.tmpl, alerts, en.log, &tmplErr) subject := tmpl(en.settings.Subject) alertPageURL := en.tmpl.ExternalURL.String() @@ -111,8 +74,8 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo // Extend alerts data with images, if available. var embeddedFiles []string - _ = withStoredImages(ctx, en.log, en.images, - func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, en.log, en.images, + func(index int, image images.Image) error { if len(image.URL) != 0 { data.Alerts[index].ImageURL = image.URL } else if len(image.Path) != 0 { @@ -127,7 +90,7 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo return nil }, alerts...) - cmd := &SendEmailSettings{ + cmd := &receivers.SendEmailSettings{ Subject: subject, Data: map[string]interface{}{ "Title": subject, @@ -161,13 +124,3 @@ func (en *EmailNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo func (en *EmailNotifier) SendResolved() bool { return !en.GetDisableResolveMessage() } - -func splitEmails(emails string) []string { - return strings.FieldsFunc(emails, func(r rune) bool { - switch r { - case ',', ';', '\n': - return true - } - return false - }) -} diff --git a/alerting/notifier/channels/email_test.go b/receivers/email/email_test.go similarity index 84% rename from alerting/notifier/channels/email_test.go rename to receivers/email/email_test.go index 282bd957..3d014227 100644 --- a/alerting/notifier/channels/email_test.go +++ b/receivers/email/email_test.go @@ -1,4 +1,4 @@ -package channels +package email import ( "context" @@ -10,13 +10,18 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestEmailNotifier_Init(t *testing.T) { testCase := []struct { Name string Config json.RawMessage - Expected *emailSettings + Expected *EmailConfig ExpectedError string }{ { @@ -29,14 +34,14 @@ func TestEmailNotifier_Init(t *testing.T) { Config: json.RawMessage(`{ "addresses": "someops@example.com;somedev@example.com" }`), - Expected: &emailSettings{ + Expected: &EmailConfig{ SingleEmail: false, Addresses: []string{ "someops@example.com", "somedev@example.com", }, Message: "", - Subject: DefaultMessageTitleEmbed, + Subject: templates.DefaultMessageTitleEmbed, }, }, { @@ -44,14 +49,14 @@ func TestEmailNotifier_Init(t *testing.T) { Config: json.RawMessage(`{ "addresses": "someops@example.com,somedev@example.com" }`), - Expected: &emailSettings{ + Expected: &EmailConfig{ SingleEmail: false, Addresses: []string{ "someops@example.com", "somedev@example.com", }, Message: "", - Subject: DefaultMessageTitleEmbed, + Subject: templates.DefaultMessageTitleEmbed, }, }, { @@ -59,14 +64,14 @@ func TestEmailNotifier_Init(t *testing.T) { Config: json.RawMessage(`{ "addresses": "someops@example.com\nsomedev@example.com" }`), - Expected: &emailSettings{ + Expected: &EmailConfig{ SingleEmail: false, Addresses: []string{ "someops@example.com", "somedev@example.com", }, Message: "", - Subject: DefaultMessageTitleEmbed, + Subject: templates.DefaultMessageTitleEmbed, }, }, { @@ -74,7 +79,7 @@ func TestEmailNotifier_Init(t *testing.T) { Config: json.RawMessage(`{ "addresses": "someops@example.com\nsomedev@example.com;somedev2@example.com,somedev3@example.com" }`), - Expected: &emailSettings{ + Expected: &EmailConfig{ SingleEmail: false, Addresses: []string{ "someops@example.com", @@ -83,7 +88,7 @@ func TestEmailNotifier_Init(t *testing.T) { "somedev3@example.com", }, Message: "", - Subject: DefaultMessageTitleEmbed, + Subject: templates.DefaultMessageTitleEmbed, }, }, { @@ -91,7 +96,7 @@ func TestEmailNotifier_Init(t *testing.T) { Config: json.RawMessage(`{ "addresses": "someops@example.com\nsomedev@example.com;somedev2@example.com,somedev3@example.com" }`), - Expected: &emailSettings{ + Expected: &EmailConfig{ SingleEmail: false, Addresses: []string{ "someops@example.com", @@ -100,7 +105,7 @@ func TestEmailNotifier_Init(t *testing.T) { "somedev3@example.com", }, Message: "", - Subject: DefaultMessageTitleEmbed, + Subject: templates.DefaultMessageTitleEmbed, }, }, { @@ -111,7 +116,7 @@ func TestEmailNotifier_Init(t *testing.T) { "message": "test-message", "subject": "test-subject" }`), - Expected: &emailSettings{ + Expected: &EmailConfig{ SingleEmail: true, Addresses: []string{ "someops@example.com", @@ -124,12 +129,12 @@ func TestEmailNotifier_Init(t *testing.T) { for _, test := range testCase { t.Run(test.Name, func(t *testing.T) { - cfg := &NotificationChannelConfig{ + cfg := &receivers.NotificationChannelConfig{ Name: "ops", Type: "email", Settings: test.Config, } - settings, err := buildEmailSettings(FactoryConfig{Config: cfg}) + settings, err := BuildEmailConfig(receivers.FactoryConfig{Config: cfg}) if test.ExpectedError != "" { require.ErrorContains(t, err, test.ExpectedError) } else { @@ -140,7 +145,7 @@ func TestEmailNotifier_Init(t *testing.T) { } func TestEmailNotifier_Notify(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost/base") require.NoError(t, err) @@ -152,10 +157,10 @@ func TestEmailNotifier_Notify(t *testing.T) { "message": "{{ template \"default.title\" . }}" }` - emailSender := mockNotificationService() + emailSender := receivers.MockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "ops", Type: "email", Settings: json.RawMessage(jsonData), @@ -164,9 +169,9 @@ func TestEmailNotifier_Notify(t *testing.T) { DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { return fallback }, - ImageStore: &UnavailableImageStore{}, + ImageStore: &images.UnavailableImageStore{}, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } emailNotifier, err := EmailFactory(fc) @@ -201,8 +206,8 @@ func TestEmailNotifier_Notify(t *testing.T) { "Title": "[FIRING:1] (AlwaysFiring warning)", "Message": "[FIRING:1] (AlwaysFiring warning)", "Status": "firing", - "Alerts": ExtendedAlerts{ - ExtendedAlert{ + "Alerts": templates.ExtendedAlerts{ + templates.ExtendedAlert{ Status: "firing", Labels: template.KV{"alertname": "AlwaysFiring", "severity": "warning"}, Annotations: template.KV{"runbook_url": "http://fix.me"}, diff --git a/alerting/notifier/channels/factory.go b/receivers/factory.go similarity index 63% rename from alerting/notifier/channels/factory.go rename to receivers/factory.go index c3038008..ee5bdd33 100644 --- a/alerting/notifier/channels/factory.go +++ b/receivers/factory.go @@ -1,30 +1,49 @@ -package channels +package receivers import ( "context" + "encoding/json" "errors" "github.com/prometheus/alertmanager/template" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" ) // GetDecryptedValueFn is a function that returns the decrypted value of // the given key. If the key is not present, then it returns the fallback value. type GetDecryptedValueFn func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string +type NotificationSender interface { + WebhookSender + EmailSender +} + +type NotificationChannelConfig struct { + OrgID int64 // only used internally + UID string `json:"uid"` + Name string `json:"name"` + Type string `json:"type"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Settings json.RawMessage `json:"settings"` + SecureSettings map[string][]byte `json:"secureSettings"` +} + type FactoryConfig struct { Config *NotificationChannelConfig // Used by some receivers to include as part of the source GrafanaBuildVersion string NotificationService NotificationSender DecryptFunc GetDecryptedValueFn - ImageStore ImageStore + ImageStore images.ImageStore // Used to retrieve image URLs for messages, or data for uploads. Template *template.Template - Logger Logger + Logger logging.Logger } func NewFactoryConfig(config *NotificationChannelConfig, notificationService NotificationSender, - decryptFunc GetDecryptedValueFn, template *template.Template, imageStore ImageStore, loggerFactory LoggerFactory, buildVersion string) (FactoryConfig, error) { + decryptFunc GetDecryptedValueFn, template *template.Template, imageStore images.ImageStore, loggerFactory logging.LoggerFactory, buildVersion string) (FactoryConfig, error) { if config.Settings == nil { return FactoryConfig{}, errors.New("no settings supplied") } @@ -35,7 +54,7 @@ func NewFactoryConfig(config *NotificationChannelConfig, notificationService Not } if imageStore == nil { - imageStore = &UnavailableImageStore{} + imageStore = &images.UnavailableImageStore{} } return FactoryConfig{ Config: config, diff --git a/receivers/googlechat/config.go b/receivers/googlechat/config.go new file mode 100644 index 00000000..5d614725 --- /dev/null +++ b/receivers/googlechat/config.go @@ -0,0 +1,35 @@ +package googlechat + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type GoogleChatConfig struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` +} + +func BuildGoogleChatConfig(fc receivers.FactoryConfig) (*GoogleChatConfig, error) { + var settings GoogleChatConfig + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + if settings.URL == "" { + return nil, errors.New("could not find url property in settings") + } + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + if settings.Message == "" { + settings.Message = templates.DefaultMessageEmbed + } + return &settings, nil +} diff --git a/alerting/notifier/channels/googlechat.go b/receivers/googlechat/googlechat.go similarity index 78% rename from alerting/notifier/channels/googlechat.go rename to receivers/googlechat/googlechat.go index c7e228ee..40a41da4 100644 --- a/alerting/notifier/channels/googlechat.go +++ b/receivers/googlechat/googlechat.go @@ -1,58 +1,42 @@ -package channels +package googlechat import ( "context" "encoding/json" - "errors" "fmt" "net/url" "time" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) // GoogleChatNotifier is responsible for sending // alert notifications to Google chat. type GoogleChatNotifier struct { - *Base - log Logger - ns WebhookSender - images ImageStore + *receivers.Base + log logging.Logger + ns receivers.WebhookSender + images images.ImageStore tmpl *template.Template - settings *googleChatSettings + settings *GoogleChatConfig appVersion string } -type googleChatSettings struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` -} - -func buildGoogleChatSettings(fc FactoryConfig) (*googleChatSettings, error) { - var settings googleChatSettings - err := json.Unmarshal(fc.Config.Settings, &settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - if settings.URL == "" { - return nil, errors.New("could not find url property in settings") - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - return &settings, nil -} +var ( + // Provides current time. Can be overwritten in tests. + timeNow = time.Now +) -func GoogleChatFactory(fc FactoryConfig) (NotificationChannel, error) { +func GoogleChatFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { gcn, err := newGoogleChatNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -60,13 +44,13 @@ func GoogleChatFactory(fc FactoryConfig) (NotificationChannel, error) { return gcn, nil } -func newGoogleChatNotifier(fc FactoryConfig) (*GoogleChatNotifier, error) { - settings, err := buildGoogleChatSettings(fc) +func newGoogleChatNotifier(fc receivers.FactoryConfig) (*GoogleChatNotifier, error) { + settings, err := BuildGoogleChatConfig(fc) if err != nil { return nil, err } return &GoogleChatNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, images: fc.ImageStore, @@ -81,7 +65,7 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( gcn.log.Debug("executing Google Chat notification") var tmplErr error - tmpl, _ := TmplText(ctx, gcn.tmpl, as, gcn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, gcn.tmpl, as, gcn.log, &tmplErr) var widgets []widget @@ -96,7 +80,7 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( tmplErr = nil } - ruleURL := joinURLPath(gcn.tmpl.ExternalURL.String(), "/alerting/list", gcn.log) + ruleURL := receivers.JoinURLPath(gcn.tmpl.ExternalURL.String(), "/alerting/list", gcn.log) if gcn.isURLAbsolute(ruleURL) { // Add a button widget (link to Grafana). widgets = append(widgets, buttonWidget{ @@ -158,7 +142,7 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( return false, fmt.Errorf("marshal json: %w", err) } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: u, HTTPMethod: "POST", HTTPHeader: map[string]string{ @@ -167,7 +151,7 @@ func (gcn *GoogleChatNotifier) Notify(ctx context.Context, as ...*types.Alert) ( Body: string(body), } - if err := gcn.ns.SendWebhook(ctx, cmd); err != nil { + if err := gcn.ns.Send(ctx, cmd); err != nil { gcn.log.Error("Failed to send Google Hangouts Chat alert", "error", err, "webhook", gcn.Name) return false, err } @@ -195,8 +179,8 @@ func (gcn *GoogleChatNotifier) buildScreenshotCard(ctx context.Context, alerts [ Sections: []section{}, } - _ = withStoredImages(ctx, gcn.log, gcn.images, - func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, gcn.log, gcn.images, + func(index int, image images.Image) error { if len(image.URL) == 0 { return nil } diff --git a/alerting/notifier/channels/googlechat_test.go b/receivers/googlechat/googlechat_test.go similarity index 95% rename from alerting/notifier/channels/googlechat_test.go rename to receivers/googlechat/googlechat_test.go index 90b2e276..ab654fc2 100644 --- a/alerting/notifier/channels/googlechat_test.go +++ b/receivers/googlechat/googlechat_test.go @@ -1,4 +1,4 @@ -package channels +package googlechat import ( "context" @@ -13,6 +13,11 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestGoogleChatNotifier(t *testing.T) { @@ -456,17 +461,17 @@ func TestGoogleChatNotifier(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse(c.externalURL) require.NoError(t, err) tmpl.ExternalURL = externalURL - webhookSender := mockNotificationService() - imageStore := &UnavailableImageStore{} + webhookSender := receivers.MockNotificationService() + imageStore := &images.UnavailableImageStore{} - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "googlechat_testing", Type: "googlechat", Settings: json.RawMessage(c.settings), @@ -474,7 +479,7 @@ func TestGoogleChatNotifier(t *testing.T) { ImageStore: imageStore, NotificationService: webhookSender, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, GrafanaBuildVersion: appVersion, } @@ -507,3 +512,15 @@ func TestGoogleChatNotifier(t *testing.T) { }) } } + +// resetTimeNow resets the global variable timeNow to the default value, which is time.Now +func resetTimeNow() { + timeNow = time.Now +} + +func mockTimeNow(constTime time.Time) func() { + timeNow = func() time.Time { + return constTime + } + return resetTimeNow +} diff --git a/receivers/kafka/config.go b/receivers/kafka/config.go new file mode 100644 index 00000000..8281e0c1 --- /dev/null +++ b/receivers/kafka/config.go @@ -0,0 +1,67 @@ +package kafka + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +// The user can choose which API version to use when sending +// messages to Kafka. The default is v2. +// Details on how these versions differ can be found here: +// https://docs.confluent.io/platform/current/kafka-rest/api.html +const ( + KafkaAPIVersionV2 = "v2" + KafkaAPIVersionV3 = "v3" +) + +type KafkaConfig struct { + Endpoint string `json:"kafkaRestProxy,omitempty" yaml:"kafkaRestProxy,omitempty"` + Topic string `json:"kafkaTopic,omitempty" yaml:"kafkaTopic,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Details string `json:"details,omitempty" yaml:"details,omitempty"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` + APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` + KafkaClusterID string `json:"kafkaClusterId,omitempty" yaml:"kafkaClusterId,omitempty"` +} + +func BuildKafkaConfig(fc receivers.FactoryConfig) (*KafkaConfig, error) { + var settings KafkaConfig + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + if settings.Endpoint == "" { + return nil, errors.New("could not find kafka rest proxy endpoint property in settings") + } + settings.Endpoint = strings.TrimRight(settings.Endpoint, "/") + + if settings.Topic == "" { + return nil, errors.New("could not find kafka topic property in settings") + } + if settings.Description == "" { + settings.Description = templates.DefaultMessageTitleEmbed + } + if settings.Details == "" { + settings.Details = templates.DefaultMessageEmbed + } + settings.Password = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "password", settings.Password) + + if settings.APIVersion == "" { + settings.APIVersion = KafkaAPIVersionV2 + } else if settings.APIVersion == KafkaAPIVersionV3 { + if settings.KafkaClusterID == "" { + return nil, errors.New("kafka cluster id must be provided when using api version 3") + } + } else if settings.APIVersion != KafkaAPIVersionV2 && settings.APIVersion != KafkaAPIVersionV3 { + return nil, fmt.Errorf("unsupported api version: %s", settings.APIVersion) + } + return &settings, nil +} diff --git a/alerting/notifier/channels/kafka.go b/receivers/kafka/kafka.go similarity index 63% rename from alerting/notifier/channels/kafka.go rename to receivers/kafka/kafka.go index 22638d02..4a54272c 100644 --- a/alerting/notifier/channels/kafka.go +++ b/receivers/kafka/kafka.go @@ -1,16 +1,19 @@ -package channels +package kafka import ( "context" "encoding/json" - "errors" "fmt" - "strings" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) type kafkaBody struct { @@ -22,13 +25,13 @@ type kafkaRecordEnvelope struct { } type kafkaRecord struct { - Description string `json:"description"` - Client string `json:"client,omitempty"` - Details string `json:"details,omitempty"` - AlertState AlertStateType `json:"alert_state,omitempty"` - ClientURL string `json:"client_url,omitempty"` - Contexts []kafkaContext `json:"contexts,omitempty"` - IncidentKey string `json:"incident_key,omitempty"` + Description string `json:"description"` + Client string `json:"client,omitempty"` + Details string `json:"details,omitempty"` + AlertState receivers.AlertStateType `json:"alert_state,omitempty"` + ClientURL string `json:"client_url,omitempty"` + Contexts []kafkaContext `json:"contexts,omitempty"` + IncidentKey string `json:"incident_key,omitempty"` } type kafkaV3Record struct { @@ -44,73 +47,18 @@ type kafkaContext struct { // KafkaNotifier is responsible for sending // alert notifications to Kafka. type KafkaNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender + *receivers.Base + log logging.Logger + images images.ImageStore + ns receivers.WebhookSender tmpl *template.Template - settings *kafkaSettings -} - -type kafkaSettings struct { - Endpoint string `json:"kafkaRestProxy,omitempty" yaml:"kafkaRestProxy,omitempty"` - Topic string `json:"kafkaTopic,omitempty" yaml:"kafkaTopic,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Details string `json:"details,omitempty" yaml:"details,omitempty"` - Username string `json:"username,omitempty" yaml:"username,omitempty"` - Password string `json:"password,omitempty" yaml:"password,omitempty"` - APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` - KafkaClusterID string `json:"kafkaClusterId,omitempty" yaml:"kafkaClusterId,omitempty"` -} - -// The user can choose which API version to use when sending -// messages to Kafka. The default is v2. -// Details on how these versions differ can be found here: -// https://docs.confluent.io/platform/current/kafka-rest/api.html -const ( - APIVersionV2 = "v2" - APIVersionV3 = "v3" -) - -func buildKafkaSettings(fc FactoryConfig) (*kafkaSettings, error) { - var settings kafkaSettings - err := json.Unmarshal(fc.Config.Settings, &settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - if settings.Endpoint == "" { - return nil, errors.New("could not find kafka rest proxy endpoint property in settings") - } - settings.Endpoint = strings.TrimRight(settings.Endpoint, "/") - - if settings.Topic == "" { - return nil, errors.New("could not find kafka topic property in settings") - } - if settings.Description == "" { - settings.Description = DefaultMessageTitleEmbed - } - if settings.Details == "" { - settings.Details = DefaultMessageEmbed - } - settings.Password = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "password", settings.Password) - - if settings.APIVersion == "" { - settings.APIVersion = APIVersionV2 - } else if settings.APIVersion == APIVersionV3 { - if settings.KafkaClusterID == "" { - return nil, errors.New("kafka cluster id must be provided when using api version 3") - } - } else if settings.APIVersion != APIVersionV2 && settings.APIVersion != APIVersionV3 { - return nil, fmt.Errorf("unsupported api version: %s", settings.APIVersion) - } - return &settings, nil + settings *KafkaConfig } -func KafkaFactory(fc FactoryConfig) (NotificationChannel, error) { +func KafkaFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { ch, err := newKafkaNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -119,14 +67,14 @@ func KafkaFactory(fc FactoryConfig) (NotificationChannel, error) { } // newKafkaNotifier is the constructor function for the Kafka notifier. -func newKafkaNotifier(fc FactoryConfig) (*KafkaNotifier, error) { - settings, err := buildKafkaSettings(fc) +func newKafkaNotifier(fc receivers.FactoryConfig) (*KafkaNotifier, error) { + settings, err := BuildKafkaConfig(fc) if err != nil { return nil, err } return &KafkaNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, images: fc.ImageStore, ns: fc.NotificationService, @@ -137,7 +85,7 @@ func newKafkaNotifier(fc FactoryConfig) (*KafkaNotifier, error) { // Notify sends the alert notification. func (kn *KafkaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { - if kn.settings.APIVersion == APIVersionV3 { + if kn.settings.APIVersion == KafkaAPIVersionV3 { return kn.notifyWithAPIV3(ctx, as...) } return kn.notifyWithAPIV2(ctx, as...) @@ -146,7 +94,7 @@ func (kn *KafkaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, // Use the v2 API to send the alert notification. func (kn *KafkaNotifier) notifyWithAPIV2(ctx context.Context, as ...*types.Alert) (bool, error) { var tmplErr error - tmpl, _ := TmplText(ctx, kn.tmpl, as, kn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, kn.tmpl, as, kn.log, &tmplErr) topicURL := kn.settings.Endpoint + "/topics/" + tmpl(kn.settings.Topic) if tmplErr != nil { @@ -161,7 +109,7 @@ func (kn *KafkaNotifier) notifyWithAPIV2(ctx context.Context, as ...*types.Alert kn.log.Warn("failed to template Kafka message", "error", tmplErr.Error()) } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: topicURL, Body: body, HTTPMethod: "POST", @@ -173,7 +121,7 @@ func (kn *KafkaNotifier) notifyWithAPIV2(ctx context.Context, as ...*types.Alert Password: kn.settings.Password, } - if err := kn.ns.SendWebhook(ctx, cmd); err != nil { + if err := kn.ns.Send(ctx, cmd); err != nil { kn.log.Error("Failed to send notification to Kafka", "error", err, "body", body) return false, err } @@ -183,7 +131,7 @@ func (kn *KafkaNotifier) notifyWithAPIV2(ctx context.Context, as ...*types.Alert // Use the v3 API to send the alert notification. func (kn *KafkaNotifier) notifyWithAPIV3(ctx context.Context, as ...*types.Alert) (bool, error) { var tmplErr error - tmpl, _ := TmplText(ctx, kn.tmpl, as, kn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, kn.tmpl, as, kn.log, &tmplErr) // For v3 the Produce URL is like this, // /v3/clusters//topics//records @@ -200,7 +148,7 @@ func (kn *KafkaNotifier) notifyWithAPIV3(ctx context.Context, as ...*types.Alert kn.log.Warn("failed to template Kafka message", "error", tmplErr.Error()) } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: topicURL, Body: body, HTTPMethod: "POST", @@ -214,10 +162,10 @@ func (kn *KafkaNotifier) notifyWithAPIV3(ctx context.Context, as ...*types.Alert } // TODO: Convert to a stream - keep a single connection open and send records on it. - // Can be implemented nicely using channels. The v3 API can be used in streaming mode + // Can be implemented nicely using receivers. The v3 API can be used in streaming mode // by setting “Transfer-Encoding: chunked” header. // For as long as the connection is kept open, the server will keep accepting records. - if err := kn.ns.SendWebhook(ctx, cmd); err != nil { + if err := kn.ns.Send(ctx, cmd); err != nil { kn.log.Error("Failed to send notification to Kafka", "error", err, "body", body) return false, err } @@ -262,7 +210,7 @@ func (kn *KafkaNotifier) SendResolved() bool { } func (kn *KafkaNotifier) buildBody(ctx context.Context, tmpl func(string) string, as ...*types.Alert) (string, error) { - if kn.settings.APIVersion == APIVersionV3 { + if kn.settings.APIVersion == KafkaAPIVersionV3 { return kn.buildV3Body(ctx, tmpl, as...) } return kn.buildV2Body(ctx, tmpl, as...) @@ -312,7 +260,7 @@ func (kn *KafkaNotifier) buildKafkaRecord(ctx context.Context, record *kafkaReco kn.log.Debug("notifying Kafka", "alert_state", state) record.AlertState = state - ruleURL := joinURLPath(kn.tmpl.ExternalURL.String(), "/alerting/list", kn.log) + ruleURL := receivers.JoinURLPath(kn.tmpl.ExternalURL.String(), "/alerting/list", kn.log) record.ClientURL = ruleURL contexts := buildContextImages(ctx, kn.log, kn.images, as...) @@ -328,19 +276,19 @@ func (kn *KafkaNotifier) buildKafkaRecord(ctx context.Context, record *kafkaReco return nil } -func buildState(as ...*types.Alert) AlertStateType { +func buildState(as ...*types.Alert) receivers.AlertStateType { // We are using the state from 7.x to not break kafka. // TODO: should we switch to the new ones? if types.Alerts(as...).Status() == model.AlertResolved { - return AlertStateOK + return receivers.AlertStateOK } - return AlertStateAlerting + return receivers.AlertStateAlerting } -func buildContextImages(ctx context.Context, l Logger, imageStore ImageStore, as ...*types.Alert) []kafkaContext { +func buildContextImages(ctx context.Context, l logging.Logger, imageStore images.ImageStore, as ...*types.Alert) []kafkaContext { var contexts []kafkaContext - _ = withStoredImages(ctx, l, imageStore, - func(_ int, image Image) error { + _ = receivers.WithStoredImages(ctx, l, imageStore, + func(_ int, image images.Image) error { if image.URL != "" { contexts = append(contexts, kafkaContext{ Type: "image", diff --git a/alerting/notifier/channels/kafka_test.go b/receivers/kafka/kafka_test.go similarity index 97% rename from alerting/notifier/channels/kafka_test.go rename to receivers/kafka/kafka_test.go index 1ff545e2..5922c3f0 100644 --- a/alerting/notifier/channels/kafka_test.go +++ b/receivers/kafka/kafka_test.go @@ -1,4 +1,4 @@ -package channels +package kafka import ( "context" @@ -11,12 +11,16 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestKafkaNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) - images := newFakeImageStore(2) + images := receivers.NewFakeImageStore(2) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -384,10 +388,10 @@ func TestKafkaNotifier(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "kafka_testing", Type: "kafka", Settings: json.RawMessage(c.settings), @@ -399,7 +403,7 @@ func TestKafkaNotifier(t *testing.T) { return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := newKafkaNotifier(fc) diff --git a/receivers/line/config.go b/receivers/line/config.go new file mode 100644 index 00000000..164e06e6 --- /dev/null +++ b/receivers/line/config.go @@ -0,0 +1,36 @@ +package line + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type LineConfig struct { + Token string `json:"token,omitempty" yaml:"token,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` +} + +func BuildLineConfig(fc receivers.FactoryConfig) (*LineConfig, error) { + var settings LineConfig + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + settings.Token = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "token", settings.Token) + if settings.Token == "" { + return nil, errors.New("could not find token in settings") + } + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + if settings.Description == "" { + settings.Description = templates.DefaultMessageEmbed + } + return &settings, nil +} diff --git a/alerting/notifier/channels/line.go b/receivers/line/line.go similarity index 59% rename from alerting/notifier/channels/line.go rename to receivers/line/line.go index feffbab4..b104cd16 100644 --- a/alerting/notifier/channels/line.go +++ b/receivers/line/line.go @@ -1,15 +1,17 @@ -package channels +package line import ( "context" - "encoding/json" - "errors" "fmt" "net/url" "path" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) var ( @@ -19,42 +21,17 @@ var ( // LineNotifier is responsible for sending // alert notifications to LINE. type LineNotifier struct { - *Base - log Logger - ns WebhookSender + *receivers.Base + log logging.Logger + ns receivers.WebhookSender tmpl *template.Template - settings *lineSettings -} - -type lineSettings struct { - Token string `json:"token,omitempty" yaml:"token,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` -} - -func buildLineSettings(fc FactoryConfig) (*lineSettings, error) { - var settings lineSettings - err := json.Unmarshal(fc.Config.Settings, &settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - settings.Token = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "token", settings.Token) - if settings.Token == "" { - return nil, errors.New("could not find token in settings") - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - if settings.Description == "" { - settings.Description = DefaultMessageEmbed - } - return &settings, nil + settings *LineConfig } -func LineFactory(fc FactoryConfig) (NotificationChannel, error) { +func LineFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { n, err := newLineNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -63,14 +40,14 @@ func LineFactory(fc FactoryConfig) (NotificationChannel, error) { } // newLineNotifier is the constructor for the LINE notifier -func newLineNotifier(fc FactoryConfig) (*LineNotifier, error) { - settings, err := buildLineSettings(fc) +func newLineNotifier(fc receivers.FactoryConfig) (*LineNotifier, error) { + settings, err := BuildLineConfig(fc) if err != nil { return nil, err } return &LineNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, tmpl: fc.Template, @@ -87,7 +64,7 @@ func (ln *LineNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e form := url.Values{} form.Add("message", body) - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: LineNotifyURL, HTTPMethod: "POST", HTTPHeader: map[string]string{ @@ -113,7 +90,7 @@ func (ln *LineNotifier) buildMessage(ctx context.Context, as ...*types.Alert) st ruleURL := path.Join(ln.tmpl.ExternalURL.String(), "/alerting/list") var tmplErr error - tmpl, _ := TmplText(ctx, ln.tmpl, as, ln.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, ln.tmpl, as, ln.log, &tmplErr) body := fmt.Sprintf( "%s\n%s\n\n%s", diff --git a/alerting/notifier/channels/line_test.go b/receivers/line/line_test.go similarity index 93% rename from alerting/notifier/channels/line_test.go rename to receivers/line/line_test.go index e75cbc28..7f7ba187 100644 --- a/alerting/notifier/channels/line_test.go +++ b/receivers/line/line_test.go @@ -1,4 +1,4 @@ -package channels +package line import ( "context" @@ -10,10 +10,14 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestLineNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -95,10 +99,10 @@ func TestLineNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON := json.RawMessage(c.settings) secureSettings := make(map[string][]byte) - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "line_testing", Type: "line", Settings: settingsJSON, @@ -110,7 +114,7 @@ func TestLineNotifier(t *testing.T) { return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := newLineNotifier(fc) if c.expInitError != "" { diff --git a/receivers/opsgenie/config.go b/receivers/opsgenie/config.go new file mode 100644 index 00000000..c47a019d --- /dev/null +++ b/receivers/opsgenie/config.go @@ -0,0 +1,87 @@ +package opsgenie + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +const ( + OpsgenieSendTags = "tags" + OpsgenieSendDetails = "details" + OpsgenieSendBoth = "both" + + DefaultOpsgenieAlertURL = "https://api.opsgenie.com/v2/alerts" +) + +type OpsgenieConfig struct { + APIKey string + APIUrl string + Message string + Description string + AutoClose bool + OverridePriority bool + SendTagsAs string +} + +func BuildOpsgenieConfig(fc receivers.FactoryConfig) (*OpsgenieConfig, error) { + type rawSettings struct { + APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` + APIUrl string `json:"apiUrl,omitempty" yaml:"apiUrl,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + AutoClose *bool `json:"autoClose,omitempty" yaml:"autoClose,omitempty"` + OverridePriority *bool `json:"overridePriority,omitempty" yaml:"overridePriority,omitempty"` + SendTagsAs string `json:"sendTagsAs,omitempty" yaml:"sendTagsAs,omitempty"` + } + + raw := rawSettings{} + err := json.Unmarshal(fc.Config.Settings, &raw) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + raw.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apiKey", raw.APIKey) + if raw.APIKey == "" { + return nil, errors.New("could not find api key property in settings") + } + if raw.APIUrl == "" { + raw.APIUrl = DefaultOpsgenieAlertURL + } + + if strings.TrimSpace(raw.Message) == "" { + raw.Message = templates.DefaultMessageTitleEmbed + } + + switch raw.SendTagsAs { + case OpsgenieSendTags, OpsgenieSendDetails, OpsgenieSendBoth: + case "": + raw.SendTagsAs = OpsgenieSendTags + default: + return nil, fmt.Errorf("invalid value for sendTagsAs: %q", raw.SendTagsAs) + } + + if raw.AutoClose == nil { + autoClose := true + raw.AutoClose = &autoClose + } + if raw.OverridePriority == nil { + overridePriority := true + raw.OverridePriority = &overridePriority + } + + return &OpsgenieConfig{ + APIKey: raw.APIKey, + APIUrl: raw.APIUrl, + Message: raw.Message, + Description: raw.Description, + AutoClose: *raw.AutoClose, + OverridePriority: *raw.OverridePriority, + SendTagsAs: raw.SendTagsAs, + }, nil +} diff --git a/alerting/notifier/channels/opsgenie.go b/receivers/opsgenie/opsgenie.go similarity index 62% rename from alerting/notifier/channels/opsgenie.go rename to receivers/opsgenie/opsgenie.go index 49f75e00..8e44263e 100644 --- a/alerting/notifier/channels/opsgenie.go +++ b/receivers/opsgenie/opsgenie.go @@ -1,9 +1,8 @@ -package channels +package opsgenie import ( "context" "encoding/json" - "errors" "fmt" "net/http" "sort" @@ -13,102 +12,36 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) const ( - OpsgenieSendTags = "tags" - OpsgenieSendDetails = "details" - OpsgenieSendBoth = "both" // https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes. opsGenieMaxMessageLenRunes = 130 ) var ( - OpsgenieAlertURL = "https://api.opsgenie.com/v2/alerts" - ValidPriorities = map[string]bool{"P1": true, "P2": true, "P3": true, "P4": true, "P5": true} + ValidPriorities = map[string]bool{"P1": true, "P2": true, "P3": true, "P4": true, "P5": true} ) // OpsgenieNotifier is responsible for sending alert notifications to Opsgenie. type OpsgenieNotifier struct { - *Base + *receivers.Base tmpl *template.Template - log Logger - ns WebhookSender - images ImageStore - settings *opsgenieSettings -} - -type opsgenieSettings struct { - APIKey string - APIUrl string - Message string - Description string - AutoClose bool - OverridePriority bool - SendTagsAs string -} - -func buildOpsgenieSettings(fc FactoryConfig) (*opsgenieSettings, error) { - type rawSettings struct { - APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` - APIUrl string `json:"apiUrl,omitempty" yaml:"apiUrl,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - AutoClose *bool `json:"autoClose,omitempty" yaml:"autoClose,omitempty"` - OverridePriority *bool `json:"overridePriority,omitempty" yaml:"overridePriority,omitempty"` - SendTagsAs string `json:"sendTagsAs,omitempty" yaml:"sendTagsAs,omitempty"` - } - - raw := rawSettings{} - err := fc.Config.unmarshalSettings(&raw) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - raw.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apiKey", raw.APIKey) - if raw.APIKey == "" { - return nil, errors.New("could not find api key property in settings") - } - if raw.APIUrl == "" { - raw.APIUrl = OpsgenieAlertURL - } - - if strings.TrimSpace(raw.Message) == "" { - raw.Message = DefaultMessageTitleEmbed - } - - switch raw.SendTagsAs { - case OpsgenieSendTags, OpsgenieSendDetails, OpsgenieSendBoth: - case "": - raw.SendTagsAs = OpsgenieSendTags - default: - return nil, fmt.Errorf("invalid value for sendTagsAs: %q", raw.SendTagsAs) - } - - if raw.AutoClose == nil { - autoClose := true - raw.AutoClose = &autoClose - } - if raw.OverridePriority == nil { - overridePriority := true - raw.OverridePriority = &overridePriority - } - - return &opsgenieSettings{ - APIKey: raw.APIKey, - APIUrl: raw.APIUrl, - Message: raw.Message, - Description: raw.Description, - AutoClose: *raw.AutoClose, - OverridePriority: *raw.OverridePriority, - SendTagsAs: raw.SendTagsAs, - }, nil + log logging.Logger + ns receivers.WebhookSender + images images.ImageStore + settings *OpsgenieConfig } -func OpsgenieFactory(fc FactoryConfig) (NotificationChannel, error) { +func OpsgenieFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := NewOpsgenieNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -117,13 +50,13 @@ func OpsgenieFactory(fc FactoryConfig) (NotificationChannel, error) { } // NewOpsgenieNotifier is the constructor for the Opsgenie notifier -func NewOpsgenieNotifier(fc FactoryConfig) (*OpsgenieNotifier, error) { - settings, err := buildOpsgenieSettings(fc) +func NewOpsgenieNotifier(fc receivers.FactoryConfig) (*OpsgenieNotifier, error) { + settings, err := BuildOpsgenieConfig(fc) if err != nil { return nil, err } return &OpsgenieNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), tmpl: fc.Template, log: fc.Logger, ns: fc.NotificationService, @@ -153,7 +86,7 @@ func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo return true, nil } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: url, Body: string(body), HTTPMethod: http.MethodPost, @@ -163,7 +96,7 @@ func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo }, } - if err := on.ns.SendWebhook(ctx, cmd); err != nil { + if err := on.ns.Send(ctx, cmd); err != nil { return false, fmt.Errorf("send notification to Opsgenie: %w", err) } @@ -190,12 +123,12 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod return data, apiURL, err } - ruleURL := joinURLPath(on.tmpl.ExternalURL.String(), "/alerting/list", on.log) + ruleURL := receivers.JoinURLPath(on.tmpl.ExternalURL.String(), "/alerting/list", on.log) var tmplErr error - tmpl, data := TmplText(ctx, on.tmpl, as, on.log, &tmplErr) + tmpl, data := templates.TmplText(ctx, on.tmpl, as, on.log, &tmplErr) - message, truncated := TruncateInRunes(tmpl(on.settings.Message), opsGenieMaxMessageLenRunes) + message, truncated := receivers.TruncateInRunes(tmpl(on.settings.Message), opsGenieMaxMessageLenRunes) if truncated { on.log.Warn("Truncated message", "alert", key, "max_runes", opsGenieMaxMessageLenRunes) } @@ -204,15 +137,15 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod if strings.TrimSpace(description) == "" { description = fmt.Sprintf( "%s\n%s\n\n%s", - tmpl(DefaultMessageTitleEmbed), + tmpl(templates.DefaultMessageTitleEmbed), ruleURL, - tmpl(DefaultMessageEmbed), + tmpl(templates.DefaultMessageEmbed), ) } var priority string - // In the new alerting system we've moved away from the grafana-tags. Instead, annotations on the rule itself should be used. + // In the new notify system we've moved away from the grafana-tags. Instead, annotations on the rule itself should be used. lbls := make(map[string]string, len(data.CommonLabels)) for k, v := range data.CommonLabels { lbls[k] = tmpl(v) @@ -235,19 +168,19 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod for k, v := range lbls { details[k] = v } - var images []string - _ = withStoredImages(ctx, on.log, on.images, - func(_ int, image Image) error { + var imageUrls []string + _ = receivers.WithStoredImages(ctx, on.log, on.images, + func(_ int, image images.Image) error { if len(image.URL) == 0 { return nil } - images = append(images, image.URL) + imageUrls = append(imageUrls, image.URL) return nil }, as...) - if len(images) != 0 { - details["image_urls"] = images + if len(imageUrls) != 0 { + details["image_urls"] = imageUrls } } diff --git a/alerting/notifier/channels/opsgenie_test.go b/receivers/opsgenie/opsgenie_test.go similarity index 95% rename from alerting/notifier/channels/opsgenie_test.go rename to receivers/opsgenie/opsgenie_test.go index 7ee3e50c..1756ef13 100644 --- a/alerting/notifier/channels/opsgenie_test.go +++ b/receivers/opsgenie/opsgenie_test.go @@ -1,4 +1,4 @@ -package channels +package opsgenie import ( "context" @@ -11,10 +11,15 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestOpsgenieNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -230,11 +235,11 @@ func TestOpsgenieNotifier(t *testing.T) { settingsJSON := json.RawMessage(c.settings) secureSettings := make(map[string][]byte) - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() webhookSender.Webhook.Body = "" - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "opsgenie_testing", Type: "opsgenie", Settings: settingsJSON, @@ -244,9 +249,9 @@ func TestOpsgenieNotifier(t *testing.T) { DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { return fallback }, - ImageStore: &UnavailableImageStore{}, + ImageStore: &images.UnavailableImageStore{}, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } ctx := notify.WithGroupKey(context.Background(), "alertname") diff --git a/receivers/pagerduty/config.go b/receivers/pagerduty/config.go new file mode 100644 index 00000000..9b1e6c4e --- /dev/null +++ b/receivers/pagerduty/config.go @@ -0,0 +1,82 @@ +package pagerduty + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +const ( + DefaultPagerDutySeverity = "critical" + DefaultPagerDutyClass = "default" + DefaultPagerDutyGroup = "default" + DefaultPagerDutyClient = "Grafana" +) + +type PagerdutyConfig struct { + Key string `json:"integrationKey,omitempty" yaml:"integrationKey,omitempty"` + Severity string `json:"severity,omitempty" yaml:"severity,omitempty"` + CustomDetails map[string]string `json:"-" yaml:"-"` // TODO support the settings in the config + Class string `json:"class,omitempty" yaml:"class,omitempty"` + Component string `json:"component,omitempty" yaml:"component,omitempty"` + Group string `json:"group,omitempty" yaml:"group,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Source string `json:"source,omitempty" yaml:"source,omitempty"` + Client string `json:"client,omitempty" yaml:"client,omitempty"` + ClientURL string `json:"client_url,omitempty" yaml:"client_url,omitempty"` +} + +func BuildPagerdutyConfig(fc receivers.FactoryConfig) (*PagerdutyConfig, error) { + settings := PagerdutyConfig{} + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + settings.Key = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "integrationKey", settings.Key) + if settings.Key == "" { + return nil, errors.New("could not find integration key property in settings") + } + + settings.CustomDetails = map[string]string{ + "firing": `{{ template "__text_alert_list" .Alerts.Firing }}`, + "resolved": `{{ template "__text_alert_list" .Alerts.Resolved }}`, + "num_firing": `{{ .Alerts.Firing | len }}`, + "num_resolved": `{{ .Alerts.Resolved | len }}`, + } + + if settings.Severity == "" { + settings.Severity = DefaultPagerDutySeverity + } + if settings.Class == "" { + settings.Class = DefaultPagerDutyClass + } + if settings.Component == "" { + settings.Component = "Grafana" + } + if settings.Group == "" { + settings.Group = DefaultPagerDutyGroup + } + if settings.Summary == "" { + settings.Summary = templates.DefaultMessageTitleEmbed + } + if settings.Client == "" { + settings.Client = DefaultPagerDutyClient + } + if settings.ClientURL == "" { + settings.ClientURL = "{{ .ExternalURL }}" + } + if settings.Source == "" { + source, err := os.Hostname() + if err != nil { + source = settings.Client + } + settings.Source = source + } + return &settings, nil +} diff --git a/alerting/notifier/channels/pagerduty.go b/receivers/pagerduty/pagerduty.go similarity index 61% rename from alerting/notifier/channels/pagerduty.go rename to receivers/pagerduty/pagerduty.go index 63b41518..9a5f770f 100644 --- a/alerting/notifier/channels/pagerduty.go +++ b/receivers/pagerduty/pagerduty.go @@ -1,17 +1,20 @@ -package channels +package pagerduty import ( "context" "encoding/json" - "errors" "fmt" - "os" "strings" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) const ( @@ -22,96 +25,28 @@ const ( const ( pagerDutyEventTrigger = "trigger" pagerDutyEventResolve = "resolve" - - defaultSeverity = "critical" - defaultClass = "default" - defaultGroup = "default" - defaultClient = "Grafana" ) var ( - knownSeverity = map[string]struct{}{defaultSeverity: {}, "error": {}, "warning": {}, "info": {}} + knownSeverity = map[string]struct{}{DefaultPagerDutySeverity: {}, "error": {}, "warning": {}, "info": {}} PagerdutyEventAPIURL = "https://events.pagerduty.com/v2/enqueue" ) // PagerdutyNotifier is responsible for sending // alert notifications to pagerduty type PagerdutyNotifier struct { - *Base + *receivers.Base tmpl *template.Template - log Logger - ns WebhookSender - images ImageStore - settings *pagerdutySettings -} - -type pagerdutySettings struct { - Key string `json:"integrationKey,omitempty" yaml:"integrationKey,omitempty"` - Severity string `json:"severity,omitempty" yaml:"severity,omitempty"` - customDetails map[string]string - Class string `json:"class,omitempty" yaml:"class,omitempty"` - Component string `json:"component,omitempty" yaml:"component,omitempty"` - Group string `json:"group,omitempty" yaml:"group,omitempty"` - Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` - Source string `json:"source,omitempty" yaml:"source,omitempty"` - Client string `json:"client,omitempty" yaml:"client,omitempty"` - ClientURL string `json:"client_url,omitempty" yaml:"client_url,omitempty"` -} - -func buildPagerdutySettings(fc FactoryConfig) (*pagerdutySettings, error) { - settings := pagerdutySettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - settings.Key = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "integrationKey", settings.Key) - if settings.Key == "" { - return nil, errors.New("could not find integration key property in settings") - } - - settings.customDetails = map[string]string{ - "firing": `{{ template "__text_alert_list" .Alerts.Firing }}`, - "resolved": `{{ template "__text_alert_list" .Alerts.Resolved }}`, - "num_firing": `{{ .Alerts.Firing | len }}`, - "num_resolved": `{{ .Alerts.Resolved | len }}`, - } - - if settings.Severity == "" { - settings.Severity = defaultSeverity - } - if settings.Class == "" { - settings.Class = defaultClass - } - if settings.Component == "" { - settings.Component = "Grafana" - } - if settings.Group == "" { - settings.Group = defaultGroup - } - if settings.Summary == "" { - settings.Summary = DefaultMessageTitleEmbed - } - if settings.Client == "" { - settings.Client = defaultClient - } - if settings.ClientURL == "" { - settings.ClientURL = "{{ .ExternalURL }}" - } - if settings.Source == "" { - source, err := os.Hostname() - if err != nil { - source = settings.Client - } - settings.Source = source - } - return &settings, nil + log logging.Logger + ns receivers.WebhookSender + images images.ImageStore + settings *PagerdutyConfig } -func PagerdutyFactory(fc FactoryConfig) (NotificationChannel, error) { +func PagerdutyFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { pdn, err := newPagerdutyNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -120,14 +55,14 @@ func PagerdutyFactory(fc FactoryConfig) (NotificationChannel, error) { } // NewPagerdutyNotifier is the constructor for the PagerDuty notifier -func newPagerdutyNotifier(fc FactoryConfig) (*PagerdutyNotifier, error) { - settings, err := buildPagerdutySettings(fc) +func newPagerdutyNotifier(fc receivers.FactoryConfig) (*PagerdutyNotifier, error) { + settings, err := BuildPagerdutyConfig(fc) if err != nil { return nil, err } return &PagerdutyNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), tmpl: fc.Template, log: fc.Logger, ns: fc.NotificationService, @@ -155,7 +90,7 @@ func (pn *PagerdutyNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo } pn.log.Info("notifying Pagerduty", "event_type", eventType) - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: PagerdutyEventAPIURL, Body: string(body), HTTPMethod: "POST", @@ -163,7 +98,7 @@ func (pn *PagerdutyNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo "Content-Type": "application/json", }, } - if err := pn.ns.SendWebhook(ctx, cmd); err != nil { + if err := pn.ns.Send(ctx, cmd); err != nil { return false, fmt.Errorf("send notification to Pagerduty: %w", err) } @@ -182,10 +117,10 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m } var tmplErr error - tmpl, data := TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) + tmpl, data := template2.TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) - details := make(map[string]string, len(pn.settings.customDetails)) - for k, v := range pn.settings.customDetails { + details := make(map[string]string, len(pn.settings.CustomDetails)) + for k, v := range pn.settings.CustomDetails { detail, err := pn.tmpl.ExecuteTextString(v, data) if err != nil { return nil, "", fmt.Errorf("%q: failed to template %q: %w", k, v, err) @@ -195,8 +130,8 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m severity := strings.ToLower(tmpl(pn.settings.Severity)) if _, ok := knownSeverity[severity]; !ok { - pn.log.Warn("Severity is not in the list of known values - using default severity", "actualSeverity", severity, "defaultSeverity", defaultSeverity) - severity = defaultSeverity + pn.log.Warn("Severity is not in the list of known values - using default severity", "actualSeverity", severity, "defaultSeverity", DefaultPagerDutySeverity) + severity = DefaultPagerDutySeverity } msg := &pagerDutyMessage{ @@ -220,8 +155,8 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m }, } - _ = withStoredImages(ctx, pn.log, pn.images, - func(_ int, image Image) error { + _ = receivers.WithStoredImages(ctx, pn.log, pn.images, + func(_ int, image images.Image) error { if len(image.URL) != 0 { msg.Images = append(msg.Images, pagerDutyImage{Src: image.URL}) } @@ -230,7 +165,7 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m }, as...) - summary, truncated := TruncateInRunes(msg.Payload.Summary, pagerDutyMaxV2SummaryLenRunes) + summary, truncated := receivers.TruncateInRunes(msg.Payload.Summary, pagerDutyMaxV2SummaryLenRunes) if truncated { pn.log.Warn("Truncated summary", "key", key, "runes", pagerDutyMaxV2SummaryLenRunes) } diff --git a/alerting/notifier/channels/pagerduty_test.go b/receivers/pagerduty/pagerduty_test.go similarity index 95% rename from alerting/notifier/channels/pagerduty_test.go rename to receivers/pagerduty/pagerduty_test.go index 388bee43..b11cbbb8 100644 --- a/alerting/notifier/channels/pagerduty_test.go +++ b/receivers/pagerduty/pagerduty_test.go @@ -1,4 +1,4 @@ -package channels +package pagerduty import ( "context" @@ -14,10 +14,14 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestPagerdutyNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -52,7 +56,7 @@ func TestPagerdutyNotifier(t *testing.T) { Payload: pagerDutyPayload{ Summary: "[FIRING:1] (val1)", Source: hostname, - Severity: defaultSeverity, + Severity: DefaultPagerDutySeverity, Class: "default", Component: "Grafana", Group: "default", @@ -87,7 +91,7 @@ func TestPagerdutyNotifier(t *testing.T) { Payload: pagerDutyPayload{ Summary: "[FIRING:1] (val1 invalid-severity)", Source: hostname, - Severity: defaultSeverity, + Severity: DefaultPagerDutySeverity, Class: "default", Component: "Grafana", Group: "default", @@ -166,7 +170,7 @@ func TestPagerdutyNotifier(t *testing.T) { Payload: pagerDutyPayload{ Summary: "Alerts firing: 1", Source: hostname, - Severity: defaultSeverity, + Severity: DefaultPagerDutySeverity, Class: "default", Component: "Grafana", Group: "default", @@ -246,7 +250,7 @@ func TestPagerdutyNotifier(t *testing.T) { Payload: pagerDutyPayload{ Summary: fmt.Sprintf("%s…", strings.Repeat("1", 1023)), Source: hostname, - Severity: defaultSeverity, + Severity: DefaultPagerDutySeverity, Class: "default", Component: "Grafana", Group: "default", @@ -274,9 +278,9 @@ func TestPagerdutyNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON := json.RawMessage(c.settings) secureSettings := make(map[string][]byte) - webhookSender := mockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + webhookSender := receivers.MockNotificationService() + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "pageduty_testing", Type: "pagerduty", Settings: settingsJSON, @@ -287,7 +291,7 @@ func TestPagerdutyNotifier(t *testing.T) { return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := newPagerdutyNotifier(fc) if c.expInitError != "" { diff --git a/receivers/pushover/config.go b/receivers/pushover/config.go new file mode 100644 index 00000000..e3307ec0 --- /dev/null +++ b/receivers/pushover/config.go @@ -0,0 +1,94 @@ +package pushover + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type PushoverConfig struct { + UserKey string + APIToken string + AlertingPriority int64 + OkPriority int64 + Retry int64 + Expire int64 + Device string + AlertingSound string + OkSound string + Upload bool + Title string + Message string +} + +func BuildPushoverConfig(fc receivers.FactoryConfig) (PushoverConfig, error) { + settings := PushoverConfig{} + rawSettings := struct { + UserKey string `json:"userKey,omitempty" yaml:"userKey,omitempty"` + APIToken string `json:"apiToken,omitempty" yaml:"apiToken,omitempty"` + AlertingPriority json.Number `json:"priority,omitempty" yaml:"priority,omitempty"` + OKPriority json.Number `json:"okPriority,omitempty" yaml:"okPriority,omitempty"` + Retry json.Number `json:"retry,omitempty" yaml:"retry,omitempty"` + Expire json.Number `json:"expire,omitempty" yaml:"expire,omitempty"` + Device string `json:"device,omitempty" yaml:"device,omitempty"` + AlertingSound string `json:"sound,omitempty" yaml:"sound,omitempty"` + OKSound string `json:"okSound,omitempty" yaml:"okSound,omitempty"` + Upload *bool `json:"uploadImage,omitempty" yaml:"uploadImage,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + }{} + + err := json.Unmarshal(fc.Config.Settings, &rawSettings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + settings.UserKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "userKey", rawSettings.UserKey) + if settings.UserKey == "" { + return settings, errors.New("user key not found") + } + settings.APIToken = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apiToken", rawSettings.APIToken) + if settings.APIToken == "" { + return settings, errors.New("API token not found") + } + if rawSettings.AlertingPriority != "" { + settings.AlertingPriority, err = rawSettings.AlertingPriority.Int64() + if err != nil { + return settings, fmt.Errorf("failed to convert alerting priority to integer: %w", err) + } + } + + if rawSettings.OKPriority != "" { + settings.OkPriority, err = rawSettings.OKPriority.Int64() + if err != nil { + return settings, fmt.Errorf("failed to convert OK priority to integer: %w", err) + } + } + + settings.Retry, _ = rawSettings.Retry.Int64() + settings.Expire, _ = rawSettings.Expire.Int64() + + settings.Device = rawSettings.Device + settings.AlertingSound = rawSettings.AlertingSound + settings.OkSound = rawSettings.OKSound + + if rawSettings.Upload == nil || *rawSettings.Upload { + settings.Upload = true + } + + settings.Message = rawSettings.Message + if settings.Message == "" { + settings.Message = templates.DefaultMessageEmbed + } + + settings.Title = rawSettings.Title + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + + return settings, nil +} diff --git a/alerting/notifier/channels/pushover.go b/receivers/pushover/pushover.go similarity index 56% rename from alerting/notifier/channels/pushover.go rename to receivers/pushover/pushover.go index 8cf860f7..ff917d9f 100644 --- a/alerting/notifier/channels/pushover.go +++ b/receivers/pushover/pushover.go @@ -1,10 +1,8 @@ -package channels +package pushover import ( "bytes" "context" - "encoding/json" - "errors" "fmt" "io" "mime/multipart" @@ -16,6 +14,11 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) const ( @@ -35,101 +38,18 @@ var ( // PushoverNotifier is responsible for sending // alert notifications to Pushover type PushoverNotifier struct { - *Base + *receivers.Base tmpl *template.Template - log Logger - images ImageStore - ns WebhookSender - settings pushoverSettings -} - -type pushoverSettings struct { - userKey string - apiToken string - alertingPriority int64 - okPriority int64 - retry int64 - expire int64 - device string - alertingSound string - okSound string - upload bool - title string - message string -} - -func buildPushoverSettings(fc FactoryConfig) (pushoverSettings, error) { - settings := pushoverSettings{} - rawSettings := struct { - UserKey string `json:"userKey,omitempty" yaml:"userKey,omitempty"` - APIToken string `json:"apiToken,omitempty" yaml:"apiToken,omitempty"` - AlertingPriority json.Number `json:"priority,omitempty" yaml:"priority,omitempty"` - OKPriority json.Number `json:"okPriority,omitempty" yaml:"okPriority,omitempty"` - Retry json.Number `json:"retry,omitempty" yaml:"retry,omitempty"` - Expire json.Number `json:"expire,omitempty" yaml:"expire,omitempty"` - Device string `json:"device,omitempty" yaml:"device,omitempty"` - AlertingSound string `json:"sound,omitempty" yaml:"sound,omitempty"` - OKSound string `json:"okSound,omitempty" yaml:"okSound,omitempty"` - Upload *bool `json:"uploadImage,omitempty" yaml:"uploadImage,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - }{} - - err := fc.Config.unmarshalSettings(&rawSettings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - settings.userKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "userKey", rawSettings.UserKey) - if settings.userKey == "" { - return settings, errors.New("user key not found") - } - settings.apiToken = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apiToken", rawSettings.APIToken) - if settings.apiToken == "" { - return settings, errors.New("API token not found") - } - if rawSettings.AlertingPriority != "" { - settings.alertingPriority, err = rawSettings.AlertingPriority.Int64() - if err != nil { - return settings, fmt.Errorf("failed to convert alerting priority to integer: %w", err) - } - } - - if rawSettings.OKPriority != "" { - settings.okPriority, err = rawSettings.OKPriority.Int64() - if err != nil { - return settings, fmt.Errorf("failed to convert OK priority to integer: %w", err) - } - } - - settings.retry, _ = rawSettings.Retry.Int64() - settings.expire, _ = rawSettings.Expire.Int64() - - settings.device = rawSettings.Device - settings.alertingSound = rawSettings.AlertingSound - settings.okSound = rawSettings.OKSound - - if rawSettings.Upload == nil || *rawSettings.Upload { - settings.upload = true - } - - settings.message = rawSettings.Message - if settings.message == "" { - settings.message = DefaultMessageEmbed - } - - settings.title = rawSettings.Title - if settings.title == "" { - settings.title = DefaultMessageTitleEmbed - } - - return settings, nil + log logging.Logger + images images.ImageStore + ns receivers.WebhookSender + settings PushoverConfig } -func PushoverFactory(fc FactoryConfig) (NotificationChannel, error) { +func PushoverFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := NewPushoverNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -138,13 +58,13 @@ func PushoverFactory(fc FactoryConfig) (NotificationChannel, error) { } // NewSlackNotifier is the constructor for the Slack notifier -func NewPushoverNotifier(fc FactoryConfig) (*PushoverNotifier, error) { - settings, err := buildPushoverSettings(fc) +func NewPushoverNotifier(fc receivers.FactoryConfig) (*PushoverNotifier, error) { + settings, err := BuildPushoverConfig(fc) if err != nil { return nil, err } return &PushoverNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), tmpl: fc.Template, log: fc.Logger, images: fc.ImageStore, @@ -161,14 +81,14 @@ func (pn *PushoverNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo return false, err } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: PushoverEndpoint, HTTPMethod: "POST", HTTPHeader: headers, Body: uploadBody.String(), } - if err := pn.ns.SendWebhook(ctx, cmd); err != nil { + if err := pn.ns.Send(ctx, cmd); err != nil { pn.log.Error("failed to send pushover notification", "error", err, "webhook", pn.Name) return false, err } @@ -189,7 +109,7 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al w := multipart.NewWriter(&b) // tests use a non-random boundary separator - if boundary := GetBoundary(); boundary != "" { + if boundary := receivers.GetBoundary(); boundary != "" { err := w.SetBoundary(boundary) if err != nil { return nil, b, err @@ -197,22 +117,22 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al } var tmplErr error - tmpl, _ := TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) - if err := w.WriteField("user", tmpl(pn.settings.userKey)); err != nil { + if err := w.WriteField("user", tmpl(pn.settings.UserKey)); err != nil { return nil, b, fmt.Errorf("failed to write the user: %w", err) } - if err := w.WriteField("token", pn.settings.apiToken); err != nil { + if err := w.WriteField("token", pn.settings.APIToken); err != nil { return nil, b, fmt.Errorf("failed to write the token: %w", err) } - title, truncated := TruncateInRunes(tmpl(pn.settings.title), pushoverMaxTitleLenRunes) + title, truncated := receivers.TruncateInRunes(tmpl(pn.settings.Title), pushoverMaxTitleLenRunes) if truncated { pn.log.Warn("Truncated title", "incident", key, "max_runes", pushoverMaxTitleLenRunes) } - message := tmpl(pn.settings.message) - message, truncated = TruncateInRunes(message, pushoverMaxMessageLenRunes) + message := tmpl(pn.settings.Message) + message, truncated = receivers.TruncateInRunes(message, pushoverMaxMessageLenRunes) if truncated { pn.log.Warn("Truncated message", "incident", key, "max_runes", pushoverMaxMessageLenRunes) } @@ -222,33 +142,33 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al message = "(no details)" } - supplementaryURL := joinURLPath(pn.tmpl.ExternalURL.String(), "/alerting/list", pn.log) - supplementaryURL, truncated = TruncateInRunes(supplementaryURL, pushoverMaxURLLenRunes) + supplementaryURL := receivers.JoinURLPath(pn.tmpl.ExternalURL.String(), "/alerting/list", pn.log) + supplementaryURL, truncated = receivers.TruncateInRunes(supplementaryURL, pushoverMaxURLLenRunes) if truncated { pn.log.Warn("Truncated URL", "incident", key, "max_runes", pushoverMaxURLLenRunes) } status := types.Alerts(as...).Status() - priority := pn.settings.alertingPriority + priority := pn.settings.AlertingPriority if status == model.AlertResolved { - priority = pn.settings.okPriority + priority = pn.settings.OkPriority } if err := w.WriteField("priority", strconv.FormatInt(priority, 10)); err != nil { return nil, b, fmt.Errorf("failed to write the priority: %w", err) } if priority == 2 { - if err := w.WriteField("retry", strconv.FormatInt(pn.settings.retry, 10)); err != nil { + if err := w.WriteField("retry", strconv.FormatInt(pn.settings.Retry, 10)); err != nil { return nil, b, fmt.Errorf("failed to write retry: %w", err) } - if err := w.WriteField("expire", strconv.FormatInt(pn.settings.expire, 10)); err != nil { + if err := w.WriteField("expire", strconv.FormatInt(pn.settings.Expire, 10)); err != nil { return nil, b, fmt.Errorf("failed to write expire: %w", err) } } - if pn.settings.device != "" { - if err := w.WriteField("device", tmpl(pn.settings.device)); err != nil { + if pn.settings.Device != "" { + if err := w.WriteField("device", tmpl(pn.settings.Device)); err != nil { return nil, b, fmt.Errorf("failed to write the device: %w", err) } } @@ -273,9 +193,9 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al var sound string if status == model.AlertResolved { - sound = tmpl(pn.settings.okSound) + sound = tmpl(pn.settings.OkSound) } else { - sound = tmpl(pn.settings.alertingSound) + sound = tmpl(pn.settings.AlertingSound) } if sound != "default" { if err := w.WriteField("sound", sound); err != nil { @@ -305,7 +225,7 @@ func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Al func (pn *PushoverNotifier) writeImageParts(ctx context.Context, w *multipart.Writer, as ...*types.Alert) { // Pushover supports at most one image attachment with a maximum size of pushoverMaxFileSize. // If the image is larger than pushoverMaxFileSize then return an error. - _ = withStoredImages(ctx, pn.log, pn.images, func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, pn.log, pn.images, func(index int, image images.Image) error { f, err := os.Open(image.Path) if err != nil { return fmt.Errorf("failed to open the image: %w", err) @@ -334,6 +254,6 @@ func (pn *PushoverNotifier) writeImageParts(ctx context.Context, w *multipart.Wr return fmt.Errorf("failed to copy the image to the form file: %w", err) } - return ErrImagesDone + return images.ErrImagesDone }, as...) } diff --git a/alerting/notifier/channels/pushover_test.go b/receivers/pushover/pushover_test.go similarity index 94% rename from alerting/notifier/channels/pushover_test.go rename to receivers/pushover/pushover_test.go index e54a24da..01e7c4b2 100644 --- a/alerting/notifier/channels/pushover_test.go +++ b/receivers/pushover/pushover_test.go @@ -1,4 +1,4 @@ -package channels +package pushover import ( "bytes" @@ -17,12 +17,16 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestPushoverNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) - images := newFakeImageStoreWithFile(t, 2) + images := receivers.NewFakeImageStoreWithFile(t, 2) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -197,23 +201,23 @@ func TestPushoverNotifier(t *testing.T) { } for _, c := range cases { - origGetBoundary := GetBoundary + origGetBoundary := receivers.GetBoundary boundary := "abcd" - GetBoundary = func() string { + receivers.GetBoundary = func() string { return boundary } t.Cleanup(func() { - GetBoundary = origGetBoundary + receivers.GetBoundary = origGetBoundary }) t.Run(c.name, func(t *testing.T) { settingsJSON := json.RawMessage(c.settings) secureSettings := make(map[string][]byte) - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "pushover_testing", Type: "pushover", Settings: settingsJSON, @@ -225,7 +229,7 @@ func TestPushoverNotifier(t *testing.T) { return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := NewPushoverNotifier(fc) diff --git a/receivers/sensugo/config.go b/receivers/sensugo/config.go new file mode 100644 index 00000000..b4c46a2e --- /dev/null +++ b/receivers/sensugo/config.go @@ -0,0 +1,40 @@ +package sensugo + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type SensuGoConfig struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` + Check string `json:"check,omitempty" yaml:"check,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + Handler string `json:"handler,omitempty" yaml:"handler,omitempty"` + APIKey string `json:"apikey,omitempty" yaml:"apikey,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` +} + +func BuildSensuGoConfig(fc receivers.FactoryConfig) (SensuGoConfig, error) { + settings := SensuGoConfig{} + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + if settings.URL == "" { + return settings, errors.New("could not find URL property in settings") + } + settings.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apikey", settings.APIKey) + if settings.APIKey == "" { + return settings, errors.New("could not find the API key property in settings") + } + if settings.Message == "" { + settings.Message = templates.DefaultMessageEmbed + } + return settings, nil +} diff --git a/alerting/notifier/channels/sensugo.go b/receivers/sensugo/sensugo.go similarity index 62% rename from alerting/notifier/channels/sensugo.go rename to receivers/sensugo/sensugo.go index 18a88181..ec7dffe1 100644 --- a/alerting/notifier/channels/sensugo.go +++ b/receivers/sensugo/sensugo.go @@ -1,59 +1,40 @@ -package channels +package sensugo import ( "context" "encoding/json" - "errors" "fmt" "strings" + "time" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) type SensuGoNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender + *receivers.Base + log logging.Logger + images images.ImageStore + ns receivers.WebhookSender tmpl *template.Template - settings sensuGoSettings -} - -type sensuGoSettings struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - Entity string `json:"entity,omitempty" yaml:"entity,omitempty"` - Check string `json:"check,omitempty" yaml:"check,omitempty"` - Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` - Handler string `json:"handler,omitempty" yaml:"handler,omitempty"` - APIKey string `json:"apikey,omitempty" yaml:"apikey,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` + settings SensuGoConfig } -func buildSensuGoConfig(fc FactoryConfig) (sensuGoSettings, error) { - settings := sensuGoSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if settings.URL == "" { - return settings, errors.New("could not find URL property in settings") - } - settings.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apikey", settings.APIKey) - if settings.APIKey == "" { - return settings, errors.New("could not find the API key property in settings") - } - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - return settings, nil -} +var ( + // Provides current time. Can be overwritten in tests. + timeNow = time.Now +) -func SensuGoFactory(fc FactoryConfig) (NotificationChannel, error) { +func SensuGoFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := NewSensuGoNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -62,13 +43,13 @@ func SensuGoFactory(fc FactoryConfig) (NotificationChannel, error) { } // NewSensuGoNotifier is the constructor for the SensuGo notifier -func NewSensuGoNotifier(fc FactoryConfig) (*SensuGoNotifier, error) { - settings, err := buildSensuGoConfig(fc) +func NewSensuGoNotifier(fc receivers.FactoryConfig) (*SensuGoNotifier, error) { + settings, err := BuildSensuGoConfig(fc) if err != nil { return nil, err } return &SensuGoNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, images: fc.ImageStore, ns: fc.NotificationService, @@ -82,7 +63,7 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool sn.log.Debug("sending Sensu Go result") var tmplErr error - tmpl, _ := TmplText(ctx, sn.tmpl, as, sn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, sn.tmpl, as, sn.log, &tmplErr) // Sensu Go alerts require an entity and a check. We set it to the user-specified // value (optional), else we fallback and use the grafana rule anme and ruleID. @@ -115,19 +96,19 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool labels := make(map[string]string) - _ = withStoredImages(ctx, sn.log, sn.images, - func(_ int, image Image) error { + _ = receivers.WithStoredImages(ctx, sn.log, sn.images, + func(_ int, image images.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.URL != "" { labels["imageURL"] = image.URL - return ErrImagesDone + return images.ErrImagesDone } return nil }, as...) - ruleURL := joinURLPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) + ruleURL := receivers.JoinURLPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) labels["ruleURL"] = ruleURL bodyMsgType := map[string]interface{}{ @@ -160,7 +141,7 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool return false, err } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: fmt.Sprintf("%s/api/core/v2/namespaces/%s/events", strings.TrimSuffix(sn.settings.URL, "/"), namespace), Body: string(body), HTTPMethod: "POST", @@ -169,7 +150,7 @@ func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool "Authorization": fmt.Sprintf("Key %s", sn.settings.APIKey), }, } - if err := sn.ns.SendWebhook(ctx, cmd); err != nil { + if err := sn.ns.Send(ctx, cmd); err != nil { sn.log.Error("failed to send Sensu Go event", "error", err, "sensugo", sn.Name) return false, err } diff --git a/alerting/notifier/channels/sensugo_test.go b/receivers/sensugo/sensugo_test.go similarity index 89% rename from alerting/notifier/channels/sensugo_test.go rename to receivers/sensugo/sensugo_test.go index f4cd4c53..f6be9548 100644 --- a/alerting/notifier/channels/sensugo_test.go +++ b/receivers/sensugo/sensugo_test.go @@ -1,4 +1,4 @@ -package channels +package sensugo import ( "context" @@ -11,19 +11,23 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestSensuGoNotifier(t *testing.T) { constNow := time.Now() defer mockTimeNow(constNow)() - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) tmpl.ExternalURL = externalURL - images := newFakeImageStore(2) + images := receivers.NewFakeImageStore(2) cases := []struct { name string @@ -136,16 +140,16 @@ func TestSensuGoNotifier(t *testing.T) { settingsJSON := json.RawMessage(c.settings) secureSettings := make(map[string][]byte) - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ Name: "Sensu Go", Type: "sensugo", Settings: settingsJSON, SecureSettings: secureSettings, } - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, ImageStore: images, NotificationService: webhookSender, @@ -153,7 +157,7 @@ func TestSensuGoNotifier(t *testing.T) { DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { return fallback }, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } sn, err := NewSensuGoNotifier(fc) @@ -183,3 +187,15 @@ func TestSensuGoNotifier(t *testing.T) { }) } } + +// resetTimeNow resets the global variable timeNow to the default value, which is time.Now +func resetTimeNow() { + timeNow = time.Now +} + +func mockTimeNow(constTime time.Time) func() { + timeNow = func() time.Time { + return constTime + } + return resetTimeNow +} diff --git a/receivers/slack/config.go b/receivers/slack/config.go new file mode 100644 index 00000000..c3772e90 --- /dev/null +++ b/receivers/slack/config.go @@ -0,0 +1,18 @@ +package slack + +import "github.com/grafana/alerting/receivers" + +type SlackConfig struct { + EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Token string `json:"token,omitempty" yaml:"token,omitempty"` + Recipient string `json:"recipient,omitempty" yaml:"recipient,omitempty"` + Text string `json:"text,omitempty" yaml:"text,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty" yaml:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty" yaml:"icon_url,omitempty"` + MentionChannel string `json:"mentionChannel,omitempty" yaml:"mentionChannel,omitempty"` + MentionUsers receivers.CommaSeparatedStrings `json:"mentionUsers,omitempty" yaml:"mentionUsers,omitempty"` + MentionGroups receivers.CommaSeparatedStrings `json:"mentionGroups,omitempty" yaml:"mentionGroups,omitempty"` +} diff --git a/alerting/notifier/channels/slack.go b/receivers/slack/slack.go similarity index 83% rename from alerting/notifier/channels/slack.go rename to receivers/slack/slack.go index 6400d6fe..532e2197 100644 --- a/alerting/notifier/channels/slack.go +++ b/receivers/slack/slack.go @@ -1,4 +1,4 @@ -package channels +package slack import ( "bytes" @@ -17,10 +17,15 @@ import ( "strings" "time" - "github.com/prometheus/alertmanager/config" + amConfig "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) const ( @@ -49,7 +54,7 @@ var ( var SlackAPIEndpoint = "https://slack.com/api/chat.postMessage" -type sendFunc func(ctx context.Context, req *http.Request, logger Logger) (string, error) +type sendFunc func(ctx context.Context, req *http.Request, logger logging.Logger) (string, error) // https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters. const slackMaxTitleLenRunes = 1024 @@ -57,38 +62,23 @@ const slackMaxTitleLenRunes = 1024 // SlackNotifier is responsible for sending // alert notification to Slack. type SlackNotifier struct { - *Base - log Logger + *receivers.Base + log logging.Logger tmpl *template.Template - images ImageStore - webhookSender WebhookSender + images images.ImageStore + webhookSender receivers.WebhookSender sendFn sendFunc - settings slackSettings + settings SlackConfig appVersion string } -type slackSettings struct { - EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"` - URL string `json:"url,omitempty" yaml:"url,omitempty"` - Token string `json:"token,omitempty" yaml:"token,omitempty"` - Recipient string `json:"recipient,omitempty" yaml:"recipient,omitempty"` - Text string `json:"text,omitempty" yaml:"text,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Username string `json:"username,omitempty" yaml:"username,omitempty"` - IconEmoji string `json:"icon_emoji,omitempty" yaml:"icon_emoji,omitempty"` - IconURL string `json:"icon_url,omitempty" yaml:"icon_url,omitempty"` - MentionChannel string `json:"mentionChannel,omitempty" yaml:"mentionChannel,omitempty"` - MentionUsers CommaSeparatedStrings `json:"mentionUsers,omitempty" yaml:"mentionUsers,omitempty"` - MentionGroups CommaSeparatedStrings `json:"mentionGroups,omitempty" yaml:"mentionGroups,omitempty"` -} - // isIncomingWebhook returns true if the settings are for an incoming webhook. -func isIncomingWebhook(s slackSettings) bool { +func isIncomingWebhook(s SlackConfig) bool { return s.Token == "" } // uploadURL returns the upload URL for Slack. -func uploadURL(s slackSettings) (string, error) { +func uploadURL(s SlackConfig) (string, error) { u, err := url.Parse(s.URL) if err != nil { return "", fmt.Errorf("failed to parse URL: %w", err) @@ -99,10 +89,10 @@ func uploadURL(s slackSettings) (string, error) { } // SlackFactory creates a new NotificationChannel that sends notifications to Slack. -func SlackFactory(fc FactoryConfig) (NotificationChannel, error) { +func SlackFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { ch, err := buildSlackNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -110,9 +100,9 @@ func SlackFactory(fc FactoryConfig) (NotificationChannel, error) { return ch, nil } -func buildSlackNotifier(factoryConfig FactoryConfig) (*SlackNotifier, error) { +func buildSlackNotifier(factoryConfig receivers.FactoryConfig) (*SlackNotifier, error) { decryptFunc := factoryConfig.DecryptFunc - var settings slackSettings + var settings SlackConfig err := json.Unmarshal(factoryConfig.Config.Settings, &settings) if err != nil { return nil, fmt.Errorf("failed to unmarshal settings: %w", err) @@ -147,13 +137,13 @@ func buildSlackNotifier(factoryConfig FactoryConfig) (*SlackNotifier, error) { settings.Username = "Grafana" } if settings.Text == "" { - settings.Text = DefaultMessageEmbed + settings.Text = templates.DefaultMessageEmbed } if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed + settings.Title = templates.DefaultMessageTitleEmbed } return &SlackNotifier{ - Base: NewBase(factoryConfig.Config), + Base: receivers.NewBase(factoryConfig.Config), settings: settings, images: factoryConfig.ImageStore, @@ -179,18 +169,18 @@ type slackMessage struct { // attachment is used to display a richly-formatted message block. 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"` - FooterIcon string `json:"footer_icon"` - Color string `json:"color,omitempty"` - Ts int64 `json:"ts,omitempty"` - Pretext string `json:"pretext,omitempty"` - MrkdwnIn []string `json:"mrkdwn_in,omitempty"` + 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 []amConfig.SlackField `json:"fields,omitempty"` + Footer string `json:"footer"` + FooterIcon string `json:"footer_icon"` + Color string `json:"color,omitempty"` + Ts int64 `json:"ts,omitempty"` + Pretext string `json:"pretext,omitempty"` + MrkdwnIn []string `json:"mrkdwn_in,omitempty"` } // Notify sends an alert notification to Slack. @@ -211,7 +201,7 @@ func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo // Do not upload images if using an incoming webhook as incoming webhooks cannot upload files if !isIncomingWebhook(sn.settings) { - if err := withStoredImages(ctx, sn.log, sn.images, func(index int, image Image) error { + if err := receivers.WithStoredImages(ctx, sn.log, sn.images, func(index int, image images.Image) error { // If we have exceeded the maximum number of images for this threadTs // then tell the recipient and stop iterating subsequent images if index >= maxImagesPerThreadTs { @@ -222,7 +212,7 @@ func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo }); err != nil { sn.log.Error("Failed to send Slack message", "err", err) } - return ErrImagesDone + return images.ErrImagesDone } comment := initialCommentForImage(alerts[index]) return sn.uploadImage(ctx, image, sn.settings.Recipient, comment, threadTs) @@ -237,7 +227,7 @@ func (sn *SlackNotifier) Notify(ctx context.Context, alerts ...*types.Alert) (bo // sendSlackRequest sends a request to the Slack API. // Stubbable by tests. -var sendSlackRequest = func(ctx context.Context, req *http.Request, logger Logger) (string, error) { +var sendSlackRequest = func(ctx context.Context, req *http.Request, logger logging.Logger) (string, error) { resp, err := slackClient.Do(req) if err != nil { return "", fmt.Errorf("failed to send request: %w", err) @@ -268,7 +258,7 @@ var sendSlackRequest = func(ctx context.Context, req *http.Request, logger Logge return handleSlackJSONResponse(resp, logger) } -func handleSlackIncomingWebhookResponse(resp *http.Response, logger Logger) (string, error) { +func handleSlackIncomingWebhookResponse(resp *http.Response, logger logging.Logger) (string, error) { b, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response: %w", err) @@ -312,7 +302,7 @@ func handleSlackIncomingWebhookResponse(resp *http.Response, logger Logger) (str return "", fmt.Errorf("failed incoming webhook: %s", string(b)) } -func handleSlackJSONResponse(resp *http.Response, logger Logger) (string, error) { +func handleSlackJSONResponse(resp *http.Response, logger logging.Logger) (string, error) { b, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response: %w", err) @@ -346,11 +336,11 @@ func handleSlackJSONResponse(resp *http.Response, logger Logger) (string, error) func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types.Alert) (*slackMessage, error) { var tmplErr error - tmpl, _ := TmplText(ctx, sn.tmpl, alerts, sn.log, &tmplErr) + tmpl, _ := templates.TmplText(ctx, sn.tmpl, alerts, sn.log, &tmplErr) - ruleURL := joinURLPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) + ruleURL := receivers.JoinURLPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) - title, truncated := TruncateInRunes(tmpl(sn.settings.Title), slackMaxTitleLenRunes) + title, truncated := receivers.TruncateInRunes(tmpl(sn.settings.Title), slackMaxTitleLenRunes) if truncated { key, err := notify.ExtractGroupKey(ctx) if err != nil { @@ -368,11 +358,11 @@ func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types // https://api.slack.com/messaging/composing/layouts#when-to-use-attachments Attachments: []attachment{ { - Color: getAlertStatusColor(types.Alerts(alerts...).Status()), + Color: receivers.GetAlertStatusColor(types.Alerts(alerts...).Status()), Title: title, Fallback: title, Footer: "Grafana v" + sn.appVersion, - FooterIcon: FooterIconURL, + FooterIcon: receivers.FooterIconURL, Ts: time.Now().Unix(), TitleLink: ruleURL, Text: tmpl(sn.settings.Text), @@ -383,10 +373,10 @@ func (sn *SlackNotifier) createSlackMessage(ctx context.Context, alerts []*types if isIncomingWebhook(sn.settings) { // Incoming webhooks cannot upload files, instead share images via their URL - _ = withStoredImages(ctx, sn.log, sn.images, func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, sn.log, sn.images, func(index int, image images.Image) error { if image.URL != "" { req.Attachments[0].ImageURL = image.URL - return ErrImagesDone + return images.ErrImagesDone } return nil }, alerts...) @@ -466,7 +456,7 @@ func (sn *SlackNotifier) sendSlackMessage(ctx context.Context, m *slackMessage) // createImageMultipart returns the mutlipart/form-data request and headers for files.upload. // It returns an error if the image does not exist or there was an error preparing the // multipart form. -func (sn *SlackNotifier) createImageMultipart(image Image, channel, comment, threadTs string) (http.Header, []byte, error) { +func (sn *SlackNotifier) createImageMultipart(image images.Image, channel, comment, threadTs string) (http.Header, []byte, error) { buf := bytes.Buffer{} w := multipart.NewWriter(&buf) defer func() { @@ -543,7 +533,7 @@ func (sn *SlackNotifier) sendMultipart(ctx context.Context, headers http.Header, // uploadImage shares the image to the channel names or IDs. It returns an error if the file // does not exist, or if there was an error either preparing or sending the multipart/form-data // request. -func (sn *SlackNotifier) uploadImage(ctx context.Context, image Image, channel, comment, threadTs string) error { +func (sn *SlackNotifier) uploadImage(ctx context.Context, image images.Image, channel, comment, threadTs string) error { sn.log.Debug("Uploadimg image", "image", image.Token) headers, data, err := sn.createImageMultipart(image, channel, comment, threadTs) if err != nil { diff --git a/alerting/notifier/channels/slack_test.go b/receivers/slack/slack_test.go similarity index 96% rename from alerting/notifier/channels/slack_test.go rename to receivers/slack/slack_test.go index 5a9dc1d3..05bf4789 100644 --- a/alerting/notifier/channels/slack_test.go +++ b/receivers/slack/slack_test.go @@ -1,4 +1,4 @@ -package channels +package slack import ( "context" @@ -20,6 +20,11 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) var appVersion = fmt.Sprintf("%d.0.0", rand.Uint32()) @@ -377,7 +382,7 @@ type slackRequestRecorder struct { requests []*http.Request } -func (s *slackRequestRecorder) fn(_ context.Context, r *http.Request, _ Logger) (string, error) { +func (s *slackRequestRecorder) fn(_ context.Context, r *http.Request, _ logging.Logger) (string, error) { s.requests = append(s.requests, r) return "", nil } @@ -398,7 +403,7 @@ func checkMultipart(t *testing.T, expected map[string]struct{}, r io.Reader, bou } func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRequestRecorder, error) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) tmpl.ExternalURL = externalURL @@ -412,8 +417,8 @@ func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRe } }) - images := &fakeImageStore{ - Images: []*Image{{ + images := &receivers.FakeImageStore{ + Images: []*images.Image{{ Token: "image-on-disk", Path: f.Name(), }, { @@ -421,10 +426,10 @@ func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRe URL: "https://www.example.com/test.png", }}, } - notificationService := mockNotificationService() + notificationService := receivers.MockNotificationService() - c := FactoryConfig{ - Config: &NotificationChannelConfig{ + c := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "slack_testing", Type: "slack", Settings: json.RawMessage(settings), @@ -436,7 +441,7 @@ func setupSlackForTests(t *testing.T, settings string) (*SlackNotifier, *slackRe return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, GrafanaBuildVersion: appVersion, } @@ -565,7 +570,7 @@ func TestSendSlackRequest(t *testing.T) { req, err := http.NewRequest(http.MethodGet, server.URL, nil) require.NoError(tt, err) - _, err = sendSlackRequest(context.Background(), req, &FakeLogger{}) + _, err = sendSlackRequest(context.Background(), req, &logging.FakeLogger{}) if !test.expectError { require.NoError(tt, err) } else { diff --git a/receivers/teams/config.go b/receivers/teams/config.go new file mode 100644 index 00000000..f7ea810a --- /dev/null +++ b/receivers/teams/config.go @@ -0,0 +1,36 @@ +package teams + +import ( + "encoding/json" + "fmt" + + "github.com/pkg/errors" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type TeamsConfig struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + SectionTitle string `json:"sectiontitle,omitempty" yaml:"sectiontitle,omitempty"` +} + +func BuildTeamsConfig(fc receivers.FactoryConfig) (TeamsConfig, error) { + settings := TeamsConfig{} + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + if settings.URL == "" { + return settings, errors.New("could not find url property in settings") + } + if settings.Message == "" { + settings.Message = `{{ template "teams.default.message" .}}` + } + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + return settings, nil +} diff --git a/alerting/notifier/channels/teams.go b/receivers/teams/teams.go similarity index 84% rename from alerting/notifier/channels/teams.go rename to receivers/teams/teams.go index ca40c216..795acaa9 100644 --- a/alerting/notifier/channels/teams.go +++ b/receivers/teams/teams.go @@ -1,4 +1,4 @@ -package channels +package teams import ( "bytes" @@ -10,6 +10,11 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) const ( @@ -219,48 +224,23 @@ func (i AdaptiveCardOpenURLActionItem) MarshalJSON() ([]byte, error) { }) } -type teamsSettings struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - SectionTitle string `json:"sectiontitle,omitempty" yaml:"sectiontitle,omitempty"` -} - -func buildTeamsSettings(fc FactoryConfig) (teamsSettings, error) { - settings := teamsSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if settings.URL == "" { - return settings, errors.New("could not find url property in settings") - } - if settings.Message == "" { - settings.Message = `{{ template "teams.default.message" .}}` - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - return settings, nil -} - type TeamsNotifier struct { - *Base + *receivers.Base tmpl *template.Template - log Logger - ns WebhookSender - images ImageStore - settings teamsSettings + log logging.Logger + ns receivers.WebhookSender + images images.ImageStore + settings TeamsConfig } // NewTeamsNotifier is the constructor for Teams notifier. -func NewTeamsNotifier(fc FactoryConfig) (*TeamsNotifier, error) { - settings, err := buildTeamsSettings(fc) +func NewTeamsNotifier(fc receivers.FactoryConfig) (*TeamsNotifier, error) { + settings, err := BuildTeamsConfig(fc) if err != nil { return nil, err } return &TeamsNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, ns: fc.NotificationService, images: fc.ImageStore, @@ -269,10 +249,10 @@ func NewTeamsNotifier(fc FactoryConfig) (*TeamsNotifier, error) { }, nil } -func TeamsFactory(fc FactoryConfig) (NotificationChannel, error) { +func TeamsFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := NewTeamsNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -282,7 +262,7 @@ func TeamsFactory(fc FactoryConfig) (NotificationChannel, error) { func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { var tmplErr error - tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) card := NewAdaptiveCard() card.AppendItem(AdaptiveCardTextBlockItem{ @@ -298,8 +278,8 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, }) var s AdaptiveCardImageSetItem - _ = withStoredImages(ctx, tn.log, tn.images, - func(_ int, image Image) error { + _ = receivers.WithStoredImages(ctx, tn.log, tn.images, + func(_ int, image images.Image) error { if image.URL != "" { s.AppendImage(AdaptiveCardImageItem{URL: image.URL}) } @@ -319,7 +299,7 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, Actions: []AdaptiveCardActionItem{ AdaptiveCardOpenURLActionItem{ Title: "View URL", - URL: joinURLPath(tn.tmpl.ExternalURL.String(), "/alerting/list", tn.log), + URL: receivers.JoinURLPath(tn.tmpl.ExternalURL.String(), "/alerting/list", tn.log), }, }, }) @@ -344,12 +324,12 @@ func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, return false, fmt.Errorf("failed to marshal JSON: %w", err) } - cmd := &SendWebhookSettings{URL: u, Body: string(b)} + cmd := &receivers.WebhookSendSettings{URL: u, Body: string(b)} // Teams sometimes does not use status codes to show when a request has failed. Instead, the // response can contain an error message, irrespective of status code (i.e. https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#rate-limiting-for-connectors) cmd.Validation = validateResponse - if err := tn.ns.SendWebhook(ctx, cmd); err != nil { + if err := tn.ns.Send(ctx, cmd); err != nil { return false, errors.Wrap(err, "send notification to Teams") } @@ -371,7 +351,7 @@ func (tn *TeamsNotifier) SendResolved() bool { // getTeamsTextColor returns the text color for the message title. func getTeamsTextColor(alerts model.Alerts) string { - if getAlertStatusColor(alerts.Status()) == ColorAlertFiring { + if receivers.GetAlertStatusColor(alerts.Status()) == receivers.ColorAlertFiring { return TextColorAttention } return TextColorGood diff --git a/alerting/notifier/channels/teams_test.go b/receivers/teams/teams_test.go similarity index 94% rename from alerting/notifier/channels/teams_test.go rename to receivers/teams/teams_test.go index eca0897c..6e98f0fe 100644 --- a/alerting/notifier/channels/teams_test.go +++ b/receivers/teams/teams_test.go @@ -1,4 +1,4 @@ -package channels +package teams import ( "context" @@ -11,10 +11,15 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestTeamsNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -249,20 +254,20 @@ func TestTeamsNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON := json.RawMessage(c.settings) - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ Name: "teams_testing", Type: "teams", Settings: settingsJSON, } - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, - ImageStore: &UnavailableImageStore{}, + ImageStore: &images.UnavailableImageStore{}, NotificationService: webhookSender, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := NewTeamsNotifier(fc) diff --git a/receivers/telegram/config.go b/receivers/telegram/config.go new file mode 100644 index 00000000..0e80569d --- /dev/null +++ b/receivers/telegram/config.go @@ -0,0 +1,60 @@ +package telegram + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +const DefaultTelegramParseMode = "HTML" + +// TelegramSupportedParseMode is a map of all supported values for field `parse_mode`. https://core.telegram.org/bots/api#formatting-options. +// Keys are options accepted by Grafana API, values are options accepted by Telegram API +var TelegramSupportedParseMode = map[string]string{"Markdown": "Markdown", "MarkdownV2": "MarkdownV2", DefaultTelegramParseMode: "HTML", "None": ""} + +type TelegramConfig struct { + BotToken string `json:"bottoken,omitempty" yaml:"bottoken,omitempty"` + ChatID string `json:"chatid,omitempty" yaml:"chatid,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + ParseMode string `json:"parse_mode,omitempty" yaml:"parse_mode,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty" yaml:"disable_notifications,omitempty"` +} + +func BuildTelegramConfig(fc receivers.FactoryConfig) (TelegramConfig, error) { + settings := TelegramConfig{} + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + settings.BotToken = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "bottoken", settings.BotToken) + if settings.BotToken == "" { + return settings, errors.New("could not find Bot Token in settings") + } + if settings.ChatID == "" { + return settings, errors.New("could not find Chat Id in settings") + } + if settings.Message == "" { + settings.Message = templates.DefaultMessageEmbed + } + // if field is missing, then we fall back to the previous default: HTML + if settings.ParseMode == "" { + settings.ParseMode = DefaultTelegramParseMode + } + found := false + for parseMode, value := range TelegramSupportedParseMode { + if strings.EqualFold(settings.ParseMode, parseMode) { + settings.ParseMode = value + found = true + break + } + } + if !found { + return settings, fmt.Errorf("unknown parse_mode, must be Markdown, MarkdownV2, HTML or None") + } + return settings, nil +} diff --git a/alerting/notifier/channels/telegram.go b/receivers/telegram/telegram.go similarity index 59% rename from alerting/notifier/channels/telegram.go rename to receivers/telegram/telegram.go index c38c77b8..d5713546 100644 --- a/alerting/notifier/channels/telegram.go +++ b/receivers/telegram/telegram.go @@ -1,27 +1,25 @@ -package channels +package telegram import ( "bytes" "context" - "errors" "fmt" "io" "mime/multipart" "os" - "strings" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) var ( TelegramAPIURL = "https://api.telegram.org/bot%s/%s" - - DefaultParseMode = "HTML" - // SupportedParseMode is a map of all supported values for field `parse_mode`. https://core.telegram.org/bots/api#formatting-options. - // Keys are options accepted by Grafana API, values are options accepted by Telegram API - SupportedParseMode = map[string]string{"Markdown": "Markdown", "MarkdownV2": "MarkdownV2", DefaultParseMode: "HTML", "None": ""} ) // Telegram supports 4096 chars max - from https://limits.tginfo.me/en. @@ -30,60 +28,18 @@ const telegramMaxMessageLenRunes = 4096 // TelegramNotifier is responsible for sending // alert notifications to Telegram. type TelegramNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender + *receivers.Base + log logging.Logger + images images.ImageStore + ns receivers.WebhookSender tmpl *template.Template - settings telegramSettings -} - -type telegramSettings struct { - BotToken string `json:"bottoken,omitempty" yaml:"bottoken,omitempty"` - ChatID string `json:"chatid,omitempty" yaml:"chatid,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - ParseMode string `json:"parse_mode,omitempty" yaml:"parse_mode,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty" yaml:"disable_notifications,omitempty"` -} - -func buildTelegramSettings(fc FactoryConfig) (telegramSettings, error) { - settings := telegramSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - settings.BotToken = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "bottoken", settings.BotToken) - if settings.BotToken == "" { - return settings, errors.New("could not find Bot Token in settings") - } - if settings.ChatID == "" { - return settings, errors.New("could not find Chat Id in settings") - } - if settings.Message == "" { - settings.Message = DefaultMessageEmbed - } - // if field is missing, then we fall back to the previous default: HTML - if settings.ParseMode == "" { - settings.ParseMode = DefaultParseMode - } - found := false - for parseMode, value := range SupportedParseMode { - if strings.EqualFold(settings.ParseMode, parseMode) { - settings.ParseMode = value - found = true - break - } - } - if !found { - return settings, fmt.Errorf("unknown parse_mode, must be Markdown, MarkdownV2, HTML or None") - } - return settings, nil + settings TelegramConfig } -func TelegramFactory(fc FactoryConfig) (NotificationChannel, error) { +func TelegramFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := NewTelegramNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -92,13 +48,13 @@ func TelegramFactory(fc FactoryConfig) (NotificationChannel, error) { } // NewTelegramNotifier is the constructor for the Telegram notifier -func NewTelegramNotifier(fc FactoryConfig) (*TelegramNotifier, error) { - settings, err := buildTelegramSettings(fc) +func NewTelegramNotifier(fc receivers.FactoryConfig) (*TelegramNotifier, error) { + settings, err := BuildTelegramConfig(fc) if err != nil { return nil, err } return &TelegramNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), tmpl: fc.Template, log: fc.Logger, images: fc.ImageStore, @@ -129,12 +85,12 @@ func (tn *TelegramNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo if err != nil { return false, fmt.Errorf("failed to create telegram message: %w", err) } - if err := tn.ns.SendWebhook(ctx, cmd); err != nil { + if err := tn.ns.Send(ctx, cmd); err != nil { return false, fmt.Errorf("failed to send telegram message: %w", err) } // Create the cmd to upload each image - _ = withStoredImages(ctx, tn.log, tn.images, func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, tn.log, tn.images, func(index int, image images.Image) error { cmd, err = tn.newWebhookSyncCmd("sendPhoto", func(w *multipart.Writer) error { f, err := os.Open(image.Path) if err != nil { @@ -157,7 +113,7 @@ func (tn *TelegramNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo if err != nil { return fmt.Errorf("failed to create image: %w", err) } - if err := tn.ns.SendWebhook(ctx, cmd); err != nil { + if err := tn.ns.Send(ctx, cmd); err != nil { return fmt.Errorf("failed to upload image to telegram: %w", err) } return nil @@ -174,9 +130,9 @@ func (tn *TelegramNotifier) buildTelegramMessage(ctx context.Context, as []*type } }() - tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) // Telegram supports 4096 chars max - messageText, truncated := TruncateInRunes(tmpl(tn.settings.Message), telegramMaxMessageLenRunes) + messageText, truncated := receivers.TruncateInRunes(tmpl(tn.settings.Message), telegramMaxMessageLenRunes) if truncated { key, err := notify.ExtractGroupKey(ctx) if err != nil { @@ -196,11 +152,11 @@ func (tn *TelegramNotifier) buildTelegramMessage(ctx context.Context, as []*type return m, nil } -func (tn *TelegramNotifier) newWebhookSyncCmd(action string, fn func(writer *multipart.Writer) error) (*SendWebhookSettings, error) { +func (tn *TelegramNotifier) newWebhookSyncCmd(action string, fn func(writer *multipart.Writer) error) (*receivers.WebhookSendSettings, error) { b := bytes.Buffer{} w := multipart.NewWriter(&b) - boundary := GetBoundary() + boundary := receivers.GetBoundary() if boundary != "" { if err := w.SetBoundary(boundary); err != nil { return nil, err @@ -223,7 +179,7 @@ func (tn *TelegramNotifier) newWebhookSyncCmd(action string, fn func(writer *mul return nil, fmt.Errorf("failed to close multipart: %w", err) } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: fmt.Sprintf(TelegramAPIURL, tn.settings.BotToken, action), Body: b.String(), HTTPMethod: "POST", diff --git a/alerting/notifier/channels/telegram_test.go b/receivers/telegram/telegram_test.go similarity index 92% rename from alerting/notifier/channels/telegram_test.go rename to receivers/telegram/telegram_test.go index 0f084dbe..0c96277c 100644 --- a/alerting/notifier/channels/telegram_test.go +++ b/receivers/telegram/telegram_test.go @@ -1,4 +1,4 @@ -package channels +package telegram import ( "context" @@ -11,11 +11,15 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestTelegramNotifier(t *testing.T) { - tmpl := templateForTests(t) - images := newFakeImageStoreWithFile(t, 2) + tmpl := templates.ForTests(t) + images := receivers.NewFakeImageStoreWithFile(t, 2) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) tmpl.ExternalURL = externalURL @@ -117,10 +121,10 @@ func TestTelegramNotifier(t *testing.T) { require.NoError(t, err) secureSettings := make(map[string][]byte) - notificationService := mockNotificationService() + notificationService := receivers.MockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "telegram_tests", Type: "telegram", Settings: settingsJSON, @@ -132,7 +136,7 @@ func TestTelegramNotifier(t *testing.T) { return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } n, err := NewTelegramNotifier(fc) diff --git a/receivers/testing.go b/receivers/testing.go new file mode 100644 index 00000000..261d991b --- /dev/null +++ b/receivers/testing.go @@ -0,0 +1,22 @@ +package receivers + +import ( + "context" +) + +type NotificationServiceMock struct { + Webhook WebhookSendSettings + EmailSync SendEmailSettings + ShouldError error +} + +func (ns *NotificationServiceMock) SendWebhook(ctx context.Context, cmd *WebhookSendSettings) error { + ns.Webhook = *cmd + return ns.ShouldError +} +func (ns *NotificationServiceMock) SendEmail(ctx context.Context, cmd *SendEmailSettings) error { + ns.EmailSync = *cmd + return ns.ShouldError +} + +func MockNotificationService() *NotificationServiceMock { return &NotificationServiceMock{} } diff --git a/receivers/threema/config.go b/receivers/threema/config.go new file mode 100644 index 00000000..368c6dd8 --- /dev/null +++ b/receivers/threema/config.go @@ -0,0 +1,59 @@ +package threema + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type ThreemaConfig struct { + GatewayID string `json:"gateway_id,omitempty" yaml:"gateway_id,omitempty"` + RecipientID string `json:"recipient_id,omitempty" yaml:"recipient_id,omitempty"` + APISecret string `json:"api_secret,omitempty" yaml:"api_secret,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` +} + +func BuildThreemaConfig(fc receivers.FactoryConfig) (ThreemaConfig, error) { + settings := ThreemaConfig{} + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + // GatewayID validaiton + if settings.GatewayID == "" { + return settings, errors.New("could not find Threema Gateway ID in settings") + } + if !strings.HasPrefix(settings.GatewayID, "*") { + return settings, errors.New("invalid Threema Gateway ID: Must start with a *") + } + if len(settings.GatewayID) != 8 { + return settings, errors.New("invalid Threema Gateway ID: Must be 8 characters long") + } + + // RecipientID validation + if settings.RecipientID == "" { + return settings, errors.New("could not find Threema Recipient ID in settings") + } + if len(settings.RecipientID) != 8 { + return settings, errors.New("invalid Threema Recipient ID: Must be 8 characters long") + } + settings.APISecret = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "api_secret", settings.APISecret) + if settings.APISecret == "" { + return settings, errors.New("could not find Threema API secret in settings") + } + + if settings.Description == "" { + settings.Description = templates.DefaultMessageEmbed + } + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + + return settings, nil +} diff --git a/alerting/notifier/channels/threema.go b/receivers/threema/threema.go similarity index 50% rename from alerting/notifier/channels/threema.go rename to receivers/threema/threema.go index a8052ca4..af246091 100644 --- a/alerting/notifier/channels/threema.go +++ b/receivers/threema/threema.go @@ -1,16 +1,19 @@ -package channels +package threema import ( "context" - "errors" "fmt" "net/url" "path" - "strings" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) var ( @@ -20,65 +23,18 @@ var ( // ThreemaNotifier is responsible for sending // alert notifications to Threema. type ThreemaNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender + *receivers.Base + log logging.Logger + images images.ImageStore + ns receivers.WebhookSender tmpl *template.Template - settings threemaSettings -} - -type threemaSettings struct { - GatewayID string `json:"gateway_id,omitempty" yaml:"gateway_id,omitempty"` - RecipientID string `json:"recipient_id,omitempty" yaml:"recipient_id,omitempty"` - APISecret string `json:"api_secret,omitempty" yaml:"api_secret,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` -} - -func buildThreemaSettings(fc FactoryConfig) (threemaSettings, error) { - settings := threemaSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - // GatewayID validaiton - if settings.GatewayID == "" { - return settings, errors.New("could not find Threema Gateway ID in settings") - } - if !strings.HasPrefix(settings.GatewayID, "*") { - return settings, errors.New("invalid Threema Gateway ID: Must start with a *") - } - if len(settings.GatewayID) != 8 { - return settings, errors.New("invalid Threema Gateway ID: Must be 8 characters long") - } - - // RecipientID validation - if settings.RecipientID == "" { - return settings, errors.New("could not find Threema Recipient ID in settings") - } - if len(settings.RecipientID) != 8 { - return settings, errors.New("invalid Threema Recipient ID: Must be 8 characters long") - } - settings.APISecret = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "api_secret", settings.APISecret) - if settings.APISecret == "" { - return settings, errors.New("could not find Threema API secret in settings") - } - - if settings.Description == "" { - settings.Description = DefaultMessageEmbed - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - - return settings, nil + settings ThreemaConfig } -func ThreemaFactory(fc FactoryConfig) (NotificationChannel, error) { +func ThreemaFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := NewThreemaNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -86,13 +42,13 @@ func ThreemaFactory(fc FactoryConfig) (NotificationChannel, error) { return notifier, nil } -func NewThreemaNotifier(fc FactoryConfig) (*ThreemaNotifier, error) { - settings, err := buildThreemaSettings(fc) +func NewThreemaNotifier(fc receivers.FactoryConfig) (*ThreemaNotifier, error) { + settings, err := BuildThreemaConfig(fc) if err != nil { return nil, err } return &ThreemaNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, images: fc.ImageStore, ns: fc.NotificationService, @@ -112,7 +68,7 @@ func (tn *ThreemaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool data.Set("secret", tn.settings.APISecret) data.Set("text", tn.buildMessage(ctx, as...)) - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: ThreemaGwBaseURL, Body: data.Encode(), HTTPMethod: "POST", @@ -120,7 +76,7 @@ func (tn *ThreemaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool "Content-Type": "application/x-www-form-urlencoded", }, } - if err := tn.ns.SendWebhook(ctx, cmd); err != nil { + if err := tn.ns.Send(ctx, cmd); err != nil { tn.log.Error("Failed to send threema notification", "error", err, "webhook", tn.Name) return false, err } @@ -134,7 +90,7 @@ func (tn *ThreemaNotifier) SendResolved() bool { func (tn *ThreemaNotifier) buildMessage(ctx context.Context, as ...*types.Alert) string { var tmplErr error - tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) message := fmt.Sprintf("%s%s\n\n*Message:*\n%s\n*URL:* %s\n", selectEmoji(as...), @@ -147,8 +103,8 @@ func (tn *ThreemaNotifier) buildMessage(ctx context.Context, as ...*types.Alert) tn.log.Warn("failed to template Threema message", "error", tmplErr.Error()) } - _ = withStoredImages(ctx, tn.log, tn.images, - func(_ int, image Image) error { + _ = receivers.WithStoredImages(ctx, tn.log, tn.images, + func(_ int, image images.Image) error { if image.URL != "" { message += fmt.Sprintf("*Image:* %s\n", image.URL) } diff --git a/alerting/notifier/channels/threema_test.go b/receivers/threema/threema_test.go similarity index 93% rename from alerting/notifier/channels/threema_test.go rename to receivers/threema/threema_test.go index 27725841..6a03161e 100644 --- a/alerting/notifier/channels/threema_test.go +++ b/receivers/threema/threema_test.go @@ -1,4 +1,4 @@ -package channels +package threema import ( "context" @@ -10,12 +10,16 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestThreemaNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) - images := newFakeImageStore(2) + images := receivers.NewFakeImageStore(2) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -117,10 +121,10 @@ func TestThreemaNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON := json.RawMessage(c.settings) secureSettings := make(map[string][]byte) - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "threema_testing", Type: "threema", Settings: settingsJSON, @@ -132,7 +136,7 @@ func TestThreemaNotifier(t *testing.T) { DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { return fallback }, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := NewThreemaNotifier(fc) diff --git a/alerting/notifier/channels/util.go b/receivers/util.go similarity index 61% rename from alerting/notifier/channels/util.go rename to receivers/util.go index 9ec71e89..6916a512 100644 --- a/alerting/notifier/channels/util.go +++ b/receivers/util.go @@ -1,10 +1,9 @@ -package channels +package receivers import ( "bytes" "context" "crypto/tls" - "encoding/json" "errors" "fmt" "io" @@ -20,9 +19,10 @@ import ( "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" - "gopkg.in/yaml.v3" - "github.com/grafana/alerting/alerting/models" + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/models" ) type AlertStateType string @@ -39,22 +39,11 @@ const ( ImageStoreTimeout time.Duration = 500 * time.Millisecond ) -var ( - // Provides current time. Can be overwritten in tests. - timeNow = time.Now - - // ErrImagesDone is used to stop iteration of subsequent images. It should be - // returned from forEachFunc when either the intended image has been found or - // the maximum number of images has been iterated. - ErrImagesDone = errors.New("images done") - ErrImagesUnavailable = errors.New("alert screenshots are unavailable") -) - -type forEachImageFunc func(index int, image Image) error +type forEachImageFunc func(index int, image images.Image) error // getImage returns the image for the alert or an error. It returns a nil // image if the alert does not have an image token or the image does not exist. -func getImage(ctx context.Context, l Logger, imageStore ImageStore, alert types.Alert) (*Image, error) { +func getImage(ctx context.Context, l logging.Logger, imageStore images.ImageStore, alert types.Alert) (*images.Image, error) { token := getTokenFromAnnotations(alert.Annotations) if token == "" { return nil, nil @@ -64,7 +53,7 @@ func getImage(ctx context.Context, l Logger, imageStore ImageStore, alert types. defer cancelFunc() img, err := imageStore.GetImage(ctx, token) - if errors.Is(err, ErrImageNotFound) || errors.Is(err, ErrImagesUnavailable) { + if errors.Is(err, images.ErrImageNotFound) || errors.Is(err, images.ErrImagesUnavailable) { return nil, nil } else if err != nil { l.Warn("failed to get image with token", "token", token, "error", err) @@ -74,14 +63,14 @@ func getImage(ctx context.Context, l Logger, imageStore ImageStore, alert types. } } -// withStoredImages retrieves the image for each alert and then calls forEachFunc +// WithStoredImages retrieves the image for each alert and then calls forEachFunc // with the index of the alert and the retrieved image struct. If the alert does // not have an image token, or the image does not exist then forEachFunc will not be // called for that alert. If forEachFunc returns an error, withStoredImages will return // the error and not iterate the remaining alerts. A forEachFunc can return ErrImagesDone // to stop the iteration of remaining alerts if the intended image or maximum number of // images have been found. -func withStoredImages(ctx context.Context, l Logger, imageStore ImageStore, forEachFunc forEachImageFunc, alerts ...*types.Alert) error { +func WithStoredImages(ctx context.Context, l logging.Logger, imageStore images.ImageStore, forEachFunc forEachImageFunc, alerts ...*types.Alert) error { for index, alert := range alerts { logger := l.New("alert", alert.String()) img, err := getImage(ctx, logger, imageStore, *alert) @@ -89,7 +78,7 @@ func withStoredImages(ctx context.Context, l Logger, imageStore ImageStore, forE return err } else if img != nil { if err := forEachFunc(index, *img); err != nil { - if errors.Is(err, ErrImagesDone) { + if errors.Is(err, images.ErrImagesDone) { return nil } logger.Error("Failed to attach image to notification", "error", err) @@ -100,15 +89,15 @@ func withStoredImages(ctx context.Context, l Logger, imageStore ImageStore, forE return nil } -// The path argument here comes from reading internal image storage, not user +// The path argument here comes from reading internal image storage, not User // input, so we ignore the security check here. // //nolint:gosec, unused, deadcode //TODO yuri. Remove unused and deadcode after migration is done -func openImage(path string) (io.ReadCloser, error) { +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 + return nil, images.ErrImageNotFound } f, err := os.Open(fp) @@ -126,20 +115,13 @@ func getTokenFromAnnotations(annotations model.LabelSet) string { return "" } -type UnavailableImageStore struct{} - -// Get returns the image with the corresponding token, or ErrImageNotFound. -func (u *UnavailableImageStore) GetImage(ctx context.Context, token string) (*Image, error) { - return nil, ErrImagesUnavailable -} - -type receiverInitError struct { +type ReceiverInitError struct { Reason string Err error Cfg NotificationChannelConfig } -func (e receiverInitError) Error() string { +func (e ReceiverInitError) Error() string { name := "" if e.Cfg.Name != "" { name = fmt.Sprintf("%q ", e.Cfg.Name) @@ -153,9 +135,9 @@ func (e receiverInitError) Error() string { return s } -func (e receiverInitError) Unwrap() error { return e.Err } +func (e ReceiverInitError) Unwrap() error { return e.Err } -func getAlertStatusColor(status model.AlertStatus) string { +func GetAlertStatusColor(status model.AlertStatus) string { if status == model.AlertFiring { return ColorAlertFiring } @@ -166,41 +148,28 @@ type NotificationChannel interface { notify.Notifier notify.ResolvedSender } -type NotificationChannelConfig struct { - OrgID int64 // only used internally - UID string `json:"uid"` - Name string `json:"name"` - Type string `json:"type"` - DisableResolveMessage bool `json:"disableResolveMessage"` - Settings json.RawMessage `json:"settings"` - SecureSettings map[string][]byte `json:"secureSettings"` -} - -func (c NotificationChannelConfig) unmarshalSettings(v interface{}) error { - return json.Unmarshal(c.Settings, v) -} -type httpCfg struct { - body []byte - user string - password string +type HTTPCfg struct { + Body []byte + User string + Password string } -// sendHTTPRequest sends an HTTP request. +// SendHTTPRequest sends an HTTP request. // Stubbable by tests. // -//nolint:deadcode, unused, varcheck //TODO yuri. Remove after migration is done -var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger Logger) ([]byte, error) { +//nolint:unused, varcheck +var SendHTTPRequest = func(ctx context.Context, url *url.URL, cfg HTTPCfg, logger logging.Logger) ([]byte, error) { var reader io.Reader - if len(cfg.body) > 0 { - reader = bytes.NewReader(cfg.body) + if len(cfg.Body) > 0 { + reader = bytes.NewReader(cfg.Body) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), reader) if err != nil { return nil, fmt.Errorf("failed to create HTTP request: %w", err) } - if cfg.user != "" && cfg.password != "" { - request.SetBasicAuth(cfg.user, cfg.password) + if cfg.User != "" && cfg.Password != "" { + request.SetBasicAuth(cfg.User, cfg.Password) } request.Header.Set("Content-Type", "application/json") @@ -225,17 +194,17 @@ var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logge } defer func() { if err := resp.Body.Close(); err != nil { - logger.Warn("failed to close response body", "error", err) + logger.Warn("failed to close response Body", "error", err) } }() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, fmt.Errorf("failed to read response Body: %w", err) } if resp.StatusCode/100 != 2 { - logger.Warn("HTTP request failed", "url", request.URL.String(), "statusCode", resp.Status, "body", + logger.Warn("HTTP request failed", "url", request.URL.String(), "statusCode", resp.Status, "Body", string(respBody)) return nil, fmt.Errorf("failed to send HTTP request - status code %d", resp.StatusCode) } @@ -244,7 +213,7 @@ var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logge return respBody, nil } -func joinURLPath(base, additionalPath string, logger Logger) string { +func JoinURLPath(base, additionalPath string, logger logging.Logger) string { u, err := url.Parse(base) if err != nil { logger.Debug("failed to parse URL while joining URL", "url", base, "error", err.Error()) @@ -257,64 +226,11 @@ func joinURLPath(base, additionalPath string, logger Logger) string { } // GetBoundary is used for overriding the behaviour for tests -// and set a boundary for multipart body. DO NOT set this outside tests. +// and set a boundary for multipart Body. DO NOT set this outside tests. var GetBoundary = func() string { return "" } -type CommaSeparatedStrings []string - -func (r *CommaSeparatedStrings) UnmarshalJSON(b []byte) error { - var str string - if err := json.Unmarshal(b, &str); err != nil { - return err - } - if len(str) > 0 { - res := CommaSeparatedStrings(splitCommaDelimitedString(str)) - *r = res - } - return nil -} - -func (r *CommaSeparatedStrings) MarshalJSON() ([]byte, error) { - if r == nil { - return nil, nil - } - str := strings.Join(*r, ",") - return json.Marshal(str) -} - -func (r *CommaSeparatedStrings) UnmarshalYAML(b []byte) error { - var str string - if err := yaml.Unmarshal(b, &str); err != nil { - return err - } - if len(str) > 0 { - res := CommaSeparatedStrings(splitCommaDelimitedString(str)) - *r = res - } - return nil -} - -func (r *CommaSeparatedStrings) MarshalYAML() ([]byte, error) { - if r == nil { - return nil, nil - } - str := strings.Join(*r, ",") - return yaml.Marshal(str) -} - -func splitCommaDelimitedString(str string) []string { - split := strings.Split(str, ",") - res := make([]string, 0, len(split)) - for _, s := range split { - if tr := strings.TrimSpace(s); tr != "" { - res = append(res, tr) - } - } - return res -} - // 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 = "…" diff --git a/alerting/notifier/channels/util_test.go b/receivers/util_test.go similarity index 70% rename from alerting/notifier/channels/util_test.go rename to receivers/util_test.go index 8810979b..63c971f6 100644 --- a/alerting/notifier/channels/util_test.go +++ b/receivers/util_test.go @@ -1,4 +1,4 @@ -package channels +package receivers import ( "context" @@ -10,7 +10,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/grafana/alerting/alerting/models" + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/models" ) func TestWithStoredImages(t *testing.T) { @@ -28,7 +30,7 @@ func TestWithStoredImages(t *testing.T) { }, }, }} - imageStore := &fakeImageStore{Images: []*Image{{ + imageStore := &images.FakeImageStore{Images: []*images.Image{{ Token: "test-image-1", URL: "https://www.example.com/test-image-1.jpg", CreatedAt: time.Now().UTC(), @@ -44,7 +46,7 @@ func TestWithStoredImages(t *testing.T) { ) // should iterate all images - err = withStoredImages(ctx, &FakeLogger{}, imageStore, func(index int, image Image) error { + err = WithStoredImages(ctx, &logging.FakeLogger{}, imageStore, func(index int, image images.Image) error { i++ return nil }, alerts...) @@ -53,9 +55,9 @@ func TestWithStoredImages(t *testing.T) { // should iterate just the first image i = 0 - err = withStoredImages(ctx, &FakeLogger{}, imageStore, func(index int, image Image) error { + err = WithStoredImages(ctx, &logging.FakeLogger{}, imageStore, func(index int, image images.Image) error { i++ - return ErrImagesDone + return images.ErrImagesDone }, alerts...) require.NoError(t, err) assert.Equal(t, 1, i) diff --git a/receivers/victorops/config.go b/receivers/victorops/config.go new file mode 100644 index 00000000..1a7e9a39 --- /dev/null +++ b/receivers/victorops/config.go @@ -0,0 +1,43 @@ +package victorops + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +const ( + // DefaultVictoropsMessageType - Victorops uses "CRITICAL" string to indicate "Alerting" state + DefaultVictoropsMessageType = "CRITICAL" +) + +type VictorOpsConfig struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + MessageType string `json:"messageType,omitempty" yaml:"messageType,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` +} + +func BuildVictorOpsConfig(fc receivers.FactoryConfig) (VictorOpsConfig, error) { + settings := VictorOpsConfig{} + err := json.Unmarshal(fc.Config.Settings, &settings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + if settings.URL == "" { + return settings, errors.New("could not find victorops url property in settings") + } + if settings.MessageType == "" { + settings.MessageType = DefaultVictoropsMessageType + } + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + if settings.Description == "" { + settings.Description = templates.DefaultMessageEmbed + } + return settings, nil +} diff --git a/alerting/notifier/channels/victorops.go b/receivers/victorops/victorops.go similarity index 60% rename from alerting/notifier/channels/victorops.go rename to receivers/victorops/victorops.go index 9826239b..faf19069 100644 --- a/alerting/notifier/channels/victorops.go +++ b/receivers/victorops/victorops.go @@ -1,10 +1,8 @@ -package channels +package victorops import ( "context" "encoding/json" - "errors" - "fmt" "strings" "time" @@ -12,51 +10,25 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" ) // https://help.victorops.com/knowledge-base/incident-fields-glossary/ - 20480 characters. const victorOpsMaxMessageLenRunes = 20480 const ( - // victoropsAlertStateCritical - Victorops uses "CRITICAL" string to indicate "Alerting" state - victoropsAlertStateCritical = "CRITICAL" - // victoropsAlertStateRecovery - VictorOps "RECOVERY" message type victoropsAlertStateRecovery = "RECOVERY" ) -type victorOpsSettings struct { - URL string `json:"url,omitempty" yaml:"url,omitempty"` - MessageType string `json:"messageType,omitempty" yaml:"messageType,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` -} - -func buildVictorOpsSettings(fc FactoryConfig) (victorOpsSettings, error) { - settings := victorOpsSettings{} - err := fc.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - if settings.URL == "" { - return settings, errors.New("could not find victorops url property in settings") - } - if settings.MessageType == "" { - settings.MessageType = victoropsAlertStateCritical - } - if settings.Title == "" { - settings.Title = DefaultMessageTitleEmbed - } - if settings.Description == "" { - settings.Description = DefaultMessageEmbed - } - return settings, nil -} - -func VictorOpsFactory(fc FactoryConfig) (NotificationChannel, error) { +func VictorOpsFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := NewVictoropsNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -66,13 +38,13 @@ func VictorOpsFactory(fc FactoryConfig) (NotificationChannel, error) { // NewVictoropsNotifier creates an instance of VictoropsNotifier that // handles posting notifications to Victorops REST API -func NewVictoropsNotifier(fc FactoryConfig) (*VictoropsNotifier, error) { - settings, err := buildVictorOpsSettings(fc) +func NewVictoropsNotifier(fc receivers.FactoryConfig) (*VictoropsNotifier, error) { + settings, err := BuildVictorOpsConfig(fc) if err != nil { return nil, err } return &VictoropsNotifier{ - Base: NewBase(fc.Config), + Base: receivers.NewBase(fc.Config), log: fc.Logger, images: fc.ImageStore, ns: fc.NotificationService, @@ -86,12 +58,12 @@ func NewVictoropsNotifier(fc FactoryConfig) (*VictoropsNotifier, error) { // and handles notification process by formatting POST body according to // Victorops specifications (http://victorops.force.com/knowledgebase/articles/Integration/Alert-Ingestion-API-Documentation/) type VictoropsNotifier struct { - *Base - log Logger - images ImageStore - ns WebhookSender + *receivers.Base + log logging.Logger + images images.ImageStore + ns receivers.WebhookSender tmpl *template.Template - settings victorOpsSettings + settings VictorOpsConfig appVersion string } @@ -100,7 +72,7 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo vn.log.Debug("sending notification", "notification", vn.Name) var tmplErr error - tmpl, _ := TmplText(ctx, vn.tmpl, as, vn.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, vn.tmpl, as, vn.log, &tmplErr) messageType := buildMessageType(vn.log, tmpl, vn.settings.MessageType, as...) @@ -109,7 +81,7 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo return false, err } - stateMessage, truncated := TruncateInRunes(tmpl(vn.settings.Description), victorOpsMaxMessageLenRunes) + stateMessage, truncated := receivers.TruncateInRunes(tmpl(vn.settings.Description), victorOpsMaxMessageLenRunes) if truncated { vn.log.Warn("Truncated stateMessage", "incident", groupKey, "max_runes", victorOpsMaxMessageLenRunes) } @@ -129,16 +101,16 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo tmplErr = nil } - _ = withStoredImages(ctx, vn.log, vn.images, - func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, vn.log, vn.images, + func(index int, image images.Image) error { if image.URL != "" { bodyJSON["image_url"] = image.URL - return ErrImagesDone + return images.ErrImagesDone } return nil }, as...) - ruleURL := joinURLPath(vn.tmpl.ExternalURL.String(), "/alerting/list", vn.log) + ruleURL := receivers.JoinURLPath(vn.tmpl.ExternalURL.String(), "/alerting/list", vn.log) bodyJSON["alert_url"] = ruleURL u := tmpl(vn.settings.URL) @@ -151,12 +123,12 @@ func (vn *VictoropsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bo if err != nil { return false, err } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: u, Body: string(b), } - if err := vn.ns.SendWebhook(ctx, cmd); err != nil { + if err := vn.ns.Send(ctx, cmd); err != nil { vn.log.Error("failed to send notification", "error", err, "webhook", vn.Name) return false, err } @@ -168,13 +140,13 @@ func (vn *VictoropsNotifier) SendResolved() bool { return !vn.GetDisableResolveMessage() } -func buildMessageType(l Logger, tmpl func(string) string, msgType string, as ...*types.Alert) string { +func buildMessageType(l logging.Logger, tmpl func(string) string, msgType string, as ...*types.Alert) string { if types.Alerts(as...).Status() == model.AlertResolved { return victoropsAlertStateRecovery } if messageType := strings.ToUpper(tmpl(msgType)); messageType != "" { return messageType } - l.Warn("expansion of message type template resulted in an empty string. Using fallback", "fallback", victoropsAlertStateCritical, "template", msgType) - return victoropsAlertStateCritical + l.Warn("expansion of message type template resulted in an empty string. Using fallback", "fallback", DefaultVictoropsMessageType, "template", msgType) + return DefaultVictoropsMessageType } diff --git a/alerting/notifier/channels/victorops_test.go b/receivers/victorops/victorops_test.go similarity index 95% rename from alerting/notifier/channels/victorops_test.go rename to receivers/victorops/victorops_test.go index 763e0c5a..94f1e3ac 100644 --- a/alerting/notifier/channels/victorops_test.go +++ b/receivers/victorops/victorops_test.go @@ -1,4 +1,4 @@ -package channels +package victorops import ( "context" @@ -12,12 +12,16 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestVictoropsNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) - images := newFakeImageStore(2) + images := receivers.NewFakeImageStore(2) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -190,20 +194,20 @@ func TestVictoropsNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON := json.RawMessage(c.settings) - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ Name: "victorops_testing", Type: "victorops", Settings: settingsJSON, } - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, NotificationService: webhookSender, ImageStore: images, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, GrafanaBuildVersion: version, } diff --git a/receivers/webex/config.go b/receivers/webex/config.go new file mode 100644 index 00000000..e4e56711 --- /dev/null +++ b/receivers/webex/config.go @@ -0,0 +1,52 @@ +package webex + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +const ( + DefaultWebexAPIURL = "https://webexapis.com/v1/messages" +) + +// 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 WebexConfig 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"` +} + +// BuildWebexConfig is the constructor for the Webex notifier. +func BuildWebexConfig(factoryConfig receivers.FactoryConfig) (*WebexConfig, error) { + settings := &WebexConfig{} + err := json.Unmarshal(factoryConfig.Config.Settings, &settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + if settings.APIURL == "" { + settings.APIURL = DefaultWebexAPIURL + } + + if settings.Message == "" { + settings.Message = templates.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 +} diff --git a/alerting/notifier/channels/webex.go b/receivers/webex/webex.go similarity index 52% rename from alerting/notifier/channels/webex.go rename to receivers/webex/webex.go index df34967c..3546187d 100644 --- a/alerting/notifier/channels/webex.go +++ b/receivers/webex/webex.go @@ -1,69 +1,35 @@ -package channels +package webex import ( "context" "encoding/json" "fmt" "net/http" - "net/url" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" -) -const webexAPIURL = "https://webexapis.com/v1/messages" + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" +) // WebexNotifier is responsible for sending alert notifications as webex messages. type WebexNotifier struct { - *Base - ns WebhookSender - log Logger - images ImageStore + *receivers.Base + ns receivers.WebhookSender + log logging.Logger + images 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 + settings *WebexConfig } -func WebexFactory(fc FactoryConfig) (NotificationChannel, error) { +func WebexFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { notifier, err := buildWebexNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -71,15 +37,14 @@ func WebexFactory(fc FactoryConfig) (NotificationChannel, error) { return notifier, nil } -// buildWebexSettings is the constructor for the Webex notifier. -func buildWebexNotifier(factoryConfig FactoryConfig) (*WebexNotifier, error) { - settings, err := buildWebexSettings(factoryConfig) +func buildWebexNotifier(factoryConfig receivers.FactoryConfig) (*WebexNotifier, error) { + settings, err := BuildWebexConfig(factoryConfig) if err != nil { return nil, err } return &WebexNotifier{ - Base: NewBase(factoryConfig.Config), + Base: receivers.NewBase(factoryConfig.Config), orgID: factoryConfig.Config.OrgID, log: factoryConfig.Logger, ns: factoryConfig.NotificationService, @@ -99,9 +64,9 @@ type WebexMessage struct { // 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) + tmpl, data := template2.TmplText(ctx, wn.tmpl, as, wn.log, &tmplErr) - message, truncated := TruncateInBytes(tmpl(wn.settings.Message), 4096) + message, truncated := receivers.TruncateInBytes(tmpl(wn.settings.Message), 4096) if truncated { wn.log.Warn("Webex message too long, truncating message", "OriginalMessage", wn.settings.Message) } @@ -118,12 +83,12 @@ func (wn *WebexNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, } // Augment our Alert data with ImageURLs if available. - _ = withStoredImages(ctx, wn.log, wn.images, func(index int, image Image) error { + _ = receivers.WithStoredImages(ctx, wn.log, wn.images, func(index int, image images.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 images.ErrImagesDone } return nil @@ -139,7 +104,7 @@ func (wn *WebexNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, return false, tmplErr } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: parsedURL, Body: string(body), HTTPMethod: http.MethodPost, @@ -151,7 +116,7 @@ func (wn *WebexNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, cmd.HTTPHeader = headers } - if err := wn.ns.SendWebhook(ctx, cmd); err != nil { + if err := wn.ns.Send(ctx, cmd); err != nil { return false, err } diff --git a/alerting/notifier/channels/webex_test.go b/receivers/webex/webex_test.go similarity index 92% rename from alerting/notifier/channels/webex_test.go rename to receivers/webex/webex_test.go index 7c3f730d..0ebfb522 100644 --- a/alerting/notifier/channels/webex_test.go +++ b/receivers/webex/webex_test.go @@ -1,4 +1,4 @@ -package channels +package webex import ( "context" @@ -12,11 +12,15 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestWebexNotifier(t *testing.T) { - tmpl := templateForTests(t) - images := newFakeImageStoreWithFile(t, 2) + tmpl := templates.ForTests(t) + images := receivers.NewFakeImageStoreWithFile(t, 2) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -105,10 +109,10 @@ func TestWebexNotifier(t *testing.T) { settingsJSON := json.RawMessage(c.settings) secureSettings := make(map[string][]byte) - notificationService := mockNotificationService() + notificationService := receivers.MockNotificationService() - fc := FactoryConfig{ - Config: &NotificationChannelConfig{ + fc := receivers.FactoryConfig{ + Config: &receivers.NotificationChannelConfig{ Name: "webex_tests", Type: "webex", Settings: settingsJSON, @@ -120,7 +124,7 @@ func TestWebexNotifier(t *testing.T) { return fallback }, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } n, err := buildWebexNotifier(fc) diff --git a/receivers/webhook.go b/receivers/webhook.go new file mode 100644 index 00000000..ac82a096 --- /dev/null +++ b/receivers/webhook.go @@ -0,0 +1,18 @@ +package receivers + +import "context" + +type WebhookSendSettings struct { + URL string + User string + Password string + Body string + HTTPMethod string + HTTPHeader map[string]string + ContentType string + Validation func(body []byte, statusCode int) error +} + +type WebhookSender interface { + SendWebhook(ctx context.Context, cmd *WebhookSendSettings) error +} diff --git a/receivers/webhook/config.go b/receivers/webhook/config.go new file mode 100644 index 00000000..bba9d29e --- /dev/null +++ b/receivers/webhook/config.go @@ -0,0 +1,81 @@ +package webhook + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +type WebhookConfig struct { + URL string + HTTPMethod string + MaxAlerts int + // Authorization Header. + AuthorizationScheme string + AuthorizationCredentials string + // HTTP Basic Authentication. + User string + Password string + + Title string + Message string +} + +func BuildWebhookConfig(factoryConfig receivers.FactoryConfig) (WebhookConfig, error) { + settings := WebhookConfig{} + rawSettings := struct { + URL string `json:"url,omitempty" yaml:"url,omitempty"` + HTTPMethod string `json:"httpMethod,omitempty" yaml:"httpMethod,omitempty"` + MaxAlerts json.Number `json:"maxAlerts,omitempty" yaml:"maxAlerts,omitempty"` + AuthorizationScheme string `json:"authorization_scheme,omitempty" yaml:"authorization_scheme,omitempty"` + AuthorizationCredentials string `json:"authorization_credentials,omitempty" yaml:"authorization_credentials,omitempty"` + User string `json:"username,omitempty" yaml:"username,omitempty"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + }{} + + err := json.Unmarshal(factoryConfig.Config.Settings, &rawSettings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + if rawSettings.URL == "" { + return settings, errors.New("required field 'url' is not specified") + } + settings.URL = rawSettings.URL + + if rawSettings.HTTPMethod == "" { + rawSettings.HTTPMethod = http.MethodPost + } + settings.HTTPMethod = rawSettings.HTTPMethod + + if rawSettings.MaxAlerts != "" { + settings.MaxAlerts, _ = strconv.Atoi(rawSettings.MaxAlerts.String()) + } + + settings.User = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "username", rawSettings.User) + settings.Password = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "password", rawSettings.Password) + settings.AuthorizationCredentials = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "authorization_credentials", rawSettings.AuthorizationCredentials) + + if settings.AuthorizationCredentials != "" && settings.AuthorizationScheme == "" { + settings.AuthorizationScheme = "Bearer" + } + if settings.User != "" && settings.Password != "" && settings.AuthorizationScheme != "" && settings.AuthorizationCredentials != "" { + return settings, errors.New("both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted") + } + settings.Title = rawSettings.Title + if settings.Title == "" { + settings.Title = templates.DefaultMessageTitleEmbed + } + settings.Message = rawSettings.Message + if settings.Message == "" { + settings.Message = templates.DefaultMessageEmbed + } + return settings, err +} diff --git a/receivers/webhook/webhook.go b/receivers/webhook/webhook.go new file mode 100644 index 00000000..cecbb4ee --- /dev/null +++ b/receivers/webhook/webhook.go @@ -0,0 +1,156 @@ +package webhook + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" + "github.com/prometheus/common/model" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" +) + +// WebhookNotifier is responsible for sending +// alert notifications as webhooks. +type WebhookNotifier struct { + *receivers.Base + log logging.Logger + ns receivers.WebhookSender + images images.ImageStore + tmpl *template.Template + orgID int64 + settings WebhookConfig +} + +func WebHookFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { + notifier, err := buildWebhookNotifier(fc) + if err != nil { + return nil, receivers.ReceiverInitError{ + Reason: err.Error(), + Cfg: *fc.Config, + } + } + return notifier, nil +} + +// buildWebhookNotifier is the constructor for +// the WebHook notifier. +func buildWebhookNotifier(factoryConfig receivers.FactoryConfig) (*WebhookNotifier, error) { + settings, err := BuildWebhookConfig(factoryConfig) + if err != nil { + return nil, err + } + return &WebhookNotifier{ + Base: receivers.NewBase(factoryConfig.Config), + orgID: factoryConfig.Config.OrgID, + log: factoryConfig.Logger, + ns: factoryConfig.NotificationService, + images: factoryConfig.ImageStore, + tmpl: factoryConfig.Template, + settings: settings, + }, nil +} + +// WebhookMessage defines the JSON object send to webhook endpoints. +type WebhookMessage struct { + *template2.ExtendedData + + // The protocol version. + Version string `json:"version"` + GroupKey string `json:"groupKey"` + TruncatedAlerts int `json:"truncatedAlerts"` + OrgID int64 `json:"orgId"` + Title string `json:"title"` + State string `json:"state"` + Message string `json:"message"` +} + +// Notify implements the Notifier interface. +func (wn *WebhookNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + groupKey, err := notify.ExtractGroupKey(ctx) + if err != nil { + return false, err + } + + as, numTruncated := truncateAlerts(wn.settings.MaxAlerts, as) + var tmplErr error + tmpl, data := template2.TmplText(ctx, wn.tmpl, as, wn.log, &tmplErr) + + // Augment our Alert data with ImageURLs if available. + _ = receivers.WithStoredImages(ctx, wn.log, wn.images, + func(index int, image images.Image) error { + if len(image.URL) != 0 { + data.Alerts[index].ImageURL = image.URL + } + return nil + }, + as...) + + msg := &WebhookMessage{ + Version: "1", + ExtendedData: data, + GroupKey: groupKey.String(), + TruncatedAlerts: numTruncated, + OrgID: wn.orgID, + Title: tmpl(wn.settings.Title), + Message: tmpl(wn.settings.Message), + } + if types.Alerts(as...).Status() == model.AlertFiring { + msg.State = string(receivers.AlertStateAlerting) + } else { + msg.State = string(receivers.AlertStateOK) + } + + if tmplErr != nil { + wn.log.Warn("failed to template webhook message", "error", tmplErr.Error()) + tmplErr = nil + } + + body, err := json.Marshal(msg) + if err != nil { + return false, err + } + + headers := make(map[string]string) + if wn.settings.AuthorizationScheme != "" && wn.settings.AuthorizationCredentials != "" { + headers["Authorization"] = fmt.Sprintf("%s %s", wn.settings.AuthorizationScheme, wn.settings.AuthorizationCredentials) + } + + parsedURL := tmpl(wn.settings.URL) + if tmplErr != nil { + return false, tmplErr + } + + cmd := &receivers.WebhookSendSettings{ + URL: parsedURL, + User: wn.settings.User, + Password: wn.settings.Password, + Body: string(body), + HTTPMethod: wn.settings.HTTPMethod, + HTTPHeader: headers, + } + + if err := wn.ns.Send(ctx, cmd); err != nil { + return false, err + } + + return true, nil +} + +func truncateAlerts(maxAlerts int, alerts []*types.Alert) ([]*types.Alert, int) { + if maxAlerts > 0 && len(alerts) > maxAlerts { + return alerts[:maxAlerts], len(alerts) - maxAlerts + } + + return alerts, 0 +} + +func (wn *WebhookNotifier) SendResolved() bool { + return !wn.GetDisableResolveMessage() +} diff --git a/alerting/notifier/channels/webhook_test.go b/receivers/webhook/webhook_test.go similarity index 94% rename from alerting/notifier/channels/webhook_test.go rename to receivers/webhook/webhook_test.go index 2f981336..92e06858 100644 --- a/alerting/notifier/channels/webhook_test.go +++ b/receivers/webhook/webhook_test.go @@ -1,4 +1,4 @@ -package channels +package webhook import ( "context" @@ -12,10 +12,15 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/images" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestWebhookNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -51,10 +56,10 @@ func TestWebhookNotifier(t *testing.T) { expURL: "http://localhost/test", expHTTPMethod: "POST", expMsg: &WebhookMessage{ - ExtendedData: &ExtendedData{ + ExtendedData: &templates.ExtendedData{ Receiver: "my_receiver", Status: "firing", - Alerts: ExtendedAlerts{ + Alerts: templates.ExtendedAlerts{ { Status: "firing", Labels: template.KV{ @@ -125,10 +130,10 @@ func TestWebhookNotifier(t *testing.T) { expUsername: "user1", expPassword: "mysecret", expMsg: &WebhookMessage{ - ExtendedData: &ExtendedData{ + ExtendedData: &templates.ExtendedData{ Receiver: "my_receiver", Status: "firing", - Alerts: ExtendedAlerts{ + Alerts: templates.ExtendedAlerts{ { Status: "firing", Labels: template.KV{ @@ -192,10 +197,10 @@ func TestWebhookNotifier(t *testing.T) { expURL: "http://localhost/test?numAlerts=2&status=firing", expHTTPMethod: "POST", expMsg: &WebhookMessage{ - ExtendedData: &ExtendedData{ + ExtendedData: &templates.ExtendedData{ Receiver: "my_receiver", Status: "firing", - Alerts: ExtendedAlerts{ + Alerts: templates.ExtendedAlerts{ { Status: "firing", Labels: template.KV{ @@ -257,10 +262,10 @@ func TestWebhookNotifier(t *testing.T) { }, }, expMsg: &WebhookMessage{ - ExtendedData: &ExtendedData{ + ExtendedData: &templates.ExtendedData{ Receiver: "my_receiver", Status: "firing", - Alerts: ExtendedAlerts{ + Alerts: templates.ExtendedAlerts{ { Status: "firing", Labels: template.KV{ @@ -336,7 +341,7 @@ func TestWebhookNotifier(t *testing.T) { settingsJSON := json.RawMessage(c.settings) secureSettings := make(map[string][]byte) - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ OrgID: orgID, Name: "webhook_testing", Type: "webhook", @@ -344,17 +349,17 @@ func TestWebhookNotifier(t *testing.T) { SecureSettings: secureSettings, } - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, NotificationService: webhookSender, DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { return fallback }, - ImageStore: &UnavailableImageStore{}, + ImageStore: &images.UnavailableImageStore{}, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := buildWebhookNotifier(fc) diff --git a/receivers/wecom/config.go b/receivers/wecom/config.go new file mode 100644 index 00000000..bbb75be5 --- /dev/null +++ b/receivers/wecom/config.go @@ -0,0 +1,88 @@ +package wecom + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" +) + +var weComEndpoint = "https://qyapi.weixin.qq.com" + +const DefaultWeComChannelType = "groupRobot" +const DefaultWeComMsgType = "markdown" +const DefaultWeComToUser = "@all" + +type WeComMsgType string + +const WeComMsgTypeMarkdown WeComMsgType = "markdown" // use these in available_receivers.go too +const WeComMsgTypeText WeComMsgType = "text" + +// IsValid checks wecom message type +func (mt WeComMsgType) IsValid() bool { + return mt == WeComMsgTypeMarkdown || mt == WeComMsgTypeText +} + +type WecomConfig struct { + Channel string `json:"-" yaml:"-"` + EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"` + URL string `json:"url" yaml:"url"` + AgentID string `json:"agent_id,omitempty" yaml:"agent_id,omitempty"` + CorpID string `json:"corp_id,omitempty" yaml:"corp_id,omitempty"` + Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` + MsgType WeComMsgType `json:"msgtype,omitempty" yaml:"msgtype,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty"` + ToUser string `json:"touser,omitempty" yaml:"touser,omitempty"` +} + +func BuildWecomConfig(factoryConfig receivers.FactoryConfig) (WecomConfig, error) { + var settings = WecomConfig{ + Channel: DefaultWeComChannelType, + } + + err := json.Unmarshal(factoryConfig.Config.Settings, &settings) + if err != nil { + return settings, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + if len(settings.EndpointURL) == 0 { + settings.EndpointURL = weComEndpoint + } + + if !settings.MsgType.IsValid() { + settings.MsgType = DefaultWeComMsgType + } + + if len(settings.Message) == 0 { + settings.Message = templates.DefaultMessageEmbed + } + if len(settings.Title) == 0 { + settings.Title = templates.DefaultMessageTitleEmbed + } + if len(settings.ToUser) == 0 { + settings.ToUser = DefaultWeComToUser + } + + settings.URL = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "url", settings.URL) + settings.Secret = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "secret", settings.Secret) + + if len(settings.URL) == 0 && len(settings.Secret) == 0 { + return settings, errors.New("either url or secret is required") + } + + if len(settings.URL) == 0 { + settings.Channel = "apiapp" + if len(settings.AgentID) == 0 { + return settings, errors.New("could not find AgentID in settings") + } + if len(settings.CorpID) == 0 { + return settings, errors.New("could not find CorpID in settings") + } + } + + return settings, nil +} diff --git a/alerting/notifier/channels/wecom.go b/receivers/wecom/wecom.go similarity index 54% rename from alerting/notifier/channels/wecom.go rename to receivers/wecom/wecom.go index dacc6d5e..ac5dccef 100644 --- a/alerting/notifier/channels/wecom.go +++ b/receivers/wecom/wecom.go @@ -1,9 +1,8 @@ -package channels +package wecom import ( "context" "encoding/json" - "errors" "fmt" "net/http" "time" @@ -11,89 +10,16 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "golang.org/x/sync/singleflight" -) - -var weComEndpoint = "https://qyapi.weixin.qq.com" - -const defaultWeComChannelType = "groupRobot" -const defaultWeComMsgType = WeComMsgTypeMarkdown -const defaultWeComToUser = "@all" - -type WeComMsgType string - -const WeComMsgTypeMarkdown WeComMsgType = "markdown" // use these in available_channels.go too -const WeComMsgTypeText WeComMsgType = "text" - -// IsValid checks wecom message type -func (mt WeComMsgType) IsValid() bool { - return mt == WeComMsgTypeMarkdown || mt == WeComMsgTypeText -} - -type wecomSettings struct { - channel string - EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"` - URL string `json:"url" yaml:"url"` - AgentID string `json:"agent_id,omitempty" yaml:"agent_id,omitempty"` - CorpID string `json:"corp_id,omitempty" yaml:"corp_id,omitempty"` - Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` - MsgType WeComMsgType `json:"msgtype,omitempty" yaml:"msgtype,omitempty"` - Message string `json:"message,omitempty" yaml:"message,omitempty"` - Title string `json:"title,omitempty" yaml:"title,omitempty"` - ToUser string `json:"touser,omitempty" yaml:"touser,omitempty"` -} - -func buildWecomSettings(factoryConfig FactoryConfig) (wecomSettings, error) { - var settings = wecomSettings{ - channel: defaultWeComChannelType, - } - - err := factoryConfig.Config.unmarshalSettings(&settings) - if err != nil { - return settings, fmt.Errorf("failed to unmarshal settings: %w", err) - } - - if len(settings.EndpointURL) == 0 { - settings.EndpointURL = weComEndpoint - } - if !settings.MsgType.IsValid() { - settings.MsgType = defaultWeComMsgType - } - - if len(settings.Message) == 0 { - settings.Message = DefaultMessageEmbed - } - if len(settings.Title) == 0 { - settings.Title = DefaultMessageTitleEmbed - } - if len(settings.ToUser) == 0 { - settings.ToUser = defaultWeComToUser - } - - settings.URL = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "url", settings.URL) - settings.Secret = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "secret", settings.Secret) - - if len(settings.URL) == 0 && len(settings.Secret) == 0 { - return settings, errors.New("either url or secret is required") - } - - if len(settings.URL) == 0 { - settings.channel = "apiapp" - if len(settings.AgentID) == 0 { - return settings, errors.New("could not find AgentID in settings") - } - if len(settings.CorpID) == 0 { - return settings, errors.New("could not find CorpID in settings") - } - } - - return settings, nil -} + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + template2 "github.com/grafana/alerting/templates" +) -func WeComFactory(fc FactoryConfig) (NotificationChannel, error) { +func WeComFactory(fc receivers.FactoryConfig) (receivers.NotificationChannel, error) { ch, err := buildWecomNotifier(fc) if err != nil { - return nil, receiverInitError{ + return nil, receivers.ReceiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } @@ -101,13 +27,13 @@ func WeComFactory(fc FactoryConfig) (NotificationChannel, error) { return ch, nil } -func buildWecomNotifier(factoryConfig FactoryConfig) (*WeComNotifier, error) { - settings, err := buildWecomSettings(factoryConfig) +func buildWecomNotifier(factoryConfig receivers.FactoryConfig) (*WeComNotifier, error) { + settings, err := BuildWecomConfig(factoryConfig) if err != nil { return nil, err } return &WeComNotifier{ - Base: NewBase(factoryConfig.Config), + Base: receivers.NewBase(factoryConfig.Config), tmpl: factoryConfig.Template, log: factoryConfig.Logger, ns: factoryConfig.NotificationService, @@ -117,11 +43,11 @@ func buildWecomNotifier(factoryConfig FactoryConfig) (*WeComNotifier, error) { // WeComNotifier is responsible for sending alert notifications to WeCom. type WeComNotifier struct { - *Base + *receivers.Base tmpl *template.Template - log Logger - ns WebhookSender - settings wecomSettings + log logging.Logger + ns receivers.WebhookSender + settings WecomConfig tok *WeComAccessToken tokExpireAt time.Time group singleflight.Group @@ -132,7 +58,7 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e w.log.Info("executing WeCom notification", "notification", w.Name) var tmplErr error - tmpl, _ := TmplText(ctx, w.tmpl, as, w.log, &tmplErr) + tmpl, _ := template2.TmplText(ctx, w.tmpl, as, w.log, &tmplErr) bodyMsg := map[string]interface{}{ "msgtype": w.settings.MsgType, @@ -141,7 +67,7 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e tmpl(w.settings.Title), tmpl(w.settings.Message), ) - if w.settings.MsgType != defaultWeComMsgType { + if w.settings.MsgType != DefaultWeComMsgType { content = fmt.Sprintf("%s\n%s\n", tmpl(w.settings.Title), tmpl(w.settings.Message), @@ -154,7 +80,7 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e } url := w.settings.URL - if w.settings.channel != defaultWeComChannelType { + if w.settings.Channel != DefaultWeComChannelType { bodyMsg["agentid"] = w.settings.AgentID bodyMsg["touser"] = w.settings.ToUser token, err := w.GetAccessToken(ctx) @@ -173,12 +99,12 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e w.log.Warn("failed to template WeCom message", "error", tmplErr.Error()) } - cmd := &SendWebhookSettings{ + cmd := &receivers.WebhookSendSettings{ URL: url, Body: string(body), } - if err = w.ns.SendWebhook(ctx, cmd); err != nil { + if err = w.ns.Send(ctx, cmd); err != nil { w.log.Error("failed to send WeCom webhook", "error", err, "notification", w.Name) return false, err } diff --git a/alerting/notifier/channels/wecom_test.go b/receivers/wecom/wecom_test.go similarity index 95% rename from alerting/notifier/channels/wecom_test.go rename to receivers/wecom/wecom_test.go index e6a81f85..26c904bc 100644 --- a/alerting/notifier/channels/wecom_test.go +++ b/receivers/wecom/wecom_test.go @@ -1,4 +1,4 @@ -package channels +package wecom import ( "context" @@ -16,10 +16,14 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/receivers" + "github.com/grafana/alerting/templates" ) func TestWeComNotifier(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -156,15 +160,15 @@ func TestWeComNotifier(t *testing.T) { t.Run(c.name, func(t *testing.T) { settingsJSON := json.RawMessage(c.settings) - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ Name: "wecom_testing", Type: "wecom", Settings: settingsJSON, } - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, NotificationService: webhookSender, DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { @@ -172,7 +176,7 @@ func TestWeComNotifier(t *testing.T) { }, ImageStore: nil, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := buildWecomNotifier(fc) @@ -205,7 +209,7 @@ func TestWeComNotifier(t *testing.T) { // TestWeComNotifierAPIAPP Testing API Channels func TestWeComNotifierAPIAPP(t *testing.T) { - tmpl := templateForTests(t) + tmpl := templates.ForTests(t) externalURL, err := url.Parse("http://localhost") require.NoError(t, err) @@ -340,15 +344,15 @@ func TestWeComNotifierAPIAPP(t *testing.T) { })) defer server.Close() - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ Name: "wecom_testing", Type: "wecom", Settings: json.RawMessage(tt.settings), } - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, NotificationService: webhookSender, DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { @@ -356,7 +360,7 @@ func TestWeComNotifierAPIAPP(t *testing.T) { }, ImageStore: nil, Template: tmpl, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } pn, err := buildWecomNotifier(fc) @@ -468,7 +472,7 @@ func TestWeComNotifier_GetAccessToken(t *testing.T) { defer server.Close() w := &WeComNotifier{ - settings: wecomSettings{ + settings: WecomConfig{ EndpointURL: server.URL, CorpID: tt.fields.corpid, Secret: tt.fields.secret, @@ -525,22 +529,22 @@ func TestWeComFactory(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := &NotificationChannelConfig{ + m := &receivers.NotificationChannelConfig{ Name: "wecom_testing", Type: "wecom", Settings: json.RawMessage(tt.settings), } - webhookSender := mockNotificationService() + webhookSender := receivers.MockNotificationService() - fc := FactoryConfig{ + fc := receivers.FactoryConfig{ Config: m, NotificationService: webhookSender, DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { return fallback }, ImageStore: nil, - Logger: &FakeLogger{}, + Logger: &logging.FakeLogger{}, } _, err := WeComFactory(fc) diff --git a/alerting/notifier/channels/default_template.go b/templates/default_template.go similarity index 98% rename from alerting/notifier/channels/default_template.go rename to templates/default_template.go index 0d359aed..1299160e 100644 --- a/alerting/notifier/channels/default_template.go +++ b/templates/default_template.go @@ -1,4 +1,4 @@ -package channels +package templates import ( "os" @@ -101,7 +101,7 @@ Labels: {{ define "teams.default.message" }}{{ template "default.message" . }}{{ end }} ` -func templateForTests(t *testing.T) *template.Template { +func ForTests(t *testing.T) *template.Template { f, err := os.CreateTemp("/tmp", "template") require.NoError(t, err) defer func(f *os.File) { diff --git a/alerting/notifier/channels/default_template_test.go b/templates/default_template_test.go similarity index 98% rename from alerting/notifier/channels/default_template_test.go rename to templates/default_template_test.go index 27c03bf7..baf264d4 100644 --- a/alerting/notifier/channels/default_template_test.go +++ b/templates/default_template_test.go @@ -1,4 +1,4 @@ -package channels +package templates import ( "context" @@ -11,6 +11,8 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" + + "github.com/grafana/alerting/logging" ) func TestDefaultTemplateString(t *testing.T) { @@ -105,7 +107,7 @@ func TestDefaultTemplateString(t *testing.T) { tmpl.ExternalURL = externalURL var tmplErr error - l := &FakeLogger{} + l := &logging.FakeLogger{} expand, _ := TmplText(context.Background(), tmpl, alerts, l, &tmplErr) cases := []struct { diff --git a/alerting/notifier/channels/template_data.go b/templates/template_data.go similarity index 95% rename from alerting/notifier/channels/template_data.go rename to templates/template_data.go index 73b0ac18..578af6e9 100644 --- a/alerting/notifier/channels/template_data.go +++ b/templates/template_data.go @@ -1,4 +1,4 @@ -package channels +package templates import ( "context" @@ -14,7 +14,8 @@ import ( "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" - "github.com/grafana/alerting/alerting/models" + "github.com/grafana/alerting/logging" + "github.com/grafana/alerting/models" ) type ExtendedAlert struct { @@ -57,7 +58,7 @@ func removePrivateItems(kv template.KV) template.KV { return kv } -func extendAlert(alert template.Alert, externalURL string, logger Logger) *ExtendedAlert { +func extendAlert(alert template.Alert, externalURL string, logger logging.Logger) *ExtendedAlert { // remove "private" annotations & labels so they don't show up in the template extended := &ExtendedAlert{ Status: alert.Status, @@ -150,7 +151,7 @@ func setOrgIDQueryParam(url *url.URL, orgID string) string { return url.String() } -func ExtendData(data *template.Data, logger Logger) *ExtendedData { +func ExtendData(data *template.Data, logger logging.Logger) *ExtendedData { alerts := []ExtendedAlert{} for _, alert := range data.Alerts { @@ -171,7 +172,7 @@ func ExtendData(data *template.Data, logger Logger) *ExtendedData { return extended } -func TmplText(ctx context.Context, tmpl *template.Template, alerts []*types.Alert, l Logger, tmplErr *error) (func(string) string, *ExtendedData) { +func TmplText(ctx context.Context, tmpl *template.Template, alerts []*types.Alert, l logging.Logger, tmplErr *error) (func(string) string, *ExtendedData) { promTmplData := notify.GetTemplateData(ctx, tmpl, alerts, l) data := ExtendData(promTmplData, l)