This commit is contained in:
2025-04-04 19:06:29 -07:00
parent ebb45b13bb
commit 393381d456
275 changed files with 56094 additions and 2 deletions

View File

@ -0,0 +1,284 @@
package opsgenie
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
const (
restAPI = "https://api.opsgenie.com/v2/alerts"
)
var (
ErrAPIKeyNotSet = errors.New("api-key not set")
)
type Config struct {
// APIKey to use for
APIKey string `yaml:"api-key"`
// Priority to be used in Opsgenie alert payload
//
// default: P1
Priority string `yaml:"priority"`
// Source define source to be used in Opsgenie alert payload
//
// default: gatus
Source string `yaml:"source"`
// EntityPrefix is a prefix to be used in entity argument in Opsgenie alert payload
//
// default: gatus-
EntityPrefix string `yaml:"entity-prefix"`
//AliasPrefix is a prefix to be used in alias argument in Opsgenie alert payload
//
// default: gatus-healthcheck-
AliasPrefix string `yaml:"alias-prefix"`
// Tags to be used in Opsgenie alert payload
//
// default: []
Tags []string `yaml:"tags"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.Source) == 0 {
cfg.Source = "gatus"
}
if len(cfg.EntityPrefix) == 0 {
cfg.EntityPrefix = "gatus-"
}
if len(cfg.AliasPrefix) == 0 {
cfg.AliasPrefix = "gatus-healthcheck-"
}
if len(cfg.Priority) == 0 {
cfg.Priority = "P1"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.Priority) > 0 {
cfg.Priority = override.Priority
}
if len(override.Source) > 0 {
cfg.Source = override.Source
}
if len(override.EntityPrefix) > 0 {
cfg.EntityPrefix = override.EntityPrefix
}
if len(override.AliasPrefix) > 0 {
cfg.AliasPrefix = override.AliasPrefix
}
if len(override.Tags) > 0 {
cfg.Tags = override.Tags
}
}
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
//
// Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
err = provider.sendAlertRequest(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
if resolved {
err = provider.closeAlert(cfg, ep, alert)
if err != nil {
return err
}
}
if alert.IsSendingOnResolved() {
if resolved {
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = ""
} else {
alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
}
}
return nil
}
func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
return provider.sendRequest(cfg, restAPI, http.MethodPost, payload)
}
func (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(ep, alert)
url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
return provider.sendRequest(cfg, url, http.MethodPost, payload)
}
func (provider *AlertProvider) sendRequest(cfg *Config, url, method string, payload interface{}) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("error build alert with payload %v: %w", payload, err)
}
request, err := http.NewRequest(method, url, bytes.NewBuffer(body))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "GenieKey "+cfg.APIKey)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
rBody, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(rBody))
}
return nil
}
func (provider *AlertProvider) buildCreateRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
var message, description string
if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("%s - %s", ep.Name, alert.GetDescription())
description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if ep.Group != "" {
message = fmt.Sprintf("[%s] %s", ep.Group, message)
}
var formattedConditionResults string
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "▣"
} else {
prefix = "▢"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
}
description = description + "\n" + formattedConditionResults
key := buildKey(ep)
details := map[string]string{
"endpoint:url": ep.URL,
"endpoint:group": ep.Group,
"result:hostname": result.Hostname,
"result:ip": result.IP,
"result:dns_code": result.DNSRCode,
"result:errors": strings.Join(result.Errors, ","),
}
for k, v := range details {
if v == "" {
delete(details, k)
}
}
if result.HTTPStatus > 0 {
details["result:http_status"] = strconv.Itoa(result.HTTPStatus)
}
return alertCreateRequest{
Message: message,
Description: description,
Source: cfg.Source,
Priority: cfg.Priority,
Alias: cfg.AliasPrefix + key,
Entity: cfg.EntityPrefix + key,
Tags: cfg.Tags,
Details: details,
}
}
func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, alert *alert.Alert) alertCloseRequest {
return alertCloseRequest{
Source: buildKey(ep),
Note: fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()),
}
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(ep.Name)
if ep.Group == "" {
return name
}
return toKebabCase(ep.Group) + "-" + name
}
func toKebabCase(val string) string {
return strings.ToLower(strings.ReplaceAll(val, " ", "-"))
}
type alertCreateRequest struct {
Message string `json:"message"`
Priority string `json:"priority"`
Source string `json:"source"`
Entity string `json:"entity"`
Alias string `json:"alias"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Details map[string]string `json:"details"`
}
type alertCloseRequest struct {
Source string `json:"source"`
Note string `json:"note"`
}

View File

@ -0,0 +1,361 @@
package opsgenie
import (
"net/http"
"reflect"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
description := "my bad alert description"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
Resolved: false,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedError: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedError: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
t.Parallel()
description := "alert description"
scenarios := []struct {
Name string
Provider *AlertProvider
Alert *alert.Alert
Endpoint *endpoint.Endpoint
Result *endpoint.Result
Resolved bool
want alertCreateRequest
}{
{
Name: "missing all params (unresolved)",
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
Resolved: false,
want: alertCreateRequest{
Message: " - ",
Priority: "P1",
Source: "gatus",
Entity: "gatus-",
Alias: "gatus-healthcheck-",
Description: "An alert for ** has been triggered due to having failed 0 time(s) in a row\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "missing all params (resolved)",
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{},
Resolved: true,
want: alertCreateRequest{
Message: "RESOLVED: - ",
Priority: "P1",
Source: "gatus",
Entity: "gatus-",
Alias: "gatus-healthcheck-",
Description: "An alert for ** has been resolved after passing successfully 0 time(s) in a row\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "with default options (unresolved)",
Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{
Description: &description,
FailureThreshold: 3,
},
Endpoint: &endpoint.Endpoint{
Name: "my super app",
},
Result: &endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
{
Condition: "[BODY] == OK",
Success: false,
},
},
},
Resolved: false,
want: alertCreateRequest{
Message: "my super app - " + description,
Priority: "P1",
Source: "gatus",
Entity: "gatus-my-super-app",
Alias: "gatus-healthcheck-my-super-app",
Description: "An alert for *my super app* has been triggered due to having failed 3 time(s) in a row\n▣ - `[STATUS] == 200`\n▢ - `[BODY] == OK`\n",
Tags: nil,
Details: map[string]string{},
},
},
{
Name: "with custom options (resolved)",
Provider: &AlertProvider{
DefaultConfig: Config{
Priority: "P5",
EntityPrefix: "oompa-",
AliasPrefix: "loompa-",
Source: "gatus-hc",
Tags: []string{"do-ba-dee-doo"},
},
},
Alert: &alert.Alert{
Description: &description,
SuccessThreshold: 4,
},
Endpoint: &endpoint.Endpoint{
Name: "my mega app",
},
Result: &endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: true,
},
},
},
Resolved: true,
want: alertCreateRequest{
Message: "RESOLVED: my mega app - " + description,
Priority: "P5",
Source: "gatus-hc",
Entity: "oompa-my-mega-app",
Alias: "loompa-my-mega-app",
Description: "An alert for *my mega app* has been resolved after passing successfully 4 time(s) in a row\n▣ - `[STATUS] == 200`\n",
Tags: []string{"do-ba-dee-doo"},
Details: map[string]string{},
},
},
{
Name: "with default options and details (unresolved)",
Provider: &AlertProvider{
DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
},
Alert: &alert.Alert{
Description: &description,
FailureThreshold: 6,
},
Endpoint: &endpoint.Endpoint{
Name: "my app",
Group: "end game",
URL: "https://my.go/app",
},
Result: &endpoint.Result{
HTTPStatus: 400,
Hostname: "my.go",
Errors: []string{"error 01", "error 02"},
Success: false,
ConditionResults: []*endpoint.ConditionResult{
{
Condition: "[STATUS] == 200",
Success: false,
},
},
},
Resolved: false,
want: alertCreateRequest{
Message: "[end game] my app - " + description,
Priority: "P1",
Source: "gatus",
Entity: "gatus-end-game-my-app",
Alias: "gatus-healthcheck-end-game-my-app",
Description: "An alert for *end game/my app* has been triggered due to having failed 6 time(s) in a row\n▢ - `[STATUS] == 200`\n",
Tags: []string{"foo"},
Details: map[string]string{
"endpoint:url": "https://my.go/app",
"endpoint:group": "end game",
"result:hostname": "my.go",
"result:errors": "error 01,error 02",
"result:http_status": "400",
},
},
},
}
for _, scenario := range scenarios {
actual := scenario
t.Run(actual.Name, func(t *testing.T) {
_ = scenario.Provider.Validate()
if got := actual.Provider.buildCreateRequestBody(&scenario.Provider.DefaultConfig, actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
t.Errorf("got:\n%v\nwant:\n%v", got, actual.want)
}
})
}
}
func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
t.Parallel()
description := "alert description"
scenarios := []struct {
Name string
Provider *AlertProvider
Alert *alert.Alert
Endpoint *endpoint.Endpoint
want alertCloseRequest
}{
{
Name: "Missing all values",
Provider: &AlertProvider{},
Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{},
want: alertCloseRequest{
Source: "",
Note: "RESOLVED: - ",
},
},
{
Name: "Basic values",
Provider: &AlertProvider{},
Alert: &alert.Alert{
Description: &description,
},
Endpoint: &endpoint.Endpoint{
Name: "endpoint name",
},
want: alertCloseRequest{
Source: "endpoint-name",
Note: "RESOLVED: endpoint name - alert description",
},
},
}
for _, scenario := range scenarios {
actual := scenario
t.Run(actual.Name, func(t *testing.T) {
if got := actual.Provider.buildCloseRequestBody(actual.Endpoint, actual.Alert); !reflect.DeepEqual(got, actual.want) {
t.Errorf("buildCloseRequestBody() = %v, want %v", got, actual.want)
}
})
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
{
Name: "provider-with-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "00000000-0000-0000-0000-000000000001"}},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000001"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.APIKey != scenario.ExpectedOutput.APIKey {
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}