From 72e149498a41ff3f2236a6a56e9e7e4b4f795197 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Tue, 24 Oct 2023 16:04:54 -0400 Subject: [PATCH] support for responders in opsgenie --- receivers/opsgenie/config.go | 51 +++++++++-- receivers/opsgenie/config_test.go | 62 ++++++++++++- receivers/opsgenie/opsgenie.go | 34 ++++++++ receivers/opsgenie/opsgenie_test.go | 130 ++++++++++++++++++++++++++++ receivers/opsgenie/testing.go | 28 ++++-- 5 files changed, 290 insertions(+), 15 deletions(-) diff --git a/receivers/opsgenie/config.go b/receivers/opsgenie/config.go index 00f50736..22c8d6dd 100644 --- a/receivers/opsgenie/config.go +++ b/receivers/opsgenie/config.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "text/template" "github.com/grafana/alerting/receivers" "github.com/grafana/alerting/templates" @@ -18,6 +19,15 @@ const ( DefaultAlertsURL = "https://api.opsgenie.com/v2/alerts" ) +var SupportedResponderTypes = []string{"team", "teams", "user", "escalation", "schedule"} + +type MessageResponder struct { + ID string `json:"id,omitempty" yaml:"id,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Username string `json:"username,omitempty" yaml:"username,omitempty"` + Type string `json:"type" yaml:"type"` // team, user, escalation, schedule etc. +} + type Config struct { APIKey string APIUrl string @@ -26,17 +36,19 @@ type Config struct { AutoClose bool OverridePriority bool SendTagsAs string + Responders []MessageResponder } func NewConfig(jsonData json.RawMessage, decryptFn receivers.DecryptFunc) (Config, 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"` + 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"` + Responders []MessageResponder `json:"responders,omitempty" yaml:"responders,omitempty"` } raw := rawSettings{} @@ -74,6 +86,30 @@ func NewConfig(jsonData json.RawMessage, decryptFn receivers.DecryptFunc) (Confi raw.OverridePriority = &overridePriority } + for idx, r := range raw.Responders { + if r.ID == "" && r.Username == "" && r.Name == "" { + return Config{}, fmt.Errorf("responder at index [%d] must have at least one of id, username or name specified", idx) + } + if strings.Contains(r.Type, "{{") { + _, err := template.New("").Parse(r.Type) + if err != nil { + return Config{}, fmt.Errorf("responder at index [%d] type is not a valid template: %v", idx, err) + } + } else { + r.Type = strings.ToLower(r.Type) + match := false + for _, t := range SupportedResponderTypes { + if r.Type == t { + match = true + break + } + } + if !match { + return Config{}, fmt.Errorf("responder at index [%d] has unsupported type. Supported only: %s", idx, strings.Join(SupportedResponderTypes, ",")) + } + } + } + return Config{ APIKey: raw.APIKey, APIUrl: raw.APIUrl, @@ -82,5 +118,6 @@ func NewConfig(jsonData json.RawMessage, decryptFn receivers.DecryptFunc) (Confi AutoClose: *raw.AutoClose, OverridePriority: *raw.OverridePriority, SendTagsAs: raw.SendTagsAs, + Responders: raw.Responders, }, nil } diff --git a/receivers/opsgenie/config_test.go b/receivers/opsgenie/config_test.go index 544c6b54..7c70e7a9 100644 --- a/receivers/opsgenie/config_test.go +++ b/receivers/opsgenie/config_test.go @@ -129,6 +129,36 @@ func TestNewConfig(t *testing.T) { }, expectedInitError: `invalid value for sendTagsAs: "test-tags"`, }, + { + name: "Error if responder type is not supported", + settings: `{ "responders" : [ + { "type" : "test-123", "id": "test" } + ] }`, + secureSettings: map[string][]byte{ + "apiKey": []byte("test-api-key"), + }, + expectedInitError: `responder at index [0] has unsupported type. Supported only: team,teams,user,escalation,schedule`, + }, + { + name: "Error if responder type is not supported", + settings: `{ "responders" : [ + { "type" : "test-123", "id": "test" } + ] }`, + secureSettings: map[string][]byte{ + "apiKey": []byte("test-api-key"), + }, + expectedInitError: `responder at index [0] has unsupported type. Supported only: team,teams,user,escalation,schedule`, + }, + { + name: "Error if responder ID,name,username are empty", + settings: `{ "responders" : [ + { "type" : "user" } + ] }`, + secureSettings: map[string][]byte{ + "apiKey": []byte("test-api-key"), + }, + expectedInitError: `responder at index [0] must have at least one of id, username or name specified`, + }, { name: "Should use default message if all spaces", settings: `{ "message" : " " }`, @@ -157,7 +187,8 @@ func TestNewConfig(t *testing.T) { "description": "", "autoClose": null, "overridePriority": null, - "sendTagsAs": "" + "sendTagsAs": "", + "responders": null }`, expectedConfig: Config{ APIKey: "test-api-key", @@ -167,6 +198,7 @@ func TestNewConfig(t *testing.T) { AutoClose: true, OverridePriority: true, SendTagsAs: SendTags, + Responders: nil, }, }, { @@ -181,6 +213,20 @@ func TestNewConfig(t *testing.T) { AutoClose: false, OverridePriority: false, SendTagsAs: "both", + Responders: []MessageResponder{ + { + ID: "test-id", + Type: "team", + }, + { + Username: "test-user", + Type: "user", + }, + { + Name: "test-schedule", + Type: "schedule", + }, + }, }, }, { @@ -195,6 +241,20 @@ func TestNewConfig(t *testing.T) { AutoClose: false, OverridePriority: false, SendTagsAs: "both", + Responders: []MessageResponder{ + { + ID: "test-id", + Type: "team", + }, + { + Username: "test-user", + Type: "user", + }, + { + Name: "test-schedule", + Type: "schedule", + }, + }, }, }, } diff --git a/receivers/opsgenie/opsgenie.go b/receivers/opsgenie/opsgenie.go index 22596386..376dfd00 100644 --- a/receivers/opsgenie/opsgenie.go +++ b/receivers/opsgenie/opsgenie.go @@ -176,6 +176,39 @@ func (on *Notifier) buildOpsgenieMessage(ctx context.Context, alerts model.Alert } sort.Strings(tags) + responders := make([]opsGenieCreateMessageResponder, 0, len(on.settings.Responders)) + for idx, r := range on.settings.Responders { + responder := opsGenieCreateMessageResponder{ + ID: tmpl(r.ID), + Name: tmpl(r.Name), + Username: tmpl(r.Username), + Type: tmpl(r.Type), + } + + if responder == (opsGenieCreateMessageResponder{}) { + on.log.Warn("templates in the responder were expanded to empty responder. Skipping it", "idx", idx) + // Filter out empty responders. This is useful if you want to fill + // responders dynamically from alert's common labels. + continue + } + + if responder.Type == "teams" { + teams := strings.Split(responder.Name, ",") + for _, team := range teams { + if team == "" { + continue + } + newResponder := opsGenieCreateMessageResponder{ + Name: tmpl(team), + Type: "team", + } + responders = append(responders, newResponder) + } + continue + } + responders = append(responders, responder) + } + result := opsGenieCreateMessage{ Alias: key.Hash(), Description: description, @@ -184,6 +217,7 @@ func (on *Notifier) buildOpsgenieMessage(ctx context.Context, alerts model.Alert Message: message, Details: details, Priority: priority, + Responders: responders, } apiURL = tmpl(on.settings.APIUrl) diff --git a/receivers/opsgenie/opsgenie_test.go b/receivers/opsgenie/opsgenie_test.go index 3ad3b448..03fce7e1 100644 --- a/receivers/opsgenie/opsgenie_test.go +++ b/receivers/opsgenie/opsgenie_test.go @@ -250,6 +250,136 @@ func TestNotify(t *testing.T) { }`, expMsgError: nil, }, + { + name: "Config with responders", + settings: Config{ + APIKey: "abcdefgh0123456789", + APIUrl: DefaultAlertsURL, + Message: templates.DefaultMessageTitleEmbed, + Description: "", + AutoClose: true, + OverridePriority: true, + SendTagsAs: SendBoth, + Responders: []MessageResponder{ + { + Name: "Test User", + Type: "user", + }, + { + Name: "{{ .CommonAnnotations.user }}", + Type: "{{ .CommonAnnotations.type }}", + }, + { + ID: "{{ .CommonAnnotations.user }}", + Type: "user", + }, + { + Username: "{{ .CommonAnnotations.user }}", + Type: "team", + }, + }, + }, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"user": "test", "type": "team"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"user": "test", "type": "team"}, + }, + }, + }, + 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 - type = team\n - user = test\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 - type = team\n - user = test\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"], + "responders": [ + { + "name": "Test User", + "type": "user" + }, + { + "name": "test", + "type": "team" + }, + { + "id": "test", + "type": "user" + }, + { + "username": "test", + "type": "team" + } + ] + }`, + expMsgError: nil, + }, + { + name: "Config with teams responders should be exploded", + settings: Config{ + APIKey: "abcdefgh0123456789", + APIUrl: DefaultAlertsURL, + Message: templates.DefaultMessageTitleEmbed, + Description: "", + AutoClose: true, + OverridePriority: true, + SendTagsAs: SendBoth, + Responders: []MessageResponder{ + { + Name: "team1,team2,{{ .CommonAnnotations.user }}", + Type: "teams", + }, + }, + }, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"user": "test", "type": "team"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"user": "test", "type": "team"}, + }, + }, + }, + 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 - type = team\n - user = test\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 - type = team\n - user = test\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"], + "responders": [ + { + "name": "team1", + "type": "team" + }, + { + "name": "team2", + "type": "team" + }, + { + "name": "test", + "type": "team" + } + ] + }`, + expMsgError: nil, + }, { name: "Resolved is not sent when auto close is false", settings: Config{ diff --git a/receivers/opsgenie/testing.go b/receivers/opsgenie/testing.go index a46e7fc0..f3b21262 100644 --- a/receivers/opsgenie/testing.go +++ b/receivers/opsgenie/testing.go @@ -2,13 +2,27 @@ package opsgenie // FullValidConfigForTesting is a string representation of a JSON object that contains all fields supported by the notifier Config. It can be used without secrets. const FullValidConfigForTesting = `{ - "apiUrl" : "http://localhost", - "apiKey": "test-api-key", - "message" : "test-message", - "description": "test-description", - "autoClose": false, - "overridePriority": false, - "sendTagsAs": "both" + "apiUrl": "http://localhost", + "apiKey": "test-api-key", + "message": "test-message", + "description": "test-description", + "autoClose": false, + "overridePriority": false, + "sendTagsAs": "both", + "responders": [ + { + "type": "team", + "id": "test-id" + }, + { + "type": "user", + "username": "test-user" + }, + { + "type": "schedule", + "name": "test-schedule" + } + ] }` // FullValidSecretsForTesting is a string representation of JSON object that contains all fields that can be overridden from secrets