Skip to content

Commit

Permalink
feat: add support for opsgenie (#140) (#73)
Browse files Browse the repository at this point in the history
Co-authored-by: Jonas Pfannschmidt <[email protected]>
  • Loading branch information
piksel and JonasPf authored Feb 21, 2021
1 parent fd3e82d commit 8aa6f46
Show file tree
Hide file tree
Showing 12 changed files with 802 additions and 3 deletions.
59 changes: 59 additions & 0 deletions docs/services/opsgenie.md
Original file line number Diff line number Diff line change
@@ -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.
<br/>
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&note=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"

Binary file added docs/services/opsgenie/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/services/opsgenie/2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion pkg/format/format_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/router/servicemap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{} },
}
16 changes: 15 additions & 1 deletion pkg/services/ifttt/ifttt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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() {
Expand Down
107 changes: 107 additions & 0 deletions pkg/services/opsgenie/opsgenie.go
Original file line number Diff line number Diff line change
@@ -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
}
96 changes: 96 additions & 0 deletions pkg/services/opsgenie/opsgenie_config.go
Original file line number Diff line number Diff line change
@@ -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"
)
71 changes: 71 additions & 0 deletions pkg/services/opsgenie/opsgenie_entity.go
Original file line number Diff line number Diff line change
@@ -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":"[email protected]", "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)
}
Loading

0 comments on commit 8aa6f46

Please sign in to comment.