diff --git a/.github/assets/teams-alerts.png b/.github/assets/teams-alerts.png new file mode 100644 index 00000000..44b6e898 Binary files /dev/null and b/.github/assets/teams-alerts.png differ diff --git a/README.md b/README.md index c7462504..380a9cf4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ For more details, see [Usage](#usage) - [Configuring Twilio alerts](#configuring-twilio-alerts) - [Configuring Mattermost alerts](#configuring-mattermost-alerts) - [Configuring Messagebird alerts](#configuring-messagebird-alerts) + - [Configuring Teams alerts](#configuring-teams-alerts) - [Configuring Telegram alerts](#configuring-telegram-alerts) - [Configuring custom alerts](#configuring-custom-alerts) - [Kubernetes (ALPHA)](#kubernetes-alpha) @@ -92,7 +93,7 @@ The main features of Gatus are: - **Highly flexible health check conditions**: While checking the response status may be enough for some use cases, Gatus goes much further and allows you to add conditions on the response time, the response body and even the IP address. - **Ability to use Gatus for user acceptance tests**: Thanks to the point above, you can leverage this application to create automated user acceptance tests. - **Very easy to configure**: Not only is the configuration designed to be as readable as possible, it's also extremely easy to add a new service or a new endpoint to monitor. -- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty and Twilio are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks. +- **Alerting**: While having a pretty visual dashboard is useful to keep track of the state of your application(s), you probably don't want to stare at it all day. Thus, notifications via Slack, Mattermost, Messagebird, PagerDuty, Twilio and Teams are supported out of the box with the ability to configure a custom alerting provider for any needs you might have, whether it be a different provider or a custom application that manages automated rollbacks. - **Metrics** - **Low resource consumption**: As with most Go applications, the resource footprint that this application requires is negligibly small. - **GitHub uptime badges**: ![Uptime 1h](https://status.twinnation.org/api/v1/badges/uptime/1h/core_twinnation-external.svg) ![Uptime 24h](https://status.twinnation.org/api/v1/badges/uptime/24h/core_twinnation-external.svg) ![Uptime 7d](https://status.twinnation.org/api/v1/badges/uptime/7d/core_twinnation-external.svg) @@ -151,7 +152,7 @@ If you want to test it locally, see [Docker](#docker). | `services[].dns` | Configuration for a service of type DNS. See [Monitoring a service using DNS queries](#monitoring-a-service-using-dns-queries). | `""` | | `services[].dns.query-type` | Query type for DNS service. | `""` | | `services[].dns.query-name` | Query name for DNS service. | `""` | -| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `custom`. | Required `""` | +| `services[].alerts[].type` | Type of alert. Valid types: `slack`, `discord`, `pagerduty`, `twilio`, `mattermost`, `messagebird`, `teams` `custom`. | Required `""` | | `services[].alerts[].enabled` | Whether to enable the alert. | `false` | | `services[].alerts[].failure-threshold` | Number of failures in a row needed before triggering the alert. | `3` | | `services[].alerts[].success-threshold` | Number of successes in a row before an ongoing incident is marked as resolved. | `2` | @@ -265,6 +266,8 @@ ignored. | `alerting.messagebird.access-key` | Messagebird access key | Required `""` | | `alerting.messagebird.originator` | The sender of the message | Required `""` | | `alerting.messagebird.recipients` | The recipients of the message | Required `""` | +| `alerting.teams` | Configuration for alerts of type `teams` | `{}` | +| `alerting.teams.webhook-url` | Teams Webhook URL | Required `""` | | `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | | `alerting.telegram.token` | Telegram Bot Token | Required `""` | | `alerting.telegram.id` | Telegram User ID | Required `""` | @@ -442,6 +445,30 @@ services: - "[RESPONSE_TIME] < 300" ``` +#### Configuring Teams alerts +```yaml +alerting: + teams: + webhook-url: "http://**********/hooks/**********" + +services: + - name: twinnation + url: "https://twinnation.org/health" + interval: 30s + alerts: + - type: teams + enabled: true + description: "healthcheck failed" + send-on-resolved: true + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" +``` + +Here's an example of what the notifications look like: + +![Teams notifications](.github/assets/teams-alerts.png) #### Configuring Telegram alerts ```yaml diff --git a/alerting/alert/type.go b/alerting/alert/type.go index bbf226b6..b62ef423 100644 --- a/alerting/alert/type.go +++ b/alerting/alert/type.go @@ -28,4 +28,7 @@ const ( // TypeTwilio is the Type for the twilio alerting provider TypeTwilio Type = "twilio" + + // Teams is the Type for the teams alerting provider + TypeTeams Type = "teams" ) diff --git a/alerting/config.go b/alerting/config.go index 43cff00a..38390e44 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -11,6 +11,7 @@ import ( "github.com/TwinProduction/gatus/alerting/provider/slack" "github.com/TwinProduction/gatus/alerting/provider/telegram" "github.com/TwinProduction/gatus/alerting/provider/twilio" + "github.com/TwinProduction/gatus/alerting/provider/teams" ) // Config is the configuration for alerting providers @@ -38,6 +39,9 @@ type Config struct { // Twilio is the configuration for the twilio alerting provider Twilio *twilio.AlertProvider `yaml:"twilio"` + + // Teams is the configuration for the teams alerting provider + Teams *teams.AlertProvider `yaml:"teams"` } // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type @@ -91,6 +95,12 @@ func (config Config) GetAlertingProviderByAlertType(alertType alert.Type) provid return nil } return config.Twilio + case alert.TypeTeams: + if config.Teams == nil { + // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil + return nil + } + return config.Teams } return nil } diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index b1a65188..5b26cba6 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -10,6 +10,7 @@ import ( "github.com/TwinProduction/gatus/alerting/provider/slack" "github.com/TwinProduction/gatus/alerting/provider/telegram" "github.com/TwinProduction/gatus/alerting/provider/twilio" + "github.com/TwinProduction/gatus/alerting/provider/teams" "github.com/TwinProduction/gatus/core" ) @@ -57,4 +58,5 @@ var ( _ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil) + _ AlertProvider = (*teams.AlertProvider)(nil) ) diff --git a/alerting/provider/teams/teams.go b/alerting/provider/teams/teams.go new file mode 100644 index 00000000..7774c90c --- /dev/null +++ b/alerting/provider/teams/teams.go @@ -0,0 +1,73 @@ +package teams + +import ( + "fmt" + "net/http" + + "github.com/TwinProduction/gatus/alerting/alert" + "github.com/TwinProduction/gatus/alerting/provider/custom" + "github.com/TwinProduction/gatus/core" +) + +// AlertProvider is the configuration necessary for sending an alert using Teams +type AlertProvider struct { + WebhookURL string `yaml:"webhook-url"` + + // DefaultAlert is the default alert configuration to use for services with an alert of the appropriate type + DefaultAlert *alert.Alert `yaml:"default-alert"` +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + return len(provider.WebhookURL) > 0 +} + +// ToCustomAlertProvider converts the provider into a custom.AlertProvider +func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *alert.Alert, result *core.Result, resolved bool) *custom.AlertProvider { + var message string + var color string + if resolved { + message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", service.Name, 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", service.Name, alert.FailureThreshold) + color = "#DD0000" + } + var results string + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "✅" + } else { + prefix = "❌" + } + results += fmt.Sprintf("%s - `%s`
", prefix, conditionResult.Condition) + } + return &custom.AlertProvider{ + URL: provider.WebhookURL, + Method: http.MethodPost, + Body: fmt.Sprintf(`{ + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "themeColor": "%s", + "title": "🚨 Gatus", + "text": "%s:\n> %s", + "sections": [ + { + "activityTitle": "URL", + "text": "%s" + }, + { + "activityTitle": "Condition results", + "text": "%s" + } + ] +}`, color, message, alert.GetDescription(), service.URL, results), + Headers: map[string]string{"Content-Type": "application/json"}, + } +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} diff --git a/alerting/provider/teams/teams_test.go b/alerting/provider/teams/teams_test.go new file mode 100644 index 00000000..1bf8b4d3 --- /dev/null +++ b/alerting/provider/teams/teams_test.go @@ -0,0 +1,66 @@ +package teams + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/TwinProduction/gatus/alerting/alert" + "github.com/TwinProduction/gatus/core" +) + +func TestAlertProvider_IsValid(t *testing.T) { + invalidProvider := AlertProvider{WebhookURL: ""} + if invalidProvider.IsValid() { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{WebhookURL: "http://example.com"} + if !validProvider.IsValid() { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { + provider := AlertProvider{WebhookURL: "http://example.org"} + customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) + if customAlertProvider == nil { + t.Fatal("customAlertProvider shouldn't have been nil") + } + if !strings.Contains(customAlertProvider.Body, "resolved") { + t.Error("customAlertProvider.Body should've contained the substring resolved") + } + if customAlertProvider.URL != "http://example.org" { + t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) + } + if customAlertProvider.Method != http.MethodPost { + t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) + } + body := make(map[string]interface{}) + err := json.Unmarshal([]byte(customAlertProvider.Body), &body) + if err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } +} + +func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { + provider := AlertProvider{WebhookURL: "http://example.org"} + customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &alert.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) + if customAlertProvider == nil { + t.Fatal("customAlertProvider shouldn't have been nil") + } + if !strings.Contains(customAlertProvider.Body, "triggered") { + t.Error("customAlertProvider.Body should've contained the substring triggered") + } + if customAlertProvider.URL != "http://example.org" { + t.Errorf("expected URL to be %s, got %s", "http://example.org", customAlertProvider.URL) + } + if customAlertProvider.Method != http.MethodPost { + t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) + } + body := make(map[string]interface{}) + err := json.Unmarshal([]byte(customAlertProvider.Body), &body) + if err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } +} diff --git a/config.yaml b/config.yaml index 4e7c68d5..9c508ae7 100644 --- a/config.yaml +++ b/config.yaml @@ -1,47 +1,19 @@ -services: - - name: front-end - group: core - url: "https://twinnation.org/health" - interval: 1m - conditions: - - "[STATUS] == 200" - - "[BODY].status == UP" - - "[RESPONSE_TIME] < 150" - - - name: back-end - group: core - url: "https://example.org/" - interval: 5m - conditions: - - "[STATUS] == 200" - - "[CERTIFICATE_EXPIRATION] > 48h" - - - name: monitoring - group: internal - url: "https://example.org/" - interval: 5m - conditions: - - "[STATUS] == 200" - - - name: nas - group: internal - url: "https://example.org/" - interval: 5m - conditions: - - "[STATUS] == 200" - - - name: example-dns-query - url: "8.8.8.8" # Address of the DNS server to use - interval: 5m - dns: - query-name: "example.com" - query-type: "A" - conditions: - - "[BODY] == 93.184.216.34" - - "[DNS_RCODE] == NOERROR" - - - name: icmp-ping - url: "icmp://example.org" - interval: 1m - conditions: - - "[CONNECTED] == true" +alerting: + teams: + webhook-url: "https://quartum.webhook.office.com/webhookb2/fa1bd615-fe85-4248-ab47-c775c6269f66@e4945be5-4b78-49eb-ac7e-de2875b58a6f/IncomingWebhook/b2333e8489f44f23ac020b5b3d8d314e/4e96d5d6-e1d3-42fc-a022-cc4608ac4d9f" + default-alert: + enabled: true + failure-threshold: 1 + success-threshold: 1 + send-on-resolved: true +services: + - name: front-end + group: core + url: "https://twinnation.org/health" + alerts: + - type: teams + interval: 1m + conditions: + - "[STATUS] == 201" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 150" diff --git a/config/config.go b/config/config.go index 1c546fda..620fb91b 100644 --- a/config/config.go +++ b/config/config.go @@ -272,6 +272,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, services []*core.Se alert.TypeSlack, alert.TypeTelegram, alert.TypeTwilio, + alert.TypeTeams, } var validProviders, invalidProviders []alert.Type for _, alertType := range alertTypes { diff --git a/config/config_test.go b/config/config_test.go index c9cb1d31..a6d674bb 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -16,6 +16,7 @@ import ( "github.com/TwinProduction/gatus/alerting/provider/slack" "github.com/TwinProduction/gatus/alerting/provider/telegram" "github.com/TwinProduction/gatus/alerting/provider/twilio" + "github.com/TwinProduction/gatus/alerting/provider/teams" "github.com/TwinProduction/gatus/core" "github.com/TwinProduction/gatus/k8stest" v1 "k8s.io/api/core/v1" @@ -351,6 +352,8 @@ alerting: token: "5678" from: "+1-234-567-8901" to: "+1-234-567-8901" + teams: + webhook-url: "http://example.com" services: - name: twinnation @@ -375,6 +378,8 @@ services: enabled: true failure-threshold: 12 success-threshold: 15 + - type: teams + enabled: true conditions: - "[STATUS] == 200" `)) @@ -401,8 +406,8 @@ services: if config.Services[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } - if len(config.Services[0].Alerts) != 7 { - t.Fatal("There should've been 7 alerts configured") + if len(config.Services[0].Alerts) != 8 { + t.Fatal("There should've been 8 alerts configured") } if config.Services[0].Alerts[0].Type != alert.TypeSlack { @@ -489,6 +494,19 @@ services: if config.Services[0].Alerts[6].SuccessThreshold != 15 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Services[0].Alerts[6].SuccessThreshold) } + + if config.Services[0].Alerts[7].Type != alert.TypeTeams { + t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Services[0].Alerts[7].Type) + } + if !config.Services[0].Alerts[7].IsEnabled() { + t.Error("The alert should've been enabled") + } + if config.Services[0].Alerts[7].FailureThreshold != 3 { + t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[7].FailureThreshold) + } + if config.Services[0].Alerts[7].SuccessThreshold != 2 { + t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[7].SuccessThreshold) + } } func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlert(t *testing.T) { @@ -538,6 +556,10 @@ alerting: enabled: true failure-threshold: 12 success-threshold: 15 + teams: + webhook-url: "http://example.com" + default-alert: + enabled: true services: - name: twinnation @@ -551,6 +573,7 @@ services: success-threshold: 2 # test service alert override - type: telegram - type: twilio + - type: teams conditions: - "[STATUS] == 200" `)) @@ -642,6 +665,14 @@ services: if config.Alerting.Twilio.GetDefaultAlert() == nil { t.Fatal("Twilio.GetDefaultAlert() shouldn't have returned nil") } + + if config.Alerting.Teams == nil || !config.Alerting.Teams.IsValid() { + t.Fatal("Teams alerting config should've been valid") + } + if config.Alerting.Teams.GetDefaultAlert() == nil { + t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil") + } + // Services if len(config.Services) != 1 { t.Error("There should've been 1 service") @@ -652,8 +683,8 @@ services: if config.Services[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } - if len(config.Services[0].Alerts) != 7 { - t.Fatal("There should've been 7 alerts configured") + if len(config.Services[0].Alerts) != 8 { + t.Fatal("There should've been 8 alerts configured") } if config.Services[0].Alerts[0].Type != alert.TypeSlack { @@ -743,6 +774,20 @@ services: if config.Services[0].Alerts[6].SuccessThreshold != 15 { t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 15, config.Services[0].Alerts[6].SuccessThreshold) } + + if config.Services[0].Alerts[7].Type != alert.TypeTeams { + t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeTeams, config.Services[0].Alerts[7].Type) + } + if !config.Services[0].Alerts[7].IsEnabled() { + t.Error("The alert should've been enabled") + } + if config.Services[0].Alerts[7].FailureThreshold != 3 { + t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Services[0].Alerts[7].FailureThreshold) + } + if config.Services[0].Alerts[7].SuccessThreshold != 2 { + t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Services[0].Alerts[7].SuccessThreshold) + } + } func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) { @@ -1249,6 +1294,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { Slack: &slack.AlertProvider{}, Telegram: &telegram.AlertProvider{}, Twilio: &twilio.AlertProvider{}, + Teams: &teams.AlertProvider{}, } if alertingConfig.GetAlertingProviderByAlertType(alert.TypeCustom) != alertingConfig.Custom { t.Error("expected Custom configuration") @@ -1274,4 +1320,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTwilio) != alertingConfig.Twilio { t.Error("expected Twilio configuration") } + if alertingConfig.GetAlertingProviderByAlertType(alert.TypeTeams) != alertingConfig.Teams { + t.Error("expected Teams configuration") + } }