Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for opsgenie #140

Merged
merged 38 commits into from
Feb 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2da7a9c
feat: add support for opsgenie
JonasPf Nov 9, 2020
3530a13
Fix for issue https://github.com/containrrr/shoutrrr/issues/71 (#75)
Nov 15, 2020
c78b769
docs: add atighineanu as a contributor (#81)
allcontributors[bot] Nov 15, 2020
b67f9b1
added custom port option for rocketchat (#80)
Nov 15, 2020
d3c987d
fix for https://github.com/containrrr/shoutrrr/issues/70 (#74)
ellisab Nov 15, 2020
ca5e1f2
docs: add ellisab as a contributor (#82)
allcontributors[bot] Nov 15, 2020
bdbdcf4
do not rewrite channel name without hashes (#85)
ellisab Nov 17, 2020
0f4ed7c
Added few testcases for rawURL passed as path/#####channel and path/…
Nov 24, 2020
54f0f63
refactor: rename JSON to AlertPayload
JonasPf Nov 27, 2020
c4ba3b9
refactor: rename `plugin` to `service`
JonasPf Nov 27, 2020
c108686
feat: add support for setting parameters via query string
JonasPf Dec 10, 2020
88d19ae
Fix query parameter documentation
JonasPf Dec 28, 2020
5277afa
Implement GetURL
JonasPf Dec 28, 2020
6bee1e0
Merge remote-tracking branch 'upstream/master' into opsgenie
JonasPf Jan 2, 2021
3a4d5c6
Add opsgenie to servicemap
JonasPf Jan 6, 2021
993bce8
Support generating and verifying opsgenie URLs
JonasPf Jan 6, 2021
cddfbf8
Use OpsGenie convention for URL params instead of json
JonasPf Jan 25, 2021
453c63c
Merge branch 'main' into opsgenie
JonasPf Jan 25, 2021
5cb1ad3
Use 'key:value,key2:value2' format for details and overall code cleanup
JonasPf Jan 26, 2021
584deee
Remove comment about spaces in URL
JonasPf Feb 2, 2021
b168211
Remove comment about spaces; Add test instead
JonasPf Feb 2, 2021
33fddea
Merge branch 'main' into opsgenie
JonasPf Feb 2, 2021
59819e7
Fix IFTTT test by removing obsolete values; Add another test for values
JonasPf Feb 4, 2021
a511157
Merge branch 'main' into opsgenie
JonasPf Feb 4, 2021
d87e5d4
Fix teams test by removing obsolete key without value
JonasPf Feb 4, 2021
3145f46
Fix OpsGenie test by using URL encoded strings for space, comma, colon
JonasPf Feb 4, 2021
ec73129
Simplify `getURL` by using query.Set/Encode instead of diy-ing it
JonasPf Feb 4, 2021
cc9d75d
Fix test after simplifying `getURL`, it encodes commas etc. now
JonasPf Feb 4, 2021
6390b12
Move `newAlertPayload` to `Service` struct
JonasPf Feb 4, 2021
8f3aac4
Preparation for implementing an interface for de-serializing structs
JonasPf Feb 4, 2021
3305147
Fix linting issues
JonasPf Feb 4, 2021
7bd4ba0
feat: add support for struct and map fields
piksel Feb 7, 2021
2fc0dbc
Fix: Use default port if no port was specified
JonasPf Feb 16, 2021
7e19f85
Merge branch 'feature/format-struct-map-fields' into opsgenie
JonasPf Feb 16, 2021
26bd837
Remove custom serialization logic; use formatters functionality instead
JonasPf Feb 16, 2021
38c1f10
Fix: Expect default port (443) in test
JonasPf Feb 16, 2021
f0c460d
Fix test: formatter lowercases keys
JonasPf Feb 16, 2021
394e38c
Merge branch 'main' into opsgenie
piksel Feb 21, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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