diff --git a/docs/services/opsgenie.md b/docs/services/opsgenie.md new file mode 100644 index 00000000..7d9c74fd --- /dev/null +++ b/docs/services/opsgenie.md @@ -0,0 +1,59 @@ +# OpsGenie + +## URL Format + +## Creating a REST API endpoint in OpsGenie + +1. Open up the Integration List page by clicking on *Settings => Integration List* within the menu +![Screenshot 1](opsgenie/1.png) + +2. Click *API => Add* + +3. Make sure *Create and Update Access* and *Enabled* are checked and click *Save Integration* +![Screenshot 2](opsgenie/2.png) + +4. Copy the *API Key* + +5. Format the service URL + +The host can be either api.opsgenie.com or api.eu.opsgenie.com depending on the location of your instance. See +the [OpsGenie documentation](https://docs.opsgenie.com/docs/alert-api) for details. + +``` +opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889 + └───────────────────────────────────┘ + token +``` + +## Passing parameters via code + +If you want to, you can pass additional parameters to the `send` function. +
+The following example contains all parameters that are currently supported. + +```gotemplate +service.Send("An example alert message", &types.Params{ + "alias": "Life is too short for no alias", + "description": "Every alert needs a description", + "responders": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"NOC","type":"team"}]`, + "visibleTo": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"rocket_team","type":"team"}]`, + "actions": "An action", + "tags": "tag1 tag2", + "details": `{"key1": "value1", "key2": "value2"}`, + "entity": "An example entity", + "source": "The source", + "priority": "P1", + "user": "Dracula", + "note": "Here is a note", +}) +``` + +# Optional parameters + +You can optionally specify the parameters in the URL: +opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889?alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&actions=An+action&tags=["tag1","tag2"]&entity=An+example+entity&source=The+source&priority=P1&user=Dracula¬e=Here+is+a+note + +Example using the command line: + + shoutrrr send -u 'opsgenie://api.eu.opsgenie.com/token?tags=["tag1","tag2"]&description=testing&responders=[{"username":"superuser", "type": "user"}]&entity=Example Entity&source=Example Source&actions=["asdf", "bcde"]' -m "Hello World6" + diff --git a/docs/services/opsgenie/1.png b/docs/services/opsgenie/1.png new file mode 100644 index 00000000..dc4cadf2 Binary files /dev/null and b/docs/services/opsgenie/1.png differ diff --git a/docs/services/opsgenie/2.png b/docs/services/opsgenie/2.png new file mode 100644 index 00000000..0bf1cc29 Binary files /dev/null and b/docs/services/opsgenie/2.png differ diff --git a/pkg/format/format_query.go b/pkg/format/format_query.go index 4f49ede5..bbb0b665 100644 --- a/pkg/format/format_query.go +++ b/pkg/format/format_query.go @@ -19,7 +19,7 @@ func BuildQuery(cqr types.ConfigQueryResolver) string { } value, err := cqr.Get(key) - if err == nil { + if err == nil && value != "" { query.Set(key, value) } } diff --git a/pkg/router/servicemap.go b/pkg/router/servicemap.go index 0af8cb6e..89aa1efd 100644 --- a/pkg/router/servicemap.go +++ b/pkg/router/servicemap.go @@ -8,6 +8,7 @@ import ( "github.com/containrrr/shoutrrr/pkg/services/join" "github.com/containrrr/shoutrrr/pkg/services/logger" "github.com/containrrr/shoutrrr/pkg/services/mattermost" + "github.com/containrrr/shoutrrr/pkg/services/opsgenie" "github.com/containrrr/shoutrrr/pkg/services/pushbullet" "github.com/containrrr/shoutrrr/pkg/services/pushover" "github.com/containrrr/shoutrrr/pkg/services/rocketchat" @@ -37,4 +38,5 @@ var serviceMap = map[string]func() t.Service{ "zulip": func() t.Service { return &zulip.Service{} }, "join": func() t.Service { return &join.Service{} }, "rocketchat": func() t.Service { return &rocketchat.Service{} }, + "opsgenie": func() t.Service { return &opsgenie.Service{} }, } diff --git a/pkg/services/ifttt/ifttt_test.go b/pkg/services/ifttt/ifttt_test.go index f91df427..03d6bc85 100644 --- a/pkg/services/ifttt/ifttt_test.go +++ b/pkg/services/ifttt/ifttt_test.go @@ -102,7 +102,7 @@ var _ = Describe("the ifttt package", func() { When("serializing a config to URL", func() { When("given multiple events", func() { It("should return an URL with all the events comma-separated", func() { - expectedURL := "ifttt://dummyID/?events=foo%2Cbar%2Cbaz&messagevalue=0&value1=&value2=&value3=" + expectedURL := "ifttt://dummyID/?events=foo%2Cbar%2Cbaz&messagevalue=0" config := Config{ Events: []string{"foo", "bar", "baz"}, WebHookID: "dummyID", @@ -112,6 +112,20 @@ var _ = Describe("the ifttt package", func() { Expect(resultURL).To(Equal(expectedURL)) }) }) + + When("given values", func() { + It("should return an URL with all the values", func() { + expectedURL := "ifttt://dummyID/?messagevalue=0&value1=v1&value2=v2&value3=v3" + config := Config{ + WebHookID: "dummyID", + Value1: "v1", + Value2: "v2", + Value3: "v3", + } + resultURL := config.GetURL().String() + Expect(resultURL).To(Equal(expectedURL)) + }) + }) }) When("sending a message", func() { It("should error if the response code is not 204 no content", func() { diff --git a/pkg/services/opsgenie/opsgenie.go b/pkg/services/opsgenie/opsgenie.go new file mode 100644 index 00000000..348fdac3 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie.go @@ -0,0 +1,107 @@ +package opsgenie + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/services/standard" + "github.com/containrrr/shoutrrr/pkg/types" +) + +const ( + alertEndpointTemplate = "https://%s:%d/v2/alerts" +) + +// Service providing OpsGenie as a notification service +type Service struct { + standard.Standard + config *Config + pkr format.PropKeyResolver +} + +func (service *Service) sendAlert(url string, apiKey string, payload AlertPayload) error { + jsonBody, err := json.Marshal(payload) + if err != nil { + return err + } + + jsonBuffer := bytes.NewBuffer(jsonBody) + + req, err := http.NewRequest("POST", url, jsonBuffer) + if err != nil { + return err + } + req.Header.Add("Authorization", "GenieKey "+apiKey) + req.Header.Add("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send notification to OpsGenie: %s", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("OpsGenie notification returned %d HTTP status code. Cannot read body: %s", resp.StatusCode, err) + } + return fmt.Errorf("OpsGenie notification returned %d HTTP status code: %s", resp.StatusCode, body) + } + + return nil +} + +// Initialize loads ServiceConfig from configURL and sets logger for this Service +func (service *Service) Initialize(configURL *url.URL, logger *log.Logger) error { + service.Logger.SetLogger(logger) + service.config = &Config{} + service.pkr = format.NewPropKeyResolver(service.config) + return service.config.setURL(&service.pkr, configURL) +} + +// Send a notification message to OpsGenie +// See: https://docs.opsgenie.com/docs/alert-api#create-alert +func (service *Service) Send(message string, params *types.Params) error { + config := service.config + url := fmt.Sprintf(alertEndpointTemplate, config.Host, config.Port) + payload, err := service.newAlertPayload(message, params) + if err != nil { + return err + } + return service.sendAlert(url, config.APIKey, payload) +} + +func (service *Service) newAlertPayload(message string, params *types.Params) (AlertPayload, error) { + if params == nil { + params = &types.Params{} + } + + // Defensive copy + payloadFields := *service.config + + if err := service.pkr.UpdateConfigFromParams(&payloadFields, params); err != nil { + return AlertPayload{}, err + } + + result := AlertPayload{ + Message: message, + Alias: payloadFields.Alias, + Description: payloadFields.Description, + Responders: payloadFields.Responders, + VisibleTo: payloadFields.VisibleTo, + Actions: payloadFields.Actions, + Tags: payloadFields.Tags, + Details: payloadFields.Details, + Entity: payloadFields.Entity, + Source: payloadFields.Source, + Priority: payloadFields.Priority, + User: payloadFields.User, + Note: payloadFields.Note, + } + return result, nil +} diff --git a/pkg/services/opsgenie/opsgenie_config.go b/pkg/services/opsgenie/opsgenie_config.go new file mode 100644 index 00000000..39fdb56f --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_config.go @@ -0,0 +1,96 @@ +package opsgenie + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/types" +) + +const defaultPort = 443 + +// Config for use within the opsgenie service +type Config struct { + APIKey string `desc:"The OpsGenie API key"` + Host string `desc:"The OpsGenie API host. Use 'api.eu.opsgenie.com' for EU instances" default:"api.opsgenie.com"` + Port uint16 `desc:"The OpsGenie API port." default:"443"` + Alias string `key:"alias" desc:"Client-defined identifier of the alert" optional:"true"` + Description string `key:"description" desc:"Description field of the alert" optional:"true"` + Responders []Entity `key:"responders" desc:"Teams, users, escalations and schedules that the alert will be routed to send notifications" optional:"true"` + VisibleTo []Entity `key:"visibleTo" desc:"Teams and users that the alert will become visible to without sending any notification" optional:"true"` + Actions []string `key:"actions" desc:"Custom actions that will be available for the alert" optional:"true"` + Tags []string `key:"tags" desc:"Tags of the alert" optional:"true"` + Details map[string]string `key:"details" desc:"Map of key-value pairs to use as custom properties of the alert" optional:"true"` + Entity string `key:"entity" desc:"Entity field of the alert that is generally used to specify which domain the Source field of the alert" optional:"true"` + Source string `key:"source" desc:"Source field of the alert" optional:"true"` + Priority string `key:"priority" desc:"Priority level of the alert. Possible values are P1, P2, P3, P4 and P5" optional:"true"` + Note string `key:"note" desc:"Additional note that will be added while creating the alert" optional:"true"` + User string `key:"user" desc:"Display name of the request owner" optional:"true"` +} + +// Enums returns an empty map because the OpsGenie service doesn't use Enums +func (config Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{} +} + +// GetURL is the public version of getURL that creates a new PropKeyResolver when accessed from outside the package +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + return config.getURL(&resolver) +} + +// Private version of GetURL that can use an instance of PropKeyResolver instead of rebuilding it's model from reflection +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + host := "" + if config.Port > 0 { + host = fmt.Sprintf("%s:%d", config.Host, config.Port) + } else { + host = config.Host + } + + result := &url.URL{ + Host: host, + Path: fmt.Sprintf("/%s", config.APIKey), + Scheme: Scheme, + RawQuery: format.BuildQuery(resolver), + } + + return result +} + +// SetURL updates a ServiceConfig from a URL representation of it's field values +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + return config.setURL(&resolver, url) +} + +// Private version of SetURL that can use an instance of PropKeyResolver instead of rebuilding it's model from reflection +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + config.Host = url.Hostname() + config.APIKey = url.Path[1:] + + if url.Port() != "" { + port, err := strconv.ParseUint(url.Port(), 10, 16) + if err != nil { + return err + } + config.Port = uint16(port) + } else { + config.Port = 443 + } + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return err + } + } + + return nil +} + +const ( + // Scheme is the identifying part of this service's configuration URL + Scheme = "opsgenie" +) diff --git a/pkg/services/opsgenie/opsgenie_entity.go b/pkg/services/opsgenie/opsgenie_entity.go new file mode 100644 index 00000000..9ff8f3e8 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_entity.go @@ -0,0 +1,71 @@ +package opsgenie + +import ( + "fmt" + "regexp" + "strings" +) + +// Entity represents either a user or a team +// +// The different variations are: +// +// { "id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c", "type":"team" } +// { "name":"rocket_team", "type":"team" } +// { "id":"bb4d9938-c3c2-455d-aaab-727aa701c0d8", "type":"user" } +// { "username":"trinity@opsgenie.com", "type":"user" } +type Entity struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Username string `json:"username,omitempty"` +} + +// SetFromProp deserializes an entity +func (e *Entity) SetFromProp(propValue string) error { + elements := strings.Split(propValue, ":") + + if len(elements) != 2 { + return fmt.Errorf("invalid entity, should have two elments separated by colon: %q", propValue) + } + e.Type = elements[0] + identifier := elements[1] + isID, err := isOpsGenieID(identifier) + if err != nil { + return fmt.Errorf("invalid entity, cannot parse id/name: %q", identifier) + } + + if isID { + e.ID = identifier + } else if e.Type == "team" { + e.Name = identifier + } else if e.Type == "user" { + e.Username = identifier + } else { + return fmt.Errorf("invalid entity, unexpected entity type: %q", e.Type) + } + + return nil +} + +// GetPropValue serializes an entity +func (e *Entity) GetPropValue() (string, error) { + identifier := "" + + if e.ID != "" { + identifier = e.ID + } else if e.Name != "" { + identifier = e.Name + } else if e.Username != "" { + identifier = e.Username + } else { + return "", fmt.Errorf("invalid entity, should have either ID, name or username") + } + + return fmt.Sprintf("%s:%s", e.Type, identifier), nil +} + +// Detects OpsGenie IDs in the form 4513b7ea-3b91-438f-b7e4-e3e54af9147c +func isOpsGenieID(str string) (bool, error) { + return regexp.MatchString(`^[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}$`, str) +} diff --git a/pkg/services/opsgenie/opsgenie_json.go b/pkg/services/opsgenie/opsgenie_json.go new file mode 100644 index 00000000..3d5e6c6d --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_json.go @@ -0,0 +1,33 @@ +package opsgenie + +// AlertPayload represents the payload being sent to the OpsGenie API +// +// See: https://docs.opsgenie.com/docs/alert-api#create-alert +// +// Some fields contain complex values like arrays and objects. +// Because `params` are strings only we cannot pass in slices +// or maps. Instead we "preserve" the JSON in those fields. That +// way we can pass in complex types as JSON like so: +// +// service.Send("An example alert message", &types.Params{ +// "alias": "Life is too short for no alias", +// "description": "Every alert needs a description", +// "responders": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"NOC","type":"team"}]`, +// "visibleTo": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"rocket_team","type":"team"}]`, +// "details": `{"key1": "value1", "key2": "value2"}`, +// }) +type AlertPayload struct { + Message string `json:"message"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + Responders []Entity `json:"responders,omitempty"` + VisibleTo []Entity `json:"visibleTo,omitempty"` + Actions []string `json:"actions,omitempty"` + Tags []string `json:"tags,omitempty"` + Details map[string]string `json:"details,omitempty"` + Entity string `json:"entity,omitempty"` + Source string `json:"source,omitempty"` + Priority string `json:"priority,omitempty"` + User string `json:"user,omitempty"` + Note string `json:"note,omitempty"` +} diff --git a/pkg/services/opsgenie/opsgenie_test.go b/pkg/services/opsgenie/opsgenie_test.go new file mode 100644 index 00000000..ae3b26b7 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_test.go @@ -0,0 +1,417 @@ +package opsgenie + +import ( + "bytes" + "crypto/tls" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/containrrr/shoutrrr/pkg/types" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +const ( + mockAPIKey = "eb243592-faa2-4ba2-a551q-1afdf565c889" + mockHost = "api.opsgenie.com" +) + +func TestOpsGenie(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Shoutrrr OpsGenie Suite") +} + +var _ = Describe("the OpsGenie service", func() { + var ( + // a simulated http server to mock out OpsGenie itself + mockServer *httptest.Server + // the host of our mock server + mockHost string + // function to check if the http request received by the mock server is as expected + checkRequest func(body string, header http.Header) + // the shoutrrr OpsGenie service + service *Service + // just a mock logger + mockLogger *log.Logger + ) + + BeforeEach(func() { + // Initialize a mock http server + httpHandler := func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + Expect(err).To(BeNil()) + defer r.Body.Close() + + checkRequest(string(body), r.Header) + } + mockServer = httptest.NewTLSServer(http.HandlerFunc(httpHandler)) + + // Our mock server doesn't have a valid cert + http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + // Determine the host of our mock http server + mockServerURL, err := url.Parse(mockServer.URL) + Expect(err).To(BeNil()) + mockHost = mockServerURL.Host + + // Initialize a mock logger + var buf bytes.Buffer + mockLogger = log.New(&buf, "", 0) + }) + + AfterEach(func() { + mockServer.Close() + }) + + Context("without query parameters", func() { + BeforeEach(func() { + // Initialize service + serviceURL, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey)) + Expect(err).To(BeNil()) + + service = &Service{} + err = service.Initialize(serviceURL, mockLogger) + Expect(err).To(BeNil()) + }) + + When("sending a simple alert", func() { + It("should send a request to our mock OpsGenie server", func() { + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{"message":"hello world"}`)) + } + + err := service.Send("hello world", &types.Params{}) + Expect(err).To(BeNil()) + }) + }) + + When("sending an alert with runtime parameters", func() { + It("should send a request to our mock OpsGenie server with all fields populated from runtime parameters", func() { + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{"` + + `message":"An example alert message",` + + `"alias":"Life is too short for no alias",` + + `"description":"Every alert needs a description",` + + `"responders":[{"type":"team","id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c"},{"type":"team","name":"NOC"},{"type":"user","username":"Donald"},{"type":"user","id":"696f0759-3b0f-4a15-b8c8-19d3dfca33f2"}],` + + `"visibleTo":[{"type":"team","name":"rocket"}],` + + `"actions":["action1","action2"],` + + `"tags":["tag1","tag2"],` + + `"details":{"key1":"value1","key2":"value2"},` + + `"entity":"An example entity",` + + `"source":"The source",` + + `"priority":"P1",` + + `"user":"Dracula",` + + `"note":"Here is a note"` + + `}`)) + } + + err := service.Send("An example alert message", &types.Params{ + "alias": "Life is too short for no alias", + "description": "Every alert needs a description", + "responders": "team:4513b7ea-3b91-438f-b7e4-e3e54af9147c,team:NOC,user:Donald,user:696f0759-3b0f-4a15-b8c8-19d3dfca33f2", + "visibleTo": "team:rocket", + "actions": "action1,action2", + "tags": "tag1,tag2", + "details": "key1:value1,key2:value2", + "entity": "An example entity", + "source": "The source", + "priority": "P1", + "user": "Dracula", + "note": "Here is a note", + }) + Expect(err).To(BeNil()) + }) + }) + }) + + Context("with query parameters", func() { + BeforeEach(func() { + // Initialize service + serviceURL, err := url.Parse(fmt.Sprintf(`opsgenie://%s/%s?alias=query-alias&description=query-description&responders=team:query_team&visibleTo=user:query_user&actions=queryAction1,queryAction2&tags=queryTag1,queryTag2&details=queryKey1:queryValue1,queryKey2:queryValue2&entity=query-entity&source=query-source&priority=P2&user=query-user¬e=query-note`, mockHost, mockAPIKey)) + Expect(err).To(BeNil()) + + service = &Service{} + err = service.Initialize(serviceURL, mockLogger) + Expect(err).To(BeNil()) + }) + + When("sending a simple alert", func() { + It("should send a request to our mock OpsGenie server with all fields populated from query parameters", func() { + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{` + + `"message":"An example alert message",` + + `"alias":"query-alias",` + + `"description":"query-description",` + + `"responders":[{"type":"team","name":"query_team"}],` + + `"visibleTo":[{"type":"user","username":"query_user"}],` + + `"actions":["queryAction1","queryAction2"],` + + `"tags":["queryTag1","queryTag2"],` + + `"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` + + `"entity":"query-entity",` + + `"source":"query-source",` + + `"priority":"P2",` + + `"user":"query-user",` + + `"note":"query-note"` + + `}`)) + } + + err := service.Send("An example alert message", &types.Params{}) + Expect(err).To(BeNil()) + }) + }) + + When("sending an alert with runtime parameters", func() { + It("should send a request to our mock OpsGenie server with all fields populated from runtime parameters, overwriting the query parameters", func() { + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{"` + + `message":"An example alert message",` + + `"alias":"Life is too short for no alias",` + + `"description":"Every alert needs a description",` + + `"responders":[{"type":"team","id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c"},{"type":"team","name":"NOC"},{"type":"user","username":"Donald"},{"type":"user","id":"696f0759-3b0f-4a15-b8c8-19d3dfca33f2"}],` + + `"visibleTo":[{"type":"team","name":"rocket"}],` + + `"actions":["action1","action2"],` + + `"tags":["tag1","tag2"],` + + `"details":{"key1":"value1","key2":"value2"},` + + `"entity":"An example entity",` + + `"source":"The source",` + + `"priority":"P1",` + + `"user":"Dracula",` + + `"note":"Here is a note"` + + `}`)) + } + + err := service.Send("An example alert message", &types.Params{ + "alias": "Life is too short for no alias", + "description": "Every alert needs a description", + "responders": "team:4513b7ea-3b91-438f-b7e4-e3e54af9147c,team:NOC,user:Donald,user:696f0759-3b0f-4a15-b8c8-19d3dfca33f2", + "visibleTo": "team:rocket", + "actions": "action1,action2", + "tags": "tag1,tag2", + "details": "key1:value1,key2:value2", + "entity": "An example entity", + "source": "The source", + "priority": "P1", + "user": "Dracula", + "note": "Here is a note", + }) + Expect(err).To(BeNil()) + }) + }) + + When("sending two alerts", func() { + It("should not mix-up the runtime parameters and the query parameters", func() { + // Internally the opsgenie service copies runtime parameters into the config struct + // before generating the alert payload. This test ensures that none of the parameters + // from alert 1 remain in the config struct when sending alert 2 + // In short: This tests if we clone the config struct + + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{"` + + `message":"1",` + + `"alias":"1",` + + `"description":"1",` + + `"responders":[{"type":"team","name":"1"}],` + + `"visibleTo":[{"type":"team","name":"1"}],` + + `"actions":["action1","action2"],` + + `"tags":["tag1","tag2"],` + + `"details":{"key1":"value1","key2":"value2"},` + + `"entity":"1",` + + `"source":"1",` + + `"priority":"P1",` + + `"user":"1",` + + `"note":"1"` + + `}`)) + } + + err := service.Send("1", &types.Params{ + "alias": "1", + "description": "1", + "responders": "team:1", + "visibleTo": "team:1", + "actions": "action1,action2", + "tags": "tag1,tag2", + "details": "key1:value1,key2:value2", + "entity": "1", + "source": "1", + "priority": "P1", + "user": "1", + "note": "1", + }) + Expect(err).To(BeNil()) + + checkRequest = func(body string, header http.Header) { + Expect(header["Authorization"][0]).To(Equal("GenieKey " + mockAPIKey)) + Expect(header["Content-Type"][0]).To(Equal("application/json")) + Expect(body).To(Equal(`{` + + `"message":"2",` + + `"alias":"query-alias",` + + `"description":"query-description",` + + `"responders":[{"type":"team","name":"query_team"}],` + + `"visibleTo":[{"type":"user","username":"query_user"}],` + + `"actions":["queryAction1","queryAction2"],` + + `"tags":["queryTag1","queryTag2"],` + + `"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` + + `"entity":"query-entity",` + + `"source":"query-source",` + + `"priority":"P2",` + + `"user":"query-user",` + + `"note":"query-note"` + + `}`)) + } + + err = service.Send("2", nil) + Expect(err).To(BeNil()) + }) + }) + }) +}) + +var _ = Describe("the OpsGenie Config struct", func() { + When("generating a config from a simple URL", func() { + It("should populate the config with host and apikey", func() { + url, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey)) + Expect(err).To(BeNil()) + + config := Config{} + err = config.SetURL(url) + Expect(err).To(BeNil()) + + Expect(config.APIKey).To(Equal(mockAPIKey)) + Expect(config.Host).To(Equal(mockHost)) + Expect(config.Port).To(Equal(uint16(443))) + }) + }) + + When("generating a config from a url with port", func() { + It("should populate the port field", func() { + url, err := url.Parse(fmt.Sprintf("opsgenie://%s:12345/%s", mockHost, mockAPIKey)) + Expect(err).To(BeNil()) + + config := Config{} + err = config.SetURL(url) + Expect(err).To(BeNil()) + + Expect(config.Port).To(Equal(uint16(12345))) + }) + }) + + When("generating a config from a url with query parameters", func() { + It("should populate the config fields with the query parameter values", func() { + queryParams := `alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&actions=An+action&tags=tag1,tag2&details=key:value,key2:value2&entity=An+example+entity&source=The+source&priority=P1&user=Dracula¬e=Here+is+a+note&responders=user:Test,team:NOC&visibleTo=user:A+User` + url, err := url.Parse(fmt.Sprintf("opsgenie://%s:12345/%s?%s", mockHost, mockAPIKey, queryParams)) + Expect(err).To(BeNil()) + + config := Config{} + err = config.SetURL(url) + Expect(err).To(BeNil()) + + Expect(config.Alias).To(Equal("Life is too short for no alias")) + Expect(config.Description).To(Equal("Every alert needs a description")) + Expect(config.Responders).To(Equal([]Entity{ + {Type: "user", Username: "Test"}, + {Type: "team", Name: "NOC"}, + })) + Expect(config.VisibleTo).To(Equal([]Entity{ + {Type: "user", Username: "A User"}, + })) + Expect(config.Actions).To(Equal([]string{"An action"})) + Expect(config.Tags).To(Equal([]string{"tag1", "tag2"})) + Expect(config.Details).To(Equal(map[string]string{"key": "value", "key2": "value2"})) + Expect(config.Entity).To(Equal("An example entity")) + Expect(config.Source).To(Equal("The source")) + Expect(config.Priority).To(Equal("P1")) + Expect(config.User).To(Equal("Dracula")) + Expect(config.Note).To(Equal("Here is a note")) + + }) + }) + + When("generating a config from a url with differently escaped spaces", func() { + It("should parse the escaped spaces correctly", func() { + // Use: '%20', '+' and a normal space + queryParams := `alias=Life is+too%20short+for+no+alias` + url, err := url.Parse(fmt.Sprintf("opsgenie://%s:12345/%s?%s", mockHost, mockAPIKey, queryParams)) + Expect(err).To(BeNil()) + + config := Config{} + err = config.SetURL(url) + Expect(err).To(BeNil()) + + Expect(config.Alias).To(Equal("Life is too short for no alias")) + + }) + }) + + When("generating a url from a simple config", func() { + It("should generate a url", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + } + + url := config.GetURL() + + Expect(url.String()).To(Equal("opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889")) + }) + }) + + When("generating a url from a config with a port", func() { + It("should generate a url with port", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + Port: 12345, + } + + url := config.GetURL() + + Expect(url.String()).To(Equal("opsgenie://api.opsgenie.com:12345/eb243592-faa2-4ba2-a551q-1afdf565c889")) + }) + }) + + When("generating a url from a config with all optional config fields", func() { + It("should generate a url with query parameters", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + Alias: "Life is too short for no alias", + Description: "Every alert needs a description", + Responders: []Entity{ + {Type: "user", Username: "Test"}, + {Type: "team", Name: "NOC"}, + {Type: "team", ID: "4513b7ea-3b91-438f-b7e4-e3e54af9147c"}, + }, + VisibleTo: []Entity{ + {Type: "user", Username: "A User"}, + }, + Actions: []string{"action1", "action2"}, + Tags: []string{"tag1", "tag2"}, + Details: map[string]string{"key": "value"}, + Entity: "An example entity", + Source: "The source", + Priority: "P1", + User: "Dracula", + Note: "Here is a note", + } + + url := config.GetURL() + Expect(url.String()).To(Equal(`opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889?actions=action1%2Caction2&alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&details=key%3Avalue&entity=An+example+entity¬e=Here+is+a+note&priority=P1&responders=user%3ATest%2Cteam%3ANOC%2Cteam%3A4513b7ea-3b91-438f-b7e4-e3e54af9147c&source=The+source&tags=tag1%2Ctag2&user=Dracula&visibleto=user%3AA+User`)) + }) + }) +}) diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go index d1ab0858..fa32141b 100644 --- a/pkg/services/teams/teams_test.go +++ b/pkg/services/teams/teams_test.go @@ -87,7 +87,7 @@ var _ = Describe("the teams plugin", func() { serviceURL, err := service.GetConfigURLFromCustom(customURL) Expect(err).NotTo(HaveOccurred(), "converting") - Expect(serviceURL.String()).To(Equal(testURLBase + "?color=&host=publicservice.info&title=")) + Expect(serviceURL.String()).To(Equal(testURLBase + "?host=publicservice.info")) }) It("should preserve the query params in the generated service URL", func() { service := Service{}