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 #73

Closed
wants to merge 37 commits into from
Closed
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
37 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
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 != "" {
JonasPf marked this conversation as resolved.
Show resolved Hide resolved
query.Set(key, value)
}
}
Expand Down
40 changes: 38 additions & 2 deletions pkg/format/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (v

configField.SetBool(value)
return true, nil
} else if fieldKind >= reflect.Slice || fieldKind == reflect.Array {
} else if fieldKind == reflect.Slice || fieldKind == reflect.Array {
elemType := field.Type.Elem()
elemKind := elemType.Kind()
if elemKind != reflect.String {
Expand All @@ -367,7 +367,29 @@ func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (v

configField.Set(value)
return true, nil
} else if fieldKind == reflect.Map {
keyKind := field.Type.Key().Kind()
elemKind := field.Type.Elem().Kind()
if elemKind != reflect.String || keyKind != reflect.String {
return false, errors.New("field format is not supported")
}

newMap := make(map[string]string)

pairs := strings.Split(inputValue, ",")
for _, pair := range pairs {
elems := strings.Split(pair, ":")
if len(elems) != 2 {
return false, errors.New("field format is not supported")
}
key := elems[0]
value := elems[1]

newMap[key] = value
}

configField.Set(reflect.ValueOf(newMap))
return true, nil
} else {
err = fmt.Errorf("invalid field kind %v", fieldKind)
}
Expand Down Expand Up @@ -395,14 +417,28 @@ func GetConfigFieldString(config reflect.Value, field FieldInfo) (value string,
return strconv.FormatInt(configField.Int(), field.Base), nil
} else if fieldKind == reflect.Bool {
return PrintBool(configField.Bool()), nil
} else if fieldKind >= reflect.Slice {
} else if fieldKind == reflect.Slice {
sliceLen := configField.Len()
sliceValue := configField.Slice(0, sliceLen)
if field.Type.Elem().Kind() != reflect.String {
return "", errors.New("field format is not supported")
}
slice := sliceValue.Interface().([]string)
return strings.Join(slice, ","), nil
} else if fieldKind == reflect.Map {
keyKind := field.Type.Key().Kind()
elemKind := field.Type.Elem().Kind()
if elemKind != reflect.String || keyKind != reflect.String {
return "", errors.New("field format is not supported")
}

kvPairs := []string{}
for _, key := range configField.MapKeys() {
value := configField.MapIndex(key).Interface()

kvPairs = append(kvPairs, fmt.Sprintf("%s:%s", key, value))
}
return strings.Join(kvPairs, ","), nil
}
return "", fmt.Errorf("field kind %x is not supported", fieldKind)

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{} },
}
74 changes: 74 additions & 0 deletions pkg/services/opsgenie/opsgenie.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package opsgenie

import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"

"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
}

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{}
return service.config.SetURL(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 := NewAlertPayload(message, config, params)
if err != nil {
return err
}
return service.sendAlert(url, config.ApiKey, payload)
}
121 changes: 121 additions & 0 deletions pkg/services/opsgenie/opsgenie_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package opsgenie

import (
"fmt"
"net/url"
"strconv"

"github.com/containrrr/shoutrrr/pkg/format"
"github.com/containrrr/shoutrrr/pkg/types"
)

// 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"`
}

func (config Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{}
}

// 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
JonasPf marked this conversation as resolved.
Show resolved Hide resolved
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
}

// Serialize Shoutrrr standard data types
rawQuery := format.BuildQuery(resolver)

// Serialize OpsGenie specific data types
separator := "&"
if rawQuery == "" {
separator = ""
}

if len(config.Responders) > 0 {
responders, _ := serializeEntities(config.Responders)
rawQuery = rawQuery + separator + "responders=" + responders
separator = "&"
}
if len(config.VisibleTo) > 0 {
visibleTo, _ := serializeEntities(config.VisibleTo)
rawQuery = rawQuery + separator + "visibleTo=" + visibleTo
}

result := &url.URL{
Host: host,
Path: fmt.Sprintf("/%s", config.ApiKey),
Scheme: Scheme,
RawQuery: rawQuery,
}
JonasPf marked this conversation as resolved.
Show resolved Hide resolved

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)
}

for key, vals := range url.Query() {
var err error

switch key {
case "responders":
config.Responders, err = deserializeEntities(vals[0])
case "visibleTo":
config.VisibleTo, err = deserializeEntities(vals[0])
default:
err = resolver.Set(key, vals[0])
}

if err != nil {
return err
}
}

return nil
}

JonasPf marked this conversation as resolved.
Show resolved Hide resolved
const (
// Scheme is the identifying part of this service's configuration URL
Scheme = "opsgenie"
)
Loading