diff --git a/.github/assets/teams-workflows-alerts.png b/.github/assets/teams-workflows-alerts.png new file mode 100644 index 00000000..45cc040e Binary files /dev/null and b/.github/assets/teams-workflows-alerts.png differ diff --git a/README.md b/README.md index 748499ce..3fb3d62f 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Configuring PagerDuty alerts](#configuring-pagerduty-alerts) - [Configuring Pushover alerts](#configuring-pushover-alerts) - [Configuring Slack alerts](#configuring-slack-alerts) - - [Configuring Teams alerts](#configuring-teams-alerts) + - [Configuring Teams alerts *(Deprecated)*](#configuring-teams-alerts-deprecated) + - [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts) - [Configuring Telegram alerts](#configuring-telegram-alerts) - [Configuring Twilio alerts](#configuring-twilio-alerts) - [Configuring AWS SES alerts](#configuring-aws-ses-alerts) @@ -566,7 +567,8 @@ endpoints: | `alerting.pagerduty` | Configuration for alerts of type `pagerduty`.
See [Configuring PagerDuty alerts](#configuring-pagerduty-alerts). | `{}` | | `alerting.pushover` | Configuration for alerts of type `pushover`.
See [Configuring Pushover alerts](#configuring-pushover-alerts). | `{}` | | `alerting.slack` | Configuration for alerts of type `slack`.
See [Configuring Slack alerts](#configuring-slack-alerts). | `{}` | -| `alerting.teams` | Configuration for alerts of type `teams`.
See [Configuring Teams alerts](#configuring-teams-alerts). | `{}` | +| `alerting.teams` | Configuration for alerts of type `teams`. *(Deprecated)*
See [Configuring Teams alerts](#configuring-teams-alerts-deprecated). | `{}` | +| `alerting.teams-workflows` | Configuration for alerts of type `teams-workflows`.
See [Configuring Teams Workflow alerts](#configuring-teams-workflow-alerts). | `{}` | | `alerting.telegram` | Configuration for alerts of type `telegram`.
See [Configuring Telegram alerts](#configuring-telegram-alerts). | `{}` | | `alerting.twilio` | Settings for alerts of type `twilio`.
See [Configuring Twilio alerts](#configuring-twilio-alerts). | `{}` | @@ -1176,7 +1178,12 @@ Here's an example of what the notifications look like: ![Slack notifications](.github/assets/slack-alerts.png) -#### Configuring Teams alerts +#### Configuring Teams alerts *(Deprecated)* + +> [!CAUTION] +> **Deprecated:** Office 365 Connectors within Microsoft Teams are being retired ([Source: Microsoft DevBlog](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/)). +> Existing connectors will continue to work until December 2025. The new [Teams Workflow Alerts](#configuring-teams-workflow-alerts) should be used with Microsoft Workflows instead of this legacy configuration. + | Parameter | Description | Default | |:-----------------------------------------|:-------------------------------------------------------------------------------------------|:--------------------| | `alerting.teams` | Configuration for alerts of type `teams` | `{}` | @@ -1230,6 +1237,61 @@ Here's an example of what the notifications look like: ![Teams notifications](.github/assets/teams-alerts.png) +#### Configuring Teams Workflow alerts + +> [!NOTE] +> This alert is compatible with Workflows for Microsoft Teams. To setup the workflow and get the webhook URL, follow the [Microsoft Documentation](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498). + +| Parameter | Description | Default | +|:---------------------------------------------------|:-------------------------------------------------------------------------------------------|:-------------------| +| `alerting.teams-workflows` | Configuration for alerts of type `teams` | `{}` | +| `alerting.teams-workflows.webhook-url` | Teams Webhook URL | Required `""` | +| `alerting.teams-workflows.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.teams-workflows.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.teams-workflows.title` | Title of the notification | `"⛑ Gatus"` | +| `alerting.teams-workflows.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.teams-workflows.overrides[].webhook-url` | Teams WorkFlow Webhook URL | `""` | + +```yaml +alerting: + teams-workflows: + webhook-url: "https://********.webhook.office.com/webhookb2/************" + # You can also add group-specific to keys, which will + # override the to key above for the specified groups + overrides: + - group: "core" + webhook-url: "https://********.webhook.office.com/webhookb3/************" + +endpoints: + - name: website + url: "https://twin.sh/health" + interval: 30s + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + - "[RESPONSE_TIME] < 300" + alerts: + - type: teams-workflows + description: "healthcheck failed" + send-on-resolved: true + + - name: back-end + group: core + url: "https://example.org/" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[CERTIFICATE_EXPIRATION] > 48h" + alerts: + - type: teams-workflows + description: "healthcheck failed" + send-on-resolved: true +``` + +Here's an example of what the notifications look like: + +![Teams Workflow notifications](.github/assets/teams-workflows-alerts.png) + #### Configuring Telegram alerts | Parameter | Description | Default | diff --git a/alerting/config.go b/alerting/config.go index 040931eb..9148670f 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -26,6 +26,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/slack" "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/zulip" @@ -90,6 +91,9 @@ type Config struct { // Teams is the configuration for the teams alerting provider Teams *teams.AlertProvider `yaml:"teams,omitempty"` + // TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector + TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"` + // Telegram is the configuration for the telegram alerting provider Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"` diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index 510e9f4e..5bccc8a4 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -20,6 +20,7 @@ import ( "github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/slack" "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/zulip" @@ -80,6 +81,7 @@ var ( _ AlertProvider = (*pushover.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil) + _ AlertProvider = (*teamsworkflows.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil) _ AlertProvider = (*zulip.AlertProvider)(nil) diff --git a/alerting/provider/teamsworkflows/teamsworkflows.go b/alerting/provider/teamsworkflows/teamsworkflows.go new file mode 100644 index 00000000..104e2d26 --- /dev/null +++ b/alerting/provider/teamsworkflows/teamsworkflows.go @@ -0,0 +1,182 @@ +package teamsworkflows + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/TwiN/gatus/v5/alerting/alert" + "github.com/TwiN/gatus/v5/client" + "github.com/TwiN/gatus/v5/config/endpoint" +) + +// 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 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"` + + // Title is the title of the message that will be sent + Title string `yaml:"title,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + WebhookURL string `yaml:"webhook-url"` +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { + return false + } + registeredGroups[override.Group] = true + } + } + return len(provider.WebhookURL) > 0 +} + +// Send an alert using the provider +func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) + request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), 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 > 399 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body)) + } + return err +} + +// AdaptiveCardBody represents the structure of an Adaptive Card +type AdaptiveCardBody struct { + Type string `json:"type"` + Version string `json:"version"` + Body []CardBody `json:"body"` +} + +// CardBody represents the body of the Adaptive Card +type CardBody struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Wrap bool `json:"wrap"` + Separator bool `json:"separator,omitempty"` + Size string `json:"size,omitempty"` + Weight string `json:"weight,omitempty"` + Items []CardBody `json:"items,omitempty"` + Facts []Fact `json:"facts,omitempty"` + FactSet *FactSetBody `json:"factSet,omitempty"` +} + +// FactSetBody represents the FactSet in the Adaptive Card +type FactSetBody struct { + Type string `json:"type"` + Facts []Fact `json:"facts"` +} + +// Fact represents an individual fact in the FactSet +type Fact struct { + Title string `json:"title"` + Value string `json:"value"` +} + +// buildRequestBody builds the request body for the provider +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { + var message 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) + } 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) + } + + // Configure default title if it's not provided + title := "⛑ Gatus" + if provider.Title != "" { + title = provider.Title + } + + // Build the facts from the condition results + var facts []Fact + for _, conditionResult := range result.ConditionResults { + var key string + if conditionResult.Success { + key = "✅" + } else { + key = "❌" + } + facts = append(facts, Fact{ + Title: key, + Value: conditionResult.Condition, + }) + } + + cardContent := AdaptiveCardBody{ + Type: "AdaptiveCard", + Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024 + Body: []CardBody{ + { + Type: "TextBlock", + Text: title, + Size: "Medium", + Weight: "Bolder", + }, + { + Type: "TextBlock", + Text: message, + Wrap: true, + }, + { + Type: "FactSet", + Facts: facts, + }, + }, + } + + attachment := map[string]interface{}{ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": cardContent, + } + + payload := map[string]interface{}{ + "type": "message", + "attachments": []interface{}{attachment}, + } + + bodyAsJSON, _ := json.Marshal(payload) + return bodyAsJSON +} + +// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group +func (provider *AlertProvider) getWebhookURLForGroup(group string) string { + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + return override.WebhookURL + } + } + } + return provider.WebhookURL +} + +// GetDefaultAlert returns the provider's default alert configuration +func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { + return provider.DefaultAlert +} diff --git a/alerting/provider/teamsworkflows/teamsworkflows_test.go b/alerting/provider/teamsworkflows/teamsworkflows_test.go new file mode 100644 index 00000000..6e4a9940 --- /dev/null +++ b/alerting/provider/teamsworkflows/teamsworkflows_test.go @@ -0,0 +1,269 @@ +package teamsworkflows + +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 TestAlertDefaultProvider_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_IsValidWithOverride(t *testing.T) { + providerWithInvalidOverrideGroup := AlertProvider{ + Overrides: []Override{ + { + WebhookURL: "http://example.com", + Group: "", + }, + }, + } + if providerWithInvalidOverrideGroup.IsValid() { + t.Error("provider Group shouldn't have been valid") + } + providerWithInvalidOverrideTo := AlertProvider{ + Overrides: []Override{ + { + WebhookURL: "", + Group: "group", + }, + }, + } + if providerWithInvalidOverrideTo.IsValid() { + t.Error("provider integration key shouldn't have been valid") + } + providerWithValidOverride := AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + WebhookURL: "http://example.com", + Group: "group", + }, + }, + } + if !providerWithValidOverride.IsValid() { + 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{}, + 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{}, + 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{}, + 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{}, + 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: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x274C;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x274C;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + { + Name: "resolved", + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"\\u0026#x2705;\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"\\u0026#x2705;\",\"value\":\"[STATUS] == 200\"}]}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + { + Name: "resolved-with-no-conditions", + NoConditions: true, + Provider: AlertProvider{}, + Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: true, + ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"text\":\"\\u0026#x26D1; Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}]},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}", + }, + } + 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( + &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_getWebhookURLForGroup(t *testing.T) { + tests := []struct { + Name string + Provider AlertProvider + InputGroup string + ExpectedOutput string + }{ + { + Name: "provider-no-override-specify-no-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: nil, + }, + InputGroup: "", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-no-override-specify-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: nil, + }, + InputGroup: "group", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-with-override-specify-no-group-should-default", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + Group: "group", + WebhookURL: "http://example01.com", + }, + }, + }, + InputGroup: "", + ExpectedOutput: "http://example.com", + }, + { + Name: "provider-with-override-specify-group-should-override", + Provider: AlertProvider{ + WebhookURL: "http://example.com", + Overrides: []Override{ + { + Group: "group", + WebhookURL: "http://example01.com", + }, + }, + }, + InputGroup: "group", + ExpectedOutput: "http://example01.com", + }, + } + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { + t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) + } + }) + } +}