diff --git a/README.md b/README.md index 90a7f1fd..19b2ad29 100644 --- a/README.md +++ b/README.md @@ -311,8 +311,10 @@ ignored. | Parameter | Description | Default | |:-----------------------|:---------------------------------------------------------------------------------------------------------------------- |:-------| | `alerting.discord` | Configuration for alerts of type `discord`.
See [Configuring Discord alerts](#configuring-discord-alerts). | `{}` | -| `alerting.mattermost` | Configuration for alerts of type `mattermost`.
See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` | +| `alerting.email` | Configuration for alerts of type `email`.
See [Configuring Email alerts](#configuring-email-alerts). | `{}` | +| `alerting.mattermost` | Configuration for alerts of type `mattermost`.
See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` | | `alerting.messagebird` | Configuration for alerts of type `messagebird`.
See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` | +| `alerting.opsgenie` | Configuration for alerts of type `opsgenie`.
See [Configuring Opsgenie alerts](#configuring-opsgenie-alerts). | `{}` | | `alerting.pagerduty` | Configuration for alerts of type `pagerduty`.
See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | | `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` | | `alerting.teams` | Configuration for alerts of type `teams`.
See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` | @@ -455,6 +457,24 @@ endpoints: description: "healthcheck failed" ``` +#### Configuring Opsgenie alerts +| Parameter | Description | Default | +|:------------------------------------------------------ |:----------------------------------------------------------------------------- |:-------------------- | +| `alerting.opsgenie` | Configuration for alerts of type `opsgenie` | `{}` | +| `alerting.opsgenie.api-key` | Opsgenie API Key | Required `""` | +| `alerting.opsgenie.priority` | Priority level of the alert. | `P1` | +| `alerting.opsgenie.source` | Source field of the alert. | `gatus` | +| `alerting.opsgenie.entity-prefix` | Entity field prefix. | `gatus-` | +| `alerting.opsgenie.alias-prefix` | Alias field prefix. | `gatus-healthcheck-` | +| `alerting.opsgenie.tags` | Tags of alert. | `[]` | + +Opsgenie provider will automatically open and close alerts. + +```yaml +alerting: + opsgenie: + api-key: "00000000-0000-0000-0000-000000000000" +``` #### Configuring PagerDuty alerts | Parameter | Description | Default | diff --git a/alerting/alert/type.go b/alerting/alert/type.go index 8ca233df..b921338e 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -34,4 +34,7 @@ const ( // TypeTwilio is the Type for the twilio alerting provider TypeTwilio Type = "twilio" + + // TypeOpsgenie is the Type for the opsgenie alerting provider + TypeOpsgenie Type = "opsgenie" ) diff --git a/alerting/config.go b/alerting/config.go index 6554a380..6e614619 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -8,6 +8,7 @@ import ( "github.com/TwiN/gatus/v3/alerting/provider/email" "github.com/TwiN/gatus/v3/alerting/provider/mattermost" "github.com/TwiN/gatus/v3/alerting/provider/messagebird" + "github.com/TwiN/gatus/v3/alerting/provider/opsgenie" "github.com/TwiN/gatus/v3/alerting/provider/pagerduty" "github.com/TwiN/gatus/v3/alerting/provider/slack" "github.com/TwiN/gatus/v3/alerting/provider/teams" @@ -46,6 +47,9 @@ type Config struct { // Twilio is the configuration for the twilio alerting provider Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"` + + // Opsgenie is the configuration for the opsgenie alerting provider + Opsgenie *opsgenie.AlertProvider `yaml:"opsgenie,omitempty"` } // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type @@ -81,6 +85,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid return nil } return config.Messagebird + case alert.TypeOpsgenie: + if config.Opsgenie == nil { + // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil + return nil + } + return config.Opsgenie case alert.TypePagerDuty: if config.PagerDuty == nil { // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil diff --git a/alerting/provider/opsgenie/opsgenie.go b/alerting/provider/opsgenie/opsgenie.go new file mode 100644 index 00000000..a24eff2b --- /dev/null +++ b/alerting/provider/opsgenie/opsgenie.go @@ -0,0 +1,267 @@ +package opsgenie + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/TwiN/gatus/v3/alerting/alert" + "github.com/TwiN/gatus/v3/client" + "github.com/TwiN/gatus/v3/core" + "io" + "net/http" + "strconv" + "strings" +) + +const ( + restAPI = "https://api.opsgenie.com/v2/alerts" +) + +type opsgenieAlertCreateRequest 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 opsgenieAlertCloseRequest struct { + Source string `json:"source"` + Note string `json:"note"` +} + +type AlertProvider struct { + APIKey string `yaml:"api-key"` + + //Priority define priority to be used in opsgenie alert payload + // defaults: P1 + Priority string `yaml:"priority"` + + //Source define source to be used in opsgenie alert payload + // defaults: gatus + Source string `yaml:"source"` + + //EntityPrefix is a prefix to be used in entity argument in opsgenie alert payload + // defaults: gatus- + EntityPrefix string `yaml:"entity-prefix"` + + //AliasPrefix is a prefix to be used in alias argument in opsgenie alert payload + // defaults: gatus-healthcheck- + AliasPrefix string `yaml:"alias-prefix"` + + //tags define tags to be used in opsgenie alert payload + // defaults: [] + Tags []string `yaml:"tags"` + + // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` +} + +func (provider *AlertProvider) IsValid() bool { + return len(provider.APIKey) > 0 +} + +// Send an alert using the provider +// +// Relevant: https://docs.opsgenie.com/docs/alert-api +func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + err := provider.createAlert(endpoint, alert, result, resolved) + + if err != nil { + return err + } + + if resolved { + err = provider.closeAlert(endpoint, 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 = provider.alias(buildKey(endpoint)) + } + } + + return nil +} + +func (provider *AlertProvider) createAlert(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error { + payload := provider.buildCreateRequestBody(endpoint, alert, result, resolved) + + _, err := provider.sendRequest(restAPI, http.MethodPost, payload) + + return err +} + +func (provider *AlertProvider) closeAlert(endpoint *core.Endpoint, alert *alert.Alert) error { + payload := provider.buildCloseRequestBody(endpoint, alert) + url := restAPI + "/" + provider.alias(buildKey(endpoint)) + "/close?identifierType=alias" + + _, err := provider.sendRequest(url, http.MethodPost, payload) + + return err +} + +func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) (*http.Response, error) { + + body, err := json.Marshal(payload) + + if err != nil { + return nil, fmt.Errorf("fail to build alert payload: %v", payload) + } + + request, err := http.NewRequest(method, url, bytes.NewBuffer(body)) + + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "GenieKey "+provider.APIKey) + + res, err := client.GetHTTPClient(nil).Do(request) + + if err != nil { + return nil, err + } + + if res.StatusCode > 399 { + rBody, _ := io.ReadAll(res.Body) + return nil, fmt.Errorf("call to provider alert returned status code %d: %s", res.StatusCode, string(rBody)) + } + + return res, nil +} + +func (provider *AlertProvider) buildCreateRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) opsgenieAlertCreateRequest { + var message, description, results string + + if resolved { + message = fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()) + description = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", endpoint.Name, alert.SuccessThreshold) + } else { + message = fmt.Sprintf("%s - %s", endpoint.Name, alert.GetDescription()) + description = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", endpoint.Name, alert.FailureThreshold) + } + + + if endpoint.Group != "" { + message = fmt.Sprintf("[%s] %s", endpoint.Group, message) + } + + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "▣" + } else { + prefix = "▢" + } + results += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition) + } + + description = description + "\n" + results + + key := buildKey(endpoint) + details := map[string]string{ + "endpoint:url": endpoint.URL, + "endpoint:group": endpoint.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 opsgenieAlertCreateRequest{ + Message: message, + Description: description, + Source: provider.source(), + Priority: provider.priority(), + Alias: provider.alias(key), + Entity: provider.entity(key), + Tags: provider.Tags, + Details: details, + } +} + +func (provider *AlertProvider) buildCloseRequestBody(endpoint *core.Endpoint, alert *alert.Alert) opsgenieAlertCloseRequest { + return opsgenieAlertCloseRequest{ + Source: buildKey(endpoint), + Note: fmt.Sprintf("RESOLVED: %s - %s", endpoint.Name, alert.GetDescription()), + } +} + +func (provider *AlertProvider) source() string { + source := provider.Source + + if source == "" { + return "gatus" + } + + return source +} + +func (provider *AlertProvider) alias(key string) string { + alias := provider.AliasPrefix + + if alias == "" { + alias = "gatus-healthcheck-" + } + + return alias + key +} + +func (provider *AlertProvider) entity(key string) string { + alias := provider.EntityPrefix + if alias == "" { + alias = "gatus-" + } + + return alias + key +} + +func (provider *AlertProvider) priority() string { + priority := provider.Priority + + if priority == "" { + return "P1" + } + + return priority +} + +func (provider AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} + +func buildKey(endpoint *core.Endpoint) string { + name := toKebabCase(endpoint.Name) + + if endpoint.Group == "" { + return name + } + + return toKebabCase(endpoint.Group) + "-" + name +} + +func toKebabCase(val string) string { + return strings.ToLower(strings.ReplaceAll(val, " ", "-")) +} diff --git a/alerting/provider/opsgenie/opsgenie_test.go b/alerting/provider/opsgenie/opsgenie_test.go new file mode 100644 index 00000000..b9d65f5c --- /dev/null +++ b/alerting/provider/opsgenie/opsgenie_test.go @@ -0,0 +1,331 @@ +package opsgenie + +import ( + "github.com/TwiN/gatus/v3/alerting/alert" + "github.com/TwiN/gatus/v3/client" + "github.com/TwiN/gatus/v3/core" + "github.com/TwiN/gatus/v3/test" + "net/http" + "reflect" + "testing" +) + +func TestAlertProvider_IsValid(t *testing.T) { + invalidProvider := AlertProvider{APIKey: ""} + if invalidProvider.IsValid() { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"} + if !validProvider.IsValid() { + 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{}, + 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{}, + 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{}, + 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{}, + 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( + &core.Endpoint{Name: "endpoint-name"}, + &scenario.Alert, + &core.Result{ + ConditionResults: []*core.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 *core.Endpoint + Result *core.Result + Resolved bool + want opsgenieAlertCreateRequest + }{ + { + Name: "missing all params (unresolved)", + Provider: &AlertProvider{}, + Alert: &alert.Alert{}, + Endpoint: &core.Endpoint{}, + Result: &core.Result{}, + Resolved: false, + want: opsgenieAlertCreateRequest{ + 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{}, + Alert: &alert.Alert{}, + Endpoint: &core.Endpoint{}, + Result: &core.Result{}, + Resolved: true, + want: opsgenieAlertCreateRequest{ + 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{}, + Alert: &alert.Alert{ + Description: &description, + FailureThreshold: 3, + }, + Endpoint: &core.Endpoint{ + Name: "my supper app", + }, + Result: &core.Result{ + ConditionResults: []*core.ConditionResult{ + { + Condition: "[STATUS] == 200", + Success: true, + }, + { + Condition: "[BODY] == OK", + Success: false, + }, + }, + }, + Resolved: false, + want: opsgenieAlertCreateRequest{ + Message: "my supper app - " + description, + Priority: "P1", + Source: "gatus", + Entity: "gatus-my-supper-app", + Alias: "gatus-healthcheck-my-supper-app", + Description: "An alert for *my supper 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{ + Priority: "P5", + EntityPrefix: "oompa-", + AliasPrefix: "loompa-", + Source: "gatus-hc", + Tags: []string{"do-ba-dee-doo"}, + }, + Alert: &alert.Alert{ + Description: &description, + SuccessThreshold: 4, + }, + Endpoint: &core.Endpoint{ + Name: "my mega app", + }, + Result: &core.Result{ + ConditionResults: []*core.ConditionResult{ + { + Condition: "[STATUS] == 200", + Success: true, + }, + }, + }, + Resolved: true, + want: opsgenieAlertCreateRequest{ + 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{ + Tags: []string{"foo"}, + }, + Alert: &alert.Alert{ + Description: &description, + FailureThreshold: 6, + }, + Endpoint: &core.Endpoint{ + Name: "my app", + Group: "end game", + URL: "https://my.go/app", + }, + Result: &core.Result{ + HTTPStatus: 400, + Hostname: "my.go", + Errors: []string{"error 01", "error 02"}, + Success: false, + ConditionResults: []*core.ConditionResult{ + { + Condition: "[STATUS] == 200", + Success: false, + }, + }, + }, + Resolved: false, + want: opsgenieAlertCreateRequest{ + 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 *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) { + if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) { + t.Errorf("buildCreateRequestBody() = %v, want %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 *core.Endpoint + want opsgenieAlertCloseRequest + }{ + { + Name: "Missing all values", + Provider: &AlertProvider{}, + Alert: &alert.Alert{}, + Endpoint: &core.Endpoint{}, + want: opsgenieAlertCloseRequest{ + Source: "", + Note: "RESOLVED: - ", + }, + }, + + { + Name: "Basic values", + Provider: &AlertProvider{}, + Alert: &alert.Alert{ + Description: &description, + }, + Endpoint: &core.Endpoint{ + Name: "endpoint name", + }, + want: opsgenieAlertCloseRequest{ + 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) + } + }) + } +}