mirror of https://github.com/grafana/grafana
Alerting: import Grafana alerting package and update usages (#60490)
* update remaining notifiers to use alerting packagepull/60539/head
parent
9b21375d78
commit
f0cabe14d5
@ -1,22 +0,0 @@ |
||||
package channels |
||||
|
||||
// Base is the base implementation of a notifier. It contains the common fields across all notifier types.
|
||||
type Base struct { |
||||
Name string |
||||
Type string |
||||
UID string |
||||
DisableResolveMessage bool |
||||
} |
||||
|
||||
func (n *Base) GetDisableResolveMessage() bool { |
||||
return n.DisableResolveMessage |
||||
} |
||||
|
||||
func NewBase(cfg *NotificationChannelConfig) *Base { |
||||
return &Base{ |
||||
UID: cfg.UID, |
||||
Name: cfg.Name, |
||||
Type: cfg.Type, |
||||
DisableResolveMessage: cfg.DisableResolveMessage, |
||||
} |
||||
} |
@ -1,219 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"net/url" |
||||
"os" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestDefaultTemplateString(t *testing.T) { |
||||
alerts := []*types.Alert{ |
||||
{ // Firing with dashboard and panel ID.
|
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{ |
||||
"ann1": "annv1", "__orgId__": "1", "__dashboardUid__": "dbuid123", "__panelId__": "puid123", "__values__": "{\"A\": 1234}", "__value_string__": "1234", |
||||
}, |
||||
StartsAt: time.Now(), |
||||
EndsAt: time.Now().Add(1 * time.Hour), |
||||
GeneratorURL: "http://localhost/alert1?orgId=1", |
||||
}, |
||||
}, { // Firing without dashboard and panel ID.
|
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2", "__values__": "{\"A\": 1234}", "__value_string__": "1234"}, |
||||
StartsAt: time.Now(), |
||||
EndsAt: time.Now().Add(2 * time.Hour), |
||||
GeneratorURL: "http://localhost/alert2", |
||||
}, |
||||
}, { // Resolved with dashboard and panel ID.
|
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val3"}, |
||||
Annotations: model.LabelSet{ |
||||
"ann1": "annv3", "__orgId__": "1", "__dashboardUid__": "dbuid456", "__panelId__": "puid456", "__values__": "{\"A\": 1234}", "__value_string__": "1234", |
||||
}, |
||||
StartsAt: time.Now().Add(-1 * time.Hour), |
||||
EndsAt: time.Now().Add(-30 * time.Minute), |
||||
GeneratorURL: "http://localhost/alert3", |
||||
}, |
||||
}, { // Resolved without dashboard and panel ID.
|
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val4"}, |
||||
Annotations: model.LabelSet{"ann1": "annv4", "__values__": "{\"A\": 1234}", "__value_string__": "1234"}, |
||||
StartsAt: time.Now().Add(-2 * time.Hour), |
||||
EndsAt: time.Now().Add(-3 * time.Hour), |
||||
GeneratorURL: "http://localhost/alert4", |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
f, err := os.CreateTemp("/tmp", "template") |
||||
require.NoError(t, err) |
||||
defer func(f *os.File) { |
||||
_ = f.Close() |
||||
}(f) |
||||
|
||||
t.Cleanup(func() { |
||||
require.NoError(t, os.RemoveAll(f.Name())) |
||||
}) |
||||
|
||||
_, err = f.WriteString(DefaultTemplateString) |
||||
require.NoError(t, err) |
||||
|
||||
tmpl, err := template.FromGlobs(f.Name()) |
||||
require.NoError(t, err) |
||||
|
||||
externalURL, err := url.Parse("http://localhost/grafana") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
var tmplErr error |
||||
l := &FakeLogger{} |
||||
expand, _ := TmplText(context.Background(), tmpl, alerts, l, &tmplErr) |
||||
|
||||
cases := []struct { |
||||
templateString string |
||||
expected string |
||||
}{ |
||||
{ |
||||
templateString: DefaultMessageTitleEmbed, |
||||
expected: `[FIRING:2, RESOLVED:2] (alert1)`, |
||||
}, |
||||
{ |
||||
templateString: DefaultMessageEmbed, |
||||
expected: `**Firing** |
||||
|
||||
Value: A=1234 |
||||
Labels: |
||||
- alertname = alert1 |
||||
- lbl1 = val1 |
||||
Annotations: |
||||
- ann1 = annv1 |
||||
Source: http://localhost/alert1?orgId=1
|
||||
Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1
|
||||
Dashboard: http://localhost/grafana/d/dbuid123?orgId=1
|
||||
Panel: http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123
|
||||
|
||||
Value: A=1234 |
||||
Labels: |
||||
- alertname = alert1 |
||||
- lbl1 = val2 |
||||
Annotations: |
||||
- ann1 = annv2 |
||||
Source: http://localhost/alert2
|
||||
Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2
|
||||
|
||||
|
||||
**Resolved** |
||||
|
||||
Value: A=1234 |
||||
Labels: |
||||
- alertname = alert1 |
||||
- lbl1 = val3 |
||||
Annotations: |
||||
- ann1 = annv3 |
||||
Source: http://localhost/alert3?orgId=1
|
||||
Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3
|
||||
Dashboard: http://localhost/grafana/d/dbuid456?orgId=1
|
||||
Panel: http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456
|
||||
|
||||
Value: A=1234 |
||||
Labels: |
||||
- alertname = alert1 |
||||
- lbl1 = val4 |
||||
Annotations: |
||||
- ann1 = annv4 |
||||
Source: http://localhost/alert4
|
||||
Silence: http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4
|
||||
`, |
||||
}, |
||||
{ |
||||
templateString: `{{ template "teams.default.message" .}}`, |
||||
expected: `**Firing** |
||||
|
||||
Value: A=1234 |
||||
Labels: |
||||
- alertname = alert1 |
||||
- lbl1 = val1 |
||||
|
||||
Annotations: |
||||
- ann1 = annv1 |
||||
|
||||
Source: [http://localhost/alert1?orgId=1](http://localhost/alert1?orgId=1)
|
||||
|
||||
Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1)
|
||||
|
||||
Dashboard: [http://localhost/grafana/d/dbuid123?orgId=1](http://localhost/grafana/d/dbuid123?orgId=1)
|
||||
|
||||
Panel: [http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123](http://localhost/grafana/d/dbuid123?orgId=1&viewPanel=puid123)
|
||||
|
||||
|
||||
|
||||
Value: A=1234 |
||||
Labels: |
||||
- alertname = alert1 |
||||
- lbl1 = val2 |
||||
|
||||
Annotations: |
||||
- ann1 = annv2 |
||||
|
||||
Source: [http://localhost/alert2](http://localhost/alert2)
|
||||
|
||||
Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2)
|
||||
|
||||
|
||||
|
||||
|
||||
**Resolved** |
||||
|
||||
Value: A=1234 |
||||
Labels: |
||||
- alertname = alert1 |
||||
- lbl1 = val3 |
||||
|
||||
Annotations: |
||||
- ann1 = annv3 |
||||
|
||||
Source: [http://localhost/alert3?orgId=1](http://localhost/alert3?orgId=1)
|
||||
|
||||
Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval3)
|
||||
|
||||
Dashboard: [http://localhost/grafana/d/dbuid456?orgId=1](http://localhost/grafana/d/dbuid456?orgId=1)
|
||||
|
||||
Panel: [http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456](http://localhost/grafana/d/dbuid456?orgId=1&viewPanel=puid456)
|
||||
|
||||
|
||||
|
||||
Value: A=1234 |
||||
Labels: |
||||
- alertname = alert1 |
||||
- lbl1 = val4 |
||||
|
||||
Annotations: |
||||
- ann1 = annv4 |
||||
|
||||
Source: [http://localhost/alert4](http://localhost/alert4)
|
||||
|
||||
Silence: [http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4](http://localhost/grafana/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval4)
|
||||
|
||||
|
||||
`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.templateString, func(t *testing.T) { |
||||
act := expand(c.templateString) |
||||
require.NoError(t, tmplErr) |
||||
require.Equal(t, c.expected, act) |
||||
}) |
||||
} |
||||
require.NoError(t, tmplErr) |
||||
} |
@ -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) |
||||
} |
@ -1,45 +0,0 @@ |
||||
package channels |
||||
|
||||
type LoggerFactory func(ctx ...interface{}) Logger |
||||
|
||||
type Logger interface { |
||||
// New returns a new contextual Logger that has this logger's context plus the given context.
|
||||
New(ctx ...interface{}) Logger |
||||
|
||||
Log(keyvals ...interface{}) error |
||||
|
||||
// Debug logs a message with debug level and key/value pairs, if any.
|
||||
Debug(msg string, ctx ...interface{}) |
||||
|
||||
// Info logs a message with info level and key/value pairs, if any.
|
||||
Info(msg string, ctx ...interface{}) |
||||
|
||||
// Warn logs a message with warning level and key/value pairs, if any.
|
||||
Warn(msg string, ctx ...interface{}) |
||||
|
||||
// Error logs a message with error level and key/value pairs, if any.
|
||||
Error(msg string, ctx ...interface{}) |
||||
} |
||||
|
||||
type FakeLogger struct { |
||||
} |
||||
|
||||
func (f FakeLogger) New(ctx ...interface{}) Logger { |
||||
return f |
||||
} |
||||
|
||||
func (f FakeLogger) Log(keyvals ...interface{}) error { |
||||
return nil |
||||
} |
||||
|
||||
func (f FakeLogger) Debug(msg string, ctx ...interface{}) { |
||||
} |
||||
|
||||
func (f FakeLogger) Info(msg string, ctx ...interface{}) { |
||||
} |
||||
|
||||
func (f FakeLogger) Warn(msg string, ctx ...interface{}) { |
||||
} |
||||
|
||||
func (f FakeLogger) Error(msg string, ctx ...interface{}) { |
||||
} |
@ -1,316 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
"sort" |
||||
"strings" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
ptr "github.com/xorcare/pointer" |
||||
) |
||||
|
||||
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} |
||||
) |
||||
|
||||
// OpsgenieNotifier is responsible for sending alert notifications to Opsgenie.
|
||||
type OpsgenieNotifier struct { |
||||
*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 { |
||||
raw.AutoClose = ptr.Bool(true) |
||||
} |
||||
if raw.OverridePriority == nil { |
||||
raw.OverridePriority = ptr.Bool(true) |
||||
} |
||||
|
||||
return &opsgenieSettings{ |
||||
APIKey: raw.APIKey, |
||||
APIUrl: raw.APIUrl, |
||||
Message: raw.Message, |
||||
Description: raw.Description, |
||||
AutoClose: *raw.AutoClose, |
||||
OverridePriority: *raw.OverridePriority, |
||||
SendTagsAs: raw.SendTagsAs, |
||||
}, nil |
||||
} |
||||
|
||||
func OpsgenieFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
notifier, err := NewOpsgenieNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return notifier, nil |
||||
} |
||||
|
||||
// NewOpsgenieNotifier is the constructor for the Opsgenie notifier
|
||||
func NewOpsgenieNotifier(fc FactoryConfig) (*OpsgenieNotifier, error) { |
||||
settings, err := buildOpsgenieSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &OpsgenieNotifier{ |
||||
Base: NewBase(fc.Config), |
||||
tmpl: fc.Template, |
||||
log: fc.Logger, |
||||
ns: fc.NotificationService, |
||||
images: fc.ImageStore, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify sends an alert notification to Opsgenie
|
||||
func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
on.log.Debug("executing Opsgenie notification", "notification", on.Name) |
||||
|
||||
alerts := types.Alerts(as...) |
||||
if alerts.Status() == model.AlertResolved && !on.SendResolved() { |
||||
on.log.Debug("not sending a trigger to Opsgenie", "status", alerts.Status(), "auto resolve", on.SendResolved()) |
||||
return true, nil |
||||
} |
||||
|
||||
body, url, err := on.buildOpsgenieMessage(ctx, alerts, as) |
||||
if err != nil { |
||||
return false, fmt.Errorf("build Opsgenie message: %w", err) |
||||
} |
||||
|
||||
if url == "" { |
||||
// Resolved alert with no auto close.
|
||||
// Hence skip sending anything.
|
||||
return true, nil |
||||
} |
||||
|
||||
cmd := &SendWebhookSettings{ |
||||
Url: url, |
||||
Body: string(body), |
||||
HttpMethod: http.MethodPost, |
||||
HttpHeader: map[string]string{ |
||||
"Content-Type": "application/json", |
||||
"Authorization": fmt.Sprintf("GenieKey %s", on.settings.APIKey), |
||||
}, |
||||
} |
||||
|
||||
if err := on.ns.SendWebhook(ctx, cmd); err != nil { |
||||
return false, fmt.Errorf("send notification to Opsgenie: %w", err) |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts model.Alerts, as []*types.Alert) (payload []byte, apiURL string, err error) { |
||||
key, err := notify.ExtractGroupKey(ctx) |
||||
if err != nil { |
||||
return nil, "", err |
||||
} |
||||
|
||||
if alerts.Status() == model.AlertResolved { |
||||
// For resolved notification, we only need the source.
|
||||
// Don't need to run other templates.
|
||||
if !on.settings.AutoClose { // TODO This should be handled by DisableResolveMessage?
|
||||
return nil, "", nil |
||||
} |
||||
msg := opsGenieCloseMessage{ |
||||
Source: "Grafana", |
||||
} |
||||
data, err := json.Marshal(msg) |
||||
apiURL = fmt.Sprintf("%s/%s/close?identifierType=alias", on.settings.APIUrl, key.Hash()) |
||||
return data, apiURL, err |
||||
} |
||||
|
||||
ruleURL := joinUrlPath(on.tmpl.ExternalURL.String(), "/alerting/list", on.log) |
||||
|
||||
var tmplErr error |
||||
tmpl, data := TmplText(ctx, on.tmpl, as, on.log, &tmplErr) |
||||
|
||||
message, truncated := TruncateInRunes(tmpl(on.settings.Message), opsGenieMaxMessageLenRunes) |
||||
if truncated { |
||||
on.log.Warn("Truncated message", "alert", key, "max_runes", opsGenieMaxMessageLenRunes) |
||||
} |
||||
|
||||
description := tmpl(on.settings.Description) |
||||
if strings.TrimSpace(description) == "" { |
||||
description = fmt.Sprintf( |
||||
"%s\n%s\n\n%s", |
||||
tmpl(DefaultMessageTitleEmbed), |
||||
ruleURL, |
||||
tmpl(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.
|
||||
lbls := make(map[string]string, len(data.CommonLabels)) |
||||
for k, v := range data.CommonLabels { |
||||
lbls[k] = tmpl(v) |
||||
if k == "og_priority" && on.settings.OverridePriority { |
||||
if ValidPriorities[v] { |
||||
priority = v |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Check for templating errors
|
||||
if tmplErr != nil { |
||||
on.log.Warn("failed to template Opsgenie message", "error", tmplErr.Error()) |
||||
tmplErr = nil |
||||
} |
||||
|
||||
details := make(map[string]interface{}) |
||||
details["url"] = ruleURL |
||||
if on.sendDetails() { |
||||
for k, v := range lbls { |
||||
details[k] = v |
||||
} |
||||
var images []string |
||||
_ = withStoredImages(ctx, on.log, on.images, |
||||
func(_ int, image Image) error { |
||||
if len(image.URL) == 0 { |
||||
return nil |
||||
} |
||||
images = append(images, image.URL) |
||||
return nil |
||||
}, |
||||
as...) |
||||
|
||||
if len(images) != 0 { |
||||
details["image_urls"] = images |
||||
} |
||||
} |
||||
|
||||
tags := make([]string, 0, len(lbls)) |
||||
if on.sendTags() { |
||||
for k, v := range lbls { |
||||
tags = append(tags, fmt.Sprintf("%s:%s", k, v)) |
||||
} |
||||
} |
||||
sort.Strings(tags) |
||||
|
||||
result := opsGenieCreateMessage{ |
||||
Alias: key.Hash(), |
||||
Description: description, |
||||
Tags: tags, |
||||
Source: "Grafana", |
||||
Message: message, |
||||
Details: details, |
||||
Priority: priority, |
||||
} |
||||
|
||||
apiURL = tmpl(on.settings.APIUrl) |
||||
if tmplErr != nil { |
||||
on.log.Warn("failed to template Opsgenie URL", "error", tmplErr.Error(), "fallback", on.settings.APIUrl) |
||||
apiURL = on.settings.APIUrl |
||||
} |
||||
|
||||
b, err := json.Marshal(result) |
||||
return b, apiURL, err |
||||
} |
||||
|
||||
func (on *OpsgenieNotifier) SendResolved() bool { |
||||
return !on.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func (on *OpsgenieNotifier) sendDetails() bool { |
||||
return on.settings.SendTagsAs == OpsgenieSendDetails || on.settings.SendTagsAs == OpsgenieSendBoth |
||||
} |
||||
|
||||
func (on *OpsgenieNotifier) sendTags() bool { |
||||
return on.settings.SendTagsAs == OpsgenieSendTags || on.settings.SendTagsAs == OpsgenieSendBoth |
||||
} |
||||
|
||||
type opsGenieCreateMessage struct { |
||||
Alias string `json:"alias"` |
||||
Message string `json:"message"` |
||||
Description string `json:"description,omitempty"` |
||||
Details map[string]interface{} `json:"details"` |
||||
Source string `json:"source"` |
||||
Responders []opsGenieCreateMessageResponder `json:"responders,omitempty"` |
||||
Tags []string `json:"tags"` |
||||
Note string `json:"note,omitempty"` |
||||
Priority string `json:"priority,omitempty"` |
||||
Entity string `json:"entity,omitempty"` |
||||
Actions []string `json:"actions,omitempty"` |
||||
} |
||||
|
||||
type opsGenieCreateMessageResponder struct { |
||||
ID string `json:"id,omitempty"` |
||||
Name string `json:"name,omitempty"` |
||||
Username string `json:"username,omitempty"` |
||||
Type string `json:"type"` // team, user, escalation, schedule etc.
|
||||
} |
||||
|
||||
type opsGenieCloseMessage struct { |
||||
Source string `json:"source"` |
||||
} |
@ -1,279 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestOpsgenieNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg string |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Default config with one alert", |
||||
settings: `{"apiKey": "abcdefgh0123456789"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: `{ |
||||
"alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
"description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"details": { |
||||
"url": "http://localhost/alerting/list" |
||||
}, |
||||
"message": "[FIRING:1] (val1)", |
||||
"source": "Grafana", |
||||
"tags": ["alertname:alert1", "lbl1:val1"] |
||||
}`, |
||||
}, |
||||
{ |
||||
name: "Default config with one alert, custom message and description", |
||||
settings: `{"apiKey": "abcdefgh0123456789", "message": "test message", "description": "test description"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: `{ |
||||
"alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
"description": "test description", |
||||
"details": { |
||||
"url": "http://localhost/alerting/list" |
||||
}, |
||||
"message": "test message", |
||||
"source": "Grafana", |
||||
"tags": ["alertname:alert1", "lbl1:val1"] |
||||
}`, |
||||
}, |
||||
{ |
||||
name: "Default config with one alert, message length > 130", |
||||
settings: `{ |
||||
"apiKey": "abcdefgh0123456789", |
||||
"message": "IyJnsW78xQoiBJ7L7NqASv31JCFf0At3r9KUykqBVxSiC6qkDhvDLDW9VImiFcq0Iw2XwFy5fX4FcbTmlkaZzUzjVwx9VUuokhzqQlJVhWDYFqhj3a5wX0LjyvNQjsqT9WaWJAWOJanwOAWon" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: `{ |
||||
"alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
"description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"details": { |
||||
"url": "http://localhost/alerting/list" |
||||
}, |
||||
"message": "IyJnsW78xQoiBJ7L7NqASv31JCFf0At3r9KUykqBVxSiC6qkDhvDLDW9VImiFcq0Iw2XwFy5fX4FcbTmlkaZzUzjVwx9VUuokhzqQlJVhWDYFqhj3a5wX0LjyvNQjsqT9…", |
||||
"source": "Grafana", |
||||
"tags": ["alertname:alert1", "lbl1:val1"] |
||||
}`, |
||||
}, |
||||
{ |
||||
name: "Default config with one alert, templated message and description", |
||||
settings: `{"apiKey": "abcdefgh0123456789", "message": "Firing: {{ len .Alerts.Firing }}", "description": "{{ len .Alerts.Firing }} firing, {{ len .Alerts.Resolved }} resolved."}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: `{ |
||||
"alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
"description": "1 firing, 0 resolved.", |
||||
"details": { |
||||
"url": "http://localhost/alerting/list" |
||||
}, |
||||
"message": "Firing: 1", |
||||
"source": "Grafana", |
||||
"tags": ["alertname:alert1", "lbl1:val1"] |
||||
}`, |
||||
}, |
||||
{ |
||||
name: "Default config with one alert and send tags as tags, empty description and message", |
||||
settings: `{ |
||||
"apiKey": "abcdefgh0123456789", |
||||
"sendTagsAs": "tags", |
||||
"message": " ", |
||||
"description": " " |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: `{ |
||||
"alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
"description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", |
||||
"details": { |
||||
"url": "http://localhost/alerting/list" |
||||
}, |
||||
"message": "[FIRING:1] (val1)", |
||||
"source": "Grafana", |
||||
"tags": ["alertname:alert1", "lbl1:val1"] |
||||
}`, |
||||
}, |
||||
{ |
||||
name: "Default config with one alert and send tags as details", |
||||
settings: `{ |
||||
"apiKey": "abcdefgh0123456789", |
||||
"sendTagsAs": "details" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: `{ |
||||
"alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
"description": "[FIRING:1] (val1)\nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n", |
||||
"details": { |
||||
"alertname": "alert1", |
||||
"lbl1": "val1", |
||||
"url": "http://localhost/alerting/list" |
||||
}, |
||||
"message": "[FIRING:1] (val1)", |
||||
"source": "Grafana", |
||||
"tags": [] |
||||
}`, |
||||
}, |
||||
{ |
||||
name: "Custom config with multiple alerts and send tags as both details and tag", |
||||
settings: `{ |
||||
"apiKey": "abcdefgh0123456789", |
||||
"sendTagsAs": "both" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: `{ |
||||
"alias": "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
"description": "[FIRING:2] \nhttp://localhost/alerting/list\n\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", |
||||
"details": { |
||||
"alertname": "alert1", |
||||
"url": "http://localhost/alerting/list" |
||||
}, |
||||
"message": "[FIRING:2] ", |
||||
"source": "Grafana", |
||||
"tags": ["alertname:alert1"] |
||||
}`, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Resolved is not sent when auto close is false", |
||||
settings: `{"apiKey": "abcdefgh0123456789", "autoClose": false}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
EndsAt: time.Now().Add(-1 * time.Minute), |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Error when incorrect settings", |
||||
settings: `{}`, |
||||
expInitError: `could not find api key property in settings`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
secureSettings := make(map[string][]byte) |
||||
|
||||
webhookSender := mockNotificationService() |
||||
webhookSender.Webhook.Body = "<not-sent>" |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: &NotificationChannelConfig{ |
||||
Name: "opsgenie_testing", |
||||
Type: "opsgenie", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
}, |
||||
NotificationService: webhookSender, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
ImageStore: &UnavailableImageStore{}, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
pn, err := NewOpsgenieNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
ok, err := pn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.False(t, ok) |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.True(t, ok) |
||||
require.NoError(t, err) |
||||
|
||||
if c.expMsg == "" { |
||||
// No notification was expected.
|
||||
require.Equal(t, "<not-sent>", webhookSender.Webhook.Body) |
||||
} else { |
||||
require.JSONEq(t, c.expMsg, webhookSender.Webhook.Body) |
||||
} |
||||
}) |
||||
} |
||||
} |
@ -1,279 +0,0 @@ |
||||
package channels |
||||
|
||||
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" |
||||
) |
||||
|
||||
const ( |
||||
// https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTgx-send-an-alert-event - 1024 characters or runes.
|
||||
pagerDutyMaxV2SummaryLenRunes = 1024 |
||||
) |
||||
|
||||
const ( |
||||
pagerDutyEventTrigger = "trigger" |
||||
pagerDutyEventResolve = "resolve" |
||||
|
||||
defaultSeverity = "critical" |
||||
defaultClass = "default" |
||||
defaultGroup = "default" |
||||
defaultClient = "Grafana" |
||||
) |
||||
|
||||
var ( |
||||
knownSeverity = map[string]struct{}{defaultSeverity: {}, "error": {}, "warning": {}, "info": {}} |
||||
PagerdutyEventAPIURL = "https://events.pagerduty.com/v2/enqueue" |
||||
) |
||||
|
||||
// PagerdutyNotifier is responsible for sending
|
||||
// alert notifications to pagerduty
|
||||
type PagerdutyNotifier struct { |
||||
*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 |
||||
} |
||||
|
||||
func PagerdutyFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
pdn, err := newPagerdutyNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return pdn, nil |
||||
} |
||||
|
||||
// NewPagerdutyNotifier is the constructor for the PagerDuty notifier
|
||||
func newPagerdutyNotifier(fc FactoryConfig) (*PagerdutyNotifier, error) { |
||||
settings, err := buildPagerdutySettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &PagerdutyNotifier{ |
||||
Base: NewBase(fc.Config), |
||||
tmpl: fc.Template, |
||||
log: fc.Logger, |
||||
ns: fc.NotificationService, |
||||
images: fc.ImageStore, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify sends an alert notification to PagerDuty
|
||||
func (pn *PagerdutyNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
alerts := types.Alerts(as...) |
||||
if alerts.Status() == model.AlertResolved && !pn.SendResolved() { |
||||
pn.log.Debug("not sending a trigger to Pagerduty", "status", alerts.Status(), "auto resolve", pn.SendResolved()) |
||||
return true, nil |
||||
} |
||||
|
||||
msg, eventType, err := pn.buildPagerdutyMessage(ctx, alerts, as) |
||||
if err != nil { |
||||
return false, fmt.Errorf("build pagerduty message: %w", err) |
||||
} |
||||
|
||||
body, err := json.Marshal(msg) |
||||
if err != nil { |
||||
return false, fmt.Errorf("marshal json: %w", err) |
||||
} |
||||
|
||||
pn.log.Info("notifying Pagerduty", "event_type", eventType) |
||||
cmd := &SendWebhookSettings{ |
||||
Url: PagerdutyEventAPIURL, |
||||
Body: string(body), |
||||
HttpMethod: "POST", |
||||
HttpHeader: map[string]string{ |
||||
"Content-Type": "application/json", |
||||
}, |
||||
} |
||||
if err := pn.ns.SendWebhook(ctx, cmd); err != nil { |
||||
return false, fmt.Errorf("send notification to Pagerduty: %w", err) |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts model.Alerts, as []*types.Alert) (*pagerDutyMessage, string, error) { |
||||
key, err := notify.ExtractGroupKey(ctx) |
||||
if err != nil { |
||||
return nil, "", err |
||||
} |
||||
|
||||
eventType := pagerDutyEventTrigger |
||||
if alerts.Status() == model.AlertResolved { |
||||
eventType = pagerDutyEventResolve |
||||
} |
||||
|
||||
var tmplErr error |
||||
tmpl, data := TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) |
||||
|
||||
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) |
||||
} |
||||
details[k] = detail |
||||
} |
||||
|
||||
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 |
||||
} |
||||
|
||||
msg := &pagerDutyMessage{ |
||||
Client: tmpl(pn.settings.Client), |
||||
ClientURL: tmpl(pn.settings.ClientURL), |
||||
RoutingKey: pn.settings.Key, |
||||
EventAction: eventType, |
||||
DedupKey: key.Hash(), |
||||
Links: []pagerDutyLink{{ |
||||
HRef: pn.tmpl.ExternalURL.String(), |
||||
Text: "External URL", |
||||
}}, |
||||
Payload: pagerDutyPayload{ |
||||
Source: tmpl(pn.settings.Source), |
||||
Component: tmpl(pn.settings.Component), |
||||
Summary: tmpl(pn.settings.Summary), |
||||
Severity: severity, |
||||
CustomDetails: details, |
||||
Class: tmpl(pn.settings.Class), |
||||
Group: tmpl(pn.settings.Group), |
||||
}, |
||||
} |
||||
|
||||
_ = withStoredImages(ctx, pn.log, pn.images, |
||||
func(_ int, image Image) error { |
||||
if len(image.URL) != 0 { |
||||
msg.Images = append(msg.Images, pagerDutyImage{Src: image.URL}) |
||||
} |
||||
|
||||
return nil |
||||
}, |
||||
as...) |
||||
|
||||
summary, truncated := TruncateInRunes(msg.Payload.Summary, pagerDutyMaxV2SummaryLenRunes) |
||||
if truncated { |
||||
pn.log.Warn("Truncated summary", "key", key, "runes", pagerDutyMaxV2SummaryLenRunes) |
||||
} |
||||
msg.Payload.Summary = summary |
||||
|
||||
if tmplErr != nil { |
||||
pn.log.Warn("failed to template PagerDuty message", "error", tmplErr.Error()) |
||||
} |
||||
|
||||
return msg, eventType, nil |
||||
} |
||||
|
||||
func (pn *PagerdutyNotifier) SendResolved() bool { |
||||
return !pn.GetDisableResolveMessage() |
||||
} |
||||
|
||||
type pagerDutyMessage struct { |
||||
RoutingKey string `json:"routing_key,omitempty"` |
||||
ServiceKey string `json:"service_key,omitempty"` |
||||
DedupKey string `json:"dedup_key,omitempty"` |
||||
EventAction string `json:"event_action"` |
||||
Payload pagerDutyPayload `json:"payload"` |
||||
Client string `json:"client,omitempty"` |
||||
ClientURL string `json:"client_url,omitempty"` |
||||
Links []pagerDutyLink `json:"links,omitempty"` |
||||
Images []pagerDutyImage `json:"images,omitempty"` |
||||
} |
||||
|
||||
type pagerDutyLink struct { |
||||
HRef string `json:"href"` |
||||
Text string `json:"text"` |
||||
} |
||||
|
||||
type pagerDutyImage struct { |
||||
Src string `json:"src"` |
||||
} |
||||
|
||||
type pagerDutyPayload struct { |
||||
Summary string `json:"summary"` |
||||
Source string `json:"source"` |
||||
Severity string `json:"severity"` |
||||
Class string `json:"class,omitempty"` |
||||
Component string `json:"component,omitempty"` |
||||
Group string `json:"group,omitempty"` |
||||
CustomDetails map[string]string `json:"custom_details,omitempty"` |
||||
} |
@ -1,318 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"math/rand" |
||||
"net/url" |
||||
"os" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestPagerdutyNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
hostname, err := os.Hostname() |
||||
require.NoError(t, err) |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg *pagerDutyMessage |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Default config with one alert", |
||||
settings: `{"integrationKey": "abcdefgh0123456789"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &pagerDutyMessage{ |
||||
RoutingKey: "abcdefgh0123456789", |
||||
DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
EventAction: "trigger", |
||||
Payload: pagerDutyPayload{ |
||||
Summary: "[FIRING:1] (val1)", |
||||
Source: hostname, |
||||
Severity: defaultSeverity, |
||||
Class: "default", |
||||
Component: "Grafana", |
||||
Group: "default", |
||||
CustomDetails: map[string]string{ |
||||
"firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"num_firing": "1", |
||||
"num_resolved": "0", |
||||
"resolved": "", |
||||
}, |
||||
}, |
||||
Client: "Grafana", |
||||
ClientURL: "http://localhost", |
||||
Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "should map unknown severity", |
||||
settings: `{"integrationKey": "abcdefgh0123456789", "severity": "{{ .CommonLabels.severity }}"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1", "severity": "invalid-severity"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &pagerDutyMessage{ |
||||
RoutingKey: "abcdefgh0123456789", |
||||
DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
EventAction: "trigger", |
||||
Payload: pagerDutyPayload{ |
||||
Summary: "[FIRING:1] (val1 invalid-severity)", |
||||
Source: hostname, |
||||
Severity: defaultSeverity, |
||||
Class: "default", |
||||
Component: "Grafana", |
||||
Group: "default", |
||||
CustomDetails: map[string]string{ |
||||
"firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\n - severity = invalid-severity\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1&matcher=severity%3Dinvalid-severity\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"num_firing": "1", |
||||
"num_resolved": "0", |
||||
"resolved": "", |
||||
}, |
||||
}, |
||||
Client: "Grafana", |
||||
ClientURL: "http://localhost", |
||||
Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Should expand templates in fields", |
||||
settings: `{ |
||||
"integrationKey": "abcdefgh0123456789",
|
||||
"severity" : "{{ .CommonLabels.severity }}",
|
||||
"class": "{{ .CommonLabels.class }}",
|
||||
"component": "{{ .CommonLabels.component }}",
|
||||
"group" : "{{ .CommonLabels.group }}",
|
||||
"source": "{{ .CommonLabels.source }}", |
||||
"client": "client-{{ .CommonLabels.source }}", |
||||
"client_url": "http://localhost:20200/{{ .CommonLabels.group }}" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1", "severity": "critical", "class": "test-class", "group": "test-group", "component": "test-component", "source": "test-source"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &pagerDutyMessage{ |
||||
RoutingKey: "abcdefgh0123456789", |
||||
DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
EventAction: "trigger", |
||||
Payload: pagerDutyPayload{ |
||||
Summary: "[FIRING:1] (test-class test-component test-group val1 critical test-source)", |
||||
Source: "test-source", |
||||
Severity: "critical", |
||||
Class: "test-class", |
||||
Component: "test-component", |
||||
Group: "test-group", |
||||
CustomDetails: map[string]string{ |
||||
"firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - class = test-class\n - component = test-component\n - group = test-group\n - lbl1 = val1\n - severity = critical\n - source = test-source\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=class%3Dtest-class&matcher=component%3Dtest-component&matcher=group%3Dtest-group&matcher=lbl1%3Dval1&matcher=severity%3Dcritical&matcher=source%3Dtest-source\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"num_firing": "1", |
||||
"num_resolved": "0", |
||||
"resolved": "", |
||||
}, |
||||
}, |
||||
Client: "client-test-source", |
||||
ClientURL: "http://localhost:20200/test-group", |
||||
Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Default config with one alert and custom summary", |
||||
settings: `{"integrationKey": "abcdefgh0123456789", "summary": "Alerts firing: {{ len .Alerts.Firing }}"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &pagerDutyMessage{ |
||||
RoutingKey: "abcdefgh0123456789", |
||||
DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
EventAction: "trigger", |
||||
Payload: pagerDutyPayload{ |
||||
Summary: "Alerts firing: 1", |
||||
Source: hostname, |
||||
Severity: defaultSeverity, |
||||
Class: "default", |
||||
Component: "Grafana", |
||||
Group: "default", |
||||
CustomDetails: map[string]string{ |
||||
"firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"num_firing": "1", |
||||
"num_resolved": "0", |
||||
"resolved": "", |
||||
}, |
||||
}, |
||||
Client: "Grafana", |
||||
ClientURL: "http://localhost", |
||||
Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Custom config with multiple alerts", |
||||
settings: `{ |
||||
"integrationKey": "abcdefgh0123456789", |
||||
"severity": "warning", |
||||
"class": "{{ .Status }}", |
||||
"component": "My Grafana", |
||||
"group": "my_group" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &pagerDutyMessage{ |
||||
RoutingKey: "abcdefgh0123456789", |
||||
DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
EventAction: "trigger", |
||||
Payload: pagerDutyPayload{ |
||||
Summary: "[FIRING:2] ", |
||||
Source: hostname, |
||||
Severity: "warning", |
||||
Class: "firing", |
||||
Component: "My Grafana", |
||||
Group: "my_group", |
||||
CustomDetails: map[string]string{ |
||||
"firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", |
||||
"num_firing": "2", |
||||
"num_resolved": "0", |
||||
"resolved": "", |
||||
}, |
||||
}, |
||||
Client: "Grafana", |
||||
ClientURL: "http://localhost", |
||||
Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "should truncate long summary", |
||||
settings: fmt.Sprintf(`{"integrationKey": "abcdefgh0123456789", "summary": "%s"}`, strings.Repeat("1", rand.Intn(100)+1025)), |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &pagerDutyMessage{ |
||||
RoutingKey: "abcdefgh0123456789", |
||||
DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", |
||||
EventAction: "trigger", |
||||
Payload: pagerDutyPayload{ |
||||
Summary: fmt.Sprintf("%s…", strings.Repeat("1", 1023)), |
||||
Source: hostname, |
||||
Severity: defaultSeverity, |
||||
Class: "default", |
||||
Component: "Grafana", |
||||
Group: "default", |
||||
CustomDetails: map[string]string{ |
||||
"firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"num_firing": "1", |
||||
"num_resolved": "0", |
||||
"resolved": "", |
||||
}, |
||||
}, |
||||
Client: "Grafana", |
||||
ClientURL: "http://localhost", |
||||
Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Error in initing", |
||||
settings: `{}`, |
||||
expInitError: `could not find integration key property in settings`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
secureSettings := make(map[string][]byte) |
||||
webhookSender := mockNotificationService() |
||||
fc := FactoryConfig{ |
||||
Config: &NotificationChannelConfig{ |
||||
Name: "pageduty_testing", |
||||
Type: "pagerduty", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
}, |
||||
NotificationService: webhookSender, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
pn, err := newPagerdutyNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := pn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.False(t, ok) |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.True(t, ok) |
||||
require.NoError(t, err) |
||||
|
||||
expBody, err := json.Marshal(c.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,339 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"mime/multipart" |
||||
"os" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
const ( |
||||
pushoverMaxFileSize = 1 << 21 // 2MB
|
||||
// https://pushover.net/api#limits - 250 characters or runes.
|
||||
pushoverMaxTitleLenRunes = 250 |
||||
// https://pushover.net/api#limits - 1024 characters or runes.
|
||||
pushoverMaxMessageLenRunes = 1024 |
||||
// https://pushover.net/api#limits - 512 characters or runes.
|
||||
pushoverMaxURLLenRunes = 512 |
||||
) |
||||
|
||||
var ( |
||||
PushoverEndpoint = "https://api.pushover.net/1/messages.json" |
||||
) |
||||
|
||||
// PushoverNotifier is responsible for sending
|
||||
// alert notifications to Pushover
|
||||
type PushoverNotifier struct { |
||||
*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 |
||||
} |
||||
|
||||
func PushoverFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
notifier, err := NewPushoverNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return notifier, nil |
||||
} |
||||
|
||||
// NewSlackNotifier is the constructor for the Slack notifier
|
||||
func NewPushoverNotifier(fc FactoryConfig) (*PushoverNotifier, error) { |
||||
settings, err := buildPushoverSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &PushoverNotifier{ |
||||
Base: NewBase(fc.Config), |
||||
tmpl: fc.Template, |
||||
log: fc.Logger, |
||||
images: fc.ImageStore, |
||||
ns: fc.NotificationService, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify sends an alert notification to Slack.
|
||||
func (pn *PushoverNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
headers, uploadBody, err := pn.genPushoverBody(ctx, as...) |
||||
if err != nil { |
||||
pn.log.Error("Failed to generate body for pushover", "error", err) |
||||
return false, err |
||||
} |
||||
|
||||
cmd := &SendWebhookSettings{ |
||||
Url: PushoverEndpoint, |
||||
HttpMethod: "POST", |
||||
HttpHeader: headers, |
||||
Body: uploadBody.String(), |
||||
} |
||||
|
||||
if err := pn.ns.SendWebhook(ctx, cmd); err != nil { |
||||
pn.log.Error("failed to send pushover notification", "error", err, "webhook", pn.Name) |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
func (pn *PushoverNotifier) SendResolved() bool { |
||||
return !pn.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func (pn *PushoverNotifier) genPushoverBody(ctx context.Context, as ...*types.Alert) (map[string]string, bytes.Buffer, error) { |
||||
key, err := notify.ExtractGroupKey(ctx) |
||||
if err != nil { |
||||
return nil, bytes.Buffer{}, err |
||||
} |
||||
|
||||
b := bytes.Buffer{} |
||||
w := multipart.NewWriter(&b) |
||||
|
||||
// tests use a non-random boundary separator
|
||||
if boundary := GetBoundary(); boundary != "" { |
||||
err := w.SetBoundary(boundary) |
||||
if err != nil { |
||||
return nil, b, err |
||||
} |
||||
} |
||||
|
||||
var tmplErr error |
||||
tmpl, _ := TmplText(ctx, pn.tmpl, as, pn.log, &tmplErr) |
||||
|
||||
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 { |
||||
return nil, b, fmt.Errorf("failed to write the token: %w", err) |
||||
} |
||||
|
||||
title, truncated := 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) |
||||
if truncated { |
||||
pn.log.Warn("Truncated message", "incident", key, "max_runes", pushoverMaxMessageLenRunes) |
||||
} |
||||
message = strings.TrimSpace(message) |
||||
if message == "" { |
||||
// Pushover rejects empty messages.
|
||||
message = "(no details)" |
||||
} |
||||
|
||||
supplementaryURL := joinUrlPath(pn.tmpl.ExternalURL.String(), "/alerting/list", pn.log) |
||||
supplementaryURL, truncated = TruncateInRunes(supplementaryURL, pushoverMaxURLLenRunes) |
||||
if truncated { |
||||
pn.log.Warn("Truncated URL", "incident", key, "max_runes", pushoverMaxURLLenRunes) |
||||
} |
||||
|
||||
status := types.Alerts(as...).Status() |
||||
priority := pn.settings.alertingPriority |
||||
if status == model.AlertResolved { |
||||
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 { |
||||
return nil, b, fmt.Errorf("failed to write retry: %w", err) |
||||
} |
||||
|
||||
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 { |
||||
return nil, b, fmt.Errorf("failed to write the device: %w", err) |
||||
} |
||||
} |
||||
|
||||
if err := w.WriteField("title", title); err != nil { |
||||
return nil, b, fmt.Errorf("failed to write the title: %w", err) |
||||
} |
||||
|
||||
if err := w.WriteField("url", supplementaryURL); err != nil { |
||||
return nil, b, fmt.Errorf("failed to write the URL: %w", err) |
||||
} |
||||
|
||||
if err := w.WriteField("url_title", "Show alert rule"); err != nil { |
||||
return nil, b, fmt.Errorf("failed to write the URL title: %w", err) |
||||
} |
||||
|
||||
if err := w.WriteField("message", message); err != nil { |
||||
return nil, b, fmt.Errorf("failed write the message: %w", err) |
||||
} |
||||
|
||||
pn.writeImageParts(ctx, w, as...) |
||||
|
||||
var sound string |
||||
if status == model.AlertResolved { |
||||
sound = tmpl(pn.settings.okSound) |
||||
} else { |
||||
sound = tmpl(pn.settings.alertingSound) |
||||
} |
||||
if sound != "default" { |
||||
if err := w.WriteField("sound", sound); err != nil { |
||||
return nil, b, fmt.Errorf("failed to write the sound: %w", err) |
||||
} |
||||
} |
||||
|
||||
// Mark the message as HTML
|
||||
if err := w.WriteField("html", "1"); err != nil { |
||||
return nil, b, fmt.Errorf("failed to mark the message as HTML: %w", err) |
||||
} |
||||
if err := w.Close(); err != nil { |
||||
return nil, b, fmt.Errorf("failed to close the multipart request: %w", err) |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
pn.log.Warn("failed to template pushover message", "error", tmplErr.Error()) |
||||
} |
||||
|
||||
headers := map[string]string{ |
||||
"Content-Type": w.FormDataContentType(), |
||||
} |
||||
|
||||
return headers, b, nil |
||||
} |
||||
|
||||
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 { |
||||
f, err := os.Open(image.Path) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to open the image: %w", err) |
||||
} |
||||
defer func() { |
||||
if err := f.Close(); err != nil { |
||||
pn.log.Error("failed to close the image", "file", image.Path) |
||||
} |
||||
}() |
||||
|
||||
fileInfo, err := f.Stat() |
||||
if err != nil { |
||||
return fmt.Errorf("failed to stat the image: %w", err) |
||||
} |
||||
|
||||
if fileInfo.Size() > pushoverMaxFileSize { |
||||
return fmt.Errorf("image would exceeded maximum file size: %d", fileInfo.Size()) |
||||
} |
||||
|
||||
fw, err := w.CreateFormFile("attachment", image.Path) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to create form file for the image: %w", err) |
||||
} |
||||
|
||||
if _, err = io.Copy(fw, f); err != nil { |
||||
return fmt.Errorf("failed to copy the image to the form file: %w", err) |
||||
} |
||||
|
||||
return ErrImagesDone |
||||
}, as...) |
||||
} |
@ -1,273 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"mime/multipart" |
||||
"net/url" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestPushoverNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
images := newFakeImageStoreWithFile(t, 2) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg map[string]string |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Correct config with single alert", |
||||
settings: `{ |
||||
"userKey": "<userKey>", |
||||
"apiToken": "<apiToken>" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]string{ |
||||
"user": "<userKey>", |
||||
"token": "<apiToken>", |
||||
"priority": "0", |
||||
"sound": "", |
||||
"title": "[FIRING:1] (val1)", |
||||
"url": "http://localhost/alerting/list", |
||||
"url_title": "Show alert rule", |
||||
"message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh", |
||||
"attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", |
||||
"html": "1", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Custom title", |
||||
settings: `{ |
||||
"userKey": "<userKey>", |
||||
"apiToken": "<apiToken>", |
||||
"title": "Alerts firing: {{ len .Alerts.Firing }}" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]string{ |
||||
"user": "<userKey>", |
||||
"token": "<apiToken>", |
||||
"priority": "0", |
||||
"sound": "", |
||||
"title": "Alerts firing: 1", |
||||
"url": "http://localhost/alerting/list", |
||||
"url_title": "Show alert rule", |
||||
"message": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh", |
||||
"attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", |
||||
"html": "1", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Custom config with multiple alerts", |
||||
settings: `{ |
||||
"userKey": "<userKey>", |
||||
"apiToken": "<apiToken>", |
||||
"device": "device", |
||||
"priority": "2", |
||||
"okpriority": "0", |
||||
"retry": "30", |
||||
"expire": "86400", |
||||
"sound": "echo", |
||||
"oksound": "magic", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]string{ |
||||
"user": "<userKey>", |
||||
"token": "<apiToken>", |
||||
"priority": "2", |
||||
"sound": "echo", |
||||
"title": "[FIRING:2] ", |
||||
"url": "http://localhost/alerting/list", |
||||
"url_title": "Show alert rule", |
||||
"message": "2 alerts are firing, 0 are resolved", |
||||
"attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", |
||||
"html": "1", |
||||
"retry": "30", |
||||
"expire": "86400", |
||||
"device": "device", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Integer fields as integers", |
||||
settings: `{ |
||||
"userKey": "<userKey>", |
||||
"apiToken": "<apiToken>", |
||||
"device": "device", |
||||
"priority": 2, |
||||
"okpriority": 0, |
||||
"retry": 30, |
||||
"expire": 86400, |
||||
"sound": "echo", |
||||
"oksound": "magic", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]string{ |
||||
"user": "<userKey>", |
||||
"token": "<apiToken>", |
||||
"priority": "2", |
||||
"sound": "echo", |
||||
"title": "[FIRING:2] ", |
||||
"url": "http://localhost/alerting/list", |
||||
"url_title": "Show alert rule", |
||||
"message": "2 alerts are firing, 0 are resolved", |
||||
"attachment": "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\b\x04\x00\x00\x00\xb5\x1c\f\x02\x00\x00\x00\vIDATx\xdacd`\x00\x00\x00\x06\x00\x020\x81\xd0/\x00\x00\x00\x00IEND\xaeB`\x82", |
||||
"html": "1", |
||||
"retry": "30", |
||||
"expire": "86400", |
||||
"device": "device", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Missing user key", |
||||
settings: `{ |
||||
"apiToken": "<apiToken>" |
||||
}`, |
||||
expInitError: `user key not found`, |
||||
}, { |
||||
name: "Missing api key", |
||||
settings: `{ |
||||
"userKey": "<userKey>" |
||||
}`, |
||||
expInitError: `API token not found`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
origGetBoundary := GetBoundary |
||||
boundary := "abcd" |
||||
GetBoundary = func() string { |
||||
return boundary |
||||
} |
||||
t.Cleanup(func() { |
||||
GetBoundary = origGetBoundary |
||||
}) |
||||
|
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
secureSettings := make(map[string][]byte) |
||||
|
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: &NotificationChannelConfig{ |
||||
Name: "pushover_testing", |
||||
Type: "pushover", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
}, |
||||
ImageStore: images, |
||||
NotificationService: webhookSender, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
pn, err := NewPushoverNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := pn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.Error(t, err) |
||||
require.False(t, ok) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
bodyReader := multipart.NewReader(strings.NewReader(webhookSender.Webhook.Body), boundary) |
||||
for { |
||||
part, err := bodyReader.NextPart() |
||||
if part == nil || errors.Is(err, io.EOF) { |
||||
assert.Empty(t, c.expMsg, fmt.Sprintf("expected fields %v", c.expMsg)) |
||||
break |
||||
} |
||||
formField := part.FormName() |
||||
expected, ok := c.expMsg[formField] |
||||
assert.True(t, ok, fmt.Sprintf("unexpected field %s", formField)) |
||||
actual := []byte("") |
||||
if expected != "" { |
||||
buf := new(bytes.Buffer) |
||||
_, err := buf.ReadFrom(part) |
||||
require.NoError(t, err) |
||||
actual = buf.Bytes() |
||||
} |
||||
assert.Equal(t, expected, string(actual)) |
||||
delete(c.expMsg, formField) |
||||
} |
||||
}) |
||||
} |
||||
} |
@ -1,46 +0,0 @@ |
||||
package channels |
||||
|
||||
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 |
||||
SingleEmail bool |
||||
Template string |
||||
Subject string |
||||
Data map[string]interface{} |
||||
Info string |
||||
ReplyTo []string |
||||
EmbeddedFiles []string |
||||
AttachedFiles []*SendEmailAttachFile |
||||
} |
||||
|
||||
// SendEmailAttachFile is a definition of the attached files without path
|
||||
type SendEmailAttachFile struct { |
||||
Name string |
||||
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 |
||||
} |
@ -1,182 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
type SensuGoNotifier struct { |
||||
*Base |
||||
log Logger |
||||
images ImageStore |
||||
ns 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"` |
||||
} |
||||
|
||||
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 |
||||
} |
||||
|
||||
func SensuGoFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
notifier, err := NewSensuGoNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return notifier, nil |
||||
} |
||||
|
||||
// NewSensuGoNotifier is the constructor for the SensuGo notifier
|
||||
func NewSensuGoNotifier(fc FactoryConfig) (*SensuGoNotifier, error) { |
||||
settings, err := buildSensuGoConfig(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &SensuGoNotifier{ |
||||
Base: NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
images: fc.ImageStore, |
||||
ns: fc.NotificationService, |
||||
tmpl: fc.Template, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify sends an alert notification to Sensu Go
|
||||
func (sn *SensuGoNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
sn.log.Debug("sending Sensu Go result") |
||||
|
||||
var tmplErr error |
||||
tmpl, _ := 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.
|
||||
entity := tmpl(sn.settings.Entity) |
||||
if entity == "" { |
||||
entity = "default" |
||||
} |
||||
|
||||
check := tmpl(sn.settings.Check) |
||||
if check == "" { |
||||
check = "default" |
||||
} |
||||
|
||||
alerts := types.Alerts(as...) |
||||
status := 0 |
||||
if alerts.Status() == model.AlertFiring { |
||||
// TODO figure out about NoData old state (we used to send status 1 in that case)
|
||||
status = 2 |
||||
} |
||||
|
||||
namespace := tmpl(sn.settings.Namespace) |
||||
if namespace == "" { |
||||
namespace = "default" |
||||
} |
||||
|
||||
var handlers []string |
||||
if sn.settings.Handler != "" { |
||||
handlers = []string{tmpl(sn.settings.Handler)} |
||||
} |
||||
|
||||
labels := make(map[string]string) |
||||
|
||||
_ = withStoredImages(ctx, sn.log, sn.images, |
||||
func(_ int, image 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 nil |
||||
}, as...) |
||||
|
||||
ruleURL := joinUrlPath(sn.tmpl.ExternalURL.String(), "/alerting/list", sn.log) |
||||
labels["ruleURL"] = ruleURL |
||||
|
||||
bodyMsgType := map[string]interface{}{ |
||||
"entity": map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": entity, |
||||
"namespace": namespace, |
||||
}, |
||||
}, |
||||
"check": map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": check, |
||||
"labels": labels, |
||||
}, |
||||
"output": tmpl(sn.settings.Message), |
||||
"issued": timeNow().Unix(), |
||||
"interval": 86400, |
||||
"status": status, |
||||
"handlers": handlers, |
||||
}, |
||||
"ruleUrl": ruleURL, |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
sn.log.Warn("failed to template sensugo message", "error", tmplErr.Error()) |
||||
} |
||||
|
||||
body, err := json.Marshal(bodyMsgType) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
cmd := &SendWebhookSettings{ |
||||
Url: fmt.Sprintf("%s/api/core/v2/namespaces/%s/events", strings.TrimSuffix(sn.settings.URL, "/"), namespace), |
||||
Body: string(body), |
||||
HttpMethod: "POST", |
||||
HttpHeader: map[string]string{ |
||||
"Content-Type": "application/json", |
||||
"Authorization": fmt.Sprintf("Key %s", sn.settings.APIKey), |
||||
}, |
||||
} |
||||
if err := sn.ns.SendWebhook(ctx, cmd); err != nil { |
||||
sn.log.Error("failed to send Sensu Go event", "error", err, "sensugo", sn.Name) |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (sn *SensuGoNotifier) SendResolved() bool { |
||||
return !sn.GetDisableResolveMessage() |
||||
} |
@ -1,185 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestSensuGoNotifier(t *testing.T) { |
||||
constNow := time.Now() |
||||
defer mockTimeNow(constNow)() |
||||
|
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
images := newFakeImageStore(2) |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg map[string]interface{} |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Default config with one alert", |
||||
settings: `{"url": "http://sensu-api.local:8080", "apikey": "<apikey>"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"entity": map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": "default", |
||||
"namespace": "default", |
||||
}, |
||||
}, |
||||
"check": map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": "default", |
||||
"labels": map[string]string{ |
||||
"imageURL": "https://www.example.com/test-image-1.jpg", |
||||
"ruleURL": "http://localhost/alerting/list", |
||||
}, |
||||
}, |
||||
"output": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"issued": timeNow().Unix(), |
||||
"interval": 86400, |
||||
"status": 2, |
||||
"handlers": nil, |
||||
}, |
||||
"ruleUrl": "http://localhost/alerting/list", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Custom config with multiple alerts", |
||||
settings: `{ |
||||
"url": "http://sensu-api.local:8080", |
||||
"entity": "grafana_instance_01", |
||||
"check": "grafana_rule_0", |
||||
"namespace": "namespace", |
||||
"handler": "myhandler", |
||||
"apikey": "<apikey>", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"__alert_rule_uid__": "rule uid", "alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"entity": map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": "grafana_instance_01", |
||||
"namespace": "namespace", |
||||
}, |
||||
}, |
||||
"check": map[string]interface{}{ |
||||
"metadata": map[string]interface{}{ |
||||
"name": "grafana_rule_0", |
||||
"labels": map[string]string{ |
||||
"imageURL": "https://www.example.com/test-image-1.jpg", |
||||
"ruleURL": "http://localhost/alerting/list", |
||||
}, |
||||
}, |
||||
"output": "2 alerts are firing, 0 are resolved", |
||||
"issued": timeNow().Unix(), |
||||
"interval": 86400, |
||||
"status": 2, |
||||
"handlers": []string{"myhandler"}, |
||||
}, |
||||
"ruleUrl": "http://localhost/alerting/list", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Error in initing: missing URL", |
||||
settings: `{ |
||||
"apikey": "<apikey>" |
||||
}`, |
||||
expInitError: `could not find URL property in settings`, |
||||
}, { |
||||
name: "Error in initing: missing API key", |
||||
settings: `{ |
||||
"url": "http://sensu-api.local:8080" |
||||
}`, |
||||
expInitError: `could not find the API key property in settings`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
secureSettings := make(map[string][]byte) |
||||
|
||||
m := &NotificationChannelConfig{ |
||||
Name: "Sensu Go", |
||||
Type: "sensugo", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
} |
||||
|
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: m, |
||||
ImageStore: images, |
||||
NotificationService: webhookSender, |
||||
Template: tmpl, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
sn, err := NewSensuGoNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := sn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.False(t, ok) |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
expBody, err := json.Marshal(c.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,378 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
|
||||
"github.com/pkg/errors" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
const ( |
||||
ImageSizeSmall = "small" |
||||
ImageSizeMedium = "medium" |
||||
ImageSizeLarge = "large" |
||||
|
||||
TextColorDark = "dark" |
||||
TextColorLight = "light" |
||||
TextColorAccent = "accent" |
||||
TextColorGood = "good" |
||||
TextColorWarning = "warning" |
||||
TextColorAttention = "attention" |
||||
|
||||
TextSizeSmall = "small" |
||||
TextSizeMedium = "medium" |
||||
TextSizeLarge = "large" |
||||
TextSizeExtraLarge = "extraLarge" |
||||
TextSizeDefault = "default" |
||||
|
||||
TextWeightLighter = "lighter" |
||||
TextWeightBolder = "bolder" |
||||
TextWeightDefault = "default" |
||||
) |
||||
|
||||
// AdaptiveCardsMessage represents a message for adaptive cards.
|
||||
type AdaptiveCardsMessage struct { |
||||
Attachments []AdaptiveCardsAttachment `json:"attachments"` |
||||
Summary string `json:"summary,omitempty"` // Summary is the text shown in notifications
|
||||
Type string `json:"type"` |
||||
} |
||||
|
||||
// NewAdaptiveCardsMessage returns a message prepared for adaptive cards.
|
||||
// https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#send-adaptive-cards-using-an-incoming-webhook
|
||||
func NewAdaptiveCardsMessage(card AdaptiveCard) AdaptiveCardsMessage { |
||||
return AdaptiveCardsMessage{ |
||||
Attachments: []AdaptiveCardsAttachment{{ |
||||
ContentType: "application/vnd.microsoft.card.adaptive", |
||||
Content: card, |
||||
}}, |
||||
Type: "message", |
||||
} |
||||
} |
||||
|
||||
// AdaptiveCardsAttachment contains an adaptive card.
|
||||
type AdaptiveCardsAttachment struct { |
||||
Content AdaptiveCard `json:"content"` |
||||
ContentType string `json:"contentType"` |
||||
ContentURL string `json:"contentUrl,omitempty"` |
||||
} |
||||
|
||||
// AdapativeCard repesents an Adaptive Card.
|
||||
// https://adaptivecards.io/explorer/AdaptiveCard.html
|
||||
type AdaptiveCard struct { |
||||
Body []AdaptiveCardItem |
||||
Schema string |
||||
Type string |
||||
Version string |
||||
} |
||||
|
||||
// NewAdaptiveCard returns a prepared Adaptive Card.
|
||||
func NewAdaptiveCard() AdaptiveCard { |
||||
return AdaptiveCard{ |
||||
Body: make([]AdaptiveCardItem, 0), |
||||
Schema: "http://adaptivecards.io/schemas/adaptive-card.json", |
||||
Type: "AdaptiveCard", |
||||
Version: "1.4", |
||||
} |
||||
} |
||||
|
||||
func (c *AdaptiveCard) MarshalJSON() ([]byte, error) { |
||||
return json.Marshal(struct { |
||||
Body []AdaptiveCardItem `json:"body"` |
||||
Schema string `json:"$schema"` |
||||
Type string `json:"type"` |
||||
Version string `json:"version"` |
||||
MsTeams map[string]interface{} `json:"msTeams,omitempty"` |
||||
}{ |
||||
Body: c.Body, |
||||
Schema: c.Schema, |
||||
Type: c.Type, |
||||
Version: c.Version, |
||||
MsTeams: map[string]interface{}{"width": "Full"}, |
||||
}) |
||||
} |
||||
|
||||
// AppendItem appends an item, such as text or an image, to the Adaptive Card.
|
||||
func (c *AdaptiveCard) AppendItem(i AdaptiveCardItem) { |
||||
c.Body = append(c.Body, i) |
||||
} |
||||
|
||||
// AdaptiveCardItem is an interface for adaptive card items such as containers, elements and inputs.
|
||||
type AdaptiveCardItem interface { |
||||
MarshalJSON() ([]byte, error) |
||||
} |
||||
|
||||
// AdaptiveCardTextBlockItem is a TextBlock.
|
||||
type AdaptiveCardTextBlockItem struct { |
||||
Color string |
||||
Size string |
||||
Text string |
||||
Weight string |
||||
Wrap bool |
||||
} |
||||
|
||||
func (i AdaptiveCardTextBlockItem) MarshalJSON() ([]byte, error) { |
||||
return json.Marshal(struct { |
||||
Type string `json:"type"` |
||||
Text string `json:"text"` |
||||
Color string `json:"color,omitempty"` |
||||
Size string `json:"size,omitempty"` |
||||
Weight string `json:"weight,omitempty"` |
||||
Wrap bool `json:"wrap,omitempty"` |
||||
}{ |
||||
Type: "TextBlock", |
||||
Text: i.Text, |
||||
Color: i.Color, |
||||
Size: i.Size, |
||||
Weight: i.Weight, |
||||
Wrap: i.Wrap, |
||||
}) |
||||
} |
||||
|
||||
// AdaptiveCardImageSetItem is an ImageSet.
|
||||
type AdaptiveCardImageSetItem struct { |
||||
Images []AdaptiveCardImageItem |
||||
Size string |
||||
} |
||||
|
||||
// AppendImage appends an image to image set.
|
||||
func (i *AdaptiveCardImageSetItem) AppendImage(image AdaptiveCardImageItem) { |
||||
i.Images = append(i.Images, image) |
||||
} |
||||
|
||||
func (i AdaptiveCardImageSetItem) MarshalJSON() ([]byte, error) { |
||||
return json.Marshal(struct { |
||||
Type string `json:"type"` |
||||
Images []AdaptiveCardImageItem `json:"images"` |
||||
Size string `json:"imageSize"` |
||||
}{ |
||||
Type: "ImageSet", |
||||
Images: i.Images, |
||||
Size: i.Size, |
||||
}) |
||||
} |
||||
|
||||
// AdaptiveCardImageItem is an Image.
|
||||
type AdaptiveCardImageItem struct { |
||||
AltText string |
||||
Size string |
||||
URL string |
||||
} |
||||
|
||||
func (i AdaptiveCardImageItem) MarshalJSON() ([]byte, error) { |
||||
return json.Marshal(struct { |
||||
Type string `json:"type"` |
||||
URL string `json:"url"` |
||||
AltText string `json:"altText,omitempty"` |
||||
Size string `json:"size,omitempty"` |
||||
MsTeams map[string]interface{} `json:"msTeams,omitempty"` |
||||
}{ |
||||
Type: "Image", |
||||
URL: i.URL, |
||||
AltText: i.AltText, |
||||
Size: i.Size, |
||||
MsTeams: map[string]interface{}{"allowExpand": true}, |
||||
}) |
||||
} |
||||
|
||||
// AdaptiveCardActionSetItem is an ActionSet.
|
||||
type AdaptiveCardActionSetItem struct { |
||||
Actions []AdaptiveCardActionItem |
||||
} |
||||
|
||||
func (i AdaptiveCardActionSetItem) MarshalJSON() ([]byte, error) { |
||||
return json.Marshal(struct { |
||||
Type string `json:"type"` |
||||
Actions []AdaptiveCardActionItem `json:"actions"` |
||||
}{ |
||||
Type: "ActionSet", |
||||
Actions: i.Actions, |
||||
}) |
||||
} |
||||
|
||||
type AdaptiveCardActionItem interface { |
||||
MarshalJSON() ([]byte, error) |
||||
} |
||||
|
||||
// AdapativeCardOpenURLActionItem is an Action.OpenUrl action.
|
||||
type AdaptiveCardOpenURLActionItem struct { |
||||
IconURL string |
||||
Title string |
||||
URL string |
||||
} |
||||
|
||||
func (i AdaptiveCardOpenURLActionItem) MarshalJSON() ([]byte, error) { |
||||
return json.Marshal(struct { |
||||
Type string `json:"type"` |
||||
Title string `json:"title"` |
||||
URL string `json:"url"` |
||||
IconURL string `json:"iconUrl,omitempty"` |
||||
}{ |
||||
Type: "Action.OpenUrl", |
||||
Title: i.Title, |
||||
URL: i.URL, |
||||
IconURL: i.IconURL, |
||||
}) |
||||
} |
||||
|
||||
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 |
||||
tmpl *template.Template |
||||
log Logger |
||||
ns WebhookSender |
||||
images ImageStore |
||||
settings teamsSettings |
||||
} |
||||
|
||||
// NewTeamsNotifier is the constructor for Teams notifier.
|
||||
func NewTeamsNotifier(fc FactoryConfig) (*TeamsNotifier, error) { |
||||
settings, err := buildTeamsSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &TeamsNotifier{ |
||||
Base: NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
ns: fc.NotificationService, |
||||
images: fc.ImageStore, |
||||
tmpl: fc.Template, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
func TeamsFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
notifier, err := NewTeamsNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return notifier, nil |
||||
} |
||||
|
||||
func (tn *TeamsNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
var tmplErr error |
||||
tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) |
||||
|
||||
card := NewAdaptiveCard() |
||||
card.AppendItem(AdaptiveCardTextBlockItem{ |
||||
Color: getTeamsTextColor(types.Alerts(as...)), |
||||
Text: tmpl(tn.settings.Title), |
||||
Size: TextSizeLarge, |
||||
Weight: TextWeightBolder, |
||||
Wrap: true, |
||||
}) |
||||
card.AppendItem(AdaptiveCardTextBlockItem{ |
||||
Text: tmpl(tn.settings.Message), |
||||
Wrap: true, |
||||
}) |
||||
|
||||
var s AdaptiveCardImageSetItem |
||||
_ = withStoredImages(ctx, tn.log, tn.images, |
||||
func(_ int, image Image) error { |
||||
if image.URL != "" { |
||||
s.AppendImage(AdaptiveCardImageItem{URL: image.URL}) |
||||
} |
||||
return nil |
||||
}, |
||||
as...) |
||||
|
||||
if len(s.Images) > 2 { |
||||
s.Size = ImageSizeMedium |
||||
card.AppendItem(s) |
||||
} else if len(s.Images) > 0 { |
||||
s.Size = ImageSizeLarge |
||||
card.AppendItem(s) |
||||
} |
||||
|
||||
card.AppendItem(AdaptiveCardActionSetItem{ |
||||
Actions: []AdaptiveCardActionItem{ |
||||
AdaptiveCardOpenURLActionItem{ |
||||
Title: "View URL", |
||||
URL: joinUrlPath(tn.tmpl.ExternalURL.String(), "/alerting/list", tn.log), |
||||
}, |
||||
}, |
||||
}) |
||||
|
||||
msg := NewAdaptiveCardsMessage(card) |
||||
msg.Summary = tmpl(tn.settings.Title) |
||||
|
||||
// This check for tmplErr must happen before templating the URL
|
||||
if tmplErr != nil { |
||||
tn.log.Warn("failed to template Teams message", "error", tmplErr.Error()) |
||||
tmplErr = nil |
||||
} |
||||
|
||||
u := tmpl(tn.settings.URL) |
||||
if tmplErr != nil { |
||||
tn.log.Warn("failed to template Teams URL", "error", tmplErr.Error(), "fallback", tn.settings.URL) |
||||
u = tn.settings.URL |
||||
} |
||||
|
||||
b, err := json.Marshal(msg) |
||||
if err != nil { |
||||
return false, fmt.Errorf("failed to marshal JSON: %w", err) |
||||
} |
||||
|
||||
cmd := &SendWebhookSettings{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 { |
||||
return false, errors.Wrap(err, "send notification to Teams") |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func validateResponse(b []byte, statusCode int) error { |
||||
// The request succeeded if the response is "1"
|
||||
// https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#send-messages-using-curl-and-powershell
|
||||
if !bytes.Equal(b, []byte("1")) { |
||||
return errors.New(string(b)) |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (tn *TeamsNotifier) SendResolved() bool { |
||||
return !tn.GetDisableResolveMessage() |
||||
} |
||||
|
||||
// getTeamsTextColor returns the text color for the message title.
|
||||
func getTeamsTextColor(alerts model.Alerts) string { |
||||
if getAlertStatusColor(alerts.Status()) == ColorAlertFiring { |
||||
return TextColorAttention |
||||
} |
||||
return TextColorGood |
||||
} |
@ -1,309 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"math/rand" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestTeamsNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg map[string]interface{} |
||||
expInitError string |
||||
expMsgError error |
||||
}{{ |
||||
name: "Default config with one alert", |
||||
settings: `{"url": "http://localhost"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"attachments": []map[string]interface{}{{ |
||||
"content": map[string]interface{}{ |
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", |
||||
"body": []map[string]interface{}{{ |
||||
"color": "attention", |
||||
"size": "large", |
||||
"text": "[FIRING:1] (val1)", |
||||
"type": "TextBlock", |
||||
"weight": "bolder", |
||||
"wrap": true, |
||||
}, { |
||||
"text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"type": "TextBlock", |
||||
"wrap": true, |
||||
}, { |
||||
"actions": []map[string]interface{}{{ |
||||
"title": "View URL", |
||||
"type": "Action.OpenUrl", |
||||
"url": "http://localhost/alerting/list", |
||||
}}, |
||||
"type": "ActionSet", |
||||
}}, |
||||
"type": "AdaptiveCard", |
||||
"version": "1.4", |
||||
"msTeams": map[string]interface{}{ |
||||
"width": "Full", |
||||
}, |
||||
}, |
||||
"contentType": "application/vnd.microsoft.card.adaptive", |
||||
}}, |
||||
"summary": "[FIRING:1] (val1)", |
||||
"type": "message", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Custom config with multiple alerts", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"title": "{{ .CommonLabels.alertname }}", |
||||
"sectiontitle": "Details", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"attachments": []map[string]interface{}{{ |
||||
"content": map[string]interface{}{ |
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", |
||||
"body": []map[string]interface{}{{ |
||||
"color": "attention", |
||||
"size": "large", |
||||
"text": "alert1", |
||||
"type": "TextBlock", |
||||
"weight": "bolder", |
||||
"wrap": true, |
||||
}, { |
||||
"text": "2 alerts are firing, 0 are resolved", |
||||
"type": "TextBlock", |
||||
"wrap": true, |
||||
}, { |
||||
"actions": []map[string]interface{}{{ |
||||
"title": "View URL", |
||||
"type": "Action.OpenUrl", |
||||
"url": "http://localhost/alerting/list", |
||||
}}, |
||||
"type": "ActionSet", |
||||
}}, |
||||
"type": "AdaptiveCard", |
||||
"version": "1.4", |
||||
"msTeams": map[string]interface{}{ |
||||
"width": "Full", |
||||
}, |
||||
}, |
||||
"contentType": "application/vnd.microsoft.card.adaptive", |
||||
}}, |
||||
"summary": "alert1", |
||||
"type": "message", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Missing field in template", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"title": "{{ .CommonLabels.alertname }}", |
||||
"sectiontitle": "Details", |
||||
"message": "I'm a custom template {{ .NotAField }} bad template" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"attachments": []map[string]interface{}{{ |
||||
"content": map[string]interface{}{ |
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", |
||||
"body": []map[string]interface{}{{ |
||||
"color": "attention", |
||||
"size": "large", |
||||
"text": "alert1", |
||||
"type": "TextBlock", |
||||
"weight": "bolder", |
||||
"wrap": true, |
||||
}, { |
||||
"text": "I'm a custom template ", |
||||
"type": "TextBlock", |
||||
"wrap": true, |
||||
}, { |
||||
"actions": []map[string]interface{}{{ |
||||
"title": "View URL", |
||||
"type": "Action.OpenUrl", |
||||
"url": "http://localhost/alerting/list", |
||||
}}, |
||||
"type": "ActionSet", |
||||
}}, |
||||
"type": "AdaptiveCard", |
||||
"version": "1.4", |
||||
"msTeams": map[string]interface{}{ |
||||
"width": "Full", |
||||
}, |
||||
}, |
||||
"contentType": "application/vnd.microsoft.card.adaptive", |
||||
}}, |
||||
"type": "message", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Invalid template", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"title": "{{ .CommonLabels.alertname }}", |
||||
"sectiontitle": "Details", |
||||
"message": "I'm a custom template {{ {.NotAField }} bad template" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"attachments": []map[string]interface{}{{ |
||||
"content": map[string]interface{}{ |
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json", |
||||
"body": []map[string]interface{}{{ |
||||
"color": "attention", |
||||
"size": "large", |
||||
"text": "alert1", |
||||
"type": "TextBlock", |
||||
"weight": "bolder", |
||||
"wrap": true, |
||||
}, { |
||||
"text": "", |
||||
"type": "TextBlock", |
||||
"wrap": true, |
||||
}, { |
||||
"actions": []map[string]interface{}{{ |
||||
"title": "View URL", |
||||
"type": "Action.OpenUrl", |
||||
"url": "http://localhost/alerting/list", |
||||
}}, |
||||
"type": "ActionSet", |
||||
}}, |
||||
"type": "AdaptiveCard", |
||||
"version": "1.4", |
||||
"msTeams": map[string]interface{}{ |
||||
"width": "Full", |
||||
}, |
||||
}, |
||||
"contentType": "application/vnd.microsoft.card.adaptive", |
||||
}}, |
||||
"type": "message", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Error in initing", |
||||
settings: `{}`, |
||||
expInitError: `could not find url property in settings`, |
||||
}} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
|
||||
m := &NotificationChannelConfig{ |
||||
Name: "teams_testing", |
||||
Type: "teams", |
||||
Settings: settingsJSON, |
||||
} |
||||
|
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: m, |
||||
ImageStore: &UnavailableImageStore{}, |
||||
NotificationService: webhookSender, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
pn, err := NewTeamsNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
|
||||
ok, err := pn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.False(t, ok) |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.True(t, ok) |
||||
require.NoError(t, err) |
||||
|
||||
require.NotNil(t, webhookSender.Webhook) |
||||
lastRequest := webhookSender.Webhook |
||||
|
||||
require.NotEmpty(t, lastRequest.Url) |
||||
|
||||
expBody, err := json.Marshal(c.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), lastRequest.Body) |
||||
|
||||
require.NotNil(t, lastRequest.Validation) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func Test_ValidateResponse(t *testing.T) { |
||||
require.NoError(t, validateResponse([]byte("1"), rand.Int())) |
||||
err := validateResponse([]byte("some error message"), rand.Int()) |
||||
require.Error(t, err) |
||||
require.Equal(t, "some error message", err.Error()) |
||||
} |
@ -1,239 +0,0 @@ |
||||
package channels |
||||
|
||||
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" |
||||
) |
||||
|
||||
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.
|
||||
const telegramMaxMessageLenRunes = 4096 |
||||
|
||||
// TelegramNotifier is responsible for sending
|
||||
// alert notifications to Telegram.
|
||||
type TelegramNotifier struct { |
||||
*Base |
||||
log Logger |
||||
images ImageStore |
||||
ns 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 |
||||
} |
||||
|
||||
func TelegramFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
notifier, err := NewTelegramNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return notifier, nil |
||||
} |
||||
|
||||
// NewTelegramNotifier is the constructor for the Telegram notifier
|
||||
func NewTelegramNotifier(fc FactoryConfig) (*TelegramNotifier, error) { |
||||
settings, err := buildTelegramSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &TelegramNotifier{ |
||||
Base: NewBase(fc.Config), |
||||
tmpl: fc.Template, |
||||
log: fc.Logger, |
||||
images: fc.ImageStore, |
||||
ns: fc.NotificationService, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify send an alert notification to Telegram.
|
||||
func (tn *TelegramNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
// Create the cmd for sendMessage
|
||||
cmd, err := tn.newWebhookSyncCmd("sendMessage", func(w *multipart.Writer) error { |
||||
msg, err := tn.buildTelegramMessage(ctx, as) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to build message: %w", err) |
||||
} |
||||
for k, v := range msg { |
||||
fw, err := w.CreateFormField(k) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to create form field: %w", err) |
||||
} |
||||
if _, err := fw.Write([]byte(v)); err != nil { |
||||
return fmt.Errorf("failed to write value: %w", err) |
||||
} |
||||
} |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return false, fmt.Errorf("failed to create telegram message: %w", err) |
||||
} |
||||
if err := tn.ns.SendWebhook(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 { |
||||
cmd, err = tn.newWebhookSyncCmd("sendPhoto", func(w *multipart.Writer) error { |
||||
f, err := os.Open(image.Path) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to open image: %w", err) |
||||
} |
||||
defer func() { |
||||
if err := f.Close(); err != nil { |
||||
tn.log.Warn("failed to close image", "error", err) |
||||
} |
||||
}() |
||||
fw, err := w.CreateFormFile("photo", image.Path) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to create form file: %w", err) |
||||
} |
||||
if _, err := io.Copy(fw, f); err != nil { |
||||
return fmt.Errorf("failed to write to form file: %w", err) |
||||
} |
||||
return nil |
||||
}) |
||||
if err != nil { |
||||
return fmt.Errorf("failed to create image: %w", err) |
||||
} |
||||
if err := tn.ns.SendWebhook(ctx, cmd); err != nil { |
||||
return fmt.Errorf("failed to upload image to telegram: %w", err) |
||||
} |
||||
return nil |
||||
}, as...) |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (tn *TelegramNotifier) buildTelegramMessage(ctx context.Context, as []*types.Alert) (map[string]string, error) { |
||||
var tmplErr error |
||||
defer func() { |
||||
if tmplErr != nil { |
||||
tn.log.Warn("failed to template Telegram message", "error", tmplErr) |
||||
} |
||||
}() |
||||
|
||||
tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) |
||||
// Telegram supports 4096 chars max
|
||||
messageText, truncated := TruncateInRunes(tmpl(tn.settings.Message), telegramMaxMessageLenRunes) |
||||
if truncated { |
||||
key, err := notify.ExtractGroupKey(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
tn.log.Warn("Truncated message", "alert", key, "max_runes", telegramMaxMessageLenRunes) |
||||
} |
||||
|
||||
m := make(map[string]string) |
||||
m["text"] = messageText |
||||
if tn.settings.ParseMode != "" { |
||||
m["parse_mode"] = tn.settings.ParseMode |
||||
} |
||||
if tn.settings.DisableNotifications { |
||||
m["disable_notification"] = "true" |
||||
} |
||||
return m, nil |
||||
} |
||||
|
||||
func (tn *TelegramNotifier) newWebhookSyncCmd(action string, fn func(writer *multipart.Writer) error) (*SendWebhookSettings, error) { |
||||
b := bytes.Buffer{} |
||||
w := multipart.NewWriter(&b) |
||||
|
||||
boundary := GetBoundary() |
||||
if boundary != "" { |
||||
if err := w.SetBoundary(boundary); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
fw, err := w.CreateFormField("chat_id") |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if _, err := fw.Write([]byte(tn.settings.ChatID)); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := fn(w); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := w.Close(); err != nil { |
||||
return nil, fmt.Errorf("failed to close multipart: %w", err) |
||||
} |
||||
|
||||
cmd := &SendWebhookSettings{ |
||||
Url: fmt.Sprintf(TelegramAPIURL, tn.settings.BotToken, action), |
||||
Body: b.String(), |
||||
HttpMethod: "POST", |
||||
HttpHeader: map[string]string{ |
||||
"Content-Type": w.FormDataContentType(), |
||||
}, |
||||
} |
||||
return cmd, nil |
||||
} |
||||
|
||||
func (tn *TelegramNotifier) SendResolved() bool { |
||||
return !tn.GetDisableResolveMessage() |
||||
} |
@ -1,162 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestTelegramNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
images := newFakeImageStoreWithFile(t, 2) |
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg map[string]string |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "A single alert with default template", |
||||
settings: `{ |
||||
"bottoken": "abcdefgh0123456789", |
||||
"chatid": "someid", |
||||
"parse_mode": "markdown", |
||||
"disable_notifications": true |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, |
||||
GeneratorURL: "a URL", |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]string{ |
||||
"parse_mode": "Markdown", |
||||
"text": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
"disable_notification": "true", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Multiple alerts with custom template", |
||||
settings: `{ |
||||
"bottoken": "abcdefgh0123456789", |
||||
"chatid": "someid", |
||||
"message": "__Custom Firing__\n{{len .Alerts.Firing}} Firing\n{{ template \"__text_alert_list\" .Alerts.Firing }}" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, |
||||
GeneratorURL: "a URL", |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]string{ |
||||
"parse_mode": "HTML", |
||||
"text": "__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Truncate long message", |
||||
settings: `{ |
||||
"bottoken": "abcdefgh0123456789", |
||||
"chatid": "someid", |
||||
"message": "{{ .CommonLabels.alertname }}" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": model.LabelValue(strings.Repeat("1", 4097))}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]string{ |
||||
"parse_mode": "HTML", |
||||
"text": strings.Repeat("1", 4096-1) + "…", |
||||
}, |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Error in initing", |
||||
settings: `{}`, |
||||
expInitError: `could not find Bot Token in settings`, |
||||
}, { |
||||
name: "Invalid parse mode", |
||||
settings: `{
|
||||
"bottoken": "abcdefgh0123456789", |
||||
"chatid": "someid", |
||||
"parse_mode": "test" |
||||
}`, |
||||
expInitError: "unknown parse_mode, must be Markdown, MarkdownV2, HTML or None", |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
require.NoError(t, err) |
||||
secureSettings := make(map[string][]byte) |
||||
|
||||
notificationService := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: &NotificationChannelConfig{ |
||||
Name: "telegram_tests", |
||||
Type: "telegram", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
}, |
||||
ImageStore: images, |
||||
NotificationService: notificationService, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
n, err := NewTelegramNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := n.Notify(ctx, c.alerts...) |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
msg, err := n.buildTelegramMessage(ctx, c.alerts) |
||||
if c.expMsgError != nil { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
require.Equal(t, c.expMsg, msg) |
||||
}) |
||||
} |
||||
} |
@ -1,206 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"path" |
||||
"sort" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" |
||||
) |
||||
|
||||
type ExtendedAlert struct { |
||||
Status string `json:"status"` |
||||
Labels template.KV `json:"labels"` |
||||
Annotations template.KV `json:"annotations"` |
||||
StartsAt time.Time `json:"startsAt"` |
||||
EndsAt time.Time `json:"endsAt"` |
||||
GeneratorURL string `json:"generatorURL"` |
||||
Fingerprint string `json:"fingerprint"` |
||||
SilenceURL string `json:"silenceURL"` |
||||
DashboardURL string `json:"dashboardURL"` |
||||
PanelURL string `json:"panelURL"` |
||||
Values map[string]float64 `json:"values"` |
||||
ValueString string `json:"valueString"` // TODO: Remove in Grafana 10
|
||||
ImageURL string `json:"imageURL,omitempty"` |
||||
EmbeddedImage string `json:"embeddedImage,omitempty"` |
||||
} |
||||
|
||||
type ExtendedAlerts []ExtendedAlert |
||||
|
||||
type ExtendedData struct { |
||||
Receiver string `json:"receiver"` |
||||
Status string `json:"status"` |
||||
Alerts ExtendedAlerts `json:"alerts"` |
||||
|
||||
GroupLabels template.KV `json:"groupLabels"` |
||||
CommonLabels template.KV `json:"commonLabels"` |
||||
CommonAnnotations template.KV `json:"commonAnnotations"` |
||||
|
||||
ExternalURL string `json:"externalURL"` |
||||
} |
||||
|
||||
func removePrivateItems(kv template.KV) template.KV { |
||||
for key := range kv { |
||||
if strings.HasPrefix(key, "__") && strings.HasSuffix(key, "__") { |
||||
kv = kv.Remove([]string{key}) |
||||
} |
||||
} |
||||
return kv |
||||
} |
||||
|
||||
func extendAlert(alert template.Alert, externalURL string, logger Logger) *ExtendedAlert { |
||||
// remove "private" annotations & labels so they don't show up in the template
|
||||
extended := &ExtendedAlert{ |
||||
Status: alert.Status, |
||||
Labels: removePrivateItems(alert.Labels), |
||||
Annotations: removePrivateItems(alert.Annotations), |
||||
StartsAt: alert.StartsAt, |
||||
EndsAt: alert.EndsAt, |
||||
GeneratorURL: alert.GeneratorURL, |
||||
Fingerprint: alert.Fingerprint, |
||||
} |
||||
|
||||
// fill in some grafana-specific urls
|
||||
if len(externalURL) == 0 { |
||||
return extended |
||||
} |
||||
u, err := url.Parse(externalURL) |
||||
if err != nil { |
||||
logger.Debug("failed to parse external URL while extending template data", "url", externalURL, "error", err.Error()) |
||||
return extended |
||||
} |
||||
externalPath := u.Path |
||||
dashboardUid := alert.Annotations[ngmodels.DashboardUIDAnnotation] |
||||
if len(dashboardUid) > 0 { |
||||
u.Path = path.Join(externalPath, "/d/", dashboardUid) |
||||
extended.DashboardURL = u.String() |
||||
panelId := alert.Annotations[ngmodels.PanelIDAnnotation] |
||||
if len(panelId) > 0 { |
||||
u.RawQuery = "viewPanel=" + panelId |
||||
extended.PanelURL = u.String() |
||||
} |
||||
|
||||
generatorUrl, err := url.Parse(extended.GeneratorURL) |
||||
if err != nil { |
||||
logger.Debug("failed to parse generator URL while extending template data", "url", extended.GeneratorURL, "err", err.Error()) |
||||
return extended |
||||
} |
||||
|
||||
dashboardUrl, err := url.Parse(extended.DashboardURL) |
||||
if err != nil { |
||||
logger.Debug("failed to parse dashboard URL while extending template data", "url", extended.DashboardURL, "err", err.Error()) |
||||
return extended |
||||
} |
||||
|
||||
orgId := alert.Annotations[ngmodels.OrgIDAnnotation] |
||||
if len(orgId) > 0 { |
||||
extended.DashboardURL = setOrgIdQueryParam(dashboardUrl, orgId) |
||||
extended.PanelURL = setOrgIdQueryParam(u, orgId) |
||||
extended.GeneratorURL = setOrgIdQueryParam(generatorUrl, orgId) |
||||
} |
||||
} |
||||
|
||||
if alert.Annotations != nil { |
||||
if s, ok := alert.Annotations[ngmodels.ValuesAnnotation]; ok { |
||||
if err := json.Unmarshal([]byte(s), &extended.Values); err != nil { |
||||
logger.Warn("failed to unmarshal values annotation", "error", err) |
||||
} |
||||
} |
||||
// TODO: Remove in Grafana 10
|
||||
extended.ValueString = alert.Annotations[ngmodels.ValueStringAnnotation] |
||||
} |
||||
|
||||
matchers := make([]string, 0) |
||||
for key, value := range alert.Labels { |
||||
if !(strings.HasPrefix(key, "__") && strings.HasSuffix(key, "__")) { |
||||
matchers = append(matchers, key+"="+value) |
||||
} |
||||
} |
||||
sort.Strings(matchers) |
||||
u.Path = path.Join(externalPath, "/alerting/silence/new") |
||||
|
||||
query := make(url.Values) |
||||
query.Add("alertmanager", "grafana") |
||||
for _, matcher := range matchers { |
||||
query.Add("matcher", matcher) |
||||
} |
||||
|
||||
u.RawQuery = query.Encode() |
||||
|
||||
extended.SilenceURL = u.String() |
||||
|
||||
return extended |
||||
} |
||||
|
||||
func setOrgIdQueryParam(url *url.URL, orgId string) string { |
||||
q := url.Query() |
||||
q.Set("orgId", orgId) |
||||
url.RawQuery = q.Encode() |
||||
|
||||
return url.String() |
||||
} |
||||
|
||||
func ExtendData(data *template.Data, logger Logger) *ExtendedData { |
||||
alerts := []ExtendedAlert{} |
||||
|
||||
for _, alert := range data.Alerts { |
||||
extendedAlert := extendAlert(alert, data.ExternalURL, logger) |
||||
alerts = append(alerts, *extendedAlert) |
||||
} |
||||
|
||||
extended := &ExtendedData{ |
||||
Receiver: data.Receiver, |
||||
Status: data.Status, |
||||
Alerts: alerts, |
||||
GroupLabels: data.GroupLabels, |
||||
CommonLabels: removePrivateItems(data.CommonLabels), |
||||
CommonAnnotations: removePrivateItems(data.CommonAnnotations), |
||||
|
||||
ExternalURL: data.ExternalURL, |
||||
} |
||||
return extended |
||||
} |
||||
|
||||
func TmplText(ctx context.Context, tmpl *template.Template, alerts []*types.Alert, l Logger, tmplErr *error) (func(string) string, *ExtendedData) { |
||||
promTmplData := notify.GetTemplateData(ctx, tmpl, alerts, l) |
||||
data := ExtendData(promTmplData, l) |
||||
|
||||
return func(name string) (s string) { |
||||
if *tmplErr != nil { |
||||
return |
||||
} |
||||
s, *tmplErr = tmpl.ExecuteTextString(name, data) |
||||
return s |
||||
}, data |
||||
} |
||||
|
||||
// Firing returns the subset of alerts that are firing.
|
||||
func (as ExtendedAlerts) Firing() []ExtendedAlert { |
||||
res := []ExtendedAlert{} |
||||
for _, a := range as { |
||||
if a.Status == string(model.AlertFiring) { |
||||
res = append(res, a) |
||||
} |
||||
} |
||||
return res |
||||
} |
||||
|
||||
// Resolved returns the subset of alerts that are resolved.
|
||||
func (as ExtendedAlerts) Resolved() []ExtendedAlert { |
||||
res := []ExtendedAlert{} |
||||
for _, a := range as { |
||||
if a.Status == string(model.AlertResolved) { |
||||
res = append(res, a) |
||||
} |
||||
} |
||||
return res |
||||
} |
@ -1,166 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"net/url" |
||||
"path" |
||||
"strings" |
||||
|
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
) |
||||
|
||||
var ( |
||||
ThreemaGwBaseURL = "https://msgapi.threema.ch/send_simple" |
||||
) |
||||
|
||||
// ThreemaNotifier is responsible for sending
|
||||
// alert notifications to Threema.
|
||||
type ThreemaNotifier struct { |
||||
*Base |
||||
log Logger |
||||
images ImageStore |
||||
ns 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 |
||||
} |
||||
|
||||
func ThreemaFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
notifier, err := NewThreemaNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return notifier, nil |
||||
} |
||||
|
||||
func NewThreemaNotifier(fc FactoryConfig) (*ThreemaNotifier, error) { |
||||
settings, err := buildThreemaSettings(fc) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &ThreemaNotifier{ |
||||
Base: NewBase(fc.Config), |
||||
log: fc.Logger, |
||||
images: fc.ImageStore, |
||||
ns: fc.NotificationService, |
||||
tmpl: fc.Template, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// Notify send an alert notification to Threema
|
||||
func (tn *ThreemaNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
tn.log.Debug("sending threema alert notification", "from", tn.settings.GatewayID, "to", tn.settings.RecipientID) |
||||
|
||||
// Set up basic API request data
|
||||
data := url.Values{} |
||||
data.Set("from", tn.settings.GatewayID) |
||||
data.Set("to", tn.settings.RecipientID) |
||||
data.Set("secret", tn.settings.APISecret) |
||||
data.Set("text", tn.buildMessage(ctx, as...)) |
||||
|
||||
cmd := &SendWebhookSettings{ |
||||
Url: ThreemaGwBaseURL, |
||||
Body: data.Encode(), |
||||
HttpMethod: "POST", |
||||
HttpHeader: map[string]string{ |
||||
"Content-Type": "application/x-www-form-urlencoded", |
||||
}, |
||||
} |
||||
if err := tn.ns.SendWebhook(ctx, cmd); err != nil { |
||||
tn.log.Error("Failed to send threema notification", "error", err, "webhook", tn.Name) |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (tn *ThreemaNotifier) SendResolved() bool { |
||||
return !tn.GetDisableResolveMessage() |
||||
} |
||||
|
||||
func (tn *ThreemaNotifier) buildMessage(ctx context.Context, as ...*types.Alert) string { |
||||
var tmplErr error |
||||
tmpl, _ := TmplText(ctx, tn.tmpl, as, tn.log, &tmplErr) |
||||
|
||||
message := fmt.Sprintf("%s%s\n\n*Message:*\n%s\n*URL:* %s\n", |
||||
selectEmoji(as...), |
||||
tmpl(tn.settings.Title), |
||||
tmpl(tn.settings.Description), |
||||
path.Join(tn.tmpl.ExternalURL.String(), "/alerting/list"), |
||||
) |
||||
|
||||
if tmplErr != nil { |
||||
tn.log.Warn("failed to template Threema message", "error", tmplErr.Error()) |
||||
} |
||||
|
||||
_ = withStoredImages(ctx, tn.log, tn.images, |
||||
func(_ int, image Image) error { |
||||
if image.URL != "" { |
||||
message += fmt.Sprintf("*Image:* %s\n", image.URL) |
||||
} |
||||
return nil |
||||
}, as...) |
||||
|
||||
return message |
||||
} |
||||
|
||||
func selectEmoji(as ...*types.Alert) string { |
||||
if types.Alerts(as...).Status() == model.AlertResolved { |
||||
return "\u2705 " // Check Mark Button
|
||||
} |
||||
return "\u26A0\uFE0F " // Warning sign
|
||||
} |
@ -1,161 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestThreemaNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
images := newFakeImageStore(2) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg string |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "A single alert with an image", |
||||
settings: `{ |
||||
"gateway_id": "*1234567", |
||||
"recipient_id": "87654321", |
||||
"api_secret": "supersecret" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A1%5D++%28val1%29%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0ADashboard%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%0APanel%3A+http%3A%2F%2Flocalhost%2Fd%2Fabcd%3FviewPanel%3Defgh%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A%2AImage%3A%2A+https%3A%2F%2Fwww.example.com%2Ftest-image-1.jpg%0A&to=87654321", |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Multiple alerts with images", |
||||
settings: `{ |
||||
"gateway_id": "*1234567", |
||||
"recipient_id": "87654321", |
||||
"api_secret": "supersecret" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+%5BFIRING%3A2%5D++%0A%0A%2AMessage%3A%2A%0A%2A%2AFiring%2A%2A%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val1%0AAnnotations%3A%0A+-+ann1+%3D+annv1%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval1%0A%0AValue%3A+%5Bno+value%5D%0ALabels%3A%0A+-+alertname+%3D+alert1%0A+-+lbl1+%3D+val2%0AAnnotations%3A%0A+-+ann1+%3D+annv2%0ASilence%3A+http%3A%2F%2Flocalhost%2Falerting%2Fsilence%2Fnew%3Falertmanager%3Dgrafana%26matcher%3Dalertname%253Dalert1%26matcher%3Dlbl1%253Dval2%0A%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A%2AImage%3A%2A+https%3A%2F%2Fwww.example.com%2Ftest-image-1.jpg%0A%2AImage%3A%2A+https%3A%2F%2Fwww.example.com%2Ftest-image-2.jpg%0A&to=87654321", |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "A single alert with an image and custom title and description", |
||||
settings: `{ |
||||
"gateway_id": "*1234567", |
||||
"recipient_id": "87654321", |
||||
"api_secret": "supersecret", |
||||
"title": "customTitle {{ .Alerts.Firing | len }}", |
||||
"description": "customDescription" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: "from=%2A1234567&secret=supersecret&text=%E2%9A%A0%EF%B8%8F+customTitle+1%0A%0A%2AMessage%3A%2A%0AcustomDescription%0A%2AURL%3A%2A+http%3A%2Flocalhost%2Falerting%2Flist%0A%2AImage%3A%2A+https%3A%2F%2Fwww.example.com%2Ftest-image-1.jpg%0A&to=87654321", |
||||
expMsgError: nil, |
||||
}, { |
||||
name: "Invalid gateway id", |
||||
settings: `{ |
||||
"gateway_id": "12345678", |
||||
"recipient_id": "87654321", |
||||
"api_secret": "supersecret" |
||||
}`, |
||||
expInitError: `invalid Threema Gateway ID: Must start with a *`, |
||||
}, { |
||||
name: "Invalid receipent id", |
||||
settings: `{ |
||||
"gateway_id": "*1234567", |
||||
"recipient_id": "8765432", |
||||
"api_secret": "supersecret" |
||||
}`, |
||||
expInitError: `invalid Threema Recipient ID: Must be 8 characters long`, |
||||
}, { |
||||
name: "No API secret", |
||||
settings: `{ |
||||
"gateway_id": "*1234567", |
||||
"recipient_id": "87654321" |
||||
}`, |
||||
expInitError: `could not find Threema API secret in settings`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
secureSettings := make(map[string][]byte) |
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: &NotificationChannelConfig{ |
||||
Name: "threema_testing", |
||||
Type: "threema", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
}, |
||||
NotificationService: webhookSender, |
||||
ImageStore: images, |
||||
Template: tmpl, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
pn, err := NewThreemaNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := pn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.False(t, ok) |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
require.Equal(t, c.expMsg, webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,163 +0,0 @@ |
||||
package channels |
||||
|
||||
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" |
||||
|
||||
// WebexNotifier is responsible for sending alert notifications as webex messages.
|
||||
type WebexNotifier struct { |
||||
*Base |
||||
ns WebhookSender |
||||
log Logger |
||||
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 |
||||
} |
||||
|
||||
func WebexFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
notifier, err := buildWebexNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return notifier, nil |
||||
} |
||||
|
||||
// buildWebexSettings is the constructor for the Webex notifier.
|
||||
func buildWebexNotifier(factoryConfig FactoryConfig) (*WebexNotifier, error) { |
||||
settings, err := buildWebexSettings(factoryConfig) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &WebexNotifier{ |
||||
Base: NewBase(factoryConfig.Config), |
||||
orgID: factoryConfig.Config.OrgID, |
||||
log: factoryConfig.Logger, |
||||
ns: factoryConfig.NotificationService, |
||||
images: factoryConfig.ImageStore, |
||||
tmpl: factoryConfig.Template, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// WebexMessage defines the JSON object to send to Webex endpoints.
|
||||
type WebexMessage struct { |
||||
RoomID string `json:"roomId,omitempty"` |
||||
Message string `json:"markdown"` |
||||
Files []string `json:"files,omitempty"` |
||||
} |
||||
|
||||
// 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) |
||||
|
||||
message, truncated := TruncateInBytes(tmpl(wn.settings.Message), 4096) |
||||
if truncated { |
||||
wn.log.Warn("Webex message too long, truncating message", "OriginalMessage", wn.settings.Message) |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
wn.log.Warn("Failed to template webex message", "Error", tmplErr.Error()) |
||||
tmplErr = nil |
||||
} |
||||
|
||||
msg := &WebexMessage{ |
||||
RoomID: wn.settings.RoomID, |
||||
Message: message, |
||||
Files: []string{}, |
||||
} |
||||
|
||||
// Augment our Alert data with ImageURLs if available.
|
||||
_ = withStoredImages(ctx, wn.log, wn.images, func(index int, image 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 nil |
||||
}, as...) |
||||
|
||||
body, err := json.Marshal(msg) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
parsedURL := tmpl(wn.settings.APIURL) |
||||
if tmplErr != nil { |
||||
return false, tmplErr |
||||
} |
||||
|
||||
cmd := &SendWebhookSettings{ |
||||
Url: parsedURL, |
||||
Body: string(body), |
||||
HttpMethod: http.MethodPost, |
||||
} |
||||
|
||||
if wn.settings.Token != "" { |
||||
headers := make(map[string]string) |
||||
headers["Authorization"] = fmt.Sprintf("Bearer %s", wn.settings.Token) |
||||
cmd.HttpHeader = headers |
||||
} |
||||
|
||||
if err := wn.ns.SendWebhook(ctx, cmd); err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
func (wn *WebexNotifier) SendResolved() bool { |
||||
return !wn.GetDisableResolveMessage() |
||||
} |
@ -1,145 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"net/url" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestWebexNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
images := newFakeImageStoreWithFile(t, 2) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expHeaders map[string]string |
||||
expMsg string |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "A single alert with default template", |
||||
settings: `{ |
||||
"bot_token": "abcdefgh0123456789", |
||||
"room_id": "someid" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh", "__alertImageToken__": "test-image-1"}, |
||||
GeneratorURL: "a URL", |
||||
}, |
||||
}, |
||||
}, |
||||
expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, |
||||
expMsg: `{"roomId":"someid","markdown":"**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n","files":["https://www.example.com/test-image-1"]}`, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Multiple alerts with custom template", |
||||
settings: `{ |
||||
"bot_token": "abcdefgh0123456789", |
||||
"room_id": "someid", |
||||
"message": "__Custom Firing__\n{{len .Alerts.Firing}} Firing\n{{ template \"__text_alert_list\" .Alerts.Firing }}" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__alertImageToken__": "test-image-1"}, |
||||
GeneratorURL: "a URL", |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2", "__alertImageToken__": "test-image-2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, |
||||
expMsg: `{"roomId":"someid","markdown":"__Custom Firing__\n2 Firing\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSource: a URL\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana\u0026matcher=alertname%3Dalert1\u0026matcher=lbl1%3Dval2\n","files":["https://www.example.com/test-image-1"]}`, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Truncate long message", |
||||
settings: `{ |
||||
"bot_token": "abcdefgh0123456789", |
||||
"room_id": "someid", |
||||
"message": "{{ .CommonLabels.alertname }}" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": model.LabelValue(strings.Repeat("1", 4097))}, |
||||
}, |
||||
}, |
||||
}, |
||||
expHeaders: map[string]string{"Authorization": "Bearer abcdefgh0123456789"}, |
||||
expMsg: fmt.Sprintf(`{"roomId":"someid","markdown":"%s…"}`, strings.Repeat("1", 4093)), |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Error in initing", |
||||
settings: `{ "api_url": "ostgres://user:abc{DEf1=ghi@example.com:5432/db?sslmode=require" }`, |
||||
expInitError: `invalid URL "ostgres://user:abc{DEf1=ghi@example.com:5432/db?sslmode=require"`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
secureSettings := make(map[string][]byte) |
||||
|
||||
notificationService := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: &NotificationChannelConfig{ |
||||
Name: "webex_tests", |
||||
Type: "webex", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
}, |
||||
ImageStore: images, |
||||
NotificationService: notificationService, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
n, err := buildWebexNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ok, err := n.Notify(ctx, c.alerts...) |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
require.NoError(t, err) |
||||
require.Equal(t, c.expHeaders, notificationService.Webhook.HttpHeader) |
||||
require.JSONEq(t, c.expMsg, notificationService.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
@ -1,224 +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" |
||||
|
||||
"github.com/grafana/grafana/pkg/models" |
||||
) |
||||
|
||||
// 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_scheme", 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(models.AlertStateAlerting) |
||||
} else { |
||||
msg.State = string(models.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() |
||||
} |
@ -1,392 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/template" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestWebhookNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
orgID := int64(1) |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
|
||||
expMsg *WebhookMessage |
||||
expUrl string |
||||
expUsername string |
||||
expPassword string |
||||
expHeaders map[string]string |
||||
expHttpMethod string |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Default config with one alert with custom message", |
||||
settings: `{"url": "http://localhost/test", "message": "Custom message"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expUrl: "http://localhost/test", |
||||
expHttpMethod: "POST", |
||||
expMsg: &WebhookMessage{ |
||||
ExtendedData: &ExtendedData{ |
||||
Receiver: "my_receiver", |
||||
Status: "firing", |
||||
Alerts: ExtendedAlerts{ |
||||
{ |
||||
Status: "firing", |
||||
Labels: template.KV{ |
||||
"alertname": "alert1", |
||||
"lbl1": "val1", |
||||
}, |
||||
Annotations: template.KV{ |
||||
"ann1": "annv1", |
||||
}, |
||||
Fingerprint: "fac0861a85de433a", |
||||
DashboardURL: "http://localhost/d/abcd", |
||||
PanelURL: "http://localhost/d/abcd?viewPanel=efgh", |
||||
SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", |
||||
}, |
||||
}, |
||||
GroupLabels: template.KV{ |
||||
"alertname": "", |
||||
}, |
||||
CommonLabels: template.KV{ |
||||
"alertname": "alert1", |
||||
"lbl1": "val1", |
||||
}, |
||||
CommonAnnotations: template.KV{ |
||||
"ann1": "annv1", |
||||
}, |
||||
ExternalURL: "http://localhost", |
||||
}, |
||||
Version: "1", |
||||
GroupKey: "alertname", |
||||
Title: "[FIRING:1] (val1)", |
||||
State: "alerting", |
||||
Message: "Custom message", |
||||
OrgID: orgID, |
||||
}, |
||||
expMsgError: nil, |
||||
expHeaders: map[string]string{}, |
||||
}, |
||||
{ |
||||
name: "Custom config with multiple alerts with custom title", |
||||
settings: `{ |
||||
"url": "http://localhost/test1", |
||||
"title": "Alerts firing: {{ len .Alerts.Firing }}", |
||||
"username": "user1", |
||||
"password": "mysecret", |
||||
"httpMethod": "PUT", |
||||
"maxAlerts": "2" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val3"}, |
||||
Annotations: model.LabelSet{"ann1": "annv3"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expUrl: "http://localhost/test1", |
||||
expHttpMethod: "PUT", |
||||
expUsername: "user1", |
||||
expPassword: "mysecret", |
||||
expMsg: &WebhookMessage{ |
||||
ExtendedData: &ExtendedData{ |
||||
Receiver: "my_receiver", |
||||
Status: "firing", |
||||
Alerts: ExtendedAlerts{ |
||||
{ |
||||
Status: "firing", |
||||
Labels: template.KV{ |
||||
"alertname": "alert1", |
||||
"lbl1": "val1", |
||||
}, |
||||
Annotations: template.KV{ |
||||
"ann1": "annv1", |
||||
}, |
||||
Fingerprint: "fac0861a85de433a", |
||||
SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", |
||||
}, { |
||||
Status: "firing", |
||||
Labels: template.KV{ |
||||
"alertname": "alert1", |
||||
"lbl1": "val2", |
||||
}, |
||||
Annotations: template.KV{ |
||||
"ann1": "annv2", |
||||
}, |
||||
Fingerprint: "fab6861a85d5eeb5", |
||||
SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2", |
||||
}, |
||||
}, |
||||
GroupLabels: template.KV{ |
||||
"alertname": "", |
||||
}, |
||||
CommonLabels: template.KV{ |
||||
"alertname": "alert1", |
||||
}, |
||||
CommonAnnotations: template.KV{}, |
||||
ExternalURL: "http://localhost", |
||||
}, |
||||
Version: "1", |
||||
GroupKey: "alertname", |
||||
TruncatedAlerts: 1, |
||||
Title: "Alerts firing: 2", |
||||
State: "alerting", |
||||
Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", |
||||
OrgID: orgID, |
||||
}, |
||||
expMsgError: nil, |
||||
expHeaders: map[string]string{}, |
||||
}, |
||||
{ |
||||
name: "Default config, template variables in URL", |
||||
settings: `{"url": "http://localhost/test?numAlerts={{len .Alerts}}&status={{.Status}}"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expUrl: "http://localhost/test?numAlerts=2&status=firing", |
||||
expHttpMethod: "POST", |
||||
expMsg: &WebhookMessage{ |
||||
ExtendedData: &ExtendedData{ |
||||
Receiver: "my_receiver", |
||||
Status: "firing", |
||||
Alerts: ExtendedAlerts{ |
||||
{ |
||||
Status: "firing", |
||||
Labels: template.KV{ |
||||
"alertname": "alert1", |
||||
"lbl1": "val1", |
||||
}, |
||||
Annotations: template.KV{ |
||||
"ann1": "annv1", |
||||
}, |
||||
Fingerprint: "fac0861a85de433a", |
||||
SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", |
||||
}, { |
||||
Status: "firing", |
||||
Labels: template.KV{ |
||||
"alertname": "alert1", |
||||
"lbl1": "val2", |
||||
}, |
||||
Annotations: template.KV{ |
||||
"ann1": "annv2", |
||||
}, |
||||
Fingerprint: "fab6861a85d5eeb5", |
||||
SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2", |
||||
}, |
||||
}, |
||||
GroupLabels: template.KV{ |
||||
"alertname": "", |
||||
}, |
||||
CommonLabels: template.KV{ |
||||
"alertname": "alert1", |
||||
}, |
||||
CommonAnnotations: template.KV{}, |
||||
ExternalURL: "http://localhost", |
||||
}, |
||||
Version: "1", |
||||
GroupKey: "alertname", |
||||
TruncatedAlerts: 0, |
||||
Title: "[FIRING:2] ", |
||||
State: "alerting", |
||||
Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val2\nAnnotations:\n - ann1 = annv2\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval2\n", |
||||
OrgID: orgID, |
||||
}, |
||||
expMsgError: nil, |
||||
expHeaders: map[string]string{}, |
||||
}, |
||||
{ |
||||
name: "with Authorization set", |
||||
settings: `{ |
||||
"url": "http://localhost/test1", |
||||
"authorization_credentials": "mysecret", |
||||
"httpMethod": "POST", |
||||
"maxAlerts": 2 |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: &WebhookMessage{ |
||||
ExtendedData: &ExtendedData{ |
||||
Receiver: "my_receiver", |
||||
Status: "firing", |
||||
Alerts: ExtendedAlerts{ |
||||
{ |
||||
Status: "firing", |
||||
Labels: template.KV{ |
||||
"alertname": "alert1", |
||||
"lbl1": "val1", |
||||
}, |
||||
Annotations: template.KV{ |
||||
"ann1": "annv1", |
||||
}, |
||||
Fingerprint: "fac0861a85de433a", |
||||
DashboardURL: "http://localhost/d/abcd", |
||||
PanelURL: "http://localhost/d/abcd?viewPanel=efgh", |
||||
SilenceURL: "http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1", |
||||
}, |
||||
}, |
||||
GroupLabels: template.KV{ |
||||
"alertname": "", |
||||
}, |
||||
CommonLabels: template.KV{ |
||||
"alertname": "alert1", |
||||
"lbl1": "val1", |
||||
}, |
||||
CommonAnnotations: template.KV{ |
||||
"ann1": "annv1", |
||||
}, |
||||
ExternalURL: "http://localhost", |
||||
}, |
||||
Version: "1", |
||||
GroupKey: "alertname", |
||||
Title: "[FIRING:1] (val1)", |
||||
State: "alerting", |
||||
Message: "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", |
||||
OrgID: orgID, |
||||
}, |
||||
expUrl: "http://localhost/test1", |
||||
expHttpMethod: "POST", |
||||
expHeaders: map[string]string{"Authorization": "Bearer mysecret"}, |
||||
}, |
||||
{ |
||||
name: "bad template in url", |
||||
settings: `{"url": "http://localhost/test1?numAlerts={{len Alerts}}"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsgError: fmt.Errorf("template: :1: function \"Alerts\" not defined"), |
||||
}, |
||||
{ |
||||
name: "with both HTTP basic auth and Authorization Header set", |
||||
settings: `{ |
||||
"url": "http://localhost/test1", |
||||
"username": "user1", |
||||
"password": "mysecret", |
||||
"authorization_credentials": "mysecret", |
||||
"httpMethod": "POST", |
||||
"maxAlerts": "2" |
||||
}`, |
||||
expInitError: "both HTTP Basic Authentication and Authorization Header are set, only 1 is permitted", |
||||
}, |
||||
{ |
||||
name: "Error in initing", |
||||
settings: `{}`, |
||||
expInitError: `required field 'url' is not specified`, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
secureSettings := make(map[string][]byte) |
||||
|
||||
m := &NotificationChannelConfig{ |
||||
OrgID: orgID, |
||||
Name: "webhook_testing", |
||||
Type: "webhook", |
||||
Settings: settingsJSON, |
||||
SecureSettings: secureSettings, |
||||
} |
||||
|
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: m, |
||||
NotificationService: webhookSender, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
ImageStore: &UnavailableImageStore{}, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
pn, err := buildWebhookNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
ctx = notify.WithReceiverName(ctx, "my_receiver") |
||||
ok, err := pn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.False(t, ok) |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
expBody, err := json.Marshal(c.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) |
||||
require.Equal(t, c.expUrl, webhookSender.Webhook.Url) |
||||
require.Equal(t, c.expUsername, webhookSender.Webhook.User) |
||||
require.Equal(t, c.expPassword, webhookSender.Webhook.Password) |
||||
require.Equal(t, c.expHttpMethod, webhookSender.Webhook.HttpMethod) |
||||
require.Equal(t, c.expHeaders, webhookSender.Webhook.HttpHeader) |
||||
}) |
||||
} |
||||
} |
@ -1,252 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
"time" |
||||
|
||||
"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 |
||||
} |
||||
|
||||
func WeComFactory(fc FactoryConfig) (NotificationChannel, error) { |
||||
ch, err := buildWecomNotifier(fc) |
||||
if err != nil { |
||||
return nil, receiverInitError{ |
||||
Reason: err.Error(), |
||||
Cfg: *fc.Config, |
||||
} |
||||
} |
||||
return ch, nil |
||||
} |
||||
|
||||
func buildWecomNotifier(factoryConfig FactoryConfig) (*WeComNotifier, error) { |
||||
settings, err := buildWecomSettings(factoryConfig) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
return &WeComNotifier{ |
||||
Base: NewBase(factoryConfig.Config), |
||||
tmpl: factoryConfig.Template, |
||||
log: factoryConfig.Logger, |
||||
ns: factoryConfig.NotificationService, |
||||
settings: settings, |
||||
}, nil |
||||
} |
||||
|
||||
// WeComNotifier is responsible for sending alert notifications to WeCom.
|
||||
type WeComNotifier struct { |
||||
*Base |
||||
tmpl *template.Template |
||||
log Logger |
||||
ns WebhookSender |
||||
settings wecomSettings |
||||
tok *WeComAccessToken |
||||
tokExpireAt time.Time |
||||
group singleflight.Group |
||||
} |
||||
|
||||
// Notify send an alert notification to WeCom.
|
||||
func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { |
||||
w.log.Info("executing WeCom notification", "notification", w.Name) |
||||
|
||||
var tmplErr error |
||||
tmpl, _ := TmplText(ctx, w.tmpl, as, w.log, &tmplErr) |
||||
|
||||
bodyMsg := map[string]interface{}{ |
||||
"msgtype": w.settings.MsgType, |
||||
} |
||||
content := fmt.Sprintf("# %s\n%s\n", |
||||
tmpl(w.settings.Title), |
||||
tmpl(w.settings.Message), |
||||
) |
||||
if w.settings.MsgType != defaultWeComMsgType { |
||||
content = fmt.Sprintf("%s\n%s\n", |
||||
tmpl(w.settings.Title), |
||||
tmpl(w.settings.Message), |
||||
) |
||||
} |
||||
|
||||
msgType := string(w.settings.MsgType) |
||||
bodyMsg[msgType] = map[string]interface{}{ |
||||
"content": content, |
||||
} |
||||
|
||||
url := w.settings.URL |
||||
if w.settings.channel != defaultWeComChannelType { |
||||
bodyMsg["agentid"] = w.settings.AgentID |
||||
bodyMsg["touser"] = w.settings.ToUser |
||||
token, err := w.GetAccessToken(ctx) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
url = fmt.Sprintf(w.settings.EndpointURL+"/cgi-bin/message/send?access_token=%s", token) |
||||
} |
||||
|
||||
body, err := json.Marshal(bodyMsg) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
if tmplErr != nil { |
||||
w.log.Warn("failed to template WeCom message", "error", tmplErr.Error()) |
||||
} |
||||
|
||||
cmd := &SendWebhookSettings{ |
||||
Url: url, |
||||
Body: string(body), |
||||
} |
||||
|
||||
if err = w.ns.SendWebhook(ctx, cmd); err != nil { |
||||
w.log.Error("failed to send WeCom webhook", "error", err, "notification", w.Name) |
||||
return false, err |
||||
} |
||||
|
||||
return true, nil |
||||
} |
||||
|
||||
// GetAccessToken returns the access token for apiapp
|
||||
func (w *WeComNotifier) GetAccessToken(ctx context.Context) (string, error) { |
||||
t := w.tok |
||||
if w.tokExpireAt.Before(time.Now()) || w.tok == nil { |
||||
// avoid multiple calls when there are multiple alarms
|
||||
tok, err, _ := w.group.Do("GetAccessToken", func() (interface{}, error) { |
||||
return w.getAccessToken(ctx) |
||||
}) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
t = tok.(*WeComAccessToken) |
||||
// expire five minutes in advance to avoid using it when it is about to expire
|
||||
w.tokExpireAt = time.Now().Add(time.Second * time.Duration(t.ExpireIn-300)) |
||||
w.tok = t |
||||
} |
||||
return t.AccessToken, nil |
||||
} |
||||
|
||||
type WeComAccessToken struct { |
||||
AccessToken string `json:"access_token"` |
||||
ErrMsg string `json:"errmsg"` |
||||
ErrCode int `json:"errcode"` |
||||
ExpireIn int `json:"expire_in"` |
||||
} |
||||
|
||||
func (w *WeComNotifier) getAccessToken(ctx context.Context) (*WeComAccessToken, error) { |
||||
geTokenURL := fmt.Sprintf(w.settings.EndpointURL+"/cgi-bin/gettoken?corpid=%s&corpsecret=%s", w.settings.CorpID, w.settings.Secret) |
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, geTokenURL, nil) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
request.Header.Add("Content-Type", "application/json") |
||||
request.Header.Add("User-Agent", "Grafana") |
||||
|
||||
resp, err := http.DefaultClient.Do(request) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if resp.StatusCode/100 != 2 { |
||||
return nil, fmt.Errorf("WeCom returned statuscode invalid status code: %v", resp.Status) |
||||
} |
||||
defer func() { |
||||
_ = resp.Body.Close() |
||||
}() |
||||
|
||||
var accessToken WeComAccessToken |
||||
err = json.NewDecoder(resp.Body).Decode(&accessToken) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if accessToken.ErrCode != 0 { |
||||
return nil, fmt.Errorf("WeCom returned errmsg: %s", accessToken.ErrMsg) |
||||
} |
||||
return &accessToken, nil |
||||
} |
||||
|
||||
func (w *WeComNotifier) SendResolved() bool { |
||||
return !w.GetDisableResolveMessage() |
||||
} |
@ -1,552 +0,0 @@ |
||||
package channels |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"net/url" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/prometheus/alertmanager/notify" |
||||
"github.com/prometheus/alertmanager/types" |
||||
"github.com/prometheus/common/model" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestWeComNotifier(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
cases := []struct { |
||||
name string |
||||
settings string |
||||
alerts []*types.Alert |
||||
expMsg map[string]interface{} |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "Default config with one alert", |
||||
settings: `{"url": "http://localhost"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"markdown": map[string]interface{}{ |
||||
"content": "# [FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", |
||||
}, |
||||
"msgtype": "markdown", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Custom config with multiple alerts", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"markdown": map[string]interface{}{ |
||||
"content": "# [FIRING:2] \n2 alerts are firing, 0 are resolved\n", |
||||
}, |
||||
"msgtype": "markdown", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Custom title and message with multiple alerts", |
||||
settings: `{ |
||||
"url": "http://localhost", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved", |
||||
"title": "This notification is {{ .Status }}!" |
||||
}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, { |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"markdown": map[string]interface{}{ |
||||
"content": "# This notification is firing!\n2 alerts are firing, 0 are resolved\n", |
||||
}, |
||||
"msgtype": "markdown", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Error in initing", |
||||
settings: `{}`, |
||||
expInitError: `either url or secret is required`, |
||||
}, |
||||
{ |
||||
name: "Use default if optional fields are explicitly empty", |
||||
settings: `{"url": "http://localhost", "message": "", "title": ""}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"markdown": map[string]interface{}{ |
||||
"content": "# [FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", |
||||
}, |
||||
"msgtype": "markdown", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Use text are explicitly empty", |
||||
settings: `{"url": "http://localhost", "message": "", "title": "", "msgtype": "text"}`, |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"text": map[string]interface{}{ |
||||
"content": "[FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", |
||||
}, |
||||
"msgtype": "text", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
t.Run(c.name, func(t *testing.T) { |
||||
settingsJSON := json.RawMessage(c.settings) |
||||
|
||||
m := &NotificationChannelConfig{ |
||||
Name: "wecom_testing", |
||||
Type: "wecom", |
||||
Settings: settingsJSON, |
||||
} |
||||
|
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: m, |
||||
NotificationService: webhookSender, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
ImageStore: nil, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
pn, err := buildWecomNotifier(fc) |
||||
if c.expInitError != "" { |
||||
require.Equal(t, c.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
|
||||
ok, err := pn.Notify(ctx, c.alerts...) |
||||
if c.expMsgError != nil { |
||||
require.False(t, ok) |
||||
require.Error(t, err) |
||||
require.Equal(t, c.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
expBody, err := json.Marshal(c.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// TestWeComNotifierAPIAPP Testing API Channels
|
||||
func TestWeComNotifierAPIAPP(t *testing.T) { |
||||
tmpl := templateForTests(t) |
||||
|
||||
externalURL, err := url.Parse("http://localhost") |
||||
require.NoError(t, err) |
||||
tmpl.ExternalURL = externalURL |
||||
|
||||
tests := []struct { |
||||
name string |
||||
settings string |
||||
statusCode int |
||||
accessToken string |
||||
alerts []*types.Alert |
||||
expMsg map[string]interface{} |
||||
expInitError string |
||||
expMsgError error |
||||
}{ |
||||
{ |
||||
name: "not AgentID", |
||||
settings: `{"secret": "secret"}`, |
||||
accessToken: "access_token", |
||||
expInitError: "could not find AgentID in settings", |
||||
}, |
||||
{ |
||||
name: "not CorpID", |
||||
settings: `{"secret": "secret", "agent_id": "agent_id"}`, |
||||
accessToken: "access_token", |
||||
expInitError: "could not find CorpID in settings", |
||||
}, |
||||
{ |
||||
name: "Default APIAPP config with one alert", |
||||
settings: `{"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id"}`, |
||||
accessToken: "access_token", |
||||
expInitError: "", |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"markdown": map[string]interface{}{ |
||||
"content": "# [FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n", |
||||
}, |
||||
"msgtype": "markdown", |
||||
"agentid": "agent_id", |
||||
"touser": "@all", |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Custom message(markdown) with multiple alert", |
||||
settings: `{ |
||||
"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"} |
||||
`, |
||||
accessToken: "access_token", |
||||
expInitError: "", |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"markdown": map[string]interface{}{ |
||||
"content": "# [FIRING:2] \n2 alerts are firing, 0 are resolved\n", |
||||
}, |
||||
"msgtype": "markdown", |
||||
"agentid": "agent_id", |
||||
"touser": "@all", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
{ |
||||
name: "Custom message(Text) with multiple alert", |
||||
settings: `{ |
||||
"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id", |
||||
"msgtype": "text", |
||||
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"} |
||||
`, |
||||
accessToken: "access_token", |
||||
expInitError: "", |
||||
alerts: []*types.Alert{ |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, |
||||
Annotations: model.LabelSet{"ann1": "annv1"}, |
||||
}, |
||||
}, |
||||
{ |
||||
Alert: model.Alert{ |
||||
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, |
||||
Annotations: model.LabelSet{"ann1": "annv2"}, |
||||
}, |
||||
}, |
||||
}, |
||||
expMsg: map[string]interface{}{ |
||||
"text": map[string]interface{}{ |
||||
"content": "[FIRING:2] \n2 alerts are firing, 0 are resolved\n", |
||||
}, |
||||
"msgtype": "text", |
||||
"agentid": "agent_id", |
||||
"touser": "@all", |
||||
}, |
||||
expMsgError: nil, |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
accessToken := r.URL.Query().Get("access_token") |
||||
if accessToken != tt.accessToken { |
||||
t.Errorf("Expected access_token=%s got %s", tt.accessToken, accessToken) |
||||
return |
||||
} |
||||
|
||||
expBody, err := json.Marshal(tt.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
b, err := io.ReadAll(r.Body) |
||||
require.NoError(t, err) |
||||
require.JSONEq(t, string(expBody), string(b)) |
||||
})) |
||||
defer server.Close() |
||||
|
||||
m := &NotificationChannelConfig{ |
||||
Name: "wecom_testing", |
||||
Type: "wecom", |
||||
Settings: json.RawMessage(tt.settings), |
||||
} |
||||
|
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := FactoryConfig{ |
||||
Config: m, |
||||
NotificationService: webhookSender, |
||||
DecryptFunc: func(ctx context.Context, sjd map[string][]byte, key string, fallback string) string { |
||||
return fallback |
||||
}, |
||||
ImageStore: nil, |
||||
Template: tmpl, |
||||
Logger: &FakeLogger{}, |
||||
} |
||||
|
||||
pn, err := buildWecomNotifier(fc) |
||||
if tt.expInitError != "" { |
||||
require.Equal(t, tt.expInitError, err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
|
||||
ctx := notify.WithGroupKey(context.Background(), "alertname") |
||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) |
||||
|
||||
// Avoid calling GetAccessToken interfaces
|
||||
pn.tokExpireAt = time.Now().Add(10 * time.Second) |
||||
pn.tok = &WeComAccessToken{AccessToken: tt.accessToken} |
||||
|
||||
ok, err := pn.Notify(ctx, tt.alerts...) |
||||
if tt.expMsgError != nil { |
||||
require.False(t, ok) |
||||
require.Error(t, err) |
||||
require.Equal(t, tt.expMsgError.Error(), err.Error()) |
||||
return |
||||
} |
||||
require.NoError(t, err) |
||||
require.True(t, ok) |
||||
|
||||
expBody, err := json.Marshal(tt.expMsg) |
||||
require.NoError(t, err) |
||||
|
||||
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestWeComNotifier_GetAccessToken(t *testing.T) { |
||||
type fields struct { |
||||
tok *WeComAccessToken |
||||
tokExpireAt time.Time |
||||
corpid string |
||||
secret string |
||||
} |
||||
tests := []struct { |
||||
name string |
||||
fields fields |
||||
want string |
||||
wantErr assert.ErrorAssertionFunc |
||||
}{ |
||||
{ |
||||
name: "no corpid", |
||||
fields: fields{ |
||||
tok: nil, |
||||
tokExpireAt: time.Now().Add(-time.Minute), |
||||
}, |
||||
want: "", |
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { |
||||
return assert.Error(t, err, i...) |
||||
}, |
||||
}, |
||||
{ |
||||
name: "no corpsecret", |
||||
fields: fields{ |
||||
tok: nil, |
||||
tokExpireAt: time.Now().Add(-time.Minute), |
||||
}, |
||||
want: "", |
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { |
||||
return assert.Error(t, err, i...) |
||||
}, |
||||
}, |
||||
{ |
||||
name: "get access token", |
||||
fields: fields{ |
||||
corpid: "corpid", |
||||
secret: "secret", |
||||
}, |
||||
want: "access_token", |
||||
wantErr: assert.NoError, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
corpid := r.URL.Query().Get("corpid") |
||||
corpsecret := r.URL.Query().Get("corpsecret") |
||||
|
||||
assert.Equal(t, corpid, tt.fields.corpid, fmt.Sprintf("Expected corpid=%s got %s", tt.fields.corpid, corpid)) |
||||
if len(corpid) == 0 { |
||||
w.WriteHeader(http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
assert.Equal(t, corpsecret, tt.fields.secret, fmt.Sprintf("Expected corpsecret=%s got %s", tt.fields.secret, corpsecret)) |
||||
if len(corpsecret) == 0 { |
||||
w.WriteHeader(http.StatusBadRequest) |
||||
return |
||||
} |
||||
|
||||
b, err := json.Marshal(map[string]interface{}{ |
||||
"errcode": 0, |
||||
"errmsg": "ok", |
||||
"access_token": tt.want, |
||||
"expires_in": 7200, |
||||
}) |
||||
assert.NoError(t, err) |
||||
w.WriteHeader(http.StatusOK) |
||||
_, err = w.Write(b) |
||||
assert.NoError(t, err) |
||||
})) |
||||
defer server.Close() |
||||
|
||||
w := &WeComNotifier{ |
||||
settings: wecomSettings{ |
||||
EndpointURL: server.URL, |
||||
CorpID: tt.fields.corpid, |
||||
Secret: tt.fields.secret, |
||||
}, |
||||
tok: tt.fields.tok, |
||||
tokExpireAt: tt.fields.tokExpireAt, |
||||
} |
||||
got, err := w.GetAccessToken(context.Background()) |
||||
if !tt.wantErr(t, err, "GetAccessToken()") { |
||||
return |
||||
} |
||||
assert.Equalf(t, tt.want, got, "GetAccessToken()") |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestWeComFactory(t *testing.T) { |
||||
tests := []struct { |
||||
name string |
||||
settings string |
||||
wantErr assert.ErrorAssertionFunc |
||||
}{ |
||||
{ |
||||
name: "null", |
||||
settings: "{}", |
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { |
||||
return assert.Contains(t, err.Error(), "either url or secret is required", i...) |
||||
}, |
||||
}, |
||||
{ |
||||
name: "webhook url", |
||||
settings: `{"url": "https://example.com"}`, |
||||
wantErr: assert.NoError, |
||||
}, |
||||
{ |
||||
name: "apiapp missing AgentID", |
||||
settings: `{"secret": "secret"}`, |
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { |
||||
return assert.Contains(t, err.Error(), "could not find AgentID in settings", i...) |
||||
}, |
||||
}, |
||||
{ |
||||
name: "apiapp missing CorpID", |
||||
settings: `{"secret": "secret", "agent_id": "agent_id"}`, |
||||
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { |
||||
return assert.Contains(t, err.Error(), "could not find CorpID in settings", i...) |
||||
}, |
||||
}, |
||||
{ |
||||
name: "apiapp", |
||||
settings: `{"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id"}`, |
||||
wantErr: assert.NoError, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
m := &NotificationChannelConfig{ |
||||
Name: "wecom_testing", |
||||
Type: "wecom", |
||||
Settings: json.RawMessage(tt.settings), |
||||
} |
||||
|
||||
webhookSender := mockNotificationService() |
||||
|
||||
fc := 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{}, |
||||
} |
||||
|
||||
_, err := WeComFactory(fc) |
||||
if !tt.wantErr(t, err, fmt.Sprintf("WeComFactory(%v)", fc)) { |
||||
return |
||||
} |
||||
}) |
||||
} |
||||
} |
Loading…
Reference in new issue