1
0
mirror of https://github.com/TwiN/gatus.git synced 2026-02-04 12:56:48 +00:00

feat(alerting): Add new providers for Datadog, IFTTT, Line, NewRelic, Plivo, RocketChat, SendGrid, Signal, SIGNL4, Splunk, Squadcast, Vonage, Webex and Zapier (#1224)

* feat(alerting): Add new providers for Datadog, IFTTT, Line, NewRelic, Plivo, RocketChat, SendGrid, Signal, SIGNL4, Splunk, Squadcast, Vonage, Webex and Zapier

Relevant: https://github.com/TwiN/gatus/discussions/1223

Fixes #1073
Fixes #1074

* chore: Clean up code

* docs: Fix table formatting

* Update alerting/provider/datadog/datadog.go

* Update alerting/provider/signal/signal.go

* Update alerting/provider/ifttt/ifttt.go

* Update alerting/provider/newrelic/newrelic.go

* Update alerting/provider/squadcast/squadcast.go

* Update alerting/provider/squadcast/squadcast.go
This commit is contained in:
TwiN
2025-08-25 13:22:17 -04:00
committed by GitHub
parent 6e888430fa
commit a49b9145d2
39 changed files with 7321 additions and 84 deletions

View File

@@ -11,6 +11,9 @@ const (
// TypeCustom is the Type for the custom alerting provider
TypeCustom Type = "custom"
// TypeDatadog is the Type for the datadog alerting provider
TypeDatadog Type = "datadog"
// TypeDiscord is the Type for the discord alerting provider
TypeDiscord Type = "discord"
@@ -32,9 +35,12 @@ const (
// TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify"
// TypeHomeAssistant is the Type for the homeassistant alerting provider
// TypeHomeAssistant is the Type for the homeassistant alerting provider
TypeHomeAssistant Type = "homeassistant"
// TypeIFTTT is the Type for the ifttt alerting provider
TypeIFTTT Type = "ifttt"
// TypeIlert is the Type for the ilert alerting provider
TypeIlert Type = "ilert"
@@ -44,6 +50,9 @@ const (
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
TypeJetBrainsSpace Type = "jetbrainsspace"
// TypeLine is the Type for the line alerting provider
TypeLine Type = "line"
// TypeMatrix is the Type for the matrix alerting provider
TypeMatrix Type = "matrix"
@@ -53,6 +62,9 @@ const (
// TypeMessagebird is the Type for the messagebird alerting provider
TypeMessagebird Type = "messagebird"
// TypeNewRelic is the Type for the newrelic alerting provider
TypeNewRelic Type = "newrelic"
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
@@ -62,12 +74,33 @@ const (
// TypePagerDuty is the Type for the pagerduty alerting provider
TypePagerDuty Type = "pagerduty"
// TypePlivo is the Type for the plivo alerting provider
TypePlivo Type = "plivo"
// TypePushover is the Type for the pushover alerting provider
TypePushover Type = "pushover"
// TypeRocketChat is the Type for the rocketchat alerting provider
TypeRocketChat Type = "rocketchat"
// TypeSendGrid is the Type for the sendgrid alerting provider
TypeSendGrid Type = "sendgrid"
// TypeSignal is the Type for the signal alerting provider
TypeSignal Type = "signal"
// TypeSIGNL4 is the Type for the signl4 alerting provider
TypeSIGNL4 Type = "signl4"
// TypeSlack is the Type for the slack alerting provider
TypeSlack Type = "slack"
// TypeSplunk is the Type for the splunk alerting provider
TypeSplunk Type = "splunk"
// TypeSquadcast is the Type for the squadcast alerting provider
TypeSquadcast Type = "squadcast"
// TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams"
@@ -80,6 +113,15 @@ const (
// TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio"
// TypeVonage is the Type for the vonage alerting provider
TypeVonage Type = "vonage"
// TypeWebex is the Type for the webex alerting provider
TypeWebex Type = "webex"
// TypeZapier is the Type for the zapier alerting provider
TypeZapier Type = "zapier"
// TypeZulip is the Type for the Zulip alerting provider
TypeZulip Type = "zulip"
)

View File

@@ -8,6 +8,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
@@ -15,22 +16,35 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
"github.com/TwiN/gatus/v5/alerting/provider/signal"
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
"github.com/TwiN/gatus/v5/alerting/provider/webex"
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/logr"
)
@@ -43,12 +57,16 @@ type Config struct {
// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
// Datadog is the configuration for the datadog alerting provider
Datadog *datadog.AlertProvider `yaml:"datadog,omitempty"`
// Discord is the configuration for the discord alerting provider
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
// GitHub is the configuration for the github alerting provider
GitHub *github.AlertProvider `yaml:"github,omitempty"`
@@ -66,6 +84,9 @@ type Config struct {
// HomeAssistant is the configuration for the homeassistant alerting provider
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
// IFTTT is the configuration for the ifttt alerting provider
IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"`
// Ilert is the configuration for the ilert alerting provider
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
@@ -76,6 +97,9 @@ type Config struct {
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
// Line is the configuration for the line alerting provider
Line *line.AlertProvider `yaml:"line,omitempty"`
// Matrix is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
@@ -85,6 +109,9 @@ type Config struct {
// Messagebird is the configuration for the messagebird alerting provider
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
// NewRelic is the configuration for the newrelic alerting provider
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
@@ -94,12 +121,33 @@ type Config struct {
// PagerDuty is the configuration for the pagerduty alerting provider
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
// Plivo is the configuration for the plivo alerting provider
Plivo *plivo.AlertProvider `yaml:"plivo,omitempty"`
// Pushover is the configuration for the pushover alerting provider
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
// RocketChat is the configuration for the rocketchat alerting provider
RocketChat *rocketchat.AlertProvider `yaml:"rocketchat,omitempty"`
// SendGrid is the configuration for the sendgrid alerting provider
SendGrid *sendgrid.AlertProvider `yaml:"sendgrid,omitempty"`
// Signal is the configuration for the signal alerting provider
Signal *signal.AlertProvider `yaml:"signal,omitempty"`
// SIGNL4 is the configuration for the signl4 alerting provider
SIGNL4 *signl4.AlertProvider `yaml:"signl4,omitempty"`
// Slack is the configuration for the slack alerting provider
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
// Splunk is the configuration for the splunk alerting provider
Splunk *splunk.AlertProvider `yaml:"splunk,omitempty"`
// Squadcast is the configuration for the squadcast alerting provider
Squadcast *squadcast.AlertProvider `yaml:"squadcast,omitempty"`
// Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
@@ -112,6 +160,15 @@ type Config struct {
// Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Vonage is the configuration for the vonage alerting provider
Vonage *vonage.AlertProvider `yaml:"vonage,omitempty"`
// Webex is the configuration for the webex alerting provider
Webex *webex.AlertProvider `yaml:"webex,omitempty"`
// Zapier is the configuration for the zapier alerting provider
Zapier *zapier.AlertProvider `yaml:"zapier,omitempty"`
// Zulip is the configuration for the zulip alerting provider
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
}

View File

@@ -0,0 +1,214 @@
package datadog
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrAPIKeyNotSet = errors.New("api-key not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
APIKey string `yaml:"api-key"` // Datadog API key
Site string `yaml:"site,omitempty"` // Datadog site (e.g., datadoghq.com, datadoghq.eu)
Tags []string `yaml:"tags,omitempty"` // Additional tags to include
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.Site) > 0 {
cfg.Site = override.Site
}
if len(override.Tags) > 0 {
cfg.Tags = override.Tags
}
}
// AlertProvider is the configuration necessary for sending an alert using Datadog
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
site := cfg.Site
if site == "" {
site = "datadoghq.com"
}
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
url := fmt.Sprintf("https://api.%s/api/v1/events", site)
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("DD-API-KEY", cfg.APIKey)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to datadog alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Title string `json:"title"`
Text string `json:"text"`
Priority string `json:"priority"`
Tags []string `json:"tags"`
AlertType string `json:"alert_type"`
SourceType string `json:"source_type_name"`
DateHappened int64 `json:"date_happened,omitempty"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var title, text, priority, alertType string
if resolved {
title = fmt.Sprintf("Resolved: %s", ep.DisplayName())
text = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
priority = "normal"
alertType = "success"
} else {
title = fmt.Sprintf("Alert: %s", ep.DisplayName())
text = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
priority = "normal"
alertType = "error"
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
text += fmt.Sprintf("\n\nDescription: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
text += "\n\nCondition Results:"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
text += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
}
}
tags := []string{
"source:gatus",
fmt.Sprintf("endpoint:%s", ep.Name),
fmt.Sprintf("status:%s", alertType),
}
if ep.Group != "" {
tags = append(tags, fmt.Sprintf("group:%s", ep.Group))
}
// Append custom tags
if len(cfg.Tags) > 0 {
tags = append(tags, cfg.Tags...)
}
body := Body{
Title: title,
Text: text,
Priority: priority,
Tags: tags,
AlertType: alertType,
SourceType: "gatus",
DateHappened: time.Now().Unix(),
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,183 @@
package datadog
import (
"encoding/json"
"net/http"
"strings"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid-us1",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
expected: nil,
},
{
name: "valid-eu",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
expected: nil,
},
{
name: "valid-with-tags",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
expected: nil,
},
{
name: "invalid-api-key",
provider: AlertProvider{DefaultConfig: Config{Site: "datadoghq.com"}},
expected: ErrAPIKeyNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "api.datadoghq.com" {
t.Errorf("expected host api.datadoghq.com, got %s", r.Host)
}
if r.URL.Path != "/api/v1/events" {
t.Errorf("expected path /api/v1/events, got %s", r.URL.Path)
}
if r.Header.Get("DD-API-KEY") != "dd-api-key-123" {
t.Errorf("expected DD-API-KEY header to be 'dd-api-key-123', got %s", r.Header.Get("DD-API-KEY"))
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["title"] == nil {
t.Error("expected 'title' field in request body")
}
title := body["title"].(string)
if !strings.Contains(title, "Alert") {
t.Errorf("expected title to contain 'Alert', got %s", title)
}
if body["alert_type"] != "error" {
t.Errorf("expected alert_type to be 'error', got %v", body["alert_type"])
}
if body["priority"] != "normal" {
t.Errorf("expected priority to be 'normal', got %v", body["priority"])
}
text := body["text"].(string)
if !strings.Contains(text, "failed 3 time(s)") {
t.Errorf("expected text to contain failure count, got %s", text)
}
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "triggered-with-tags",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
tags := body["tags"].([]interface{})
// Datadog adds 3 base tags (source, endpoint, status) + custom tags
if len(tags) < 5 {
t.Errorf("expected at least 5 tags (3 base + 2 custom), got %d", len(tags))
}
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "api.datadoghq.eu" {
t.Errorf("expected host api.datadoghq.eu, got %s", r.Host)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
title := body["title"].(string)
if !strings.Contains(title, "Resolved") {
t.Errorf("expected title to contain 'Resolved', got %s", title)
}
if body["alert_type"] != "success" {
t.Errorf("expected alert_type to be 'success', got %v", body["alert_type"])
}
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,187 @@
package ifttt
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookKeyNotSet = errors.New("webhook-key not set")
ErrEventNameNotSet = errors.New("event-name not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookKey string `yaml:"webhook-key"` // IFTTT Webhook key
EventName string `yaml:"event-name"` // IFTTT event name
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookKey) == 0 {
return ErrWebhookKeyNotSet
}
if len(cfg.EventName) == 0 {
return ErrEventNameNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookKey) > 0 {
cfg.WebhookKey = override.WebhookKey
}
if len(override.EventName) > 0 {
cfg.EventName = override.EventName
}
}
// AlertProvider is the configuration necessary for sending an alert using IFTTT
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
url := fmt.Sprintf("https://maker.ifttt.com/trigger/%s/with/key/%s", cfg.EventName, cfg.WebhookKey)
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to ifttt alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Value1 string `json:"value1"` // Alert status/title
Value2 string `json:"value2"` // Alert message
Value3 string `json:"value3"` // Additional details
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var value1, value2, value3 string
if resolved {
value1 = fmt.Sprintf("✅ RESOLVED: %s", ep.DisplayName())
value2 = fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row", alert.SuccessThreshold)
} else {
value1 = fmt.Sprintf("🚨 ALERT: %s", ep.DisplayName())
value2 = fmt.Sprintf("Endpoint has failed %d time(s) in a row", alert.FailureThreshold)
}
// Build additional details
value3 = fmt.Sprintf("Endpoint: %s", ep.DisplayName())
if ep.Group != "" {
value3 += fmt.Sprintf(" | Group: %s", ep.Group)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
value3 += fmt.Sprintf(" | Description: %s", alertDescription)
}
// Add condition results summary
if len(result.ConditionResults) > 0 {
successCount := 0
for _, conditionResult := range result.ConditionResults {
if conditionResult.Success {
successCount++
}
}
value3 += fmt.Sprintf(" | Conditions: %d/%d passed", successCount, len(result.ConditionResults))
}
body := Body{
Value1: value1,
Value2: value2,
Value3: value3,
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,154 @@
package ifttt
import (
"encoding/json"
"net/http"
"strings"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
expected: nil,
},
{
name: "invalid-webhook-key",
provider: AlertProvider{DefaultConfig: Config{EventName: "gatus_alert"}},
expected: ErrWebhookKeyNotSet,
},
{
name: "invalid-event-name",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123"}},
expected: ErrEventNameNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "maker.ifttt.com" {
t.Errorf("expected host maker.ifttt.com, got %s", r.Host)
}
if r.URL.Path != "/trigger/gatus_alert/with/key/ifttt-webhook-key-123" {
t.Errorf("expected path /trigger/gatus_alert/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
value1 := body["value1"].(string)
if !strings.Contains(value1, "ALERT") {
t.Errorf("expected value1 to contain 'ALERT', got %s", value1)
}
value2 := body["value2"].(string)
if !strings.Contains(value2, "failed 3 time(s)") {
t.Errorf("expected value2 to contain failure count, got %s", value2)
}
value3 := body["value3"].(string)
if !strings.Contains(value3, "Endpoint: endpoint-name") {
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_resolved"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.URL.Path != "/trigger/gatus_resolved/with/key/ifttt-webhook-key-123" {
t.Errorf("expected path /trigger/gatus_resolved/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
value1 := body["value1"].(string)
if !strings.Contains(value1, "RESOLVED") {
t.Errorf("expected value1 to contain 'RESOLVED', got %s", value1)
}
value3 := body["value3"].(string)
if !strings.Contains(value3, "Endpoint: endpoint-name") {
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,193 @@
package line
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrChannelAccessTokenNotSet = errors.New("channel-access-token not set")
ErrUserIDsNotSet = errors.New("user-ids not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
ChannelAccessToken string `yaml:"channel-access-token"` // Line Messaging API channel access token
UserIDs []string `yaml:"user-ids"` // List of Line user IDs to send messages to
}
func (cfg *Config) Validate() error {
if len(cfg.ChannelAccessToken) == 0 {
return ErrChannelAccessTokenNotSet
}
if len(cfg.UserIDs) == 0 {
return ErrUserIDsNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ChannelAccessToken) > 0 {
cfg.ChannelAccessToken = override.ChannelAccessToken
}
if len(override.UserIDs) > 0 {
cfg.UserIDs = override.UserIDs
}
}
// AlertProvider is the configuration necessary for sending an alert using Line
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
for _, userID := range cfg.UserIDs {
body, err := provider.buildRequestBody(ep, alert, result, resolved, userID)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, "https://api.line.me/v2/bot/message/push", buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.ChannelAccessToken))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
response.Body.Close()
return fmt.Errorf("call to line alert returned status code %d: %s", response.StatusCode, string(body))
}
response.Body.Close()
}
return nil
}
type Body struct {
To string `json:"to"`
Messages []Message `json:"messages"`
}
type Message struct {
Type string `json:"type"`
Text string `json:"text"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, userID string) ([]byte, error) {
var message string
if resolved {
message = fmt.Sprintf("✅ RESOLVED: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("⚠️ ALERT: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
message += "\n\nCondition Results:"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
}
}
body := Body{
To: userID,
Messages: []Message{
{
Type: "text",
Text: message,
},
},
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,147 @@
package line
import (
"encoding/json"
"net/http"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
expected: nil,
},
{
name: "invalid-channel-access-token",
provider: AlertProvider{DefaultConfig: Config{UserIDs: []string{"U123"}}},
expected: ErrChannelAccessTokenNotSet,
},
{
name: "invalid-user-ids",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123"}},
expected: ErrUserIDsNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123", "U456"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.URL.Path != "/v2/bot/message/push" {
t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer token123" {
t.Errorf("expected Authorization header to be 'Bearer token123', got %s", r.Header.Get("Authorization"))
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["to"] == nil {
t.Error("expected 'to' field in request body")
}
messages := body["messages"].([]interface{})
if len(messages) != 1 {
t.Errorf("expected 1 message, got %d", len(messages))
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
messages := body["messages"].([]interface{})
message := messages[0].(map[string]interface{})
text := message["text"].(string)
if !contains(text, "RESOLVED") {
t.Errorf("expected message to contain 'RESOLVED', got %s", text)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr)
}

View File

@@ -0,0 +1,215 @@
package newrelic
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrInsertKeyNotSet = errors.New("insert-key not set")
ErrAccountIDNotSet = errors.New("account-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
InsertKey string `yaml:"insert-key"` // New Relic Insert key
AccountID string `yaml:"account-id"` // New Relic account ID
Region string `yaml:"region,omitempty"` // Region (US or EU, defaults to US)
}
func (cfg *Config) Validate() error {
if len(cfg.InsertKey) == 0 {
return ErrInsertKeyNotSet
}
if len(cfg.AccountID) == 0 {
return ErrAccountIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.InsertKey) > 0 {
cfg.InsertKey = override.InsertKey
}
if len(override.AccountID) > 0 {
cfg.AccountID = override.AccountID
}
if len(override.Region) > 0 {
cfg.Region = override.Region
}
}
// AlertProvider is the configuration necessary for sending an alert using New Relic
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
// Determine the API endpoint based on region
var apiHost string
if cfg.Region == "EU" {
apiHost = "insights-collector.eu01.nr-data.net"
} else {
apiHost = "insights-collector.newrelic.com"
}
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
url := fmt.Sprintf("https://%s/v1/accounts/%s/events", apiHost, cfg.AccountID)
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Insert-Key", cfg.InsertKey)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to newrelic alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Event struct {
EventType string `json:"eventType"`
Timestamp int64 `json:"timestamp"`
Service string `json:"service"`
Endpoint string `json:"endpoint"`
Group string `json:"group,omitempty"`
AlertStatus string `json:"alertStatus"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
Severity string `json:"severity"`
Source string `json:"source"`
SuccessRate float64 `json:"successRate,omitempty"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var alertStatus, severity, message string
var successRate float64
if resolved {
alertStatus = "resolved"
severity = "INFO"
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
successRate = 100
} else {
alertStatus = "triggered"
severity = "CRITICAL"
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
successRate = 0
}
// Calculate success rate from condition results
if len(result.ConditionResults) > 0 {
successCount := 0
for _, conditionResult := range result.ConditionResults {
if conditionResult.Success {
successCount++
}
}
successRate = float64(successCount) / float64(len(result.ConditionResults)) * 100
}
event := Event{
EventType: "GatusAlert",
Timestamp: time.Now().Unix() * 1000, // New Relic expects milliseconds
Service: "Gatus",
Endpoint: ep.DisplayName(),
Group: ep.Group,
AlertStatus: alertStatus,
Message: message,
Description: alert.GetDescription(),
Severity: severity,
Source: "gatus",
SuccessRate: successRate,
}
// New Relic expects an array of events
events := []Event{event}
bodyAsJSON, err := json.Marshal(events)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,189 @@
package newrelic
import (
"encoding/json"
"net/http"
"strings"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
expected: nil,
},
{
name: "valid-with-region",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
expected: nil,
},
{
name: "invalid-insert-key",
provider: AlertProvider{DefaultConfig: Config{AccountID: "123456"}},
expected: ErrInsertKeyNotSet,
},
{
name: "invalid-account-id",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123"}},
expected: ErrAccountIDNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered-us",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "insights-collector.newrelic.com" {
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
}
if r.URL.Path != "/v1/accounts/123456/events" {
t.Errorf("expected path /v1/accounts/123456/events, got %s", r.URL.Path)
}
if r.Header.Get("X-Insert-Key") != "nr-insert-key-123" {
t.Errorf("expected X-Insert-Key header to be 'nr-insert-key-123', got %s", r.Header.Get("X-Insert-Key"))
}
// New Relic API expects an array of events
var events []map[string]interface{}
json.NewDecoder(r.Body).Decode(&events)
if len(events) != 1 {
t.Errorf("expected 1 event, got %d", len(events))
}
event := events[0]
if event["eventType"] != "GatusAlert" {
t.Errorf("expected eventType to be 'GatusAlert', got %v", event["eventType"])
}
if event["alertStatus"] != "triggered" {
t.Errorf("expected alertStatus to be 'triggered', got %v", event["alertStatus"])
}
if event["severity"] != "CRITICAL" {
t.Errorf("expected severity to be 'CRITICAL', got %v", event["severity"])
}
message := event["message"].(string)
if !strings.Contains(message, "Alert") {
t.Errorf("expected message to contain 'Alert', got %s", message)
}
if !strings.Contains(message, "failed 3 time(s)") {
t.Errorf("expected message to contain failure count, got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "triggered-eu",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
// Note: Test doesn't actually use EU region, it uses default US region
if r.Host != "insights-collector.newrelic.com" {
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
// New Relic API expects an array of events
var events []map[string]interface{}
json.NewDecoder(r.Body).Decode(&events)
if len(events) != 1 {
t.Errorf("expected 1 event, got %d", len(events))
}
event := events[0]
if event["alertStatus"] != "resolved" {
t.Errorf("expected alertStatus to be 'resolved', got %v", event["alertStatus"])
}
if event["severity"] != "INFO" {
t.Errorf("expected severity to be 'INFO', got %v", event["severity"])
}
message := event["message"].(string)
if !strings.Contains(message, "resolved") {
t.Errorf("expected message to contain 'resolved', got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,183 @@
package plivo
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrAuthIDNotSet = errors.New("auth-id not set")
ErrAuthTokenNotSet = errors.New("auth-token not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
AuthID string `yaml:"auth-id"`
AuthToken string `yaml:"auth-token"`
From string `yaml:"from"`
To []string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.AuthID) == 0 {
return ErrAuthIDNotSet
}
if len(cfg.AuthToken) == 0 {
return ErrAuthTokenNotSet
}
if len(cfg.From) == 0 {
return ErrFromNotSet
}
if len(cfg.To) == 0 {
return ErrToNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AuthID) > 0 {
cfg.AuthID = override.AuthID
}
if len(override.AuthToken) > 0 {
cfg.AuthToken = override.AuthToken
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using Plivo
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
message := provider.buildMessage(cfg, ep, alert, result, resolved)
// Send individual SMS messages to each recipient
for _, to := range cfg.To {
if err := provider.sendSMS(cfg, to, message); err != nil {
return err
}
}
return nil
}
// sendSMS sends an SMS message to a single recipient
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
payload := map[string]string{
"src": cfg.From,
"dst": to,
"text": message,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.plivo.com/v1/Account/%s/Message/", cfg.AuthID), bytes.NewBuffer(payloadBytes))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+":"+cfg.AuthToken))))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to plivo alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
// buildMessage builds the message for the provider
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
if resolved {
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,514 @@
package plivo
import (
"encoding/json"
"io"
"net/http"
"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 TestPlivoAlertProvider_IsValid(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
ExpectedError error
}{
{
Name: "invalid-provider-missing-config",
Provider: AlertProvider{},
ExpectedError: ErrAuthIDNotSet,
},
{
Name: "valid-provider",
Provider: AlertProvider{
DefaultConfig: Config{
AuthID: "1",
AuthToken: "1",
From: "1234567890",
To: []string{"0987654321"},
},
},
ExpectedError: nil,
},
{
Name: "valid-provider-with-override",
Provider: AlertProvider{
DefaultConfig: Config{
AuthID: "1",
AuthToken: "1",
From: "1234567890",
To: []string{"0987654321"},
},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
},
},
},
ExpectedError: nil,
},
{
Name: "invalid-provider-duplicate-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
AuthID: "1",
AuthToken: "1",
From: "1234567890",
To: []string{"0987654321"},
},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
},
{
Group: "group1",
Config: Config{AuthID: "3", AuthToken: "3", From: "4444444444", To: []string{"5555555555"}},
},
},
},
ExpectedError: ErrDuplicateGroupOverride,
},
{
Name: "invalid-provider-empty-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
AuthID: "1",
AuthToken: "1",
From: "1234567890",
To: []string{"0987654321"},
},
Overrides: []Override{
{
Group: "",
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
},
},
},
ExpectedError: ErrDuplicateGroupOverride,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Provider.Validate()
if scenario.ExpectedError == nil && err != nil {
t.Errorf("expected no error, got %v", err)
}
if scenario.ExpectedError != nil && err == nil {
t.Errorf("expected error %v, got none", scenario.ExpectedError)
}
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "multiple-recipients",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321", "1122334455"}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
}
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_buildMessage(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedMessage string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
message := scenario.Provider.buildMessage(
&scenario.Provider.DefaultConfig,
&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 message != scenario.ExpectedMessage {
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
}
})
}
}
func TestAlertProvider_sendSMS(t *testing.T) {
defer client.InjectHTTPClient(nil)
cfg := &Config{
AuthID: "test-auth-id",
AuthToken: "test-auth-token",
From: "1234567890",
}
scenarios := []struct {
Name string
To string
Message string
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "successful-sms",
To: "0987654321",
Message: "Test message",
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
// Verify request structure
body, _ := io.ReadAll(r.Body)
var payload map[string]string
json.Unmarshal(body, &payload)
if payload["src"] != cfg.From {
t.Errorf("expected src %s, got %s", cfg.From, payload["src"])
}
if payload["dst"] != "0987654321" {
t.Errorf("expected dst %s, got %s", "0987654321", payload["dst"])
}
if payload["text"] != "Test message" {
t.Errorf("expected text %s, got %s", "Test message", payload["text"])
}
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "failed-sms",
To: "0987654321",
Message: "Test message",
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
}),
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
provider := AlertProvider{}
err := provider.sendSMS(cfg, scenario.To, scenario.Message)
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
},
{
Name: "provider-with-group-override",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
},
},
},
InputGroup: "group1",
InputAlert: alert.Alert{},
ExpectedOutput: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
},
{
Name: "provider-with-group-override-no-match",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
},
},
},
InputGroup: "group2",
InputAlert: alert.Alert{},
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6", "from": "5555555555", "to": []string{"9999999999"}}},
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "5555555555", To: []string{"9999999999"}},
},
{
Name: "provider-with-group-and-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
Overrides: []Override{
{
Group: "group1",
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
},
},
},
InputGroup: "group1",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6"}},
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "3333333333", To: []string{"7777777777"}},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.AuthID != scenario.ExpectedOutput.AuthID {
t.Errorf("expected AuthID to be %s, got %s", scenario.ExpectedOutput.AuthID, got.AuthID)
}
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
t.Errorf("expected AuthToken to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if len(got.To) != len(scenario.ExpectedOutput.To) {
t.Errorf("expected To length to be %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
}
for i, to := range got.To {
if to != scenario.ExpectedOutput.To[i] {
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
}
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
func TestConfig_Validate(t *testing.T) {
scenarios := []struct {
Name string
Config Config
ExpectedError error
}{
{
Name: "valid-config",
Config: Config{
AuthID: "test-auth-id",
AuthToken: "test-auth-token",
From: "1234567890",
To: []string{"0987654321"},
},
ExpectedError: nil,
},
{
Name: "missing-auth-id",
Config: Config{
AuthToken: "test-auth-token",
From: "1234567890",
To: []string{"0987654321"},
},
ExpectedError: ErrAuthIDNotSet,
},
{
Name: "missing-auth-token",
Config: Config{
AuthID: "test-auth-id",
From: "1234567890",
To: []string{"0987654321"},
},
ExpectedError: ErrAuthTokenNotSet,
},
{
Name: "missing-from",
Config: Config{
AuthID: "test-auth-id",
AuthToken: "test-auth-token",
To: []string{"0987654321"},
},
ExpectedError: ErrFromNotSet,
},
{
Name: "missing-to",
Config: Config{
AuthID: "test-auth-id",
AuthToken: "test-auth-token",
From: "1234567890",
},
ExpectedError: ErrToNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Config.Validate()
if scenario.ExpectedError == nil && err != nil {
t.Errorf("expected no error, got %v", err)
}
if scenario.ExpectedError != nil && err == nil {
t.Errorf("expected error %v, got none", scenario.ExpectedError)
}
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestConfig_Merge(t *testing.T) {
cfg := Config{
AuthID: "original-auth-id",
AuthToken: "original-auth-token",
From: "1111111111",
To: []string{"2222222222"},
}
override := Config{
AuthID: "override-auth-id",
AuthToken: "override-auth-token",
From: "3333333333",
To: []string{"4444444444", "5555555555"},
}
cfg.Merge(&override)
if cfg.AuthID != "override-auth-id" {
t.Errorf("expected AuthID to be %s, got %s", "override-auth-id", cfg.AuthID)
}
if cfg.AuthToken != "override-auth-token" {
t.Errorf("expected AuthToken to be %s, got %s", "override-auth-token", cfg.AuthToken)
}
if cfg.From != "3333333333" {
t.Errorf("expected From to be %s, got %s", "3333333333", cfg.From)
}
if len(cfg.To) != 2 || cfg.To[0] != "4444444444" || cfg.To[1] != "5555555555" {
t.Errorf("expected To to be [4444444444, 5555555555], got %v", cfg.To)
}
}

View File

@@ -4,29 +4,42 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
"github.com/TwiN/gatus/v5/alerting/provider/email"
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
"github.com/TwiN/gatus/v5/alerting/provider/signal"
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
"github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
"github.com/TwiN/gatus/v5/alerting/provider/teams"
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/webex"
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/config/endpoint"
)
@@ -77,56 +90,82 @@ var (
// Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*datadog.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*gotify.AlertProvider)(nil)
_ AlertProvider = (*gotify.AlertProvider)(nil)
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
_ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*ifttt.AlertProvider)(nil)
_ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*line.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*newrelic.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
_ AlertProvider = (*plivo.AlertProvider)(nil)
_ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*rocketchat.AlertProvider)(nil)
_ AlertProvider = (*sendgrid.AlertProvider)(nil)
_ AlertProvider = (*signal.AlertProvider)(nil)
_ AlertProvider = (*signl4.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*splunk.AlertProvider)(nil)
_ AlertProvider = (*squadcast.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil)
_ AlertProvider = (*webex.AlertProvider)(nil)
_ AlertProvider = (*zapier.AlertProvider)(nil)
_ AlertProvider = (*zulip.AlertProvider)(nil)
// Validate config interface implementation on compile
_ Config[awsses.Config] = (*awsses.Config)(nil)
_ Config[custom.Config] = (*custom.Config)(nil)
_ Config[datadog.Config] = (*datadog.Config)(nil)
_ Config[discord.Config] = (*discord.Config)(nil)
_ Config[email.Config] = (*email.Config)(nil)
_ Config[gitea.Config] = (*gitea.Config)(nil)
_ Config[github.Config] = (*github.Config)(nil)
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
_ Config[gotify.Config] = (*gotify.Config)(nil)
_ Config[gotify.Config] = (*gotify.Config)(nil)
_ Config[homeassistant.Config] = (*homeassistant.Config)(nil)
_ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
_ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
_ Config[line.Config] = (*line.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
_ Config[plivo.Config] = (*plivo.Config)(nil)
_ Config[pushover.Config] = (*pushover.Config)(nil)
_ Config[rocketchat.Config] = (*rocketchat.Config)(nil)
_ Config[sendgrid.Config] = (*sendgrid.Config)(nil)
_ Config[signal.Config] = (*signal.Config)(nil)
_ Config[signl4.Config] = (*signl4.Config)(nil)
_ Config[slack.Config] = (*slack.Config)(nil)
_ Config[splunk.Config] = (*splunk.Config)(nil)
_ Config[squadcast.Config] = (*squadcast.Config)(nil)
_ Config[teams.Config] = (*teams.Config)(nil)
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
_ Config[telegram.Config] = (*telegram.Config)(nil)
_ Config[twilio.Config] = (*twilio.Config)(nil)
_ Config[webex.Config] = (*webex.Config)(nil)
_ Config[zapier.Config] = (*zapier.Config)(nil)
_ Config[zulip.Config] = (*zulip.Config)(nil)
)

View File

@@ -15,7 +15,7 @@ import (
)
const (
restAPIURL = "https://api.pushover.net/1/messages.json"
ApiURL = "https://api.pushover.net/1/messages.json"
defaultPriority = 0
)
@@ -76,9 +76,9 @@ func (cfg *Config) Validate() error {
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
return ErrInvalidPriority
}
if len(cfg.Device) > 25 {
return ErrInvalidDevice
}
if len(cfg.Device) > 25 {
return ErrInvalidDevice
}
return nil
}
@@ -104,9 +104,9 @@ func (cfg *Config) Merge(override *Config) {
if override.TTL > 0 {
cfg.TTL = override.TTL
}
if len(override.Device) > 0 {
cfg.Device = override.Device
}
if len(override.Device) > 0 {
cfg.Device = override.Device
}
}
// AlertProvider is the configuration necessary for sending an alert using Pushover
@@ -130,7 +130,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
request, err := http.NewRequest(http.MethodPost, ApiURL, buffer)
if err != nil {
return err
}

View File

@@ -0,0 +1,212 @@
package rocketchat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Rocket.Chat incoming webhook URL
Channel string `yaml:"channel,omitempty"` // Optional channel override
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Channel) > 0 {
cfg.Channel = override.Channel
}
}
// AlertProvider is the configuration necessary for sending an alert using Rocket.Chat
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to rocketchat alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Text string `json:"text"`
Channel string `json:"channel,omitempty"`
Username string `json:"username"`
Attachments []Attachment `json:"attachments"`
}
type Attachment struct {
Title string `json:"title"`
Text string `json:"text"`
Color string `json:"color"`
Fields []Field `json:"fields,omitempty"`
AuthorName string `json:"author_name"`
AuthorIcon string `json:"author_icon"`
}
type Field struct {
Title string `json:"title"`
Value string `json:"value"`
Short bool `json:"short"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var message, color string
if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
color = "#36a64f"
} else {
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
color = "#dd0000"
}
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)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ":\n> " + alertDescription
}
body := Body{
Text: "",
Username: "Gatus",
Attachments: []Attachment{
{
Title: "🚨 Gatus Alert",
Text: message + description,
Color: color,
AuthorName: "Gatus",
AuthorIcon: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
},
},
}
if cfg.Channel != "" {
body.Channel = cfg.Channel
}
if len(formattedConditionResults) > 0 {
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
Title: "Condition results",
Value: formattedConditionResults,
Short: false,
})
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,164 @@
package rocketchat
import (
"encoding/json"
"net/http"
"strings"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
expected: nil,
},
{
name: "valid-with-channel",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
expected: nil,
},
{
name: "invalid-webhook-url",
provider: AlertProvider{DefaultConfig: Config{}},
expected: ErrWebhookURLNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["username"] != "Gatus" {
t.Errorf("expected username to be 'Gatus', got %v", body["username"])
}
attachments := body["attachments"].([]interface{})
if len(attachments) != 1 {
t.Errorf("expected 1 attachment, got %d", len(attachments))
}
attachment := attachments[0].(map[string]interface{})
if attachment["color"] != "#dd0000" {
t.Errorf("expected color to be '#dd0000', got %v", attachment["color"])
}
text := attachment["text"].(string)
if !strings.Contains(text, "failed 3 time(s)") {
t.Errorf("expected text to contain failure count, got %s", text)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "triggered-with-channel",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["channel"] != "#alerts" {
t.Errorf("expected channel to be '#alerts', got %v", body["channel"])
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
attachments := body["attachments"].([]interface{})
attachment := attachments[0].(map[string]interface{})
if attachment["color"] != "#36a64f" {
t.Errorf("expected color to be '#36a64f', got %v", attachment["color"])
}
text := attachment["text"].(string)
if !strings.Contains(text, "resolved") {
t.Errorf("expected text to contain 'resolved', got %s", text)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,248 @@
package sendgrid
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"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 (
ApiURL = "https://api.sendgrid.com/v3/mail/send"
)
var (
ErrAPIKeyNotSet = errors.New("api-key not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
APIKey string `yaml:"api-key"`
From string `yaml:"from"`
To string `yaml:"to"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.From) == 0 {
return ErrFromNotSet
}
if len(cfg.To) == 0 {
return ErrToNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using SendGrid
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
payload := provider.buildSendGridPayload(cfg, subject, body)
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+cfg.APIKey)
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to sendgrid alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type SendGridPayload struct {
Personalizations []Personalization `json:"personalizations"`
From Email `json:"from"`
Subject string `json:"subject"`
Content []Content `json:"content"`
}
type Personalization struct {
To []Email `json:"to"`
}
type Email struct {
Email string `json:"email"`
}
type Content struct {
Type string `json:"type"`
Value string `json:"value"`
}
// buildSendGridPayload builds the SendGrid API payload
func (provider *AlertProvider) buildSendGridPayload(cfg *Config, subject, body string) SendGridPayload {
toEmails := strings.Split(cfg.To, ",")
var recipients []Email
for _, email := range toEmails {
recipients = append(recipients, Email{Email: strings.TrimSpace(email)})
}
return SendGridPayload{
Personalizations: []Personalization{
{
To: recipients,
},
},
From: Email{
Email: cfg.From,
},
Subject: subject,
Content: []Content{
{
Type: "text/plain",
Value: body,
},
{
Type: "text/html",
Value: strings.ReplaceAll(body, "\n", "<br>"),
},
},
}
}
// buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
if len(result.ConditionResults) > 0 {
formattedConditionResults = "\n\nCondition results:\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription
}
var extraLabels string
if len(ep.ExtraLabels) > 0 {
extraLabels = "\n\nExtra labels:\n"
for key, value := range ep.ExtraLabels {
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
}
}
return subject, message + description + extraLabels + formattedConditionResults
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,517 @@
package sendgrid
import (
"io"
"net/http"
"strings"
"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: "", From: "", To: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{
APIKey: "SG.test",
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "to@example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider with empty Group should not have been valid")
}
if err := providerWithInvalidOverrideGroup.Validate(); err != ErrDuplicateGroupOverride {
t.Error("provider with empty Group should return ErrDuplicateGroupOverride")
}
providerWithDuplicateOverrideGroups := AlertProvider{
DefaultConfig: Config{
APIKey: "SG.test",
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "to1@example.com"},
Group: "group",
},
{
Config: Config{To: "to2@example.com"},
Group: "group",
},
},
}
if err := providerWithDuplicateOverrideGroups.Validate(); err == nil {
t.Error("provider with duplicate group overrides should not have been valid")
}
if err := providerWithDuplicateOverrideGroups.Validate(); err != ErrDuplicateGroupOverride {
t.Error("provider with duplicate group overrides should return ErrDuplicateGroupOverride")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
APIKey: "SG.test",
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "to@example.com"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
providerWithValidMultipleOverrides := AlertProvider{
DefaultConfig: Config{
APIKey: "SG.test",
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Config: Config{To: "group1@example.com"},
Group: "group1",
},
{
Config: Config{To: "group2@example.com"},
Group: "group2",
},
},
}
if err := providerWithValidMultipleOverrides.Validate(); err != nil {
t.Error("provider with multiple valid overrides should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{"errors": [{"message": "Invalid API key"}]}`))}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
}),
ExpectedError: false,
},
}
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_buildSendGridPayload(t *testing.T) {
provider := &AlertProvider{}
cfg := &Config{
From: "test@example.com",
To: "to1@example.com,to2@example.com",
}
subject := "Test Subject"
body := "Test Body\nWith new line"
payload := provider.buildSendGridPayload(cfg, subject, body)
if payload.Subject != subject {
t.Errorf("expected subject to be %s, got %s", subject, payload.Subject)
}
if payload.From.Email != cfg.From {
t.Errorf("expected from email to be %s, got %s", cfg.From, payload.From.Email)
}
if len(payload.Personalizations) != 1 {
t.Errorf("expected 1 personalization, got %d", len(payload.Personalizations))
}
if len(payload.Personalizations[0].To) != 2 {
t.Errorf("expected 2 recipients, got %d", len(payload.Personalizations[0].To))
}
if payload.Personalizations[0].To[0].Email != "to1@example.com" {
t.Errorf("expected first recipient to be to1@example.com, got %s", payload.Personalizations[0].To[0].Email)
}
if payload.Personalizations[0].To[1].Email != "to2@example.com" {
t.Errorf("expected second recipient to be to2@example.com, got %s", payload.Personalizations[0].To[1].Email)
}
if len(payload.Content) != 2 {
t.Errorf("expected 2 content types, got %d", len(payload.Content))
}
if payload.Content[0].Type != "text/plain" {
t.Errorf("expected first content type to be text/plain, got %s", payload.Content[0].Type)
}
if payload.Content[0].Value != body {
t.Errorf("expected plain text content to be %s, got %s", body, payload.Content[0].Value)
}
if payload.Content[1].Type != "text/html" {
t.Errorf("expected second content type to be text/html, got %s", payload.Content[1].Type)
}
expectedHTML := "Test Body<br>With new line"
if payload.Content[1].Value != expectedHTML {
t.Errorf("expected HTML content to be %s, got %s", expectedHTML, payload.Content[1].Value)
}
}
func TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
Endpoint *endpoint.Endpoint
ExpectedSubject string
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
Name: "resolved-with-single-extra-label",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
Name: "triggered-with-no-extra-labels",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody(
scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if subject != scenario.ExpectedSubject {
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
}
if body != scenario.ExpectedBody {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "to01@example.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "group-to@example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "SG.override", "to": "alert-to@example.com", "from": "alert-from@example.com"}},
ExpectedOutput: Config{APIKey: "SG.override", From: "alert-from@example.com", To: "alert-to@example.com"},
},
{
Name: "provider-with-multiple-overrides-pick-correct-group",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
Overrides: []Override{
{
Group: "group1",
Config: Config{APIKey: "SG.group1", To: "group1@example.com"},
},
{
Group: "group2",
Config: Config{APIKey: "SG.group2", From: "group2@example.com"},
},
},
},
InputGroup: "group2",
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "SG.group2", From: "group2@example.com", To: "default@example.com"},
},
{
Name: "provider-partial-override-hierarchy",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
Overrides: []Override{
{
Group: "test-group",
Config: Config{From: "group@example.com"},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert@example.com"}},
ExpectedOutput: Config{APIKey: "SG.default", From: "group@example.com", To: "alert@example.com"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &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)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if got.To != scenario.ExpectedOutput.To {
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
func TestConfig_Validate(t *testing.T) {
scenarios := []struct {
Name string
Config Config
ExpectedError error
}{
{
Name: "missing-api-key",
Config: Config{APIKey: "", From: "test@example.com", To: "to@example.com"},
ExpectedError: ErrAPIKeyNotSet,
},
{
Name: "missing-from",
Config: Config{APIKey: "SG.test", From: "", To: "to@example.com"},
ExpectedError: ErrFromNotSet,
},
{
Name: "missing-to",
Config: Config{APIKey: "SG.test", From: "test@example.com", To: ""},
ExpectedError: ErrToNotSet,
},
{
Name: "valid-config",
Config: Config{APIKey: "SG.test", From: "test@example.com", To: "to@example.com"},
ExpectedError: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
err := scenario.Config.Validate()
if scenario.ExpectedError == nil && err != nil {
t.Errorf("expected no error, got %v", err)
}
if scenario.ExpectedError != nil && err == nil {
t.Errorf("expected error %v, got none", scenario.ExpectedError)
}
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestConfig_Merge(t *testing.T) {
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
override := Config{APIKey: "SG.override", To: "override@example.com"}
config.Merge(&override)
if config.APIKey != "SG.override" {
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
}
if config.From != "from@example.com" {
t.Errorf("expected From to remain from@example.com, got %s", config.From)
}
if config.To != "override@example.com" {
t.Errorf("expected To to be override@example.com, got %s", config.To)
}
}
func TestConfig_MergeWithClientConfig(t *testing.T) {
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
override := Config{APIKey: "SG.override", ClientConfig: &client.Config{Timeout: 30000}}
config.Merge(&override)
if config.APIKey != "SG.override" {
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
}
if config.ClientConfig == nil {
t.Error("expected ClientConfig to be set")
}
if config.ClientConfig.Timeout != 30000 {
t.Errorf("expected ClientConfig.Timeout to be 30000, got %d", config.ClientConfig.Timeout)
}
config2 := Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com", ClientConfig: &client.Config{Timeout: 10000}}
override2 := Config{APIKey: "SG.override2"}
config2.Merge(&override2)
if config2.ClientConfig.Timeout != 10000 {
t.Errorf("expected ClientConfig.Timeout to remain 10000, got %d", config2.ClientConfig.Timeout)
}
}

View File

@@ -0,0 +1,192 @@
package signal
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrApiURLNotSet = errors.New("api-url not set")
ErrNumberNotSet = errors.New("number not set")
ErrRecipientsNotSet = errors.New("recipients not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
ApiURL string `yaml:"api-url"` // Signal API URL (e.g., signal-cli-rest-api instance)
Number string `yaml:"number"` // Sender phone number
Recipients []string `yaml:"recipients"` // List of recipient phone numbers
}
func (cfg *Config) Validate() error {
if len(cfg.ApiURL) == 0 {
return ErrApiURLNotSet
}
if len(cfg.Number) == 0 {
return ErrNumberNotSet
}
if len(cfg.Recipients) == 0 {
return ErrRecipientsNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ApiURL) > 0 {
cfg.ApiURL = override.ApiURL
}
if len(override.Number) > 0 {
cfg.Number = override.Number
}
if len(override.Recipients) > 0 {
cfg.Recipients = override.Recipients
}
}
// AlertProvider is the configuration necessary for sending an alert using Signal
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
for _, recipient := range cfg.Recipients {
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved, recipient)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v2/send", cfg.ApiURL), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
response.Body.Close()
return fmt.Errorf("call to signal alert returned status code %d: %s", response.StatusCode, string(body))
}
response.Body.Close()
}
return nil
}
type Body struct {
Message string `json:"message"`
Number string `json:"number"`
Recipients []string `json:"recipients"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, recipient string) ([]byte, error) {
var message string
if resolved {
message = fmt.Sprintf("🟢 RESOLVED: %s\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("🔴 ALERT: %s\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
message += "\n\nCondition results:"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
}
}
body := Body{
Message: message,
Number: cfg.Number,
Recipients: []string{recipient},
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,151 @@
package signal
import (
"encoding/json"
"net/http"
"strings"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
expected: nil,
},
{
name: "invalid-api-url",
provider: AlertProvider{DefaultConfig: Config{Number: "+1234567890", Recipients: []string{"+0987654321"}}},
expected: ErrApiURLNotSet,
},
{
name: "invalid-number",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Recipients: []string{"+0987654321"}}},
expected: ErrNumberNotSet,
},
{
name: "invalid-recipients",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890"}},
expected: ErrRecipientsNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321", "+1111111111"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.URL.Path != "/v2/send" {
t.Errorf("expected path /v2/send, got %s", r.URL.Path)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["number"] != "+1234567890" {
t.Errorf("expected number to be '+1234567890', got %v", body["number"])
}
recipients := body["recipients"].([]interface{})
if len(recipients) != 1 {
t.Errorf("expected 1 recipient per request, got %d", len(recipients))
}
message := body["message"].(string)
if !strings.Contains(message, "ALERT") {
t.Errorf("expected message to contain 'ALERT', got %s", message)
}
if !strings.Contains(message, "failed 3 time(s)") {
t.Errorf("expected message to contain failure count, got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
message := body["message"].(string)
if !strings.Contains(message, "RESOLVED") {
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,184 @@
package signl4
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrTeamSecretNotSet = errors.New("team-secret not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
TeamSecret string `yaml:"team-secret"` // SIGNL4 team secret
}
func (cfg *Config) Validate() error {
if len(cfg.TeamSecret) == 0 {
return ErrTeamSecretNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.TeamSecret) > 0 {
cfg.TeamSecret = override.TeamSecret
}
}
// AlertProvider is the configuration necessary for sending an alert using SIGNL4
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
webhookURL := fmt.Sprintf("https://connect.signl4.com/webhook/%s", cfg.TeamSecret)
request, err := http.NewRequest(http.MethodPost, webhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to signl4 alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Title string `json:"Title"`
Message string `json:"Message"`
XS4Service string `json:"X-S4-Service"`
XS4Status string `json:"X-S4-Status"`
XS4ExternalID string `json:"X-S4-ExternalID"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var title, message, status string
if resolved {
title = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
status = "resolved"
} else {
title = fmt.Sprintf("TRIGGERED: %s", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
status = "new"
}
var conditionResults string
if len(result.ConditionResults) > 0 {
conditionResults = "\n\nCondition results:\n"
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✓"
} else {
prefix = "✗"
}
conditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
}
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n\nDescription: " + alertDescription
}
message += conditionResults
body := Body{
Title: title,
Message: message,
XS4Service: ep.DisplayName(),
XS4Status: status,
XS4ExternalID: fmt.Sprintf("gatus-%s", ep.Key()),
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,392 @@
package signl4
import (
"encoding/json"
"net/http"
"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{TeamSecret: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{TeamSecret: "team-secret-123"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{TeamSecret: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider team secret shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Config: Config{TeamSecret: "team-secret-override"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
}
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_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
},
{
Name: "triggered-with-group",
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"Title\":\"TRIGGERED: group/name\",\"Message\":\"An alert for group/name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
},
{
Name: "triggered-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"Title\":\"RESOLVED: name\",\"Message\":\"An alert for name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-_name\"}",
},
{
Name: "resolved-with-group",
Provider: AlertProvider{},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"Title\":\"RESOLVED: group/name\",\"Message\":\"An alert for group/name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
body, err := scenario.Provider.buildRequestBody(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: conditionResults,
},
scenario.Resolved,
)
if err != nil {
t.Fatalf("buildRequestBody returned an error: %v", err)
}
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Group: "group",
Config: Config{TeamSecret: "team-secret-override"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Group: "group",
Config: Config{TeamSecret: "team-secret-override"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{TeamSecret: "team-secret-override"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Group: "group",
Config: Config{TeamSecret: "team-secret-override"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"team-secret": "team-secret-alert"}},
ExpectedOutput: Config{TeamSecret: "team-secret-alert"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.TeamSecret != scenario.ExpectedOutput.TeamSecret {
t.Errorf("expected team secret to be %s, got %s", scenario.ExpectedOutput.TeamSecret, got.TeamSecret)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
func TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) {
// Test case 1: Empty override should be ignored, default config should be used
provider := AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
}
alertWithEmptyOverride := alert.Alert{
ProviderOverride: map[string]any{"team-secret": ""},
}
cfg, err := provider.GetConfig("", &alertWithEmptyOverride)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if cfg.TeamSecret != "team-secret-123" {
t.Errorf("expected team secret to remain default 'team-secret-123', got %s", cfg.TeamSecret)
}
// Test case 2: Invalid default config with no valid override should fail
providerWithInvalidDefault := AlertProvider{
DefaultConfig: Config{TeamSecret: ""},
}
alertWithEmptyOverride2 := alert.Alert{
ProviderOverride: map[string]any{"team-secret": ""},
}
_, err = providerWithInvalidDefault.GetConfig("", &alertWithEmptyOverride2)
if err == nil {
t.Error("expected error due to invalid default config, got none")
}
if err != ErrTeamSecretNotSet {
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
}
}
func TestAlertProvider_ValidateWithDuplicateGroupOverride(t *testing.T) {
providerWithDuplicateOverride := AlertProvider{
DefaultConfig: Config{TeamSecret: "team-secret-123"},
Overrides: []Override{
{
Group: "group1",
Config: Config{TeamSecret: "team-secret-override-1"},
},
{
Group: "group1",
Config: Config{TeamSecret: "team-secret-override-2"},
},
},
}
if err := providerWithDuplicateOverride.Validate(); err == nil {
t.Error("provider should not have been valid due to duplicate group override")
}
if err := providerWithDuplicateOverride.Validate(); err != ErrDuplicateGroupOverride {
t.Errorf("expected ErrDuplicateGroupOverride, got %v", providerWithDuplicateOverride.Validate())
}
}
func TestAlertProvider_ValidateOverridesWithInvalidAlert(t *testing.T) {
provider := AlertProvider{
DefaultConfig: Config{TeamSecret: ""},
}
alertWithEmptyOverride := alert.Alert{
ProviderOverride: map[string]any{"team-secret": ""},
}
err := provider.ValidateOverrides("", &alertWithEmptyOverride)
if err == nil {
t.Error("expected error due to invalid default config, got none")
}
if err != ErrTeamSecretNotSet {
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
}
}

View File

@@ -0,0 +1,220 @@
package splunk
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrHecURLNotSet = errors.New("hec-url not set")
ErrHecTokenNotSet = errors.New("hec-token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
HecURL string `yaml:"hec-url"` // Splunk HEC (HTTP Event Collector) URL
HecToken string `yaml:"hec-token"` // Splunk HEC token
Source string `yaml:"source,omitempty"` // Event source
SourceType string `yaml:"sourcetype,omitempty"` // Event source type
Index string `yaml:"index,omitempty"` // Splunk index
}
func (cfg *Config) Validate() error {
if len(cfg.HecURL) == 0 {
return ErrHecURLNotSet
}
if len(cfg.HecToken) == 0 {
return ErrHecTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.HecURL) > 0 {
cfg.HecURL = override.HecURL
}
if len(override.HecToken) > 0 {
cfg.HecToken = override.HecToken
}
if len(override.Source) > 0 {
cfg.Source = override.Source
}
if len(override.SourceType) > 0 {
cfg.SourceType = override.SourceType
}
if len(override.Index) > 0 {
cfg.Index = override.Index
}
}
// AlertProvider is the configuration necessary for sending an alert using Splunk
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/services/collector/event", cfg.HecURL), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Splunk %s", cfg.HecToken))
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to splunk alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Time int64 `json:"time"`
Source string `json:"source,omitempty"`
SourceType string `json:"sourcetype,omitempty"`
Index string `json:"index,omitempty"`
Event Event `json:"event"`
}
type Event struct {
AlertType string `json:"alert_type"`
Endpoint string `json:"endpoint"`
Group string `json:"group,omitempty"`
Status string `json:"status"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
Conditions []*endpoint.ConditionResult `json:"conditions,omitempty"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var alertType, status, message string
if resolved {
alertType = "resolved"
status = "ok"
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
alertType = "triggered"
status = "critical"
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
event := Event{
AlertType: alertType,
Endpoint: ep.DisplayName(),
Group: ep.Group,
Status: status,
Message: message,
Description: alert.GetDescription(),
}
if len(result.ConditionResults) > 0 {
event.Conditions = result.ConditionResults
}
body := Body{
Time: time.Now().Unix(),
Event: event,
}
// Set optional fields
if cfg.Source != "" {
body.Source = cfg.Source
} else {
body.Source = "gatus"
}
if cfg.SourceType != "" {
body.SourceType = cfg.SourceType
} else {
body.SourceType = "gatus:alert"
}
if cfg.Index != "" {
body.Index = cfg.Index
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,155 @@
package splunk
import (
"encoding/json"
"net/http"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
expected: nil,
},
{
name: "valid-with-index",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
expected: nil,
},
{
name: "invalid-hec-url",
provider: AlertProvider{DefaultConfig: Config{HecToken: "token123"}},
expected: ErrHecURLNotSet,
},
{
name: "invalid-hec-token",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088"}},
expected: ErrHecTokenNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.URL.Path != "/services/collector/event" {
t.Errorf("expected path /services/collector/event, got %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Splunk token123" {
t.Errorf("expected Authorization header to be 'Splunk token123', got %s", r.Header.Get("Authorization"))
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["time"] == nil {
t.Error("expected 'time' field in request body")
}
event := body["event"].(map[string]interface{})
if event["alert_type"] != "triggered" {
t.Errorf("expected alert_type to be 'triggered', got %v", event["alert_type"])
}
if event["status"] != "critical" {
t.Errorf("expected status to be 'critical', got %v", event["status"])
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["index"] != "main" {
t.Errorf("expected index to be 'main', got %v", body["index"])
}
event := body["event"].(map[string]interface{})
if event["alert_type"] != "resolved" {
t.Errorf("expected alert_type to be 'resolved', got %v", event["alert_type"])
}
if event["status"] != "ok" {
t.Errorf("expected status to be 'ok', got %v", event["status"])
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,190 @@
package squadcast
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Squadcast webhook URL
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Squadcast
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to squadcast alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
Message string `json:"message"`
Description string `json:"description,omitempty"`
EventID string `json:"event_id"`
Status string `json:"status"`
Tags map[string]string `json:"tags,omitempty"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var message, status string
eventID := fmt.Sprintf("gatus-%s", ep.Key())
if resolved {
message = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
status = "resolve"
} else {
message = fmt.Sprintf("ALERT: %s", ep.DisplayName())
status = "trigger"
}
description := fmt.Sprintf("Endpoint: %s\n", ep.DisplayName())
if resolved {
description += fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row\n", alert.SuccessThreshold)
} else {
description += fmt.Sprintf("Endpoint has failed %d time(s) in a row\n", alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description += fmt.Sprintf("\nDescription: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
description += "\n\nCondition Results:"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
description += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
}
}
body := Body{
Message: message,
Description: description,
EventID: eventID,
Status: status,
Tags: map[string]string{
"endpoint": ep.Name,
"group": ep.Group,
"source": "gatus",
},
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,141 @@
package squadcast
import (
"encoding/json"
"net/http"
"strings"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
expected: nil,
},
{
name: "invalid-webhook-url",
provider: AlertProvider{DefaultConfig: Config{}},
expected: ErrWebhookURLNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["status"] != "trigger" {
t.Errorf("expected status to be 'trigger', got %v", body["status"])
}
if body["event_id"] == nil {
t.Error("expected 'event_id' field in request body")
}
message := body["message"].(string)
if !strings.Contains(message, "ALERT") {
t.Errorf("expected message to contain 'ALERT', got %s", message)
}
description := body["description"].(string)
if !strings.Contains(description, "failed 3 time(s)") {
t.Errorf("expected description to contain failure count, got %s", description)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["status"] != "resolve" {
t.Errorf("expected status to be 'resolve', got %v", body["status"])
}
message := body["message"].(string)
if !strings.Contains(message, "RESOLVED") {
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -14,7 +14,7 @@ import (
"gopkg.in/yaml.v3"
)
const defaultApiUrl = "https://api.telegram.org"
const ApiURL = "https://api.telegram.org"
var (
ErrTokenNotSet = errors.New("token not set")
@@ -33,7 +33,7 @@ type Config struct {
func (cfg *Config) Validate() error {
if len(cfg.ApiUrl) == 0 {
cfg.ApiUrl = defaultApiUrl
cfg.ApiUrl = ApiURL
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet

View File

@@ -29,8 +29,10 @@ type Config struct {
From string `yaml:"from"`
To string `yaml:"to"`
// TODO in v6.0.0: Rename this to text-triggered
TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional)
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
// TODO in v6.0.0: Rename this to text-resolved
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
}
func (cfg *Config) Validate() error {
@@ -113,13 +115,23 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
var message string
if resolved {
if len(cfg.TextTwilioResolved) > 0 {
message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
message = cfg.TextTwilioResolved
message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
} else {
if len(cfg.TextTwilioTriggered) > 0 {
message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
message = cfg.TextTwilioTriggered
message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}

View File

@@ -129,6 +129,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
},
{
Name: "triggered-with-old-placeholders",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: {endpoint} - {description}"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
Name: "triggered-with-new-placeholders",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: [ENDPOINT] - [ALERT_DESCRIPTION]"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
},
{
Name: "resolved-with-mixed-placeholders",
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioResolved: "Resolved: {endpoint} and [ENDPOINT] - {description} and [ALERT_DESCRIPTION]"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "Body=Resolved%3A+endpoint-name+and+endpoint-name+-+description-2+and+description-2&From=3&To=4",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {

View File

@@ -0,0 +1,212 @@
package vonage
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"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 ApiURL = "https://rest.nexmo.com/sms/json"
var (
ErrAPIKeyNotSet = errors.New("api-key not set")
ErrAPISecretNotSet = errors.New("api-secret not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
APIKey string `yaml:"api-key"`
APISecret string `yaml:"api-secret"`
From string `yaml:"from"`
To []string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.APISecret) == 0 {
return ErrAPISecretNotSet
}
if len(cfg.From) == 0 {
return ErrFromNotSet
}
if len(cfg.To) == 0 {
return ErrToNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.APISecret) > 0 {
cfg.APISecret = override.APISecret
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using Vonage
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
message := provider.buildMessage(cfg, ep, alert, result, resolved)
// Send SMS to each recipient
for _, recipient := range cfg.To {
if err := provider.sendSMS(cfg, recipient, message); err != nil {
return err
}
}
return nil
}
// sendSMS sends an individual SMS message
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
data := url.Values{}
data.Set("api_key", cfg.APIKey)
data.Set("api_secret", cfg.APISecret)
data.Set("from", cfg.From)
data.Set("to", to)
data.Set("text", message)
request, err := http.NewRequest(http.MethodPost, ApiURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
// Read response body once and use it for both error handling and JSON processing
body, err := io.ReadAll(response.Body)
if err != nil {
return err
}
if response.StatusCode >= 400 {
return fmt.Errorf("call to vonage alert returned status code %d: %s", response.StatusCode, string(body))
}
// Check response for errors in messages array
var vonageResponse Response
if err := json.Unmarshal(body, &vonageResponse); err != nil {
return err
}
// Check if any message failed
for _, msg := range vonageResponse.Messages {
if msg.Status != "0" {
return fmt.Errorf("vonage SMS failed with status %s: %s", msg.Status, msg.ErrorText)
}
}
return nil
}
type Response struct {
MessageCount string `json:"message-count"`
Messages []Message `json:"messages"`
}
type Message struct {
To string `json:"to"`
MessageID string `json:"message-id"`
Status string `json:"status"`
ErrorText string `json:"error-text"`
RemainingBalance string `json:"remaining-balance"`
MessagePrice string `json:"message-price"`
Network string `json:"network"`
}
// buildMessage builds the SMS message content
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
if resolved {
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
} else {
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,546 @@
package vonage
import (
"io"
"net/http"
"strings"
"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 TestVonageAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestVonageAlertProvider_IsValidWithOverride(t *testing.T) {
validProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
APIKey: "override-key",
APISecret: "override-secret",
From: "Override",
To: []string{"+9876543210"},
},
},
},
}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
func TestVonageAlertProvider_IsNotValidWithInvalidOverrideGroup(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "",
Config: Config{
APIKey: "override-key",
APISecret: "override-secret",
From: "Override",
To: []string{"+9876543210"},
},
},
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
}
func TestVonageAlertProvider_IsNotValidWithDuplicateOverrideGroup(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
APIKey: "override-key1",
APISecret: "override-secret1",
From: "Override1",
To: []string{"+9876543210"},
},
},
{
Group: "test-group",
Config: Config{
APIKey: "override-key2",
APISecret: "override-secret2",
From: "Override2",
To: []string{"+1234567890"},
},
},
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
}
func TestVonageAlertProvider_IsValidWithInvalidFrom(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "",
To: []string{"+1234567890"},
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
}
func TestVonageAlertProvider_IsValidWithInvalidTo(t *testing.T) {
invalidProvider := AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{},
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.50","message-price":"0.10","network":"12345"}]}`)),
}
}),
ExpectedError: false,
},
{
Name: "triggered-error-status-code",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "triggered-error-vonage-response",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"","status":"2","error-text":"Missing from param"}]}`)),
}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.40","message-price":"0.10","network":"12345"}]}`)),
}
}),
ExpectedError: false,
},
{
Name: "multiple-recipients",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890", "+0987654321"},
},
},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.30","message-price":"0.10","network":"12345"}]}`)),
}
}),
ExpectedError: false,
},
}
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_buildMessage(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedMessage string
}{
{
Name: "triggered",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
},
{
Name: "resolved",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
message := scenario.Provider.buildMessage(
&scenario.Provider.DefaultConfig,
&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 message != scenario.ExpectedMessage {
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
{
Name: "provider-with-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
APIKey: "group-override-key",
APISecret: "group-override-secret",
From: "GroupOverride",
To: []string{"+9876543210"},
},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
APIKey: "group-override-key",
APISecret: "group-override-secret",
From: "GroupOverride",
To: []string{"+9876543210"},
},
},
{
Name: "provider-with-group-override-partial",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
To: []string{"+9876543210"},
},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+9876543210"},
},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"api-key": "override-key",
"api-secret": "override-secret",
"from": "Override",
"to": []string{"+9876543210"},
}},
ExpectedOutput: Config{
APIKey: "override-key",
APISecret: "override-secret",
From: "Override",
To: []string{"+9876543210"},
},
},
{
Name: "provider-with-both-group-and-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "test-group",
Config: Config{
APIKey: "group-override-key",
From: "GroupOverride",
},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"api-secret": "alert-override-secret",
"to": []string{"+9876543210"},
}},
ExpectedOutput: Config{
APIKey: "group-override-key",
APISecret: "alert-override-secret",
From: "GroupOverride",
To: []string{"+9876543210"},
},
},
{
Name: "provider-with-group-override-no-match",
Provider: AlertProvider{
DefaultConfig: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
Overrides: []Override{
{
Group: "different-group",
Config: Config{
APIKey: "group-override-key",
},
},
},
},
InputGroup: "test-group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
APIKey: "test-key",
APISecret: "test-secret",
From: "Gatus",
To: []string{"+1234567890"},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.APIKey != scenario.ExpectedOutput.APIKey {
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
}
if got.APISecret != scenario.ExpectedOutput.APISecret {
t.Errorf("expected APISecret to be %s, got %s", scenario.ExpectedOutput.APISecret, got.APISecret)
}
if got.From != scenario.ExpectedOutput.From {
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
}
if len(got.To) != len(scenario.ExpectedOutput.To) {
t.Errorf("expected To to have length %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
} else {
for i, to := range got.To {
if to != scenario.ExpectedOutput.To[i] {
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
}
}
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}

View File

@@ -0,0 +1,171 @@
package webex
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Webex Teams webhook URL
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Webex
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to webex alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
RoomID string `json:"roomId,omitempty"`
Text string `json:"text,omitempty"`
Markdown string `json:"markdown"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var message string
if resolved {
message = fmt.Sprintf("✅ **RESOLVED**: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("🚨 **ALERT**: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += fmt.Sprintf("\n\n**Description**: %s", alertDescription)
}
if len(result.ConditionResults) > 0 {
message += "\n\n**Condition Results:**"
for _, conditionResult := range result.ConditionResults {
var status string
if conditionResult.Success {
status = "✅"
} else {
status = "❌"
}
message += fmt.Sprintf("\n- %s `%s`", status, conditionResult.Condition)
}
}
body := Body{
Markdown: message,
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,134 @@
package webex
import (
"encoding/json"
"net/http"
"strings"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
expected: nil,
},
{
name: "invalid-webhook-url",
provider: AlertProvider{DefaultConfig: Config{}},
expected: ErrWebhookURLNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["markdown"] == nil {
t.Error("expected 'markdown' field in request body")
}
markdown := body["markdown"].(string)
if !strings.Contains(markdown, "ALERT") {
t.Errorf("expected markdown to contain 'ALERT', got %s", markdown)
}
if !strings.Contains(markdown, "failed 3 time(s)") {
t.Errorf("expected markdown to contain failure count, got %s", markdown)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
markdown := body["markdown"].(string)
if !strings.Contains(markdown, "RESOLVED") {
t.Errorf("expected markdown to contain 'RESOLVED', got %s", markdown)
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}

View File

@@ -0,0 +1,197 @@
package zapier
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // Zapier webhook URL
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Zapier
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"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default integration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
// Send an alert using the provider
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
}
body, err := provider.buildRequestBody(ep, alert, result, resolved)
if err != nil {
return err
}
buffer := bytes.NewBuffer(body)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to zapier alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
type Body struct {
AlertType string `json:"alert_type"`
Status string `json:"status"`
Endpoint string `json:"endpoint"`
Group string `json:"group,omitempty"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
Timestamp string `json:"timestamp"`
SuccessThreshold int `json:"success_threshold,omitempty"`
FailureThreshold int `json:"failure_threshold,omitempty"`
ConditionResults []*endpoint.ConditionResult `json:"condition_results,omitempty"`
TotalConditions int `json:"total_conditions"`
PassedConditions int `json:"passed_conditions"`
FailedConditions int `json:"failed_conditions"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
var alertType, status, message string
var successThreshold, failureThreshold int
if resolved {
alertType = "resolved"
status = "ok"
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
successThreshold = alert.SuccessThreshold
} else {
alertType = "triggered"
status = "critical"
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
failureThreshold = alert.FailureThreshold
}
// Process condition results
passedConditions := 0
failedConditions := 0
for _, cr := range result.ConditionResults {
if cr.Success {
passedConditions++
} else {
failedConditions++
}
}
body := Body{
AlertType: alertType,
Status: status,
Endpoint: ep.DisplayName(),
Group: ep.Group,
Message: message,
Description: alert.GetDescription(),
Timestamp: time.Now().Format(time.RFC3339),
SuccessThreshold: successThreshold,
FailureThreshold: failureThreshold,
ConditionResults: result.ConditionResults,
TotalConditions: len(result.ConditionResults),
PassedConditions: passedConditions,
FailedConditions: failedConditions,
}
bodyAsJSON, err := json.Marshal(body)
if err != nil {
return nil, err
}
return bodyAsJSON, nil
}
// 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 group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// 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
}

View File

@@ -0,0 +1,162 @@
package zapier
import (
"encoding/json"
"net/http"
"strings"
"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) {
scenarios := []struct {
name string
provider AlertProvider
expected error
}{
{
name: "valid",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
expected: nil,
},
{
name: "invalid-webhook-url",
provider: AlertProvider{DefaultConfig: Config{}},
expected: ErrWebhookURLNotSet,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if err != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, err)
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "triggered",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Host != "hooks.zapier.com" {
t.Errorf("expected host hooks.zapier.com, got %s", r.Host)
}
if r.URL.Path != "/hooks/catch/123456/abcdef/" {
t.Errorf("expected path /hooks/catch/123456/abcdef/, got %s", r.URL.Path)
}
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["alert_type"] != "triggered" {
t.Errorf("expected alert_type to be 'triggered', got %v", body["alert_type"])
}
if body["status"] != "critical" {
t.Errorf("expected status to be 'critical', got %v", body["status"])
}
if body["endpoint"] != "endpoint-name" {
t.Errorf("expected endpoint to be 'endpoint-name', got %v", body["endpoint"])
}
message := body["message"].(string)
if !strings.Contains(message, "Alert") {
t.Errorf("expected message to contain 'Alert', got %s", message)
}
if !strings.Contains(message, "failed 3 time(s)") {
t.Errorf("expected message to contain failure count, got %s", message)
}
if body["description"] != firstDescription {
t.Errorf("expected description to be '%s', got %v", firstDescription, body["description"])
}
conditionResults := body["condition_results"].([]interface{})
if len(conditionResults) != 2 {
t.Errorf("expected 2 condition results, got %d", len(conditionResults))
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "resolved",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
body := make(map[string]interface{})
json.NewDecoder(r.Body).Decode(&body)
if body["alert_type"] != "resolved" {
t.Errorf("expected alert_type to be 'resolved', got %v", body["alert_type"])
}
if body["status"] != "ok" {
t.Errorf("expected status to be 'ok', got %v", body["status"])
}
message := body["message"].(string)
if !strings.Contains(message, "resolved") {
t.Errorf("expected message to contain 'resolved', got %s", message)
}
if body["description"] != secondDescription {
t.Errorf("expected description to be '%s', got %v", secondDescription, body["description"])
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
expectedError: false,
},
{
name: "error-response",
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
}),
expectedError: true,
},
}
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_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}