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

View File

@ -0,0 +1,196 @@
package teams
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"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
// 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.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Teams
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
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
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(cfg.ClientConfig).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Type string `json:"@type"`
Context string `json:"@context"`
ThemeColor string `json:"themeColor"`
Title string `json:"title"`
Text string `json:"text"`
Sections []Section `json:"sections,omitempty"`
}
type Section struct {
ActivityTitle string `json:"activityTitle"`
Text string `json:"text"`
}
// 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 {
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`<br/>", prefix, conditionResult.Condition)
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = ": " + alertDescription
}
body := Body{
Type: "MessageCard",
Context: "http://schema.org/extensions",
ThemeColor: color,
Title: cfg.Title,
Text: message + description,
}
if len(body.Title) == 0 {
body.Title = "&#x1F6A8; Gatus"
}
if len(formattedConditionResults) > 0 {
body.Sections = append(body.Sections, Section{
ActivityTitle: "Condition results",
Text: formattedConditionResults,
})
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// 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,298 @@
package teams
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{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
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{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
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{WebhookURL: "http://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.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://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.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://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.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://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.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
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#DD0000\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row: description-1\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x274C; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x274C; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\",\"sections\":[{\"activityTitle\":\"Condition results\",\"text\":\"\\u0026#x2705; - `[CONNECTED] == true`\\u003cbr/\\u003e\\u0026#x2705; - `[STATUS] == 200`\\u003cbr/\\u003e\"}]}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"@type\":\"MessageCard\",\"@context\":\"http://schema.org/extensions\",\"themeColor\":\"#36A64F\",\"title\":\"\\u0026#x1F6A8; Gatus\",\"text\":\"An alert for *endpoint-name* has been resolved after passing successfully 5 time(s) in a row: description-2\"}",
},
}
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 := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
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{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://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.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// 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)
}
})
}
}